mirror of
https://github.com/Kornstalx/5etools-mirror-2.github.io.git
synced 2025-10-28 20:45:35 -05:00
v1.198.1
This commit is contained in:
191
js/encounterbuilder/encounterbuilder-adjuster.js
Normal file
191
js/encounterbuilder/encounterbuilder-adjuster.js
Normal file
@@ -0,0 +1,191 @@
|
||||
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};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user