mirror of
https://github.com/Kornstalx/5etools-mirror-2.github.io.git
synced 2025-10-28 20:45:35 -05:00
207 lines
6.9 KiB
JavaScript
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;
|
|
}
|
|
}
|