Files
5etools-mirror-2.github.io/js/encounterbuilder/encounterbuilder-randomizer.js
TheGiddyLimit 8117ebddc5 v1.198.1
2024-01-01 19:34:49 +00:00

207 lines
6.9 KiB
JavaScript

import {EncounterBuilderConsts} from "./encounterbuilder-consts.js";
import {EncounterBuilderCandidateEncounter, EncounterBuilderCreatureMeta} from "./encounterbuilder-models.js";
export class EncounterBuilderRandomizer {
static _NUM_SAMPLES = 20;
constructor ({partyMeta, cache}) {
this._partyMeta = partyMeta;
this._cache = cache;
// region Pre-cache various "constants" required during generation, for performance
this._STANDARD_XP_VALUES = new Set(Object.values(Parser.XP_CHART_ALT));
this._DESCENDING_AVAILABLE_XP_VALUES = this._cache.getXpKeys().sort(SortUtil.ascSort).reverse();
if (this._DESCENDING_AVAILABLE_XP_VALUES.some(k => typeof k !== "number")) throw new Error(`Expected numerical XP values!`);
/*
Sorted array of:
{
cr: "1/2",
xp: 50,
crNum: 0.5
}
*/
this._CR_METAS = Object.entries(Parser.XP_CHART_ALT)
.map(([cr, xp]) => ({cr, xp, crNum: Parser.crToNumber(cr)}))
.sort((a, b) => SortUtil.ascSort(b.crNum, a.crNum));
// endregion
}
async pGetRandomEncounter ({difficulty, lockedEncounterCreatures}) {
const ixLow = EncounterBuilderConsts.TIERS.indexOf(difficulty);
if (!~ixLow) throw new Error(`Unhandled difficulty level: "${difficulty}"`);
const budget = this._partyMeta[EncounterBuilderConsts.TIERS[ixLow + 1]] - 1;
const closestSolution = this._pDoGenerateEncounter_getSolution({budget, lockedEncounterCreatures});
if (!closestSolution) {
JqueryUtil.doToast({content: `Failed to generate a valid encounter within the provided parameters!`, type: "warning"});
return;
}
return closestSolution.getCreatureMetas();
}
_pDoGenerateEncounter_getSolution ({budget, lockedEncounterCreatures}) {
const solutions = this._pDoGenerateEncounter_getSolutions({budget, lockedEncounterCreatures});
const validSolutions = solutions.filter(it => this._isValidEncounter({candidateEncounter: it, budget}));
if (validSolutions.length) return RollerUtil.rollOnArray(validSolutions);
return null;
}
_pDoGenerateEncounter_getSolutions ({budget, lockedEncounterCreatures}) {
// If there are enough players that single-monster XP is halved, generate twice as many solutions, half with double XP cap
if (this._partyMeta.cntPlayers >= 6) {
return [...new Array(this.constructor._NUM_SAMPLES * 2)]
.map((_, i) => {
return this._pDoGenerateEncounter_generateClosestEncounter({
budget: budget * (Number((i >= this.constructor._NUM_SAMPLES)) + 1),
rawBudget: budget,
lockedEncounterCreatures,
});
});
}
return [...new Array(this.constructor._NUM_SAMPLES)]
.map(() => this._pDoGenerateEncounter_generateClosestEncounter({budget: budget, lockedEncounterCreatures}));
}
_isValidEncounter ({candidateEncounter, budget}) {
const encounterXp = candidateEncounter.getEncounterXpInfo({partyMeta: this._partyMeta});
return encounterXp.adjustedXp >= (budget * 0.6) && encounterXp.adjustedXp <= (budget * 1.1);
}
_pDoGenerateEncounter_generateClosestEncounter ({budget, rawBudget, lockedEncounterCreatures}) {
if (rawBudget == null) rawBudget = budget;
const candidateEncounter = new EncounterBuilderCandidateEncounter({lockedEncounterCreatures});
const xps = this._getUsableXpsForBudget({budget});
let nextBudget = budget;
let skips = 0;
let steps = 0;
while (xps.length) {
if (steps++ > 100) break;
if (skips) {
skips--;
xps.shift();
continue;
}
const xp = xps[0];
if (xp > nextBudget) {
xps.shift();
continue;
}
skips = this._getNumSkips({xps, candidateEncounter, xp});
if (skips) {
skips--;
xps.shift();
continue;
}
this._mutEncounterAddCreatureByXp({candidateEncounter, xp});
nextBudget = this._getBudgetRemaining({candidateEncounter, budget, rawBudget});
}
return candidateEncounter;
}
_getUsableXpsForBudget ({budget}) {
const xps = this._DESCENDING_AVAILABLE_XP_VALUES
.filter(it => {
// Make TftYP values (i.e. those that are not real XP thresholds) get skipped 9/10 times
if (!this._STANDARD_XP_VALUES.has(it) && RollerUtil.randomise(10) !== 10) return false;
return it <= budget;
});
// region Do initial skips--discard some potential XP values early
// 50% of the time, skip the first 0-1/3rd of available CRs
if (xps.length > 4 && RollerUtil.roll(2) === 1) {
const skips = RollerUtil.roll(Math.ceil(xps.length / 3));
return xps.slice(skips);
}
return xps;
// endregion
}
_getBudgetRemaining ({candidateEncounter, budget, rawBudget}) {
if (!candidateEncounter.hasCreatures()) return budget;
const curr = candidateEncounter.getEncounterXpInfo({partyMeta: this._partyMeta});
const budgetRemaining = budget - curr.adjustedXp;
const meta = this._CR_METAS.filter(it => it.xp <= budgetRemaining);
// If we're a large party, and we're doing a "single creature worth less XP" generation, force the generation
// to stop.
if (rawBudget !== budget && curr.count === 1 && (rawBudget - curr.baseXp) <= 0) {
return 0;
}
// if the highest CR creature has CR greater than the cutoff, adjust for next multiplier
if (meta.length && meta[0].crNum >= curr.crCutoff) {
const nextMult = Parser.numMonstersToXpMult(curr.relevantCount + 1, this._partyMeta.cntPlayers);
return Math.floor((budget - (nextMult * curr.baseXp)) / nextMult);
}
// otherwise, no creature has CR greater than the cutoff, don't worry about multipliers
return budgetRemaining;
}
_mutEncounterAddCreatureByXp ({candidateEncounter, xp}) {
if (candidateEncounter.tryIncreaseExistingCreatureCount({xp})) return;
// region Try to add a new creature
// We retrieve the list of all available creatures for this XP, then randomly pick creatures from that list until
// we exhaust all options.
// Generally, the first creature picked should be usable. We only need to continue our search loop if the creature
// picked is already included in our encounter, and is locked.
const availableCreatures = [...this._cache.getCreaturesByXp(xp)];
while (availableCreatures.length) {
const ixRolled = RollerUtil.randomise(availableCreatures.length) - 1;
const rolled = availableCreatures[ixRolled];
availableCreatures.splice(ixRolled, 1);
const isAdded = candidateEncounter.addCreatureMeta(
new EncounterBuilderCreatureMeta({
creature: rolled,
count: 1,
}),
);
if (!isAdded) continue;
break;
}
// endregion
}
_getNumSkips ({xps, candidateEncounter, xp}) {
// if there are existing entries at this XP, don't skip
const existing = candidateEncounter.getCreatureMetas({xp});
if (existing.length) return 0;
if (xps.length <= 1) return 0;
// skip 70% of the time by default, less 13% chance per item skipped
const isSkip = RollerUtil.roll(100) < (70 - (13 * candidateEncounter.skipCount));
if (!isSkip) return 0;
candidateEncounter.skipCount++;
const maxSkip = xps.length - 1;
// flip coins; so long as we get heads, keep skipping
for (let i = 0; i < maxSkip; ++i) {
if (RollerUtil.roll(2) === 0) {
return i;
}
}
return maxSkip - 1;
}
}