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};
|
||||
}
|
||||
}
|
||||
19
js/encounterbuilder/encounterbuilder-cache.js
Normal file
19
js/encounterbuilder/encounterbuilder-cache.js
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* A cache of XP value -> creature.
|
||||
*/
|
||||
export class EncounterBuilderCacheBase {
|
||||
reset () { throw new Error("Unimplemented!"); }
|
||||
getCreaturesByXp (xp) { throw new Error("Unimplemented!"); }
|
||||
getXpKeys () { throw new Error("Unimplemented!"); }
|
||||
|
||||
static _UNWANTED_CR_NUMS = new Set([VeCt.CR_UNKNOWN, VeCt.CR_CUSTOM]);
|
||||
|
||||
_isUnwantedCreature (mon) {
|
||||
if (mon.isNpc) return true;
|
||||
|
||||
const crNum = Parser.crToNumber(mon.cr);
|
||||
if (this.constructor._UNWANTED_CR_NUMS.has(crNum)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
51
js/encounterbuilder/encounterbuilder-colsextraadvanced.js
Normal file
51
js/encounterbuilder/encounterbuilder-colsextraadvanced.js
Normal file
@@ -0,0 +1,51 @@
|
||||
export class EncounterBuilderRenderableCollectionColsExtraAdvanced extends RenderableCollectionBase {
|
||||
constructor (
|
||||
{
|
||||
comp,
|
||||
rdState,
|
||||
},
|
||||
) {
|
||||
super(comp, "colsExtraAdvanced");
|
||||
this._rdState = rdState;
|
||||
}
|
||||
|
||||
getNewRender (colExtra, i) {
|
||||
const comp = BaseComponent.fromObject(colExtra.entity, "*");
|
||||
comp._addHookAll("state", () => {
|
||||
this._getCollectionItem(colExtra.id).entity = comp.toObject("*");
|
||||
this._comp._triggerCollectionUpdate("colsExtraAdvanced");
|
||||
});
|
||||
|
||||
const $iptName = ComponentUiUtil.$getIptStr(comp, "name")
|
||||
.addClass("w-40p form-control--minimal no-shrink ve-text-center mr-1 bb-0");
|
||||
|
||||
const $wrpHeader = $$`<div class="ve-flex">
|
||||
${$iptName}
|
||||
</div>`
|
||||
.appendTo(this._rdState.$wrpHeadersAdvanced);
|
||||
|
||||
const $btnDelete = $(`<button class="btn btn-xxs ecgen-player__btn-inline w-40p btn-danger no-shrink mt-n2 bt-0 btl-0 btr-0" title="Remove Column" tabindex="-1"><span class="glyphicon-trash glyphicon"></span></button>`)
|
||||
.click(() => this._comp.doRemoveColExtraAdvanced(colExtra.id));
|
||||
|
||||
const $wrpFooter = $$`<div class="w-40p ve-flex-v-baseline ve-flex-h-center no-shrink no-grow mr-1">
|
||||
${$btnDelete}
|
||||
</div>`
|
||||
.appendTo(this._rdState.$wrpFootersAdvanced);
|
||||
|
||||
return {
|
||||
comp,
|
||||
$wrpHeader,
|
||||
$wrpFooter,
|
||||
fnRemoveEles: () => {
|
||||
$wrpHeader.remove();
|
||||
$wrpFooter.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
doUpdateExistingRender (renderedMeta, colExtra, i) {
|
||||
renderedMeta.comp._proxyAssignSimple("state", colExtra.entity, true);
|
||||
if (!renderedMeta.$wrpHeader.parent().is(this._rdState.$wrpHeadersAdvanced)) renderedMeta.$wrpHeader.appendTo(this._rdState.$wrpHeadersAdvanced);
|
||||
if (!renderedMeta.$wrpFooter.parent().is(this._rdState.$wrpFootersAdvanced)) renderedMeta.$wrpFooter.appendTo(this._rdState.$wrpFootersAdvanced);
|
||||
}
|
||||
}
|
||||
232
js/encounterbuilder/encounterbuilder-component.js
Normal file
232
js/encounterbuilder/encounterbuilder-component.js
Normal file
@@ -0,0 +1,232 @@
|
||||
import {EncounterPartyMeta, EncounterPartyPlayerMeta} from "./encounterbuilder-models.js";
|
||||
|
||||
export class EncounterBuilderComponent extends BaseComponent {
|
||||
static _DEFAULT_PARTY_SIZE = 4;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
get creatureMetas () { return this._state.creatureMetas; }
|
||||
set creatureMetas (val) { this._state.creatureMetas = val; }
|
||||
|
||||
get isAdvanced () { return this._state.isAdvanced; }
|
||||
set isAdvanced (val) { this._state.isAdvanced = !!val; }
|
||||
|
||||
get playersSimple () { return this._state.playersSimple; }
|
||||
set playersSimple (val) { this._state.playersSimple = val; }
|
||||
|
||||
get colsExtraAdvanced () { return this._state.colsExtraAdvanced; }
|
||||
set colsExtraAdvanced (val) { this._state.colsExtraAdvanced = val; }
|
||||
|
||||
get playersAdvanced () { return this._state.playersAdvanced; }
|
||||
set playersAdvanced (val) { this._state.playersAdvanced = val; }
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
addHookCreatureMetas (hk) { return this._addHookBase("creatureMetas", hk); }
|
||||
addHookIsAdvanced (hk) { return this._addHookBase("isAdvanced", hk); }
|
||||
addHookPlayersSimple (hk) { return this._addHookBase("playersSimple", hk); }
|
||||
addHookPlayersAdvanced (hk) { return this._addHookBase("playersAdvanced", hk); }
|
||||
addHookColsExtraAdvanced (hk) { return this._addHookBase("colsExtraAdvanced", hk); }
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
_addPlayerRow_advanced () {
|
||||
const prevRowLevel = this._state.playersAdvanced.last()?.entity?.level;
|
||||
|
||||
this._state.playersAdvanced = [
|
||||
...this._state.playersAdvanced,
|
||||
this.constructor.getDefaultPlayerRow_advanced({
|
||||
level: prevRowLevel,
|
||||
colsExtraAdvanced: this._state.colsExtraAdvanced,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
_addPlayerRow_simple () {
|
||||
const prevRowLevel = this._state.playersSimple.last()?.entity?.level;
|
||||
|
||||
this._state.playersSimple = [
|
||||
...this._state.playersSimple,
|
||||
this.constructor.getDefaultPlayerRow_simple({
|
||||
level: prevRowLevel,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
doAddPlayer () {
|
||||
if (this._state.isAdvanced) return this._addPlayerRow_advanced();
|
||||
return this._addPlayerRow_simple();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
getPartyMeta () {
|
||||
return new EncounterPartyMeta(
|
||||
this._state.isAdvanced
|
||||
? this._getPartyPlayerMetas_advanced()
|
||||
: this._getPartyPlayerMetas_simple(),
|
||||
);
|
||||
}
|
||||
|
||||
_getPartyPlayerMetas_advanced () {
|
||||
const countByLevel = {};
|
||||
this._state.playersAdvanced
|
||||
.forEach(it => {
|
||||
countByLevel[it.entity.level] = (countByLevel[it.entity.level] || 0) + 1;
|
||||
});
|
||||
|
||||
return Object.entries(countByLevel)
|
||||
.map(([level, count]) => new EncounterPartyPlayerMeta({level: Number(level), count}));
|
||||
}
|
||||
|
||||
_getPartyPlayerMetas_simple () {
|
||||
return this._state.playersSimple
|
||||
.map(it => new EncounterPartyPlayerMeta({count: it.entity.count, level: it.entity.level}));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
doAddColExtraAdvanced () {
|
||||
this._state.colsExtraAdvanced = [
|
||||
...this._state.colsExtraAdvanced,
|
||||
this.constructor.getDefaultColExtraAdvanced(),
|
||||
];
|
||||
|
||||
// region When adding a new advanced column, add a new cell to each player row
|
||||
this._state.playersAdvanced.forEach(it => it.entity.extras.push(this.constructor.getDefaultPlayerAdvancedExtra()));
|
||||
this._triggerCollectionUpdate("playersAdvanced");
|
||||
// endregion
|
||||
}
|
||||
|
||||
doRemoveColExtraAdvanced (id) {
|
||||
// region When removing an advanced column, remove matching values from player rows
|
||||
const ix = this._state.colsExtraAdvanced.findIndex(it => it.id === id);
|
||||
if (!~ix) return;
|
||||
this._state.playersAdvanced.forEach(player => {
|
||||
player.entity.extras = player.entity.extras.filter((_, i) => i !== ix);
|
||||
});
|
||||
this._triggerCollectionUpdate("playersAdvanced");
|
||||
// endregion
|
||||
|
||||
this._state.colsExtraAdvanced = this._state.colsExtraAdvanced.filter(it => it.id !== id);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
static getDefaultPlayerRow_advanced ({name = "", level = 1, extras = null, colsExtraAdvanced = null} = {}) {
|
||||
extras = extras || [...new Array(colsExtraAdvanced?.length || 0)]
|
||||
.map(() => this.getDefaultPlayerAdvancedExtra());
|
||||
return {
|
||||
id: CryptUtil.uid(),
|
||||
entity: {
|
||||
name,
|
||||
level,
|
||||
extras,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static getDefaultPlayerRow_simple (
|
||||
{
|
||||
count = this._DEFAULT_PARTY_SIZE,
|
||||
level = 1,
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
id: CryptUtil.uid(),
|
||||
entity: {
|
||||
count,
|
||||
level,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static getDefaultColExtraAdvanced (
|
||||
{
|
||||
name = "",
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
id: CryptUtil.uid(),
|
||||
entity: {
|
||||
name,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static getDefaultPlayerAdvancedExtra (
|
||||
{
|
||||
value = "",
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
id: CryptUtil.uid(),
|
||||
entity: {
|
||||
value,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
setStateFromLoaded (loadedState) {
|
||||
this._mutValidateLoadedState(loadedState);
|
||||
|
||||
const nxt = MiscUtil.copyFast(this._getDefaultState());
|
||||
Object.assign(nxt, loadedState);
|
||||
|
||||
this._proxyAssignSimple("state", nxt, true);
|
||||
}
|
||||
|
||||
setPartialStateFromLoaded (partialLoadedState) {
|
||||
this._mutValidateLoadedState(partialLoadedState);
|
||||
this._proxyAssignSimple("state", partialLoadedState);
|
||||
}
|
||||
|
||||
_mutValidateLoadedState (loadedState) {
|
||||
const defaultState = this._getDefaultState();
|
||||
|
||||
if (loadedState.playersSimple && !loadedState.playersSimple.length) loadedState.playersSimple = MiscUtil.copyFast(defaultState.playersSimple);
|
||||
|
||||
if (loadedState.playersAdvanced && !loadedState.playersAdvanced.length) {
|
||||
const colsExtraAdvanced = loadedState.colsExtraAdvanced || this._state.colsExtraAdvanced;
|
||||
|
||||
loadedState.playersAdvanced = MiscUtil.copyFast(defaultState.playersAdvanced);
|
||||
loadedState.playersAdvanced
|
||||
.forEach(({entity}) => {
|
||||
// Trim extras
|
||||
(entity.extras = entity.extras || []).slice(0, colsExtraAdvanced.length);
|
||||
// Pad extras
|
||||
colsExtraAdvanced.forEach((_, i) => entity.extras[i] = entity.extras[i] ?? this.constructor.getDefaultPlayerAdvancedExtra());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
getDefaultStateKeys () {
|
||||
return Object.keys(this.constructor._getDefaultState());
|
||||
}
|
||||
|
||||
static _getDefaultState () {
|
||||
return {
|
||||
creatureMetas: [],
|
||||
|
||||
playersSimple: [
|
||||
this.getDefaultPlayerRow_simple(),
|
||||
],
|
||||
|
||||
isAdvanced: false,
|
||||
colsExtraAdvanced: [],
|
||||
playersAdvanced: [
|
||||
this.getDefaultPlayerRow_advanced(),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
_getDefaultState () {
|
||||
return {
|
||||
...this.constructor._getDefaultState(),
|
||||
};
|
||||
}
|
||||
}
|
||||
3
js/encounterbuilder/encounterbuilder-consts.js
Normal file
3
js/encounterbuilder/encounterbuilder-consts.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export class EncounterBuilderConsts {
|
||||
static TIERS = ["easy", "medium", "hard", "deadly", "absurd"];
|
||||
}
|
||||
85
js/encounterbuilder/encounterbuilder-helpers.js
Normal file
85
js/encounterbuilder/encounterbuilder-helpers.js
Normal file
@@ -0,0 +1,85 @@
|
||||
export class EncounterBuilderHelpers {
|
||||
static getCrCutoff (creatureMetas, partyMeta) {
|
||||
creatureMetas = creatureMetas
|
||||
.filter(creatureMeta => creatureMeta.getCrNumber() != null)
|
||||
.sort((a, b) => SortUtil.ascSort(b.getCrNumber(), a.getCrNumber()));
|
||||
if (!creatureMetas.length) return 0;
|
||||
|
||||
// no cutoff for CR 0-2
|
||||
if (creatureMetas[0].getCrNumber() <= 2) return 0;
|
||||
|
||||
// ===============================================================================================================
|
||||
// "When making this calculation, don't count any monsters whose challenge rating is significantly below the average
|
||||
// challenge rating of the other monsters in the group unless you think the weak monsters significantly contribute
|
||||
// to the difficulty of the encounter." -- DMG, p. 82
|
||||
// ===============================================================================================================
|
||||
|
||||
// "unless you think the weak monsters significantly contribute to the difficulty of the encounter"
|
||||
// For player levels <5, always include every monster. We assume that levels 5> will have strong
|
||||
// AoE/multiattack, allowing trash to be quickly cleared.
|
||||
if (!partyMeta.isPartyLevelFivePlus()) return 0;
|
||||
|
||||
// Spread the CRs into a single array
|
||||
const crValues = [];
|
||||
creatureMetas.forEach(creatureMeta => {
|
||||
const cr = creatureMeta.getCrNumber();
|
||||
for (let i = 0; i < creatureMeta.count; ++i) crValues.push(cr);
|
||||
});
|
||||
|
||||
// TODO(Future) allow this to be controlled by the user
|
||||
let CR_THRESH_MODE = "statisticallySignificant";
|
||||
|
||||
switch (CR_THRESH_MODE) {
|
||||
// "Statistically significant" method--note that this produces very passive filtering; the threshold is below
|
||||
// the minimum CR in the vast majority of cases.
|
||||
case "statisticallySignificant": {
|
||||
const cpy = MiscUtil.copy(crValues)
|
||||
.sort(SortUtil.ascSort);
|
||||
|
||||
const avg = cpy.mean();
|
||||
const deviation = cpy.meanAbsoluteDeviation();
|
||||
|
||||
return avg - (deviation * 2);
|
||||
}
|
||||
|
||||
case "5etools": {
|
||||
// The ideal interpretation of this:
|
||||
// "don't count any monsters whose challenge rating is significantly below the average
|
||||
// challenge rating of the other monsters in the group"
|
||||
// Is:
|
||||
// Arrange the creatures in CR order, lowest to highest. Remove the lowest CR creature (or one of them, if there
|
||||
// are ties). Calculate the average CR without this removed creature. If the removed creature's CR is
|
||||
// "significantly below" this average, repeat the process with the next lowest CR creature.
|
||||
// However, this can produce a stair-step pattern where our average CR keeps climbing as we remove more and more
|
||||
// creatures. Therefore, only do this "remove creature -> calculate average CR" step _once_, and use the
|
||||
// resulting average CR to calculate a cutoff.
|
||||
|
||||
const crMetas = [];
|
||||
|
||||
// If there's precisely one CR value, use it
|
||||
if (crValues.length === 1) {
|
||||
crMetas.push({
|
||||
mean: crValues[0],
|
||||
deviation: 0,
|
||||
});
|
||||
} else {
|
||||
// Get an average CR for every possible encounter without one of the creatures in the encounter
|
||||
for (let i = 0; i < crValues.length; ++i) {
|
||||
const crValueFilt = crValues.filter((_, j) => i !== j);
|
||||
const crMean = crValueFilt.mean();
|
||||
const crStdDev = Math.sqrt((1 / crValueFilt.length) * crValueFilt.map(it => (it - crMean) ** 2).reduce((a, b) => a + b, 0));
|
||||
crMetas.push({mean: crMean, deviation: crStdDev});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by descending average CR -> ascending deviation
|
||||
crMetas.sort((a, b) => SortUtil.ascSort(b.mean, a.mean) || SortUtil.ascSort(a.deviation, b.deviation));
|
||||
|
||||
// "significantly below the average" -> cutoff at half the average
|
||||
return crMetas[0].mean / 2;
|
||||
}
|
||||
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
317
js/encounterbuilder/encounterbuilder-models.js
Normal file
317
js/encounterbuilder/encounterbuilder-models.js
Normal file
@@ -0,0 +1,317 @@
|
||||
import {EncounterBuilderHelpers} from "./encounterbuilder-helpers.js";
|
||||
|
||||
export class EncounterBuilderXpInfo {
|
||||
static getDefault () {
|
||||
return new this({
|
||||
baseXp: 0,
|
||||
relevantCount: 0,
|
||||
count: 0,
|
||||
adjustedXp: 0,
|
||||
|
||||
crCutoff: Parser.crToNumber(Parser.CRS.last()),
|
||||
playerCount: 0,
|
||||
playerAdjustedXpMult: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
constructor (
|
||||
{
|
||||
baseXp,
|
||||
relevantCount,
|
||||
count,
|
||||
adjustedXp,
|
||||
crCutoff,
|
||||
playerCount,
|
||||
playerAdjustedXpMult,
|
||||
},
|
||||
) {
|
||||
this._baseXp = baseXp;
|
||||
this._relevantCount = relevantCount;
|
||||
this._count = count;
|
||||
this._adjustedXp = adjustedXp;
|
||||
this._crCutoff = crCutoff;
|
||||
this._playerCount = playerCount;
|
||||
this._playerAdjustedXpMult = playerAdjustedXpMult;
|
||||
}
|
||||
|
||||
get baseXp () { return this._baseXp; }
|
||||
get relevantCount () { return this._relevantCount; }
|
||||
get count () { return this._count; }
|
||||
get adjustedXp () { return this._adjustedXp; }
|
||||
get crCutoff () { return this._crCutoff; }
|
||||
get playerCount () { return this._playerCount; }
|
||||
get playerAdjustedXpMult () { return this._playerAdjustedXpMult; }
|
||||
}
|
||||
|
||||
export class EncounterBuilderCreatureMeta {
|
||||
constructor (
|
||||
{
|
||||
creature,
|
||||
count,
|
||||
|
||||
isLocked = false,
|
||||
|
||||
customHashId = null,
|
||||
baseCreature = null,
|
||||
},
|
||||
) {
|
||||
this.creature = creature;
|
||||
this.count = count;
|
||||
|
||||
this.isLocked = isLocked;
|
||||
|
||||
// used for encounter adjuster
|
||||
this.customHashId = customHashId ?? null;
|
||||
this.baseCreature = baseCreature;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
getHash () { return UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_BESTIARY](this.creature); }
|
||||
|
||||
getCrNumber () {
|
||||
return Parser.crToNumber(this.creature.cr, {isDefaultNull: true});
|
||||
}
|
||||
|
||||
getXp () {
|
||||
return Parser.crToXpNumber(this.creature.cr);
|
||||
}
|
||||
|
||||
getApproxHp () {
|
||||
if (this.creature.hp && this.creature.hp.average && !isNaN(this.creature.hp.average)) return Number(this.creature.hp.average);
|
||||
return null;
|
||||
}
|
||||
|
||||
getApproxAc () {
|
||||
// Use the first AC listed, as this is usually the "primary"
|
||||
if (this.creature.ac && this.creature.ac[0] != null) {
|
||||
if (this.creature.ac[0].ac) return this.creature.ac[0].ac;
|
||||
if (typeof this.creature.ac[0] === "number") return this.creature.ac[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
isSameCreature (other) {
|
||||
if (this.customHashId !== other.customHashId) return false;
|
||||
return this.getHash() === other.getHash();
|
||||
}
|
||||
|
||||
hasCreature (mon) {
|
||||
const monCustomHashId = Renderer.monster.getCustomHashId(mon);
|
||||
if (this.customHashId !== monCustomHashId) return false;
|
||||
return this.getHash() === UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_BESTIARY](mon);
|
||||
}
|
||||
|
||||
copy () {
|
||||
return new this.constructor(this);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @param {Array<EncounterBuilderCreatureMeta>} creatureMetas
|
||||
* @param {?EncounterPartyMeta} partyMeta
|
||||
*/
|
||||
static getEncounterXpInfo (creatureMetas, partyMeta = null) {
|
||||
partyMeta ??= EncounterPartyMeta.getDefault();
|
||||
|
||||
// Avoid including e.g. "summon" creatures.
|
||||
// Note that this effectively discounts non-XP-carrying creatures from "creature count XP multiplier"
|
||||
// calculations. This is intentional; we make the simplifying assumption that if a creature doesn't carry XP,
|
||||
// it should have no impact on the difficulty encounter.
|
||||
creatureMetas = creatureMetas.filter(creatureMeta => creatureMeta.getCrNumber() != null)
|
||||
.sort((a, b) => SortUtil.ascSort(b.getCrNumber(), a.getCrNumber()));
|
||||
|
||||
if (!creatureMetas.length) {
|
||||
return EncounterBuilderXpInfo.getDefault();
|
||||
}
|
||||
|
||||
let baseXp = 0;
|
||||
let relevantCount = 0;
|
||||
let count = 0;
|
||||
|
||||
const crCutoff = EncounterBuilderHelpers.getCrCutoff(creatureMetas, partyMeta);
|
||||
creatureMetas
|
||||
.forEach(creatureMeta => {
|
||||
if (creatureMeta.getCrNumber() >= crCutoff) relevantCount += creatureMeta.count;
|
||||
count += creatureMeta.count;
|
||||
baseXp += (Parser.crToXpNumber(Parser.numberToCr(creatureMeta.getCrNumber())) || 0) * creatureMeta.count;
|
||||
});
|
||||
|
||||
const playerAdjustedXpMult = Parser.numMonstersToXpMult(relevantCount, partyMeta.cntPlayers);
|
||||
|
||||
const adjustedXp = playerAdjustedXpMult * baseXp;
|
||||
return new EncounterBuilderXpInfo({
|
||||
baseXp,
|
||||
relevantCount,
|
||||
count,
|
||||
adjustedXp,
|
||||
crCutoff,
|
||||
playerCount: partyMeta.cntPlayers,
|
||||
playerAdjustedXpMult,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class EncounterBuilderCandidateEncounter {
|
||||
/**
|
||||
* @param {Array<EncounterBuilderCreatureMeta>} lockedEncounterCreatures
|
||||
*/
|
||||
constructor ({lockedEncounterCreatures = []} = {}) {
|
||||
this.skipCount = 0;
|
||||
this._lockedEncounterCreatures = lockedEncounterCreatures;
|
||||
this._creatureMetas = [...lockedEncounterCreatures];
|
||||
}
|
||||
|
||||
hasCreatures () { return !!this._creatureMetas.length; }
|
||||
|
||||
getCreatureMetas ({xp = null, isSkipLocked = false} = {}) {
|
||||
return this._creatureMetas
|
||||
.filter(creatureMeta => {
|
||||
if (isSkipLocked && creatureMeta.isLocked) return false;
|
||||
return xp == null || creatureMeta.getXp() === xp;
|
||||
});
|
||||
}
|
||||
|
||||
getEncounterXpInfo ({partyMeta}) {
|
||||
return EncounterBuilderCreatureMeta.getEncounterXpInfo(this._creatureMetas, partyMeta);
|
||||
}
|
||||
|
||||
addCreatureMeta (creatureMeta) {
|
||||
const existingMeta = this._creatureMetas.find(it => it.isSameCreature(creatureMeta));
|
||||
if (existingMeta?.isLocked) return false;
|
||||
|
||||
if (existingMeta) {
|
||||
existingMeta.count++;
|
||||
return true;
|
||||
}
|
||||
|
||||
this._creatureMetas.push(creatureMeta);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try to add another copy of an existing creature
|
||||
tryIncreaseExistingCreatureCount ({xp}) {
|
||||
const existingMetas = this.getCreatureMetas({isSkipLocked: true, xp});
|
||||
if (!existingMetas.length) return false;
|
||||
|
||||
const roll = RollerUtil.roll(100);
|
||||
const chance = this._getChanceToAddNewCreature();
|
||||
if (roll < chance) return false;
|
||||
|
||||
RollerUtil.rollOnArray(existingMetas).count++;
|
||||
return true;
|
||||
}
|
||||
|
||||
_getChanceToAddNewCreature () {
|
||||
if (this._creatureMetas.length === 0) return 0;
|
||||
|
||||
// Soft-cap at 5 creatures
|
||||
if (this._creatureMetas.length >= 5) return 2;
|
||||
|
||||
/*
|
||||
* 1 -> 80% chance to add new
|
||||
* 2 -> 40%
|
||||
* 3 -> 27%
|
||||
* 4 -> 20%
|
||||
*/
|
||||
return Math.round(80 / this._creatureMetas.length);
|
||||
}
|
||||
|
||||
isCreatureLocked (mon) {
|
||||
return this._lockedEncounterCreatures
|
||||
.some(creatureMeta => creatureMeta.customHashId == null && creatureMeta.getHash() === UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_BESTIARY](mon));
|
||||
}
|
||||
}
|
||||
|
||||
export class EncounterPartyPlayerMeta {
|
||||
constructor ({level, count}) {
|
||||
this.level = level;
|
||||
this.count = count;
|
||||
}
|
||||
|
||||
getXpToNextLevel () {
|
||||
const ixCur = Math.min(Math.max(0, this.level - 1), VeCt.LEVEL_MAX - 1);
|
||||
const ixNxt = Math.min(ixCur + 1, VeCt.LEVEL_MAX - 1);
|
||||
return (Parser.LEVEL_XP_REQUIRED[ixNxt] - Parser.LEVEL_XP_REQUIRED[ixCur]) * this.count;
|
||||
}
|
||||
}
|
||||
|
||||
export class EncounterPartyMeta {
|
||||
static getDefault () {
|
||||
return new this(
|
||||
[
|
||||
new EncounterPartyPlayerMeta({level: 1, count: 1}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Array<EncounterPartyPlayerMeta>} playerMetas
|
||||
*/
|
||||
constructor (playerMetas) {
|
||||
/** @type {Array<EncounterPartyPlayerMeta>} */
|
||||
this.levelMetas = [];
|
||||
|
||||
// Combine such that each `level` has at most one entry, with the total count for players of that level
|
||||
playerMetas.forEach(it => {
|
||||
const existingLvl = this.levelMetas.find(x => x.level === it.level);
|
||||
if (existingLvl) existingLvl.count += it.count;
|
||||
else this.levelMetas.push(new EncounterPartyPlayerMeta({count: it.count, level: it.level}));
|
||||
});
|
||||
|
||||
this.cntPlayers = 0;
|
||||
this.avgPlayerLevel = 0;
|
||||
this.maxPlayerLevel = 0;
|
||||
|
||||
this.threshEasy = 0;
|
||||
this.threshMedium = 0;
|
||||
this.threshHard = 0;
|
||||
this.threshDeadly = 0;
|
||||
this.threshAbsurd = 0;
|
||||
|
||||
this.dailyBudget = 0;
|
||||
this.xpToNextLevel = 0;
|
||||
|
||||
this.levelMetas
|
||||
.forEach(meta => {
|
||||
this.cntPlayers += meta.count;
|
||||
this.avgPlayerLevel += meta.level * meta.count;
|
||||
this.maxPlayerLevel = Math.max(this.maxPlayerLevel, meta.level);
|
||||
|
||||
this.threshEasy += Parser.LEVEL_TO_XP_EASY[meta.level] * meta.count;
|
||||
this.threshMedium += Parser.LEVEL_TO_XP_MEDIUM[meta.level] * meta.count;
|
||||
this.threshHard += Parser.LEVEL_TO_XP_HARD[meta.level] * meta.count;
|
||||
this.threshDeadly += Parser.LEVEL_TO_XP_DEADLY[meta.level] * meta.count;
|
||||
|
||||
this.dailyBudget += Parser.LEVEL_TO_XP_DAILY[meta.level] * meta.count;
|
||||
|
||||
this.xpToNextLevel += meta.getXpToNextLevel();
|
||||
});
|
||||
if (this.cntPlayers) this.avgPlayerLevel /= this.cntPlayers;
|
||||
|
||||
this.threshAbsurd = this.threshDeadly + (this.threshDeadly - this.threshHard);
|
||||
}
|
||||
|
||||
/** Return true if at least a third of the party is level 5+. */
|
||||
isPartyLevelFivePlus () {
|
||||
const [levelMetasHigher, levelMetasLower] = this.levelMetas.partition(it => it.level >= 5);
|
||||
const cntLower = levelMetasLower.map(it => it.count).reduce((a, b) => a + b, 0);
|
||||
const cntHigher = levelMetasHigher.map(it => it.count).reduce((a, b) => a + b, 0);
|
||||
return (cntHigher / (cntLower + cntHigher)) >= 0.333;
|
||||
}
|
||||
|
||||
// Expose these as getters to ease factoring elsewhere
|
||||
get easy () { return this.threshEasy; }
|
||||
get medium () { return this.threshMedium; }
|
||||
get hard () { return this.threshHard; }
|
||||
get deadly () { return this.threshDeadly; }
|
||||
get absurd () { return this.threshAbsurd; }
|
||||
}
|
||||
93
js/encounterbuilder/encounterbuilder-playersadvanced.js
Normal file
93
js/encounterbuilder/encounterbuilder-playersadvanced.js
Normal file
@@ -0,0 +1,93 @@
|
||||
export class EncounterBuilderRenderableCollectionPlayersAdvanced extends RenderableCollectionGenericRows {
|
||||
constructor (
|
||||
{
|
||||
comp,
|
||||
rdState,
|
||||
},
|
||||
) {
|
||||
super(comp, "playersAdvanced", rdState.$wrpRowsAdvanced);
|
||||
}
|
||||
|
||||
_$getWrpRow () {
|
||||
return $(`<div class="ve-flex-v-center mb-2 ecgen-player__wrp-row"></div>`);
|
||||
}
|
||||
|
||||
_populateRow ({comp, $wrpRow, entity}) {
|
||||
const $iptName = ComponentUiUtil.$getIptStr(comp, "name")
|
||||
.addClass(`w-100p form-control--minimal no-shrink mr-1`);
|
||||
|
||||
const $iptLevel = ComponentUiUtil.$getIptInt(
|
||||
comp,
|
||||
"level",
|
||||
1,
|
||||
{
|
||||
min: 1,
|
||||
max: 20,
|
||||
fallbackOnNaN: 1,
|
||||
},
|
||||
).addClass("w-40p form-control--minimal no-shrink mr-1 ve-text-center");
|
||||
|
||||
const $wrpIptsExtra = $(`<div class="ve-flex-v-center"></div>`);
|
||||
const collectionExtras = new EncounterBuilderRenderableCollectionPlayerAdvancedExtras({
|
||||
comp,
|
||||
$wrpIptsExtra,
|
||||
});
|
||||
const hkExtras = () => collectionExtras.render();
|
||||
comp._addHookBase("extras", hkExtras);
|
||||
hkExtras();
|
||||
|
||||
const $btnRemove = this._utils.$getBtnDelete({entity, title: "Remove Player"})
|
||||
.addClass("ecgen-player__btn-inline h-ipt-xs no-shrink ml-n1 bl-0 bbl-0 btl-0")
|
||||
.attr("tabindex", "-1");
|
||||
|
||||
$$($wrpRow)`
|
||||
${$iptName}
|
||||
${$iptLevel}
|
||||
${$wrpIptsExtra}
|
||||
${$btnRemove}
|
||||
`;
|
||||
|
||||
return {
|
||||
$wrpIptsExtra,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class EncounterBuilderRenderableCollectionPlayerAdvancedExtras extends RenderableCollectionBase {
|
||||
constructor (
|
||||
{
|
||||
comp,
|
||||
|
||||
$wrpIptsExtra,
|
||||
},
|
||||
) {
|
||||
super(comp, "extras");
|
||||
this._$wrpIptsExtra = $wrpIptsExtra;
|
||||
}
|
||||
|
||||
getNewRender (extra, i) {
|
||||
const comp = BaseComponent.fromObject(extra.entity, "*");
|
||||
comp._addHookAll("state", () => {
|
||||
this._getCollectionItem(extra.id).entity = comp.toObject("*");
|
||||
this._comp._triggerCollectionUpdate("extras");
|
||||
});
|
||||
|
||||
const $iptVal = ComponentUiUtil.$getIptStr(comp, "value")
|
||||
.addClass(`w-40p no-shrink form-control--minimal ve-text-center mr-1`);
|
||||
|
||||
const $wrpRow = $$`<div class="ve-flex-v-h-center">
|
||||
${$iptVal}
|
||||
</div>`
|
||||
.appendTo(this._$wrpIptsExtra);
|
||||
|
||||
return {
|
||||
comp,
|
||||
$wrpRow,
|
||||
};
|
||||
}
|
||||
|
||||
doUpdateExistingRender (renderedMeta, extra, i) {
|
||||
renderedMeta.comp._proxyAssignSimple("state", extra.entity, true);
|
||||
if (!renderedMeta.$wrpRow.parent().is(this._$wrpIptsExtra)) renderedMeta.$wrpRow.appendTo(this._$wrpIptsExtra);
|
||||
}
|
||||
}
|
||||
42
js/encounterbuilder/encounterbuilder-playerssimple.js
Normal file
42
js/encounterbuilder/encounterbuilder-playerssimple.js
Normal file
@@ -0,0 +1,42 @@
|
||||
export class EncounterBuilderRenderableCollectionPlayersSimple extends RenderableCollectionGenericRows {
|
||||
constructor (
|
||||
{
|
||||
comp,
|
||||
rdState,
|
||||
},
|
||||
) {
|
||||
super(comp, "playersSimple", rdState.$wrpRowsSimple);
|
||||
}
|
||||
|
||||
_$getWrpRow () {
|
||||
return $(`<div class="ve-flex-v-center mb-2 ecgen-player__wrp-row"></div>`);
|
||||
}
|
||||
|
||||
_populateRow ({comp, $wrpRow, entity}) {
|
||||
const $selCount = ComponentUiUtil.$getSelEnum(
|
||||
comp,
|
||||
"count",
|
||||
{
|
||||
values: [...new Array(12)].map((_, i) => i + 1),
|
||||
},
|
||||
).addClass("form-control--minimal no-shrink");
|
||||
|
||||
const $selLevel = ComponentUiUtil.$getSelEnum(
|
||||
comp,
|
||||
"level",
|
||||
{
|
||||
values: [...new Array(20)].map((_, i) => i + 1),
|
||||
},
|
||||
).addClass("form-control--minimal no-shrink bl-0");
|
||||
|
||||
const $btnRemove = this._utils.$getBtnDelete({entity, title: "Remove Player Group"})
|
||||
.addClass("ecgen-player__btn-inline h-ipt-xs no-shrink bl-0 bbl-0 btl-0")
|
||||
.attr("tabindex", "-1");
|
||||
|
||||
$$($wrpRow)`
|
||||
<div class="w-20">${$selCount}</div>
|
||||
<div class="w-20">${$selLevel}</div>
|
||||
<div class="ve-flex-v-center">${$btnRemove}</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
206
js/encounterbuilder/encounterbuilder-randomizer.js
Normal file
206
js/encounterbuilder/encounterbuilder-randomizer.js
Normal file
@@ -0,0 +1,206 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
82
js/encounterbuilder/encounterbuilder-ui-help.js
Normal file
82
js/encounterbuilder/encounterbuilder-ui-help.js
Normal file
@@ -0,0 +1,82 @@
|
||||
export class EncounterBuilderUiHelp {
|
||||
static getHelpEntry ({partyMeta, encounterXpInfo}) {
|
||||
// TODO(Future) update this based on the actual method being used
|
||||
return {
|
||||
type: "entries",
|
||||
entries: [
|
||||
`{@b Adjusted by a ${encounterXpInfo.playerAdjustedXpMult}× multiplier, based on a minimum challenge rating threshold of approximately ${`${encounterXpInfo.crCutoff.toFixed(2)}`.replace(/[,.]?0+$/, "")}*†, and a party size of ${encounterXpInfo.playerCount} players.}`,
|
||||
// `{@note * If the maximum challenge rating is two or less, there is no minimum threshold. Similarly, if less than a third of the party are level 5 or higher, there is no minimum threshold. Otherwise, for each creature in the encounter, the average CR of the encounter is calculated while excluding that creature. The highest of these averages is then halved to produce a minimum CR threshold. CRs less than this minimum are ignored for the purposes of calculating the final CR multiplier.}`,
|
||||
`{@note * If the maximum challenge rating is two or less, there is no minimum threshold. Similarly, if less than a third of the party are level 5 or higher, there is no minimum threshold. Otherwise, for each creature in the encounter in lowest-to-highest CR order, the average CR of the encounter is calculated while excluding that creature. Then, if the removed creature's CR is more than one deviation less than this average, the process repeats. Once the process halts, this threshold value (average minus one deviation) becomes the final CR cutoff.}`,
|
||||
`<hr>`,
|
||||
{
|
||||
type: "quote",
|
||||
entries: [
|
||||
`† [...] don't count any monsters whose challenge rating is significantly below the average challenge rating of the other monsters in the group [...]`,
|
||||
],
|
||||
"by": "{@book Dungeon Master's Guide, page 82|DMG|3|4 Modify Total XP for Multiple Monsters}",
|
||||
},
|
||||
`<hr>`,
|
||||
{
|
||||
"type": "table",
|
||||
"caption": "Encounter Multipliers",
|
||||
"colLabels": [
|
||||
"Number of Monsters",
|
||||
"Multiplier",
|
||||
],
|
||||
"colStyles": [
|
||||
"col-6 text-center",
|
||||
"col-6 text-center",
|
||||
],
|
||||
"rows": [
|
||||
[
|
||||
"1",
|
||||
"×1",
|
||||
],
|
||||
[
|
||||
"2",
|
||||
"×1.5",
|
||||
],
|
||||
[
|
||||
"3-6",
|
||||
"×2",
|
||||
],
|
||||
[
|
||||
"7-10",
|
||||
"×2.5",
|
||||
],
|
||||
[
|
||||
"11-14",
|
||||
"×3",
|
||||
],
|
||||
[
|
||||
"15 or more",
|
||||
"×4",
|
||||
],
|
||||
],
|
||||
},
|
||||
...(partyMeta.cntPlayers < 3
|
||||
? [
|
||||
{
|
||||
type: "quote",
|
||||
entries: [
|
||||
"If the party contains fewer than three characters, apply the next highest multiplier on the Encounter Multipliers table.",
|
||||
],
|
||||
"by": "{@book Dungeon Master's Guide, page 83|DMG|3|Party Size}",
|
||||
},
|
||||
]
|
||||
: partyMeta.cntPlayers >= 6
|
||||
? [
|
||||
{
|
||||
type: "quote",
|
||||
entries: [
|
||||
"If the party contains six or more characters, use the next lowest multiplier on the table. Use a multiplier of 0.5 for a single monster.",
|
||||
],
|
||||
"by": "{@book Dungeon Master's Guide, page 83|DMG|3|Party Size}",
|
||||
},
|
||||
]
|
||||
: []
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
43
js/encounterbuilder/encounterbuilder-ui-ttk.js
Normal file
43
js/encounterbuilder/encounterbuilder-ui-ttk.js
Normal file
@@ -0,0 +1,43 @@
|
||||
export class EncounterBuilderUiTtk {
|
||||
static getApproxTurnsToKill ({partyMeta, creatureMetas}) {
|
||||
const playerMetas = partyMeta.levelMetas;
|
||||
if (!playerMetas.length) return 0;
|
||||
|
||||
const totalDpt = playerMetas
|
||||
.map(playerMeta => this._getApproxDpt(playerMeta.level) * playerMeta.count)
|
||||
.sum();
|
||||
|
||||
const totalHp = creatureMetas
|
||||
.map(creatureMeta => {
|
||||
const approxHp = creatureMeta.getApproxHp();
|
||||
const approxAc = creatureMeta.getApproxAc();
|
||||
|
||||
if (approxHp == null || approxAc == null) return 0;
|
||||
|
||||
return (approxHp * (approxAc / 10)) * creatureMeta.count;
|
||||
})
|
||||
.sum();
|
||||
|
||||
return totalHp / totalDpt;
|
||||
}
|
||||
|
||||
static _APPROX_OUTPUT_FIGHTER_CHAMPION = [
|
||||
{hit: 0, dmg: 17.38}, {hit: 0, dmg: 17.38}, {hit: 0, dmg: 17.59}, {hit: 0, dmg: 33.34}, {hit: 1, dmg: 50.92}, {hit: 2, dmg: 53.92}, {hit: 2, dmg: 53.92}, {hit: 3, dmg: 56.92}, {hit: 4, dmg: 56.92}, {hit: 4, dmg: 56.92}, {hit: 4, dmg: 76.51}, {hit: 4, dmg: 76.51}, {hit: 5, dmg: 76.51}, {hit: 5, dmg: 76.51}, {hit: 5, dmg: 77.26}, {hit: 5, dmg: 77.26}, {hit: 6, dmg: 77.26}, {hit: 6, dmg: 77.26}, {hit: 6, dmg: 77.26}, {hit: 6, dmg: 97.06},
|
||||
];
|
||||
static _APPROX_OUTPUT_ROGUE_TRICKSTER = [
|
||||
{hit: 5, dmg: 11.4}, {hit: 5, dmg: 11.4}, {hit: 10, dmg: 15.07}, {hit: 11, dmg: 16.07}, {hit: 12, dmg: 24.02}, {hit: 12, dmg: 24.02}, {hit: 12, dmg: 27.7}, {hit: 13, dmg: 28.7}, {hit: 14, dmg: 32.38}, {hit: 14, dmg: 32.38}, {hit: 14, dmg: 40.33}, {hit: 14, dmg: 40.33}, {hit: 15, dmg: 44}, {hit: 15, dmg: 44}, {hit: 15, dmg: 47.67}, {hit: 15, dmg: 47.67}, {hit: 16, dmg: 55.63}, {hit: 16, dmg: 55.63}, {hit: 16, dmg: 59.3}, {hit: 16, dmg: 59.3},
|
||||
];
|
||||
static _APPROX_OUTPUT_WIZARD = [
|
||||
{hit: 5, dmg: 14.18}, {hit: 5, dmg: 14.18}, {hit: 5, dmg: 22.05}, {hit: 6, dmg: 22.05}, {hit: 2, dmg: 28}, {hit: 2, dmg: 28}, {hit: 2, dmg: 36}, {hit: 3, dmg: 36}, {hit: 6, dmg: 67.25}, {hit: 6, dmg: 67.25}, {hit: 4, dmg: 75}, {hit: 4, dmg: 75}, {hit: 5, dmg: 85.5}, {hit: 5, dmg: 85.5}, {hit: 5, dmg: 96}, {hit: 5, dmg: 96}, {hit: 6, dmg: 140}, {hit: 6, dmg: 140}, {hit: 6, dmg: 140}, {hit: 6, dmg: 140},
|
||||
];
|
||||
static _APPROX_OUTPUT_CLERIC = [
|
||||
{hit: 5, dmg: 17.32}, {hit: 5, dmg: 17.32}, {hit: 5, dmg: 23.1}, {hit: 6, dmg: 23.1}, {hit: 7, dmg: 28.88}, {hit: 7, dmg: 28.88}, {hit: 7, dmg: 34.65}, {hit: 8, dmg: 34.65}, {hit: 9, dmg: 40.42}, {hit: 9, dmg: 40.42}, {hit: 9, dmg: 46.2}, {hit: 9, dmg: 46.2}, {hit: 10, dmg: 51.98}, {hit: 10, dmg: 51.98}, {hit: 11, dmg: 57.75}, {hit: 11, dmg: 57.75}, {hit: 11, dmg: 63.52}, {hit: 11, dmg: 63.52}, {hit: 11, dmg: 63.52}, {hit: 11, dmg: 63.52},
|
||||
];
|
||||
|
||||
static _APPROX_OUTPUTS = [this._APPROX_OUTPUT_FIGHTER_CHAMPION, this._APPROX_OUTPUT_ROGUE_TRICKSTER, this._APPROX_OUTPUT_WIZARD, this._APPROX_OUTPUT_CLERIC];
|
||||
|
||||
static _getApproxDpt (pcLevel) {
|
||||
const approxOutput = this._APPROX_OUTPUTS.map(it => it[pcLevel - 1]);
|
||||
return approxOutput.map(it => it.dmg * ((it.hit + 10.5) / 20)).mean(); // 10.5 = average d20
|
||||
}
|
||||
}
|
||||
647
js/encounterbuilder/encounterbuilder-ui.js
Normal file
647
js/encounterbuilder/encounterbuilder-ui.js
Normal file
@@ -0,0 +1,647 @@
|
||||
import {EncounterBuilderRandomizer} from "./encounterbuilder-randomizer.js";
|
||||
import {EncounterBuilderCreatureMeta, EncounterBuilderXpInfo, EncounterPartyMeta, EncounterPartyPlayerMeta} from "./encounterbuilder-models.js";
|
||||
import {EncounterBuilderUiTtk} from "./encounterbuilder-ui-ttk.js";
|
||||
import {EncounterBuilderUiHelp} from "./encounterbuilder-ui-help.js";
|
||||
import {EncounterBuilderRenderableCollectionPlayersSimple} from "./encounterbuilder-playerssimple.js";
|
||||
import {EncounterBuilderRenderableCollectionColsExtraAdvanced} from "./encounterbuilder-colsextraadvanced.js";
|
||||
import {EncounterBuilderRenderableCollectionPlayersAdvanced} from "./encounterbuilder-playersadvanced.js";
|
||||
import {EncounterBuilderAdjuster} from "./encounterbuilder-adjuster.js";
|
||||
|
||||
/**
|
||||
* TODO rework this to use doubled multipliers for XP, so we avoid the 0.5x issue for 6+ party sizes. Then scale
|
||||
* everything back down at the end.
|
||||
*/
|
||||
export class EncounterBuilderUi extends BaseComponent {
|
||||
static _RenderState = class {
|
||||
constructor () {
|
||||
this.$wrpRowsSimple = null;
|
||||
this.$wrpRowsAdvanced = null;
|
||||
this.$wrpHeadersAdvanced = null;
|
||||
this.$wrpFootersAdvanced = null;
|
||||
|
||||
this.infoHoverId = null;
|
||||
|
||||
this._collectionPlayersSimple = null;
|
||||
this._collectionColsExtraAdvanced = null;
|
||||
this._collectionPlayersAdvanced = null;
|
||||
}
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
_cache = null;
|
||||
_comp = null;
|
||||
|
||||
constructor ({cache, comp}) {
|
||||
super();
|
||||
|
||||
this._cache = cache;
|
||||
this._comp = comp;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {?jQuery} $parentRandomAndAdjust
|
||||
* @param {?jQuery} $parentViewer
|
||||
* @param {?jQuery} $parentGroupAndDifficulty
|
||||
*/
|
||||
render (
|
||||
{
|
||||
$parentRandomAndAdjust = null,
|
||||
$parentViewer = null,
|
||||
$parentGroupAndDifficulty = null,
|
||||
},
|
||||
) {
|
||||
const rdState = new this.constructor._RenderState();
|
||||
|
||||
this._render_randomAndAdjust({rdState, $parentRandomAndAdjust});
|
||||
this._render_viewer({rdState, $parentViewer});
|
||||
this._render_groupAndDifficulty({rdState, $parentGroupAndDifficulty});
|
||||
this._render_addHooks({rdState});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
_render_randomAndAdjust ({$parentRandomAndAdjust}) {
|
||||
const {
|
||||
$btnRandom,
|
||||
$btnRandomMode,
|
||||
$liRandomEasy,
|
||||
$liRandomMedium,
|
||||
$liRandomHard,
|
||||
$liRandomDeadly,
|
||||
} = this._render_randomAndAdjust_getRandomMeta();
|
||||
|
||||
const {
|
||||
$btnAdjust,
|
||||
$btnAdjustMode,
|
||||
$liAdjustEasy,
|
||||
$liAdjustMedium,
|
||||
$liAdjustHard,
|
||||
$liAdjustDeadly,
|
||||
} = this._render_randomAndAdjust_getAdjustMeta();
|
||||
|
||||
$$($parentRandomAndAdjust)`<div class="ve-flex-col">
|
||||
<div class="ve-flex-h-right">
|
||||
<div class="btn-group mr-3">
|
||||
${$btnRandom}
|
||||
${$btnRandomMode}
|
||||
<ul class="dropdown-menu">
|
||||
${$liRandomEasy}
|
||||
${$liRandomMedium}
|
||||
${$liRandomHard}
|
||||
${$liRandomDeadly}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
${$btnAdjust}
|
||||
${$btnAdjustMode}
|
||||
<ul class="dropdown-menu">
|
||||
${$liAdjustEasy}
|
||||
${$liAdjustMedium}
|
||||
${$liAdjustHard}
|
||||
${$liAdjustDeadly}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_render_randomAndAdjust_getRandomMeta () {
|
||||
let modeRandom = "medium";
|
||||
|
||||
const pSetRandomMode = async (mode) => {
|
||||
const randomizer = new EncounterBuilderRandomizer({
|
||||
partyMeta: this._getPartyMeta(),
|
||||
cache: this._cache,
|
||||
});
|
||||
const randomCreatureMetas = await randomizer.pGetRandomEncounter({
|
||||
difficulty: mode,
|
||||
lockedEncounterCreatures: this._comp.creatureMetas.filter(creatureMeta => creatureMeta.isLocked),
|
||||
});
|
||||
|
||||
if (randomCreatureMetas != null) this._comp.creatureMetas = randomCreatureMetas;
|
||||
|
||||
modeRandom = mode;
|
||||
$btnRandom
|
||||
.text(`Random ${mode.toTitleCase()}`)
|
||||
.title(`Randomly generate ${Parser.getArticle(mode)} ${mode.toTitleCase()} encounter`);
|
||||
};
|
||||
|
||||
const $getLiRandom = (mode) => {
|
||||
return $(`<li title="Randomly generate ${Parser.getArticle(mode)} ${mode.toTitleCase()} encounter"><a href="#">Random ${mode.toTitleCase()}</a></li>`)
|
||||
.click(async (evt) => {
|
||||
evt.preventDefault();
|
||||
await pSetRandomMode(mode);
|
||||
});
|
||||
};
|
||||
|
||||
const $btnRandom = $(`<button class="btn btn-primary ecgen__btn-random-adjust" title="Randomly generate a Medium encounter">Random Medium</button>`)
|
||||
.click(async evt => {
|
||||
evt.preventDefault();
|
||||
await pSetRandomMode(modeRandom);
|
||||
});
|
||||
|
||||
const $btnRandomMode = $(`<button class="btn btn-primary dropdown-toggle"><span class="caret"></span></button>`);
|
||||
JqueryUtil.bindDropdownButton($btnRandomMode);
|
||||
|
||||
return {
|
||||
$btnRandom,
|
||||
$btnRandomMode,
|
||||
$liRandomEasy: $getLiRandom("easy"),
|
||||
$liRandomMedium: $getLiRandom("medium"),
|
||||
$liRandomHard: $getLiRandom("hard"),
|
||||
$liRandomDeadly: $getLiRandom("deadly"),
|
||||
};
|
||||
}
|
||||
|
||||
_render_randomAndAdjust_getAdjustMeta () {
|
||||
let modeAdjust = "medium";
|
||||
|
||||
const pSetAdjustMode = async (mode) => {
|
||||
const adjuster = new EncounterBuilderAdjuster({
|
||||
partyMeta: this._getPartyMeta(),
|
||||
});
|
||||
const adjustedCreatureMetas = await adjuster.pGetAdjustedEncounter({
|
||||
difficulty: mode,
|
||||
creatureMetas: this._comp.creatureMetas,
|
||||
});
|
||||
|
||||
if (adjustedCreatureMetas != null) this._comp.creatureMetas = adjustedCreatureMetas;
|
||||
|
||||
modeAdjust = mode;
|
||||
$btnAdjust
|
||||
.text(`Adjust to ${mode.toTitleCase()}`)
|
||||
.title(`Adjust the current encounter difficulty to ${mode.toTitleCase()}`);
|
||||
};
|
||||
|
||||
const $getLiAdjust = (mode) => {
|
||||
return $(`<li title="Adjust the current encounter difficulty to ${mode.toTitleCase()}"><a href="#">Adjust to ${mode.toTitleCase()}</a></li>`)
|
||||
.click(async (evt) => {
|
||||
evt.preventDefault();
|
||||
await pSetAdjustMode(mode);
|
||||
});
|
||||
};
|
||||
|
||||
const $btnAdjust = $(`<button class="btn btn-primary ecgen__btn-random-adjust" title="Adjust the current encounter difficulty to Medium">Adjust to Medium</button>`)
|
||||
.click(async evt => {
|
||||
evt.preventDefault();
|
||||
await pSetAdjustMode(modeAdjust);
|
||||
});
|
||||
|
||||
const $btnAdjustMode = $(`<button class="btn btn-primary dropdown-toggle"><span class="caret"></span></button>`);
|
||||
JqueryUtil.bindDropdownButton($btnAdjustMode);
|
||||
|
||||
return {
|
||||
$btnAdjust,
|
||||
$btnAdjustMode,
|
||||
$liAdjustEasy: $getLiAdjust("easy"),
|
||||
$liAdjustMedium: $getLiAdjust("medium"),
|
||||
$liAdjustHard: $getLiAdjust("hard"),
|
||||
$liAdjustDeadly: $getLiAdjust("deadly"),
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
_render_viewer ({$parentViewer}) {
|
||||
if (!$parentViewer) return;
|
||||
|
||||
const $wrpOutput = $(`<div class="py-2 mt-5" style="background: #333"></div>`);
|
||||
|
||||
$$($parentViewer)`${$wrpOutput}`;
|
||||
|
||||
this._comp.addHookCreatureMetas(() => {
|
||||
const $lis = this._comp.creatureMetas
|
||||
.map(creatureMeta => {
|
||||
const $btnShuffle = $(`<button class="btn btn-default btn-xs"><span class="glyphicon glyphicon-random"></span></button>`)
|
||||
.click(() => {
|
||||
this._doShuffle({creatureMeta});
|
||||
});
|
||||
|
||||
return $$`<li>${$btnShuffle} <span>${Renderer.get().render(`${creatureMeta.count}× {@creature ${creatureMeta.creature.name}|${creatureMeta.creature.source}}`)}</span></li>`;
|
||||
});
|
||||
|
||||
$$($wrpOutput.empty())`<ul>
|
||||
${$lis}
|
||||
</ul>`;
|
||||
})();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
_render_groupAndDifficulty ({rdState, $parentGroupAndDifficulty}) {
|
||||
const {
|
||||
$stg: $stgSimple,
|
||||
$wrpRows: $wrpRowsSimple,
|
||||
} = this._renderGroupAndDifficulty_getGroupEles_simple();
|
||||
rdState.$wrpRowsSimple = $wrpRowsSimple;
|
||||
|
||||
const {
|
||||
$stg: $stgAdvanced,
|
||||
$wrpRows: $wrpRowsAdvanced,
|
||||
$wrpHeaders: $wrpHeadersAdvanced,
|
||||
$wrpFooters: $wrpFootersAdvanced,
|
||||
} = this._renderGroupAndDifficulty_getGroupEles_advanced();
|
||||
rdState.$wrpRowsAdvanced = $wrpRowsAdvanced;
|
||||
rdState.$wrpHeadersAdvanced = $wrpHeadersAdvanced;
|
||||
rdState.$wrpFootersAdvanced = $wrpFootersAdvanced;
|
||||
|
||||
const $hrHasCreatures = $(`<hr class="hr-1">`);
|
||||
const $wrpDifficulty = $$`<div class="ve-flex">
|
||||
${this._renderGroupAndDifficulty_$getDifficultyLhs()}
|
||||
${this._renderGroupAndDifficulty_$getDifficultyRhs({rdState})}
|
||||
</div>`;
|
||||
|
||||
this._addHookBase("derivedGroupAndDifficulty", () => {
|
||||
const {
|
||||
encounterXpInfo = EncounterBuilderXpInfo.getDefault(),
|
||||
} = this._state.derivedGroupAndDifficulty;
|
||||
$hrHasCreatures.toggleVe(encounterXpInfo.relevantCount);
|
||||
$wrpDifficulty.toggleVe(encounterXpInfo.relevantCount);
|
||||
})();
|
||||
|
||||
$$($parentGroupAndDifficulty)`
|
||||
<h3 class="mt-1 m-2">Group Info</h3>
|
||||
<div class="ve-flex">
|
||||
${$stgSimple}
|
||||
${$stgAdvanced}
|
||||
${this._renderGroupAndDifficulty_$getGroupInfoRhs()}
|
||||
</div>
|
||||
|
||||
${$hrHasCreatures}
|
||||
${$wrpDifficulty}`;
|
||||
|
||||
rdState.collectionPlayersSimple = new EncounterBuilderRenderableCollectionPlayersSimple({
|
||||
comp: this._comp,
|
||||
rdState,
|
||||
});
|
||||
|
||||
rdState.collectionColsExtraAdvanced = new EncounterBuilderRenderableCollectionColsExtraAdvanced({
|
||||
comp: this._comp,
|
||||
rdState,
|
||||
});
|
||||
|
||||
rdState.collectionPlayersAdvanced = new EncounterBuilderRenderableCollectionPlayersAdvanced({
|
||||
comp: this._comp,
|
||||
rdState,
|
||||
});
|
||||
}
|
||||
|
||||
_renderGroupAndDifficulty_getGroupEles_simple () {
|
||||
const $btnAddPlayers = $(`<button class="btn btn-primary btn-xs"><span class="glyphicon glyphicon-plus"></span> Add Another Level</button>`)
|
||||
.click(() => this._comp.doAddPlayer());
|
||||
|
||||
const $wrpRows = $(`<div class="ve-flex-col w-100"></div>`);
|
||||
|
||||
const $stg = $$`<div class="w-70 ve-flex-col">
|
||||
<div class="ve-flex">
|
||||
<div class="w-20">Players:</div>
|
||||
<div class="w-20">Level:</div>
|
||||
</div>
|
||||
|
||||
${$wrpRows}
|
||||
|
||||
<div class="mb-1 ve-flex">
|
||||
<div class="ecgen__wrp_add_players_btn_wrp">
|
||||
${$btnAddPlayers}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._renderGroupAndDifficulty_$getPtAdvancedMode()}
|
||||
|
||||
</div>`;
|
||||
|
||||
this._comp.addHookIsAdvanced(() => {
|
||||
$stg.toggleVe(!this._comp.isAdvanced);
|
||||
})();
|
||||
|
||||
return {
|
||||
$wrpRows,
|
||||
$stg,
|
||||
};
|
||||
}
|
||||
|
||||
_renderGroupAndDifficulty_getGroupEles_advanced () {
|
||||
const $btnAddPlayers = $(`<button class="btn btn-primary btn-xs"><span class="glyphicon glyphicon-plus"></span> Add Another Player</button>`)
|
||||
.click(() => this._comp.doAddPlayer());
|
||||
|
||||
const $btnAddAdvancedCol = $(`<button class="btn btn-primary btn-xxs ecgen-player__btn-inline h-ipt-xs bl-0 bb-0 bbl-0 bbr-0 btl-0 ml-n1" title="Add Column" tabindex="-1"><span class="glyphicon glyphicon-list-alt"></span></button>`)
|
||||
.click(() => this._comp.doAddColExtraAdvanced());
|
||||
|
||||
const $wrpHeaders = $(`<div class="ve-flex"></div>`);
|
||||
const $wrpFooters = $(`<div class="ve-flex"></div>`);
|
||||
|
||||
const $wrpRows = $(`<div class="ve-flex-col"></div>`);
|
||||
|
||||
const $stg = $$`<div class="w-70 overflow-x-auto ve-flex-col">
|
||||
<div class="ve-flex-h-center mb-2 bb-1p small-caps ve-self-flex-start">
|
||||
<div class="w-100p mr-1 h-ipt-xs no-shrink">Name</div>
|
||||
<div class="w-40p ve-text-center mr-1 h-ipt-xs no-shrink">Level</div>
|
||||
${$wrpHeaders}
|
||||
${$btnAddAdvancedCol}
|
||||
</div>
|
||||
|
||||
${$wrpRows}
|
||||
|
||||
<div class="mb-1 ve-flex">
|
||||
<div class="ecgen__wrp_add_players_btn_wrp no-shrink no-grow">
|
||||
${$btnAddPlayers}
|
||||
</div>
|
||||
${$wrpFooters}
|
||||
</div>
|
||||
|
||||
${this._renderGroupAndDifficulty_$getPtAdvancedMode()}
|
||||
|
||||
<div class="row">
|
||||
<div class="w-100">
|
||||
${Renderer.get().render(`{@note Additional columns will be imported into the DM Screen.}`)}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
this._comp.addHookIsAdvanced(() => {
|
||||
$stg.toggleVe(this._comp.isAdvanced);
|
||||
})();
|
||||
|
||||
return {
|
||||
$stg,
|
||||
$wrpRows,
|
||||
$wrpHeaders,
|
||||
$wrpFooters,
|
||||
};
|
||||
}
|
||||
|
||||
_renderGroupAndDifficulty_$getPtAdvancedMode () {
|
||||
const $cbAdvanced = ComponentUiUtil.$getCbBool(this._comp, "isAdvanced");
|
||||
|
||||
return $$`<div class="ve-flex-v-center">
|
||||
<label class="ve-flex-v-center">
|
||||
<div class="mr-2">Advanced Mode</div>
|
||||
${$cbAdvanced}
|
||||
</label>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
static _TITLE_DIFFICULTIES = {
|
||||
easy: "An easy encounter doesn't tax the characters' resources or put them in serious peril. They might lose a few hit points, but victory is pretty much guaranteed.",
|
||||
medium: "A medium encounter usually has one or two scary moments for the players, but the characters should emerge victorious with no casualties. One or more of them might need to use healing resources.",
|
||||
hard: "A hard encounter could go badly for the adventurers. Weaker characters might get taken out of the fight, and there's a slim chance that one or more characters might die.",
|
||||
deadly: "A deadly encounter could be lethal for one or more player characters. Survival often requires good tactics and quick thinking, and the party risks defeat",
|
||||
absurd: "An "absurd" encounter is a deadly encounter as per the rules, but is differentiated here to provide an additional tool for judging just how deadly a "deadly" encounter will be. It is calculated as: "deadly + (deadly - hard)".",
|
||||
};
|
||||
static _TITLE_BUDGET_DAILY = "This provides a rough estimate of the adjusted XP value for encounters the party can handle before the characters will need to take a long rest.";
|
||||
static _TITLE_XP_TO_NEXT_LEVEL = "The total XP required to allow each member of the party to level up to their next level.";
|
||||
static _TITLE_TTK = "Time to Kill: The estimated number of turns the party will require to defeat the encounter. This assumes single-target damage only.";
|
||||
|
||||
static _getDifficultyKey ({partyMeta, encounterXpInfo}) {
|
||||
if (encounterXpInfo.adjustedXp >= partyMeta.easy && encounterXpInfo.adjustedXp < partyMeta.medium) return "easy";
|
||||
if (encounterXpInfo.adjustedXp >= partyMeta.medium && encounterXpInfo.adjustedXp < partyMeta.hard) return "medium";
|
||||
if (encounterXpInfo.adjustedXp >= partyMeta.hard && encounterXpInfo.adjustedXp < partyMeta.deadly) return "hard";
|
||||
if (encounterXpInfo.adjustedXp >= partyMeta.deadly && encounterXpInfo.adjustedXp < partyMeta.absurd) return "deadly";
|
||||
if (encounterXpInfo.adjustedXp >= partyMeta.absurd) return "absurd";
|
||||
return "trivial";
|
||||
}
|
||||
|
||||
static _getDifficultyHtml ({partyMeta, difficulty}) {
|
||||
return `<span class="help-subtle" title="${this._TITLE_DIFFICULTIES[difficulty]}">${difficulty.toTitleCase()}:</span> ${partyMeta[difficulty].toLocaleString()} XP`;
|
||||
}
|
||||
|
||||
_renderGroupAndDifficulty_$getGroupInfoRhs () {
|
||||
const $dispXpEasy = $(`<div></div>`);
|
||||
const $dispXpMedium = $(`<div></div>`);
|
||||
const $dispXpHard = $(`<div></div>`);
|
||||
const $dispXpDeadly = $(`<div></div>`);
|
||||
const $dispXpAbsurd = $(`<div></div>`);
|
||||
|
||||
const $dispsXpDifficulty = {
|
||||
"easy": $dispXpEasy,
|
||||
"medium": $dispXpMedium,
|
||||
"hard": $dispXpHard,
|
||||
"deadly": $dispXpDeadly,
|
||||
"absurd": $dispXpAbsurd,
|
||||
};
|
||||
|
||||
const $dispTtk = $(`<div></div>`);
|
||||
|
||||
const $dispBudgetDaily = $(`<div></div>`);
|
||||
const $dispExpToLevel = $(`<div class="ve-muted"></div>`);
|
||||
|
||||
this._addHookBase("derivedGroupAndDifficulty", () => {
|
||||
const {
|
||||
partyMeta = EncounterPartyMeta.getDefault(),
|
||||
encounterXpInfo = EncounterBuilderXpInfo.getDefault(),
|
||||
} = this._state.derivedGroupAndDifficulty;
|
||||
|
||||
const difficulty = this.constructor._getDifficultyKey({partyMeta, encounterXpInfo});
|
||||
|
||||
Object.entries($dispsXpDifficulty)
|
||||
.forEach(([difficulty_, $disp]) => {
|
||||
$disp
|
||||
.toggleClass("bold", difficulty === difficulty_)
|
||||
.html(this.constructor._getDifficultyHtml({partyMeta, difficulty: difficulty_}));
|
||||
});
|
||||
|
||||
$dispTtk
|
||||
.html(`<span class="help" title="${this.constructor._TITLE_TTK}">TTK:</span> ${EncounterBuilderUiTtk.getApproxTurnsToKill({partyMeta, creatureMetas: this._comp.creatureMetas}).toFixed(2)}`);
|
||||
|
||||
$dispBudgetDaily
|
||||
.html(`<span class="help-subtle" title="${this.constructor._TITLE_BUDGET_DAILY}">Daily Budget:</span> ${partyMeta.dailyBudget.toLocaleString()} XP`);
|
||||
|
||||
$dispExpToLevel
|
||||
.html(`<span class="help-subtle" title="${this.constructor._TITLE_XP_TO_NEXT_LEVEL}">XP to Next Level:</span> ${partyMeta.xpToNextLevel.toLocaleString()} XP`);
|
||||
})();
|
||||
|
||||
return $$`<div class="w-30 text-right">
|
||||
${$dispXpEasy}
|
||||
${$dispXpMedium}
|
||||
${$dispXpHard}
|
||||
${$dispXpDeadly}
|
||||
${$dispXpAbsurd}
|
||||
<br>
|
||||
${$dispTtk}
|
||||
<br>
|
||||
${$dispBudgetDaily}
|
||||
${$dispExpToLevel}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_renderGroupAndDifficulty_$getDifficultyLhs () {
|
||||
const $dispDifficulty = $(`<h3 class="mt-2"></h3>`);
|
||||
|
||||
this._addHookBase("derivedGroupAndDifficulty", () => {
|
||||
const {
|
||||
partyMeta = EncounterPartyMeta.getDefault(),
|
||||
encounterXpInfo = EncounterBuilderXpInfo.getDefault(),
|
||||
} = this._state.derivedGroupAndDifficulty;
|
||||
|
||||
const difficulty = this.constructor._getDifficultyKey({partyMeta, encounterXpInfo});
|
||||
|
||||
$dispDifficulty.text(`Difficulty: ${difficulty.toTitleCase()}`);
|
||||
})();
|
||||
|
||||
return $$`<div class="w-50">
|
||||
${$dispDifficulty}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_renderGroupAndDifficulty_$getDifficultyRhs ({rdState}) {
|
||||
const $dispXpRawTotal = $(`<h4></h4>`);
|
||||
const $dispXpRawPerPlayer = $(`<i></i>`);
|
||||
|
||||
const $hovXpAdjustedInfo = $(`<span class="glyphicon glyphicon-info-sign mr-2"></span>`);
|
||||
|
||||
const $dispXpAdjustedTotal = $(`<h4 class="ve-flex-v-center"></h4>`);
|
||||
const $dispXpAdjustedPerPlayer = $(`<i></i>`);
|
||||
|
||||
this._addHookBase("derivedGroupAndDifficulty", () => {
|
||||
const {
|
||||
partyMeta = EncounterPartyMeta.getDefault(),
|
||||
encounterXpInfo = EncounterBuilderXpInfo.getDefault(),
|
||||
} = this._state.derivedGroupAndDifficulty;
|
||||
|
||||
$dispXpRawTotal.text(`Total XP: ${encounterXpInfo.baseXp.toLocaleString()}`);
|
||||
$dispXpRawPerPlayer.text(`(${Math.floor(encounterXpInfo.baseXp / partyMeta.cntPlayers).toLocaleString()} per player)`);
|
||||
|
||||
const infoEntry = EncounterBuilderUiHelp.getHelpEntry({partyMeta, encounterXpInfo});
|
||||
|
||||
if (rdState.infoHoverId == null) {
|
||||
const hoverMeta = Renderer.hover.getMakePredefinedHover(infoEntry, {isBookContent: true});
|
||||
rdState.infoHoverId = hoverMeta.id;
|
||||
|
||||
$hovXpAdjustedInfo
|
||||
.off("mouseover")
|
||||
.off("mousemove")
|
||||
.off("mouseleave")
|
||||
.on("mouseover", function (event) { hoverMeta.mouseOver(event, this); })
|
||||
.on("mousemove", function (event) { hoverMeta.mouseMove(event, this); })
|
||||
.on("mouseleave", function (event) { hoverMeta.mouseLeave(event, this); });
|
||||
} else {
|
||||
Renderer.hover.updatePredefinedHover(rdState.infoHoverId, infoEntry);
|
||||
}
|
||||
|
||||
$dispXpAdjustedTotal.html(`Adjusted XP <span class="ve-small ve-muted ml-2" title="XP Multiplier">(×${encounterXpInfo.playerAdjustedXpMult})</span>: <b class="ml-2">${encounterXpInfo.adjustedXp.toLocaleString()}</b>`);
|
||||
$dispXpAdjustedPerPlayer.text(`(${Math.floor(encounterXpInfo.adjustedXp / partyMeta.cntPlayers).toLocaleString()} per player)`);
|
||||
})();
|
||||
|
||||
return $$`<div class="w-50 text-right">
|
||||
${$dispXpRawTotal}
|
||||
<div>${$dispXpRawPerPlayer}</div>
|
||||
<div class="ve-flex-v-center ve-flex-h-right">${$hovXpAdjustedInfo}${$dispXpAdjustedTotal}</div>
|
||||
<div>${$dispXpAdjustedPerPlayer}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
_render_addHooks ({rdState}) {
|
||||
this._comp.addHookPlayersSimple((valNotFirstRun) => {
|
||||
rdState.collectionPlayersSimple.render();
|
||||
|
||||
if (valNotFirstRun == null) return;
|
||||
this._render_hk_setDerivedGroupAndDifficulty();
|
||||
this._render_hk_doUpdateExternalStates();
|
||||
})();
|
||||
|
||||
this._comp.addHookPlayersAdvanced((valNotFirstRun) => {
|
||||
rdState.collectionPlayersAdvanced.render();
|
||||
|
||||
if (valNotFirstRun == null) return;
|
||||
this._render_hk_setDerivedGroupAndDifficulty();
|
||||
this._render_hk_doUpdateExternalStates();
|
||||
})();
|
||||
|
||||
this._comp.addHookIsAdvanced((valNotFirstRun) => {
|
||||
if (valNotFirstRun == null) return;
|
||||
this._render_hk_setDerivedGroupAndDifficulty();
|
||||
this._render_hk_doUpdateExternalStates();
|
||||
})();
|
||||
|
||||
this._comp.addHookCreatureMetas(() => {
|
||||
this._render_hk_setDerivedGroupAndDifficulty();
|
||||
this._render_hk_doUpdateExternalStates();
|
||||
})();
|
||||
|
||||
this._comp.addHookColsExtraAdvanced(() => {
|
||||
rdState.collectionColsExtraAdvanced.render();
|
||||
this._render_hk_doUpdateExternalStates();
|
||||
})();
|
||||
}
|
||||
|
||||
_render_hk_setDerivedGroupAndDifficulty () {
|
||||
const partyMeta = this._getPartyMeta();
|
||||
const encounterXpInfo = EncounterBuilderCreatureMeta.getEncounterXpInfo(this._comp.creatureMetas, this._getPartyMeta());
|
||||
|
||||
this._state.derivedGroupAndDifficulty = {
|
||||
partyMeta,
|
||||
encounterXpInfo,
|
||||
};
|
||||
}
|
||||
|
||||
_render_hk_doUpdateExternalStates () {
|
||||
/* Implement as required */
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
_doShuffle ({creatureMeta}) {
|
||||
if (creatureMeta.isLocked) return;
|
||||
|
||||
const ix = this._comp.creatureMetas.findIndex(creatureMeta_ => creatureMeta_.isSameCreature(creatureMeta));
|
||||
if (!~ix) throw new Error(`Could not find creature ${creatureMeta.getHash()} (${creatureMeta.customHashId})`);
|
||||
|
||||
const creatureMeta_ = this._comp.creatureMetas[ix];
|
||||
if (creatureMeta_.isLocked) return;
|
||||
|
||||
const lockedHashes = new Set(
|
||||
this._comp.creatureMetas
|
||||
.filter(creatureMeta => creatureMeta.isLocked)
|
||||
.map(creatureMeta => creatureMeta.getHash()),
|
||||
);
|
||||
|
||||
const monRolled = this._doShuffle_getShuffled({creatureMeta: creatureMeta_, lockedHashes});
|
||||
if (!monRolled) return JqueryUtil.doToast({content: "Could not find another creature worth the same amount of XP!", type: "warning"});
|
||||
|
||||
const creatureMetaNxt = new EncounterBuilderCreatureMeta({
|
||||
creature: monRolled,
|
||||
count: creatureMeta_.count,
|
||||
});
|
||||
|
||||
const creatureMetasNxt = [...this._comp.creatureMetas];
|
||||
const withMonRolled = creatureMetasNxt.find(creatureMeta_ => creatureMeta_.hasCreature(monRolled));
|
||||
if (withMonRolled) {
|
||||
withMonRolled.count += creatureMetaNxt.count;
|
||||
creatureMetasNxt.splice(ix, 1);
|
||||
} else {
|
||||
creatureMetasNxt[ix] = creatureMetaNxt;
|
||||
}
|
||||
|
||||
this._comp.creatureMetas = creatureMetasNxt;
|
||||
}
|
||||
|
||||
_doShuffle_getShuffled ({creatureMeta, lockedHashes}) {
|
||||
const xp = creatureMeta.getXp();
|
||||
const hash = creatureMeta.getHash();
|
||||
|
||||
const availMons = this._cache.getCreaturesByXp(xp)
|
||||
.filter(mon => {
|
||||
const hash_ = UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_BESTIARY](mon);
|
||||
return !lockedHashes.has(hash) && hash_ !== hash;
|
||||
});
|
||||
if (!availMons.length) return null;
|
||||
|
||||
return RollerUtil.rollOnArray(availMons);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
_getPartyMeta () {
|
||||
return this._comp.getPartyMeta();
|
||||
}
|
||||
|
||||
_getDefaultState () {
|
||||
return {
|
||||
derivedGroupAndDifficulty: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user