mirror of
https://github.com/Kornstalx/5etools-mirror-2.github.io.git
synced 2025-10-28 20:45:35 -05:00
192 lines
8.1 KiB
JavaScript
192 lines
8.1 KiB
JavaScript
import {EncounterBuilderConsts} from "./encounterbuilder-consts.js";
|
|
import {EncounterBuilderHelpers} from "./encounterbuilder-helpers.js";
|
|
import {EncounterBuilderCreatureMeta} from "./encounterbuilder-models.js";
|
|
|
|
export class EncounterBuilderAdjuster {
|
|
static _INCOMPLETE_EXHAUSTED = 0;
|
|
static _INCOMPLETE_FAILED = -1;
|
|
static _COMPLETE = 1;
|
|
|
|
constructor ({partyMeta}) {
|
|
this._partyMeta = partyMeta;
|
|
}
|
|
|
|
/**
|
|
* @param {string} difficulty
|
|
* @param {Array<EncounterBuilderCreatureMeta>} creatureMetas
|
|
*/
|
|
async pGetAdjustedEncounter ({difficulty, creatureMetas}) {
|
|
if (!creatureMetas.length) {
|
|
JqueryUtil.doToast({content: `The current encounter contained no creatures! Please add some first.`, type: "warning"});
|
|
return;
|
|
}
|
|
|
|
if (creatureMetas.every(it => it.isLocked)) {
|
|
JqueryUtil.doToast({content: `The current encounter contained only locked creatures! Please unlock or add some other creatures some first.`, type: "warning"});
|
|
return;
|
|
}
|
|
|
|
creatureMetas = creatureMetas.map(creatureMeta => creatureMeta.copy());
|
|
|
|
const creatureMetasAdjustable = creatureMetas
|
|
.filter(creatureMeta => !creatureMeta.isLocked && creatureMeta.getCrNumber() != null);
|
|
|
|
if (!creatureMetasAdjustable.length) {
|
|
JqueryUtil.doToast({content: `The current encounter contained only locked creatures, or creatures without XP values! Please unlock or add some other creatures some first.`, type: "warning"});
|
|
return;
|
|
}
|
|
|
|
creatureMetasAdjustable
|
|
.forEach(creatureMeta => creatureMeta.count = 1);
|
|
|
|
const ixDifficulty = EncounterBuilderConsts.TIERS.indexOf(difficulty);
|
|
if (!~ixDifficulty) throw new Error(`Unhandled difficulty level: "${difficulty}"`);
|
|
|
|
// fudge min/max numbers slightly
|
|
const [targetMin, targetMax] = [
|
|
Math.floor(this._partyMeta[EncounterBuilderConsts.TIERS[ixDifficulty]] * 0.9),
|
|
Math.ceil((this._partyMeta[EncounterBuilderConsts.TIERS[ixDifficulty + 1]] - 1) * 1.1),
|
|
];
|
|
|
|
if (EncounterBuilderCreatureMeta.getEncounterXpInfo(creatureMetas, this._partyMeta).adjustedXp > targetMax) {
|
|
JqueryUtil.doToast({content: `Could not adjust the current encounter to ${difficulty.uppercaseFirst()}, try removing some creatures!`, type: "danger"});
|
|
return;
|
|
}
|
|
|
|
// only calculate this once rather than during the loop, to ensure stable conditions
|
|
// less accurate in some cases, but should prevent infinite loops
|
|
const crCutoff = EncounterBuilderHelpers.getCrCutoff(creatureMetas, this._partyMeta);
|
|
|
|
// randomly choose creatures to skip
|
|
// generate array of [0, 1, ... n-1] where n = number of unique creatures
|
|
// this will be used to determine how many of the unique creatures we want to skip
|
|
const numSkipTotals = [...new Array(creatureMetasAdjustable.length)]
|
|
.map((_, ix) => ix);
|
|
|
|
const invalidSolutions = [];
|
|
let lastAdjustResult;
|
|
for (let maxTries = 999; maxTries >= 0; --maxTries) {
|
|
// -1/1 = complete; 0 = continue
|
|
lastAdjustResult = this._pGetAdjustedEncounter_doTryAdjusting({creatureMetas, creatureMetasAdjustable, numSkipTotals, targetMin, targetMax});
|
|
if (lastAdjustResult !== this.constructor._INCOMPLETE_EXHAUSTED) break;
|
|
|
|
invalidSolutions.push(creatureMetas.map(creatureMeta => creatureMeta.copy()));
|
|
|
|
// reset for next attempt
|
|
creatureMetasAdjustable
|
|
.forEach(creatureMeta => creatureMeta.count = 1);
|
|
}
|
|
|
|
// no good solution was found, so pick the closest invalid solution
|
|
if (lastAdjustResult !== this.constructor._COMPLETE && invalidSolutions.length) {
|
|
creatureMetas = invalidSolutions
|
|
.map(creatureMetasInvalid => ({
|
|
encounter: creatureMetasInvalid,
|
|
distance: this._pGetAdjustedEncounter_getSolutionDistance({creatureMetas: creatureMetasInvalid, targetMin, targetMax}),
|
|
}))
|
|
.sort((a, b) => SortUtil.ascSort(a.distance, b.distance))[0].encounter;
|
|
}
|
|
|
|
// do a post-step to randomly bulk out our counts of "irrelevant" creatures, ensuring plenty of fireball fodder
|
|
this._pGetAdjustedEncounter_doIncreaseIrrelevantCreatureCount({creatureMetas, creatureMetasAdjustable, crCutoff, targetMax});
|
|
|
|
return creatureMetas;
|
|
}
|
|
|
|
_pGetAdjustedEncounter_getSolutionDistance ({creatureMetas, targetMin, targetMax}) {
|
|
const xp = EncounterBuilderCreatureMeta.getEncounterXpInfo(creatureMetas, this._partyMeta).adjustedXp;
|
|
if (xp > targetMax) return xp - targetMax;
|
|
if (xp < targetMin) return targetMin - xp;
|
|
return 0;
|
|
}
|
|
|
|
_pGetAdjustedEncounter_doTryAdjusting ({creatureMetas, creatureMetasAdjustable, numSkipTotals, targetMin, targetMax}) {
|
|
if (!numSkipTotals.length) return this.constructor._INCOMPLETE_FAILED; // no solution possible, so exit loop
|
|
|
|
let skipIx = 0;
|
|
// 7/12 * 7/12 * ... chance of moving the skipIx along one
|
|
while (!(RollerUtil.randomise(12) > 7) && skipIx < numSkipTotals.length - 1) skipIx++;
|
|
|
|
const numSkips = numSkipTotals.splice(skipIx, 1)[0]; // remove the selected skip amount; we'll try the others if this one fails
|
|
const curUniqueCreatures = [...creatureMetasAdjustable];
|
|
if (numSkips) {
|
|
[...new Array(numSkips)].forEach(() => {
|
|
const ixRemove = RollerUtil.randomise(curUniqueCreatures.length) - 1;
|
|
if (!~ixRemove) return;
|
|
curUniqueCreatures.splice(ixRemove, 1);
|
|
});
|
|
}
|
|
|
|
for (let maxTries = 999; maxTries >= 0; --maxTries) {
|
|
const encounterXp = EncounterBuilderCreatureMeta.getEncounterXpInfo(creatureMetas, this._partyMeta);
|
|
if (encounterXp.adjustedXp > targetMin && encounterXp.adjustedXp < targetMax) {
|
|
return this.constructor._COMPLETE;
|
|
}
|
|
|
|
// chance to skip each creature at each iteration
|
|
// otherwise, the case where every creature is relevant produces an equal number of every creature
|
|
const pickFrom = [...curUniqueCreatures];
|
|
if (pickFrom.length > 1) {
|
|
let loops = Math.floor(pickFrom.length / 2);
|
|
// skip [half, n-1] creatures
|
|
loops = RollerUtil.randomise(pickFrom.length - 1, loops);
|
|
while (loops-- > 0) {
|
|
const ix = RollerUtil.randomise(pickFrom.length) - 1;
|
|
pickFrom.splice(ix, 1);
|
|
}
|
|
}
|
|
|
|
while (pickFrom.length) {
|
|
const ix = RollerUtil.randomise(pickFrom.length) - 1;
|
|
const picked = pickFrom.splice(ix, 1)[0];
|
|
picked.count++;
|
|
if (EncounterBuilderCreatureMeta.getEncounterXpInfo(creatureMetas, this._partyMeta).adjustedXp > targetMax) {
|
|
picked.count--;
|
|
}
|
|
}
|
|
}
|
|
|
|
return this.constructor._INCOMPLETE_EXHAUSTED;
|
|
}
|
|
|
|
_pGetAdjustedEncounter_doIncreaseIrrelevantCreatureCount ({creatureMetas, creatureMetasAdjustable, crCutoff, targetMax}) {
|
|
const creatureMetasBelowCrCutoff = creatureMetasAdjustable.filter(creatureMeta => creatureMeta.getCrNumber() < crCutoff);
|
|
if (!creatureMetasBelowCrCutoff.length) return;
|
|
|
|
let budget = targetMax - EncounterBuilderCreatureMeta.getEncounterXpInfo(creatureMetas, this._partyMeta).adjustedXp;
|
|
if (budget <= 0) return;
|
|
|
|
const usable = creatureMetasBelowCrCutoff.filter(creatureMeta => creatureMeta.getXp() < budget);
|
|
if (!usable.length) return;
|
|
|
|
// try to avoid flooding low-level parties
|
|
const {min: playerToCreatureRatioMin, max: playerToCreatureRatioMax} = this._pGetAdjustedEncounter_getPlayerToCreatureRatios();
|
|
const minDesired = Math.floor(playerToCreatureRatioMin * this._partyMeta.cntPlayers);
|
|
const maxDesired = Math.ceil(playerToCreatureRatioMax * this._partyMeta.cntPlayers);
|
|
|
|
// keep rolling until we fail to add a creature, or until we're out of budget
|
|
while (EncounterBuilderCreatureMeta.getEncounterXpInfo(creatureMetas, this._partyMeta).adjustedXp <= targetMax) {
|
|
const totalCreatures = creatureMetas
|
|
.map(creatureMeta => creatureMeta.count)
|
|
.sum();
|
|
|
|
// if there's less than min desired, large chance of adding more
|
|
// if there's more than max desired, small chance of adding more
|
|
// if there's between min and max desired, medium chance of adding more
|
|
const chanceToAdd = totalCreatures < minDesired ? 90 : totalCreatures > maxDesired ? 40 : 75;
|
|
|
|
const isAdd = RollerUtil.roll(100) < chanceToAdd;
|
|
if (!isAdd) break;
|
|
|
|
RollerUtil.rollOnArray(creatureMetasBelowCrCutoff).count++;
|
|
}
|
|
}
|
|
|
|
_pGetAdjustedEncounter_getPlayerToCreatureRatios () {
|
|
if (this._partyMeta.avgPlayerLevel < 5) return {min: 0.8, max: 1.3};
|
|
if (this._partyMeta.avgPlayerLevel < 11) return {min: 1, max: 2};
|
|
if (this._partyMeta.avgPlayerLevel < 17) return {min: 1, max: 3};
|
|
return {min: 1, max: 4};
|
|
}
|
|
}
|