mirror of
https://github.com/Kornstalx/5etools-mirror-2.github.io.git
synced 2025-10-28 20:45:35 -05:00
2859 lines
90 KiB
JavaScript
2859 lines
90 KiB
JavaScript
"use strict";
|
|
|
|
globalThis.ScaleCreatureConsts = class {
|
|
// DMG p274
|
|
static CR_DPR_RANGES = {
|
|
"0": [0, 1],
|
|
"0.125": [2, 3],
|
|
"0.25": [4, 5],
|
|
"0.5": [6, 8],
|
|
"1": [9, 14],
|
|
"2": [15, 20],
|
|
"3": [21, 26],
|
|
"4": [27, 32],
|
|
"5": [33, 38],
|
|
"6": [39, 44],
|
|
"7": [45, 50],
|
|
"8": [51, 56],
|
|
"9": [57, 62],
|
|
"10": [63, 68],
|
|
"11": [69, 74],
|
|
"12": [75, 80],
|
|
"13": [81, 86],
|
|
"14": [87, 92],
|
|
"15": [93, 98],
|
|
"16": [99, 104],
|
|
"17": [105, 110],
|
|
"18": [111, 116],
|
|
"19": [117, 122],
|
|
"20": [123, 140],
|
|
"21": [141, 158],
|
|
"22": [159, 176],
|
|
"23": [177, 194],
|
|
"24": [195, 212],
|
|
"25": [213, 230],
|
|
"26": [231, 248],
|
|
"27": [249, 266],
|
|
"28": [267, 284],
|
|
"29": [285, 302],
|
|
"30": [303, 320],
|
|
};
|
|
|
|
// DMG p274
|
|
static CR_HP_RANGES = {
|
|
"0": [1, 6],
|
|
"0.125": [7, 35],
|
|
"0.25": [36, 49],
|
|
"0.5": [50, 70],
|
|
"1": [71, 85],
|
|
"2": [86, 100],
|
|
"3": [101, 115],
|
|
"4": [116, 130],
|
|
"5": [131, 145],
|
|
"6": [146, 160],
|
|
"7": [161, 175],
|
|
"8": [176, 190],
|
|
"9": [191, 205],
|
|
"10": [206, 220],
|
|
"11": [221, 235],
|
|
"12": [236, 250],
|
|
"13": [251, 265],
|
|
"14": [266, 280],
|
|
"15": [281, 295],
|
|
"16": [296, 310],
|
|
"17": [311, 325],
|
|
"18": [326, 340],
|
|
"19": [341, 355],
|
|
"20": [356, 400],
|
|
"21": [401, 445],
|
|
"22": [446, 490],
|
|
"23": [491, 535],
|
|
"24": [536, 580],
|
|
"25": [581, 625],
|
|
"26": [626, 670],
|
|
"27": [671, 715],
|
|
"28": [716, 760],
|
|
"29": [761, 805],
|
|
"30": [806, 850],
|
|
};
|
|
|
|
// Manual smoothing applied to ensure e.g. going down a CR doesn't increase the mod
|
|
static CR_TO_ESTIMATED_DAMAGE_MOD = {
|
|
"0": [-1, 2],
|
|
"0.125": [0, 2],
|
|
"0.25": [0, 3],
|
|
"0.5": [0, 3],
|
|
"1": [0, 3],
|
|
"2": [1, 4],
|
|
"3": [1, 4],
|
|
"4": [2, 4],
|
|
"5": [2, 5],
|
|
"6": [2, 5],
|
|
"7": [2, 5],
|
|
"8": [2, 5],
|
|
"9": [2, 6],
|
|
"10": [3, 6],
|
|
"11": [3, 6],
|
|
"12": [3, 6],
|
|
"13": [3, 7],
|
|
"14": [3, 7],
|
|
"15": [3, 7],
|
|
"16": [4, 8],
|
|
"17": [4, 8],
|
|
"18": [4, 8],
|
|
"19": [5, 8],
|
|
"20": [6, 9],
|
|
"21": [6, 9],
|
|
"22": [6, 10],
|
|
"23": [6, 10],
|
|
"24": [6, 11],
|
|
"25": [7, 11],
|
|
"26": [7, 11],
|
|
// region No creatures for these CRs; use 26 with modifications
|
|
"27": [7, 11],
|
|
"28": [8, 11],
|
|
"29": [8, 11],
|
|
// endregion
|
|
"30": [9, 11],
|
|
};
|
|
};
|
|
|
|
globalThis.ScaleCreatureUtils = class {
|
|
/**
|
|
* Calculate outVal based on a ratio equality.
|
|
*
|
|
* inVal outVal
|
|
* --------- = ----------
|
|
* inTotal outTotal
|
|
*
|
|
* @param inVal
|
|
* @param inTotal
|
|
* @param outTotal
|
|
* @returns {number} outVal
|
|
*/
|
|
static getScaledToRatio (inVal, inTotal, outTotal) {
|
|
return Math.round(inVal * (outTotal / inTotal));
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* X in L-H
|
|
* --L---X------H--
|
|
* \ \ |
|
|
* \ \ |
|
|
* --M---Y---I--
|
|
* to Y; relative position in M-I
|
|
* so (where D is "delta;" fractional position in L-H range)
|
|
* X = D(H - L) + L
|
|
* => D = X - L / H - L
|
|
*
|
|
* @param x position within L-H space
|
|
* @param lh L-H is the original space (1 dimension; a range)
|
|
* @param mi M-I is the target space (1 dimension; a range)
|
|
* @returns {number} the relative position in M-I space
|
|
*/
|
|
static interpAndTranslateToSpace (x, lh, mi) {
|
|
let [l, h] = lh;
|
|
let [m, i] = mi;
|
|
// adjust to avoid infinite delta
|
|
const OFFSET = 0.1;
|
|
l -= OFFSET; h += OFFSET;
|
|
m -= OFFSET; i += OFFSET;
|
|
const delta = (x - l) / (h - l);
|
|
return Math.round((delta * (i - m)) + m); // round to nearest whole number
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
static _RE_HIT = /{@hit ([-+]?\d+)}/g;
|
|
|
|
static applyPbDeltaToHit (str, pbDelta) {
|
|
if (!pbDelta) return str;
|
|
|
|
return str.replace(this._RE_HIT, (_, m1) => {
|
|
const curToHit = Number(m1);
|
|
const outToHit = curToHit + pbDelta;
|
|
return `{@hit ${outToHit}}`;
|
|
});
|
|
}
|
|
|
|
static _RE_DC_PLAINTEXT = /DC (\d+)/g;
|
|
// Strip display text, as it may no longer be accurate
|
|
static _RE_DC_TAG = /{@dc (\d+)(?:\|[^}]+)?}/g;
|
|
|
|
static applyPbDeltaDc (str, pbDelta) {
|
|
if (!pbDelta) return str;
|
|
|
|
return str
|
|
.replace(this._RE_DC_PLAINTEXT, (_, m1) => `{@dc ${m1}}`)
|
|
.replace(this._RE_DC_TAG, (_, m1) => {
|
|
const curDc = Number(m1);
|
|
const outDc = curDc + pbDelta;
|
|
return `{@dc ${outDc}}`;
|
|
});
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
static getDiceExpressionAverage (diceExp) {
|
|
diceExp = diceExp.replace(/\s*/g, "");
|
|
const asAverages = diceExp.replace(/d(\d+)/gi, (...m) => {
|
|
return ` * ${(Number(m[1]) + 1) / 2}`;
|
|
});
|
|
return MiscUtil.expEval(asAverages);
|
|
}
|
|
|
|
static getScaledDpr ({dprIn, crInNumber, dprTargetIn, dprTargetOut}) {
|
|
if (crInNumber === 0) dprIn = Math.min(dprIn, 0.63); // cap CR 0 DPR to prevent average damage in the thousands
|
|
return this.getScaledToRatio(dprIn, dprTargetIn, dprTargetOut);
|
|
}
|
|
};
|
|
|
|
globalThis.ScaleCreatureDamageExpression = class {
|
|
static _State = class {
|
|
constructor (
|
|
{
|
|
dprTargetRange,
|
|
prefix,
|
|
suffix,
|
|
|
|
numDice,
|
|
dprAdjusted,
|
|
diceFaces,
|
|
offsetEnchant = 0,
|
|
|
|
modOut,
|
|
|
|
isAllowAdjustingMod = true,
|
|
},
|
|
) {
|
|
// region Inputs
|
|
this.dprTargetRange = dprTargetRange;
|
|
this.prefix = prefix;
|
|
this.suffix = suffix;
|
|
this.numDice = numDice;
|
|
this.dprAdjusted = dprAdjusted;
|
|
this.diceFaces = diceFaces;
|
|
this.offsetEnchant = offsetEnchant;
|
|
this.isAllowAdjustingMod = isAllowAdjustingMod;
|
|
// endregion
|
|
|
|
// region Outputs
|
|
this.numDiceOut = numDice;
|
|
this.diceFacesOut = diceFaces;
|
|
this.modOut = modOut;
|
|
// endregion
|
|
}
|
|
|
|
get dprTargetMin () { return this.dprTargetRange[0]; }
|
|
get dprTargetMax () { return this.dprTargetRange[1]; }
|
|
|
|
isInRange (num) {
|
|
return num >= this.dprTargetRange[0] && num <= this.dprTargetRange[1];
|
|
}
|
|
|
|
getDiceExpression ({numDice, diceFaces, mod} = {}) {
|
|
numDice ??= this.numDiceOut;
|
|
diceFaces ??= this.diceFacesOut;
|
|
mod ??= this.modOut;
|
|
|
|
const ptDice = diceFaces === 1
|
|
? ((numDice || 1) * diceFaces)
|
|
: `${numDice}d${diceFaces}`;
|
|
const ptMod = mod !== 0
|
|
? ` ${mod > 0 ? "+" : ""} ${mod}`
|
|
: "";
|
|
return `${ptDice}${ptMod}`;
|
|
}
|
|
|
|
toString () {
|
|
return [
|
|
`Original expression (approx): ${this.numDice}d${this.diceFaces} + ${this.modOut}`,
|
|
`Current formula: ${this.getDiceExpression()}`,
|
|
`Current average: ${ScaleCreatureUtils.getDiceExpressionAverage(this.getDiceExpression())}`,
|
|
`Target range: ${this.dprTargetMin}-${this.dprTargetMax}`,
|
|
]
|
|
.join("\n");
|
|
}
|
|
};
|
|
|
|
static _MAX_ATTEMPTS = 100;
|
|
|
|
static getScaled (
|
|
{
|
|
dprTargetRange,
|
|
|
|
prefix,
|
|
suffix,
|
|
|
|
numDice,
|
|
dprAdjusted,
|
|
diceFaces,
|
|
|
|
modOut,
|
|
|
|
isAllowAdjustingMod = true,
|
|
},
|
|
) {
|
|
const state = new this._State({
|
|
dprTargetRange,
|
|
prefix,
|
|
suffix,
|
|
numDice,
|
|
dprAdjusted,
|
|
diceFaces,
|
|
modOut,
|
|
isAllowAdjustingMod,
|
|
});
|
|
|
|
for (let ixAttempt = 0; ixAttempt < this._MAX_ATTEMPTS; ++ixAttempt) {
|
|
if (state.isInRange(ScaleCreatureUtils.getDiceExpressionAverage(state.getDiceExpression()))) return this._getScaled_getOutput(state);
|
|
|
|
// order of preference for scaling:
|
|
// - adjusting number of dice
|
|
// - adjusting number of faces
|
|
// - adjusting modifier
|
|
if (this._getScaled_tryAdjustNumDice(state)) continue;
|
|
if (this._getScaled_tryAdjustDiceFaces(state)) continue;
|
|
this._getScaled_tryAdjustMod(state, {ixAttempt});
|
|
}
|
|
|
|
throw new Error(`Failed to find new DPR!\n${state}`);
|
|
}
|
|
|
|
static _DIR_INCREASE = 1;
|
|
static _DIR_DECREASE = -1;
|
|
|
|
static _getScaled_tryAdjustNumDice (state, {diceFacesTemp = null} = {}) {
|
|
diceFacesTemp ??= state.diceFacesOut;
|
|
let numDiceTemp = state.numDice;
|
|
|
|
let tempAvgDpr = ScaleCreatureUtils.getDiceExpressionAverage(
|
|
state.getDiceExpression({
|
|
numDice: numDiceTemp,
|
|
diceFaces: diceFacesTemp,
|
|
}),
|
|
);
|
|
|
|
const dir = state.dprAdjusted < tempAvgDpr ? this._DIR_DECREASE : this._DIR_INCREASE;
|
|
|
|
while (
|
|
(dir === this._DIR_INCREASE || numDiceTemp > 1)
|
|
&& (dir === this._DIR_INCREASE ? tempAvgDpr <= state.dprTargetMax : tempAvgDpr >= state.dprTargetMin)
|
|
) {
|
|
numDiceTemp += dir;
|
|
tempAvgDpr += dir * ((diceFacesTemp + 1) / 2);
|
|
|
|
if (
|
|
state.isInRange(
|
|
ScaleCreatureUtils.getDiceExpressionAverage(
|
|
state.getDiceExpression({
|
|
numDice: numDiceTemp,
|
|
diceFaces: diceFacesTemp,
|
|
}),
|
|
),
|
|
)
|
|
) {
|
|
state.numDiceOut = numDiceTemp;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
static _getNextDice (diceFaces) {
|
|
return Renderer.dice.getNextDice(diceFaces);
|
|
}
|
|
|
|
static _getPreviousDice (diceFaces) {
|
|
return diceFaces === 4 ? 1 : Renderer.dice.getPreviousDice(diceFaces);
|
|
}
|
|
|
|
static _getScaled_tryAdjustDiceFaces (state) {
|
|
// can't be scaled
|
|
if (state.diceFaces === 1 || state.diceFaces === 20) return false;
|
|
|
|
let diceFacesTemp = state.diceFaces;
|
|
|
|
let tempAvgDpr = ScaleCreatureUtils.getDiceExpressionAverage(
|
|
state.getDiceExpression({
|
|
diceFaces: diceFacesTemp,
|
|
}),
|
|
);
|
|
|
|
const dir = state.dprAdjusted < tempAvgDpr ? this._DIR_DECREASE : this._DIR_INCREASE;
|
|
|
|
while (
|
|
(dir === this._DIR_INCREASE ? diceFacesTemp < 20 : diceFacesTemp > 1)
|
|
&& (dir === this._DIR_INCREASE ? tempAvgDpr <= state.dprTargetMax : tempAvgDpr >= state.dprTargetMin)
|
|
) {
|
|
diceFacesTemp = dir === this._DIR_INCREASE ? this._getNextDice(diceFacesTemp) : this._getPreviousDice(diceFacesTemp);
|
|
tempAvgDpr = ScaleCreatureUtils.getDiceExpressionAverage(state.getDiceExpression({diceFaces: diceFacesTemp}));
|
|
|
|
if (
|
|
state.isInRange(
|
|
ScaleCreatureUtils.getDiceExpressionAverage(
|
|
state.getDiceExpression({diceFaces: diceFacesTemp}),
|
|
),
|
|
)
|
|
) {
|
|
state.diceFacesOut = diceFacesTemp;
|
|
return true;
|
|
}
|
|
|
|
if (this._getScaled_tryAdjustNumDice(state, {diceFacesTemp})) {
|
|
state.diceFacesOut = diceFacesTemp;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
static _getScaled_tryAdjustMod (state, {ixAttempt}) {
|
|
if (!state.isAllowAdjustingMod) return false;
|
|
|
|
// alternating sequence, going further from origin each time.
|
|
// E.g. original modOut == 0 => 1, -1, 2, -2, 3, -3, ... modOut+n, modOut-n
|
|
state.modOut += (1 - ((ixAttempt % 2) * 2)) * (ixAttempt + 1);
|
|
}
|
|
|
|
/** Alternate implementation which prevents dec/increasing AS when inc/decreasing CR */
|
|
static _getScaled_tryAdjustMod_alt (state, {crIn, crOut}) {
|
|
if (!state.isAllowAdjustingMod) return false;
|
|
|
|
state.modOut += Math.sign(crOut - crIn);
|
|
state.modOut = Math.max(-5, Math.min(state.modOut, 10)); // Cap at -5 (0) and at +10 (30)
|
|
}
|
|
|
|
static _getScaled_getOutput (state) {
|
|
const diceExpOut = state.getDiceExpression({
|
|
numDice: state.numDiceOut,
|
|
diceFaces: state.diceFacesOut,
|
|
mod: state.modOut + state.offsetEnchant,
|
|
});
|
|
|
|
const avgDamOut = Math.floor(ScaleCreatureUtils.getDiceExpressionAverage(diceExpOut));
|
|
if (avgDamOut <= 0 || diceExpOut === "1") return `1 ${state.suffix.replace(/^\W+/, " ").replace(/ +/, " ")}`;
|
|
|
|
const expression = [
|
|
Math.floor(ScaleCreatureUtils.getDiceExpressionAverage(diceExpOut)),
|
|
state.prefix,
|
|
diceExpOut,
|
|
state.suffix,
|
|
]
|
|
.filter(Boolean)
|
|
.join("");
|
|
|
|
return {
|
|
expression,
|
|
modOut: state.modOut,
|
|
};
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
static getCreatureDamageScaleMeta ({crInNumber, crOutNumber}) {
|
|
const dprRangeIn = ScaleCreatureConsts.CR_DPR_RANGES[crInNumber];
|
|
if (!dprRangeIn) return null;
|
|
const dprRangeOut = ScaleCreatureConsts.CR_DPR_RANGES[crOutNumber];
|
|
if (!dprRangeOut) return null;
|
|
|
|
const dprAverageIn = dprRangeIn.mean();
|
|
const dprAverageOut = dprRangeOut.mean();
|
|
|
|
const crOutDprVariance = (dprRangeOut[1] - dprRangeOut[0]) / 2;
|
|
|
|
return {
|
|
dprAverageIn,
|
|
dprAverageOut,
|
|
crOutDprVariance,
|
|
};
|
|
}
|
|
|
|
static getExpressionDamageScaleMeta (
|
|
{
|
|
diceExp,
|
|
|
|
crInNumber,
|
|
crOutNumber,
|
|
|
|
dprAverageIn,
|
|
dprAverageOut,
|
|
crOutDprVariance,
|
|
|
|
offsetEnchant = 0,
|
|
},
|
|
) {
|
|
diceExp = diceExp.replace(/\s+/g, "");
|
|
const avgDpr = ScaleCreatureUtils.getDiceExpressionAverage(diceExp);
|
|
const dprAdjusted = ScaleCreatureUtils.getScaledDpr({dprIn: avgDpr, crInNumber, dprTargetIn: dprAverageIn, dprTargetOut: dprAverageOut});
|
|
|
|
const dprTargetRange = [
|
|
Math.max(0, Math.floor(dprAdjusted - crOutDprVariance)),
|
|
Math.ceil(Math.max(1, dprAdjusted + crOutDprVariance)),
|
|
];
|
|
|
|
// in official data, there are no dice expressions with more than one type of dice
|
|
const [dice, modifier] = diceExp.split(/[-+]/);
|
|
const [numDice, diceFaces] = dice.split("d").map(it => Number(it));
|
|
const modFromAbil = modifier ? Number(modifier) - offsetEnchant : null;
|
|
|
|
return {
|
|
dprTargetRange,
|
|
numDice,
|
|
dprAdjusted,
|
|
diceFaces,
|
|
modFromAbil,
|
|
};
|
|
}
|
|
|
|
static getAdjustedDamageMod (
|
|
{
|
|
crInNumber,
|
|
crOutNumber,
|
|
|
|
abilBeingScaled = null,
|
|
strTmpMod = null,
|
|
dexTmpMod = null,
|
|
|
|
modFromAbil,
|
|
|
|
offsetEnchant = 0,
|
|
},
|
|
) {
|
|
if (abilBeingScaled === "str" && strTmpMod != null) return strTmpMod;
|
|
if (abilBeingScaled === "dex" && dexTmpMod != null) return dexTmpMod;
|
|
|
|
if (modFromAbil == null) return 0 - offsetEnchant; // ensure enchanted equipment is ignored even with +0 base damage mod
|
|
|
|
// calculate this without enchanted equipment; ignore them and add them back at the end
|
|
return ScaleCreatureUtils.interpAndTranslateToSpace(
|
|
modFromAbil,
|
|
ScaleCreatureConsts.CR_TO_ESTIMATED_DAMAGE_MOD[crInNumber],
|
|
ScaleCreatureConsts.CR_TO_ESTIMATED_DAMAGE_MOD[crOutNumber],
|
|
);
|
|
}
|
|
};
|
|
|
|
// Global variable for Roll20 compatibility
|
|
globalThis.ScaleCreature = {
|
|
isCrInScaleRange (mon) {
|
|
if ([VeCt.CR_UNKNOWN, VeCt.CR_CUSTOM].includes(Parser.crToNumber(mon.cr))) return false;
|
|
// Only allow scaling for creatures in the 0-30 CR range (homebrew may specify e.g. >30)
|
|
const xpVal = Parser.XP_CHART_ALT[mon.cr?.cr ?? mon.cr];
|
|
return xpVal != null;
|
|
},
|
|
|
|
_crRangeToVal (cr, ranges) {
|
|
return Object.keys(ranges).find(k => {
|
|
const [a, b] = ranges[k];
|
|
return cr >= a && cr <= b;
|
|
});
|
|
},
|
|
|
|
_acCrRanges: {
|
|
"13": [-1, 3],
|
|
"14": [4, 4],
|
|
"15": [5, 7],
|
|
"16": [8, 9],
|
|
"17": [10, 12],
|
|
"18": [13, 16],
|
|
"19": [17, 30],
|
|
},
|
|
|
|
_crToAc (cr) {
|
|
return Number(this._crRangeToVal(cr, this._acCrRanges));
|
|
},
|
|
|
|
// calculated as the mean modifier for each CR,
|
|
// -/+ the mean absolute deviation,
|
|
// rounded to the nearest integer
|
|
_crToEstimatedConModRange: {
|
|
"0": [-1, 2],
|
|
"0.125": [-1, 1],
|
|
"0.25": [0, 2],
|
|
"0.5": [0, 2],
|
|
"1": [0, 2],
|
|
"2": [0, 3],
|
|
"3": [1, 3],
|
|
"4": [1, 4],
|
|
"5": [2, 4],
|
|
"6": [2, 5],
|
|
"7": [1, 5],
|
|
"8": [1, 5],
|
|
"9": [2, 5],
|
|
"10": [2, 5],
|
|
"11": [2, 6],
|
|
"12": [1, 5],
|
|
"13": [3, 6],
|
|
"14": [3, 6],
|
|
"15": [3, 6],
|
|
"16": [4, 7],
|
|
"17": [3, 7],
|
|
"18": [1, 7],
|
|
"19": [4, 6],
|
|
"20": [5, 9],
|
|
"21": [3, 8],
|
|
"22": [4, 9],
|
|
"23": [5, 9],
|
|
"24": [5, 9],
|
|
"25": [7, 9],
|
|
"26": [7, 9],
|
|
// no creatures for these CRs; use 26
|
|
"27": [7, 9],
|
|
"28": [7, 9],
|
|
"29": [7, 9],
|
|
// end
|
|
"30": [10, 10],
|
|
},
|
|
|
|
_atkCrRanges: {
|
|
"3": [-1, 2],
|
|
"4": [3, 3],
|
|
"5": [4, 4],
|
|
"6": [5, 7],
|
|
"7": [8, 10],
|
|
"8": [11, 15],
|
|
"9": [16, 16],
|
|
"10": [17, 20],
|
|
"11": [21, 23],
|
|
"12": [24, 26],
|
|
"13": [27, 29],
|
|
"14": [30, 30],
|
|
},
|
|
|
|
_crToAtk (cr) {
|
|
return this._crRangeToVal(cr, this._atkCrRanges);
|
|
},
|
|
|
|
_dcRanges: {
|
|
"13": [-1, 3],
|
|
"14": [4, 4],
|
|
"15": [5, 7],
|
|
"16": [8, 10],
|
|
"17": [11, 12],
|
|
"18": [13, 16],
|
|
"19": [17, 20],
|
|
"20": [21, 23],
|
|
"21": [24, 26],
|
|
"22": [27, 29],
|
|
"23": [30, 30],
|
|
},
|
|
|
|
_crToDc (cr) {
|
|
return this._crRangeToVal(cr, this._dcRanges);
|
|
},
|
|
|
|
_casterLevelAndClassCantrips: {
|
|
artificer: [2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4],
|
|
bard: [2, 2, 2, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4],
|
|
cleric: [3, 3, 3, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5],
|
|
druid: [2, 2, 2, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4],
|
|
sorcerer: [4, 4, 4, 5, 5, 5, 5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6],
|
|
warlock: [2, 2, 2, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4],
|
|
wizard: [3, 3, 3, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5],
|
|
},
|
|
|
|
_casterLevelAndClassToCantrips (level, clazz) {
|
|
clazz = (clazz || "cleric").toLowerCase(); // Cleric/Wizard have middle-ground scaling
|
|
return this._casterLevelAndClassCantrips[clazz][level];
|
|
},
|
|
|
|
// cantrips that should be preserved when lowering the number of cantrips known, to ensure caster effectiveness
|
|
_protectedCantrips: ["acid splash", "chill touch", "eldritch blast", "fire bolt", "poison spray", "produce flame", "ray of frost", "sacred flame", "shocking grasp", "thorn whip", "vicious mockery"],
|
|
|
|
// analysis of official data + some manual smoothing
|
|
_crToCasterLevelAvg: {
|
|
"0": 2,
|
|
"0.125": 2,
|
|
"0.25": 2,
|
|
"0.5": 2,
|
|
"1": 3.5,
|
|
"2": 4.25,
|
|
"3": 5.75,
|
|
"4": 6.75,
|
|
"5": 8,
|
|
"6": 9.75,
|
|
"7": 10.5,
|
|
"8": 10.75,
|
|
"9": 11.5,
|
|
"10": 11.75,
|
|
"11": 12,
|
|
"12": 13,
|
|
"13": 14,
|
|
"14": 15,
|
|
"15": 16,
|
|
"16": 17,
|
|
"17": 18,
|
|
"18": 19,
|
|
"19": 20, // (no samples; manually added)
|
|
},
|
|
|
|
_crToCasterLevel (cr) {
|
|
if (cr === 0) return 2;
|
|
if (cr >= 19) return 20;
|
|
return this._crToCasterLevelAvg[cr];
|
|
},
|
|
|
|
_calcNewAbility (mon, prop, modifier) {
|
|
// at least 1
|
|
const out = Math.max(1,
|
|
((modifier + 5) * 2)
|
|
+ (mon[prop] % 2), // add trailing odd numbers from the original ability, just for fun
|
|
);
|
|
// Avoid breaking 30 unless we really mean to
|
|
return out === 31 ? 30 : out;
|
|
},
|
|
|
|
_rng: null,
|
|
_initRng (mon, toCr) {
|
|
let h = CryptUtil.hashCode(toCr);
|
|
h = 31 * h + CryptUtil.hashCode(mon.source);
|
|
h = 31 * h + CryptUtil.hashCode(mon.name);
|
|
this._rng = Math.seed(h);
|
|
},
|
|
|
|
/**
|
|
* @async
|
|
* @param mon Creature data.
|
|
* @param toCr target CR, as a number.
|
|
* @return {Promise<creature>} the scaled creature.
|
|
*/
|
|
async scale (mon, toCr) {
|
|
await this._pInitSpellCache();
|
|
|
|
if (toCr == null || toCr === "Unknown") throw new Error("Attempting to scale unknown CR!");
|
|
|
|
this._initRng(mon, toCr);
|
|
mon = MiscUtil.copyFast(mon);
|
|
|
|
const crIn = mon.cr.cr || mon.cr;
|
|
const crInNumber = Parser.crToNumber(crIn);
|
|
if (crInNumber === toCr) throw new Error("Attempting to scale creature to own CR!");
|
|
if (crInNumber > 30) throw new Error("Attempting to scale a creature beyond 30 CR!");
|
|
if (crInNumber < 0) throw new Error("Attempting to scale a creature below 0 CR!");
|
|
|
|
const pbIn = Parser.crToPb(crIn);
|
|
const pbOut = Parser.crToPb(String(toCr));
|
|
|
|
if (pbIn !== pbOut) this._applyPb(mon, pbIn, pbOut);
|
|
|
|
this._adjustHp(mon, crInNumber, toCr);
|
|
this._adjustAtkBonusAndSaveDc(mon, crInNumber, toCr, pbIn, pbOut);
|
|
this._adjustDpr(mon, crInNumber, toCr);
|
|
this._adjustSpellcasting(mon, crInNumber, toCr);
|
|
|
|
// adjust AC after DPR/etc, as DPR takes priority for adjusting DEX
|
|
this._armorClass.adjustAc(mon, crInNumber, toCr);
|
|
|
|
// TODO update not-yet-scaled abilities
|
|
|
|
this._handleUpdateAbilityScoresSkillsSaves(mon, pbOut);
|
|
|
|
// cleanup
|
|
[`strOld`, `dexOld`, `conOld`, `intOld`, `wisOld`, `chaOld`].forEach(a => delete mon[a]);
|
|
|
|
const crOutStr = Parser.numberToCr(toCr);
|
|
if (mon.cr.cr) mon.cr.cr = crOutStr;
|
|
else mon.cr = crOutStr;
|
|
|
|
Renderer.monster.updateParsed(mon);
|
|
|
|
mon._displayName = `${mon.name} (CR ${crOutStr})`;
|
|
mon._scaledCr = toCr;
|
|
mon._isScaledCr = true;
|
|
mon._originalCr = mon._originalCr || crIn;
|
|
|
|
return mon;
|
|
},
|
|
|
|
_applyPb (mon, pbIn, pbOut) {
|
|
if (mon.save) {
|
|
Object.keys(mon.save).forEach(k => {
|
|
const bonus = mon.save[k];
|
|
|
|
const fromAbility = Parser.getAbilityModNumber(mon[k]);
|
|
if (fromAbility === Number(bonus)) return; // handle the case where no-PB saves are listed
|
|
|
|
const actualPb = bonus - fromAbility;
|
|
const expert = actualPb === pbIn * 2;
|
|
|
|
mon.save[k] = this._applyPb_getNewSkillSaveMod(pbIn, pbOut, bonus, expert);
|
|
});
|
|
}
|
|
|
|
this._applyPb_skills(mon, pbIn, pbOut, mon.skill);
|
|
|
|
const pbDelta = pbOut - pbIn;
|
|
|
|
if (mon.spellcasting) {
|
|
mon.spellcasting.forEach(sc => {
|
|
if (sc.headerEntries) {
|
|
const toUpdate = JSON.stringify(sc.headerEntries);
|
|
const out = ScaleCreatureUtils.applyPbDeltaDc(
|
|
ScaleCreatureUtils.applyPbDeltaToHit(toUpdate, pbDelta),
|
|
pbDelta,
|
|
);
|
|
sc.headerEntries = JSON.parse(out);
|
|
}
|
|
});
|
|
}
|
|
|
|
const handleGenericEntries = (prop) => {
|
|
if (mon[prop]) {
|
|
mon[prop].forEach(it => {
|
|
const toUpdate = JSON.stringify(it.entries);
|
|
const out = ScaleCreatureUtils.applyPbDeltaDc(
|
|
ScaleCreatureUtils.applyPbDeltaToHit(toUpdate, pbDelta),
|
|
pbDelta,
|
|
);
|
|
it.entries = JSON.parse(out);
|
|
});
|
|
}
|
|
};
|
|
|
|
handleGenericEntries("trait");
|
|
handleGenericEntries("action");
|
|
handleGenericEntries("bonus");
|
|
handleGenericEntries("reaction");
|
|
handleGenericEntries("legendary");
|
|
handleGenericEntries("mythic");
|
|
handleGenericEntries("variant");
|
|
},
|
|
|
|
_applyPb_getNewSkillSaveMod (pbIn, pbOut, oldMod, expert) {
|
|
const mod = Number(oldMod) - (expert ? 2 * pbIn : pbIn) + (expert ? 2 * pbOut : pbOut);
|
|
return UiUtil.intToBonus(mod);
|
|
},
|
|
|
|
_applyPb_skills (mon, pbIn, pbOut, monSkill) {
|
|
if (!monSkill) return;
|
|
|
|
Object.keys(monSkill).forEach(skill => {
|
|
if (skill === "other") {
|
|
monSkill[skill].forEach(block => {
|
|
if (block.oneOf) {
|
|
this._applyPb_skills(mon, pbIn, pbOut, block.oneOf);
|
|
} else throw new Error(`Unhandled "other" skill keys: ${Object.keys(block)}`);
|
|
});
|
|
return;
|
|
}
|
|
|
|
const bonus = monSkill[skill];
|
|
|
|
const fromAbility = Parser.getAbilityModNumber(mon[Parser.skillToAbilityAbv(skill)]);
|
|
if (fromAbility === Number(bonus)) return; // handle the case where no-PB skills are listed
|
|
|
|
const actualPb = bonus - fromAbility;
|
|
const expert = actualPb === pbIn * 2;
|
|
|
|
monSkill[skill] = this._applyPb_getNewSkillSaveMod(pbIn, pbOut, bonus, expert);
|
|
|
|
if (skill === "perception" && mon.passive != null) mon.passive = 10 + Number(monSkill[skill]);
|
|
});
|
|
},
|
|
|
|
_armorClass: {
|
|
_getEnchanted (item, baseMod) {
|
|
const out = [];
|
|
for (let i = 0; i < 3; ++i) {
|
|
out.push({
|
|
tag: `+${i + 1} ${item}|dmg`,
|
|
mod: baseMod + i + 1,
|
|
});
|
|
out.push({
|
|
tag: `${item} +${i + 1}|dmg`,
|
|
mod: baseMod + i + 1,
|
|
});
|
|
}
|
|
return out;
|
|
},
|
|
|
|
_getAllVariants (obj) {
|
|
return Object.keys(obj).map(armor => {
|
|
const mod = obj[armor];
|
|
return [{
|
|
tag: `${armor}|phb`,
|
|
mod,
|
|
}].concat(this._getEnchanted(armor, mod));
|
|
}).reduce((a, b) => a.concat(b), []);
|
|
},
|
|
|
|
_getAcBaseAndMod (all, tag) {
|
|
const tagBaseType = tag.replace(/( \+\d)?\|.*$/, "");
|
|
const tagBase = all[tagBaseType];
|
|
const tagModM = /^.*? (\+\d)\|.*$/.exec(tag);
|
|
const tagMod = tagModM ? Number(tagModM[1]) : 0;
|
|
return [tagBase, tagMod];
|
|
},
|
|
|
|
_isStringContainsTag (tagSet, str) {
|
|
return tagSet.find(it => str.includes(`@item ${it}`));
|
|
},
|
|
|
|
_replaceTag (str, oldTag, nuTag) {
|
|
const out = str.replace(`@item ${oldTag}`, `@item ${nuTag}`);
|
|
const spl = out.split("|");
|
|
if (spl.length > 2) {
|
|
return `${spl.slice(0, 2).join("|")}}`;
|
|
}
|
|
return out;
|
|
},
|
|
|
|
_canDropShield (mon) {
|
|
return mon._shieldRequired === false && mon._shieldDropped === false;
|
|
},
|
|
|
|
_dropShield (acItem) {
|
|
const idxShield = acItem.from.findIndex(f => this._ALL_SHIELD_VARIANTS.find(s => f._.includes(s.tag)));
|
|
if (idxShield === -1) throw new Error("Should never occur!");
|
|
acItem.from.splice(idxShield, 1);
|
|
},
|
|
|
|
// normalises results as "value above 10"
|
|
_getAcVal (name) {
|
|
name = name.trim().toLowerCase();
|
|
const toCheck = [this._HEAVY, this._MEDIUM, this._LIGHT, {shield: 2}];
|
|
for (const tc of toCheck) {
|
|
const armorKey = Object.keys(tc).find(k => name === k);
|
|
if (armorKey) {
|
|
const acBonus = tc[armorKey];
|
|
if (acBonus > 10) return acBonus - 10;
|
|
}
|
|
}
|
|
},
|
|
|
|
_getDexCapVal (name) {
|
|
name = name.trim().toLowerCase();
|
|
const ix = [this._HEAVY, this._MEDIUM, this._LIGHT].findIndex(tc => !!Object.keys(tc).find(k => name === k));
|
|
return ix === 0 ? 0 : ix === 1 ? 2 : ix === 3 ? 999 : null;
|
|
},
|
|
|
|
// dual-wield shields is 3 AC, according to VGM's Fire Giant Dreadnought
|
|
// Therefore we assume "two shields = +1 AC"
|
|
_DUAL_SHIELD_BONUS: 1,
|
|
|
|
_HEAVY: {
|
|
"ring mail": 14,
|
|
"chain mail": 16,
|
|
"splint armor": 17,
|
|
"plate armor": 18,
|
|
},
|
|
_MEDIUM: {
|
|
"hide armor": 12,
|
|
"chain shirt": 13,
|
|
"scale mail": 14,
|
|
"breastplate": 14,
|
|
"half plate armor": 15,
|
|
},
|
|
_LIGHT: {
|
|
"padded armor": 11,
|
|
"leather armor": 11,
|
|
"studded leather armor": 12,
|
|
},
|
|
_MAGE_ARMOR: "@spell mage armor",
|
|
|
|
_ALL_SHIELD_VARIANTS: null,
|
|
_ALL_HEAVY_VARIANTS: null,
|
|
_ALL_MEDIUM_VARIANTS: null,
|
|
_ALL_LIGHT_VARIANTS: null,
|
|
_initAllVariants () {
|
|
this._ALL_SHIELD_VARIANTS = this._ALL_SHIELD_VARIANTS || [
|
|
{
|
|
tag: "shield|phb",
|
|
mod: 2,
|
|
},
|
|
...this._getEnchanted("shield", 2),
|
|
];
|
|
|
|
this._ALL_HEAVY_VARIANTS = this._ALL_HEAVY_VARIANTS || this._getAllVariants(this._HEAVY);
|
|
this._ALL_MEDIUM_VARIANTS = this._ALL_MEDIUM_VARIANTS || this._getAllVariants(this._MEDIUM);
|
|
this._ALL_LIGHT_VARIANTS = this._ALL_LIGHT_VARIANTS || this._getAllVariants(this._LIGHT);
|
|
},
|
|
|
|
adjustAc (mon, crIn, crOut) {
|
|
this._initAllVariants();
|
|
|
|
// if the DPR calculations didn't already adjust DEX, we can adjust it here
|
|
// otherwise, respect the changes made in the DPR calculations, and find a combination of AC factors to meet the desired number
|
|
mon.ac = mon.ac.map(acItem => this._getAdjustedAcItem(mon, crIn, crOut, acItem));
|
|
},
|
|
|
|
/** Update an existing AC to use our new DEX score, if we have one. */
|
|
_doPreAdjustAcs (mon, acItem) {
|
|
if (mon.dexOld == null || mon.dex === mon.dexOld) return;
|
|
if (!acItem.from) return;
|
|
|
|
const originalDexMod = Parser.getAbilityModNumber(mon.dexOld);
|
|
const currentDexMod = Parser.getAbilityModNumber(mon.dex);
|
|
|
|
if (originalDexMod === currentDexMod) return;
|
|
|
|
// Handle mage armor, light armor, and medium armor.
|
|
// Note that natural armor and "unarmored" also include DEX, but these are handled in the main loop.
|
|
|
|
if (this._isMageArmor(acItem)) {
|
|
acItem._acBeforePreAdjustment = acItem.ac;
|
|
acItem.ac = 13 + Parser.getAbilityModNumber(mon.dex);
|
|
return;
|
|
}
|
|
|
|
const lightTags = this._ALL_LIGHT_VARIANTS.map(it => it.tag);
|
|
const mediumTags = this._ALL_MEDIUM_VARIANTS.map(it => it.tag);
|
|
|
|
for (let i = 0; i < acItem.from.length; ++i) {
|
|
const from = acItem.from[i];
|
|
|
|
const lightTag = this._isStringContainsTag(lightTags, from);
|
|
if (lightTag) {
|
|
acItem._acBeforePreAdjustment = acItem.ac;
|
|
|
|
acItem.ac = acItem.ac - originalDexMod + currentDexMod;
|
|
|
|
return;
|
|
}
|
|
|
|
const mediumTag = this._isStringContainsTag(mediumTags, from);
|
|
if (mediumTag) {
|
|
const originalDexModMedium = Math.min(2, originalDexMod);
|
|
const currentDexModMedium = Math.min(2, currentDexMod);
|
|
|
|
const curAc = acItem.ac;
|
|
acItem.ac = acItem.ac - originalDexModMedium + currentDexModMedium;
|
|
if (curAc !== acItem.ac) acItem._acBeforePreAdjustment = curAc;
|
|
|
|
return;
|
|
}
|
|
}
|
|
},
|
|
|
|
_getAdjustedAcItem (mon, crIn, crOut, acItem) {
|
|
// Pre-adjust ACs to match our new DEX score, if we have one
|
|
this._doPreAdjustAcs(mon, acItem);
|
|
|
|
// region Attempt to adjust this item until we find some output that works
|
|
let iter = 0;
|
|
let out = null;
|
|
while (out == null) {
|
|
if (iter > 100) throw new Error(`Failed to calculate new AC! Input was:\n${JSON.stringify(acItem, null, "\t")}`);
|
|
out = this._getAdjustedAcItem_getAdjusted(mon, crIn, crOut, acItem, iter);
|
|
iter++;
|
|
}
|
|
// endregion
|
|
|
|
// region Finalisation/cleanup
|
|
// finalise "from"
|
|
let handledEnchBonus = !acItem._enchTotal;
|
|
if (acItem.from) {
|
|
if (acItem._enchTotal) {
|
|
acItem.from.forEach(f => {
|
|
if (handledEnchBonus) return;
|
|
|
|
if (f.ench && f.ench < 3) {
|
|
const enchToGive = Math.min(3 - f.ench, acItem._enchTotal);
|
|
acItem._enchTotal -= enchToGive;
|
|
f.ench += enchToGive;
|
|
acItem.ac += enchToGive;
|
|
f._ = `{@item +${f.ench} ${f.name}}`;
|
|
if (acItem._enchTotal <= 0) handledEnchBonus = true;
|
|
} else if (out._gearBonus) {
|
|
const enchToGive = Math.min(3, acItem._enchTotal);
|
|
acItem._enchTotal -= enchToGive;
|
|
f._ = `{@item +${enchToGive} ${f.name}}`;
|
|
if (acItem._enchTotal <= 0) handledEnchBonus = true;
|
|
}
|
|
});
|
|
}
|
|
acItem.from = acItem.from.map(it => it._);
|
|
}
|
|
|
|
// if there's an unhandled enchantment, give the creature enchanted leather. This implies an extra point of AC, but this is an acceptable workaround
|
|
if (!handledEnchBonus) {
|
|
const enchToGive = Math.min(3, acItem._enchTotal);
|
|
acItem._enchTotal -= enchToGive;
|
|
acItem.ac += enchToGive + 1;
|
|
(acItem.from = acItem.from || []).unshift(`{@item +${enchToGive} leather armor}`);
|
|
|
|
if (acItem._enchTotal > 0) acItem.ac += acItem._enchTotal; // as a fallback, add any remaining enchantment AC to the total
|
|
}
|
|
|
|
if (acItem._miscOffset != null) acItem.ac += acItem._miscOffset;
|
|
|
|
// cleanup
|
|
[
|
|
"_enchTotal",
|
|
"_gearBonus",
|
|
"_dexCap",
|
|
"_miscOffset",
|
|
"_isShield",
|
|
"_isDualShields",
|
|
].forEach(it => delete acItem[it]);
|
|
// endregion
|
|
|
|
return out;
|
|
},
|
|
|
|
_isMageArmor (acItem) {
|
|
return acItem.condition && acItem.condition.toLowerCase().includes(this._MAGE_ARMOR);
|
|
},
|
|
|
|
_getAdjustedAcItem_getAdjusted (mon, crIn, crOut, acItem, iter) {
|
|
const getEnchTotal = () => acItem._enchTotal || 0;
|
|
const getBaseGearBonus = () => acItem._gearBonus || 0;
|
|
const getDexCap = () => acItem._dexCap || 999;
|
|
|
|
// strip enchantments and total bonuses
|
|
if (typeof acItem !== "number") {
|
|
acItem._enchTotal = acItem._enchTotal || 0; // maintain this between loops, in case we throw away the enchanted gear
|
|
acItem._gearBonus = 0; // recalculate this each time
|
|
acItem._dexCap = 999; // recalculate this each time
|
|
}
|
|
|
|
if (acItem.from) {
|
|
acItem.from = acItem.from.map(f => {
|
|
if (f._) f = f._; // if a previous loop modified it
|
|
|
|
const m = /@item (\+\d+) ([^+\d]+)\|([^|}]+)/gi.exec(f); // e.g. {@item +1 chain mail}
|
|
if (m) {
|
|
const [_, name, bonus, source] = m;
|
|
|
|
const acVal = this._getAcVal(name);
|
|
if (acVal) acItem._gearBonus += acVal;
|
|
|
|
const dexCap = this._getDexCapVal(name);
|
|
if (dexCap != null) acItem._dexCap = Math.min(acItem._dexCap, dexCap);
|
|
|
|
const ench = Number(bonus);
|
|
acItem._enchTotal += ench;
|
|
return {
|
|
_: f,
|
|
name: name.trim(),
|
|
ench: ench,
|
|
source: source,
|
|
};
|
|
} else {
|
|
const m = /@item ([^|}]+)(\|[^|}]+)?(\|[^|}]+)?/gi.exec(f);
|
|
if (m) {
|
|
const [_, name, source, display] = m;
|
|
const out = {_: f, name};
|
|
if (source) out.source = source;
|
|
if (display) out.display = display;
|
|
|
|
const acVal = this._getAcVal(name);
|
|
if (acVal) {
|
|
acItem._gearBonus += acVal;
|
|
out._gearBonus = acVal;
|
|
}
|
|
|
|
const dexCap = this._getDexCapVal(name);
|
|
if (dexCap != null) acItem._dexCap = Math.min(acItem._dexCap, dexCap);
|
|
|
|
return out;
|
|
} else return {_: f, name: f};
|
|
}
|
|
});
|
|
}
|
|
|
|
// for armored creatures, try to calculate the expected AC, and use this as a starting point for scaling
|
|
const expectedBaseScore = mon.dexOld != null
|
|
? (getBaseGearBonus() + Math.min(Parser.getAbilityModNumber(mon.dexOld), getDexCap()) + (this._isMageArmor(acItem) ? 13 : 10))
|
|
: null;
|
|
|
|
let canAdjustDex = mon.dexOld == null;
|
|
const dexGain = Parser.getAbilityModNumber(mon.dex) - Parser.getAbilityModNumber((mon.dexOld || mon.dex));
|
|
|
|
const curr = acItem._acBeforePreAdjustment != null
|
|
? acItem._acBeforePreAdjustment
|
|
: (acItem.ac || acItem);
|
|
// don't include enchantments in AC-CR calculations
|
|
const currWithoutEnchants = curr - (iter === 0 ? getEnchTotal() : 0); // only take it off on the first iteration, as it gets saved
|
|
|
|
// ignore any other misc modifications from abilities, enchanted items, etc
|
|
if (typeof acItem !== "number") {
|
|
// maintain this between loops, keep the original "pure" version
|
|
acItem._miscOffset = acItem._miscOffset != null
|
|
? acItem._miscOffset
|
|
: (expectedBaseScore != null ? currWithoutEnchants - expectedBaseScore : null);
|
|
}
|
|
|
|
const idealAcIn = ScaleCreature._crToAc(crIn);
|
|
const idealAcOut = ScaleCreature._crToAc(crOut);
|
|
const effectiveCurrent = expectedBaseScore == null ? currWithoutEnchants : expectedBaseScore;
|
|
const target = ScaleCreatureUtils.getScaledToRatio(effectiveCurrent, idealAcIn, idealAcOut);
|
|
let targetNoShield = target;
|
|
const acGain = target - effectiveCurrent;
|
|
|
|
const dexMismatch = acGain - dexGain;
|
|
|
|
const adjustDex = () => {
|
|
if (mon.dexOld == null) mon.dexOld = mon.dex;
|
|
mon.dex = ScaleCreature._calcNewAbility(mon, "dex", Parser.getAbilityModNumber(mon.dex) + dexMismatch);
|
|
canAdjustDex = false;
|
|
return true;
|
|
};
|
|
|
|
const handleNoArmor = () => {
|
|
if (dexMismatch > 0) {
|
|
if (canAdjustDex) {
|
|
adjustDex();
|
|
return target;
|
|
} else {
|
|
return { // fill the gap with natural armor
|
|
ac: target,
|
|
from: ["natural armor"],
|
|
};
|
|
}
|
|
} else if (dexMismatch < 0 && canAdjustDex) { // increase/reduce DEX to move the AC up/down
|
|
adjustDex();
|
|
return target;
|
|
} else return target; // AC adjustment perfectly matches DEX adjustment; or there's nothing we can do because of a previous DEX adjustment
|
|
};
|
|
|
|
// "FROM" ADJUSTERS ========================================================================================
|
|
|
|
const handleMageArmor = () => {
|
|
// if there's mage armor, try adjusting dex
|
|
if (this._isMageArmor(acItem)) {
|
|
if (canAdjustDex) {
|
|
acItem.ac = target;
|
|
delete acItem._acBeforePreAdjustment;
|
|
return adjustDex();
|
|
} else {
|
|
// We have already set the AC in the pre-adjustment step.
|
|
// Mage armor means there was no other armor, so stop here.
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const handleShield = () => {
|
|
// if there's a shield, try dropping it
|
|
if (acItem.from) {
|
|
const fromShields = acItem.from.filter(f => this._ALL_SHIELD_VARIANTS.find(s => f._.includes(`@item ${s.tag}`)));
|
|
if (fromShields.length) {
|
|
if (fromShields.length > 1) throw new Error("AC contained multiple shields!"); // should be impossible
|
|
|
|
// check if shields are an important part of this creature
|
|
// if they have abilities/etc which refer to the shield, don't remove the shield
|
|
const shieldRequired = mon._shieldRequired != null ? mon._shieldRequired : (() => {
|
|
const checkShields = (prop) => {
|
|
if (!mon[prop]) return false;
|
|
for (const it of mon[prop]) {
|
|
if (it.name && it.name.toLowerCase().includes("shield")) return true;
|
|
if (it.entries && JSON.stringify(it.entries).match(/shield/i)) return true;
|
|
}
|
|
};
|
|
return mon._shieldRequired = checkShields("trait")
|
|
|| checkShields("action")
|
|
|| checkShields("bonus")
|
|
|| checkShields("reaction")
|
|
|| checkShields("legendary")
|
|
|| checkShields("mythic");
|
|
})();
|
|
mon._shieldDropped = false;
|
|
|
|
const fromShield = fromShields[0];
|
|
const fromShieldStr = fromShield._;
|
|
fromShield._isShield = true;
|
|
const idx = acItem.from.findIndex(it => it === fromShieldStr);
|
|
|
|
if (fromShieldStr.endsWith("|shields}")) {
|
|
fromShield._isDualShields = true;
|
|
|
|
const shieldVal = this._ALL_SHIELD_VARIANTS.find(s => fromShieldStr.includes(s.tag));
|
|
const shieldValModDual = shieldVal.mod + this._DUAL_SHIELD_BONUS;
|
|
targetNoShield -= shieldValModDual;
|
|
|
|
if (!shieldRequired && (acGain <= -shieldValModDual)) {
|
|
acItem.from.splice(idx, 1);
|
|
acItem.ac -= shieldValModDual;
|
|
mon._shieldDropped = true;
|
|
if (acItem.ac === target) return true;
|
|
}
|
|
} else {
|
|
const shieldVal = this._ALL_SHIELD_VARIANTS.find(s => fromShieldStr.includes(s.tag));
|
|
targetNoShield -= shieldVal.mod;
|
|
|
|
if (!shieldRequired && (acGain <= -shieldVal.mod)) {
|
|
acItem.from.splice(idx, 1);
|
|
acItem.ac -= shieldVal.mod;
|
|
mon._shieldDropped = true;
|
|
if (acItem.ac === target) return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
// FIXME this can result in armor with strength requirements greater than the user can manage
|
|
const handleHeavyArmor = () => {
|
|
// if there's heavy armor, try adjusting it
|
|
const PL3_PLATE = 21;
|
|
|
|
const heavyTags = this._ALL_HEAVY_VARIANTS.map(it => it.tag);
|
|
|
|
const isHeavy = (ac) => {
|
|
return ac >= 14 && ac <= PL3_PLATE; // ring mail (14) to +3 Plate (21)
|
|
};
|
|
|
|
const isBeyondHeavy = (ac) => {
|
|
return ac > PL3_PLATE; // more than +3 plate
|
|
};
|
|
|
|
const getHeavy = (ac) => {
|
|
const nonEnch = Object.keys(this._HEAVY).find(armor => this._HEAVY[armor] === ac);
|
|
if (nonEnch) return `${nonEnch}|phb`;
|
|
switch (ac) {
|
|
case 19: return [`+1 plate armor|dmg`, `+2 splint armor|dmg`][RollerUtil.roll(1, ScaleCreature._rng)];
|
|
case 20: return `+2 plate armor|dmg`;
|
|
case PL3_PLATE: return `+3 plate armor|dmg`;
|
|
}
|
|
};
|
|
|
|
const applyPl3Plate = ({ixFrom, heavyTag}) => {
|
|
acItem.from[ixFrom]._ = this._replaceTag(acItem.from[ixFrom]._, heavyTag, getHeavy(PL3_PLATE));
|
|
acItem.ac = PL3_PLATE;
|
|
delete acItem._acBeforePreAdjustment;
|
|
};
|
|
|
|
// For e.g. "Helmed Horror". Note that this should only ever *increase* shield AC.
|
|
const applyBeyondHeavyShieldUpgrade = ({idealShieldAc}) => {
|
|
const fromShield = acItem.from.find(it => it._isShield);
|
|
const shieldVal = this._ALL_SHIELD_VARIANTS.find(s => fromShield._.includes(s.tag));
|
|
const adjustmentDualShields = (fromShield._isDualShields ? this._DUAL_SHIELD_BONUS : 0);
|
|
const shieldValMod = shieldVal.mod + adjustmentDualShields;
|
|
const deltaShieldRequired = idealShieldAc - shieldValMod;
|
|
if (deltaShieldRequired <= 0) return acItem.ac += shieldValMod;
|
|
|
|
const deltaShieldMax = (5 + adjustmentDualShields) - shieldValMod;
|
|
const deltaShield = Math.min(deltaShieldRequired, deltaShieldMax);
|
|
const shieldValOut = this._ALL_SHIELD_VARIANTS.find(s => s.mod === (shieldVal.mod + deltaShield));
|
|
|
|
fromShield._ = this._replaceTag(fromShield._, shieldVal.tag, shieldValOut.tag);
|
|
|
|
acItem.ac += shieldValOut.mod + adjustmentDualShields;
|
|
};
|
|
|
|
if (acItem.from) {
|
|
for (let i = 0; i < acItem.from.length; ++i) {
|
|
const heavyTag = this._isStringContainsTag(heavyTags, acItem.from[i]._);
|
|
if (heavyTag) {
|
|
if (
|
|
targetNoShield !== target
|
|
&& isBeyondHeavy(targetNoShield)
|
|
&& isBeyondHeavy(target)
|
|
) {
|
|
const deltaHeavy = (PL3_PLATE - 10) - acItem.from[i]._gearBonus;
|
|
const idealShieldAc = target - (targetNoShield - deltaHeavy);
|
|
|
|
applyPl3Plate({ixFrom: i, heavyTag}); // cap it at +3 plate
|
|
applyBeyondHeavyShieldUpgrade({idealShieldAc}); // try to upgrade the shield
|
|
return true;
|
|
} if (isHeavy(targetNoShield)) {
|
|
const bumpOne = targetNoShield === 15; // there's no heavy armor with 15 AC
|
|
if (bumpOne) targetNoShield++;
|
|
acItem.from[i]._ = this._replaceTag(acItem.from[i]._, heavyTag, getHeavy(targetNoShield));
|
|
acItem.ac = target + (bumpOne ? 1 : 0);
|
|
delete acItem._acBeforePreAdjustment;
|
|
return true;
|
|
} else if (this._canDropShield(mon) && isHeavy(target)) {
|
|
const targetWithBump = target + (target === 15 ? 1 : 0); // there's no heavy armor with 15 AC
|
|
acItem.from[i]._ = this._replaceTag(acItem.from[i]._, heavyTag, getHeavy(targetWithBump));
|
|
acItem.ac = targetWithBump;
|
|
delete acItem._acBeforePreAdjustment;
|
|
this._dropShield(acItem);
|
|
return true;
|
|
} else if (isBeyondHeavy(targetNoShield)) {
|
|
applyPl3Plate({ixFrom: i, heavyTag}); // cap it at +3 plate and call it a day
|
|
return true;
|
|
} else { // drop to medium
|
|
const [tagBase, tagMod] = this._getAcBaseAndMod(this._LIGHT, heavyTag);
|
|
const tagAc = tagBase + tagMod;
|
|
acItem.from[i]._ = this._replaceTag(acItem.from[i]._, heavyTag, `half plate armor|phb`);
|
|
acItem.ac = (acItem.ac - tagAc) + 15 + Math.min(2, Parser.getAbilityModNumber(mon.dex));
|
|
delete acItem._acBeforePreAdjustment;
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const handleMediumArmor = () => {
|
|
// if there's medium armor, try adjusting dex, then try adjusting it
|
|
const mediumTags = this._ALL_MEDIUM_VARIANTS.map(it => it.tag);
|
|
|
|
const isMedium = (ac, asPos) => {
|
|
const min = 12 + (canAdjustDex ? -5 : Parser.getAbilityModNumber(mon.dex)); // hide; 12
|
|
const max = 18 + (canAdjustDex ? 2 : Math.min(2, Parser.getAbilityModNumber(mon.dex))); // half-plate +3; 18
|
|
if (asPos) return ac < min ? -1 : ac > max ? 1 : 0;
|
|
return ac >= min && ac <= max;
|
|
};
|
|
|
|
const getMedium = (ac, curArmor) => {
|
|
const getByBase = (base) => {
|
|
switch (base) {
|
|
case 14:
|
|
return [`scale mail|phb`, `breastplate|phb`][RollerUtil.roll(1, ScaleCreature._rng)];
|
|
case 16:
|
|
return [`+1 half plate armor|dmg`, `+2 breastplate|dmg`, `+2 scale mail|dmg`][RollerUtil.roll(2, ScaleCreature._rng)];
|
|
case 17:
|
|
return `+2 half plate armor|dmg`;
|
|
case 18:
|
|
return `+3 half plate armor|dmg`;
|
|
default: {
|
|
const nonEnch = Object.keys(this._MEDIUM).find(it => this._MEDIUM[it] === base);
|
|
return `${nonEnch}|phb`;
|
|
}
|
|
}
|
|
};
|
|
|
|
if (canAdjustDex) {
|
|
let fromArmor = curArmor.ac;
|
|
let maxFromArmor = fromArmor + 2;
|
|
let minFromArmor = fromArmor - 5;
|
|
|
|
const withinDexRange = () => {
|
|
return ac >= minFromArmor && ac <= maxFromArmor;
|
|
};
|
|
|
|
const getTotalAc = () => {
|
|
return fromArmor + Math.min(2, Parser.getAbilityModNumber(mon.dex));
|
|
};
|
|
|
|
let loops = 0;
|
|
while (1) {
|
|
if (loops > 1000) throw new Error(`Failed to find valid light armor!`);
|
|
|
|
if (withinDexRange()) {
|
|
canAdjustDex = false;
|
|
if (mon.dexOld == null) mon.dexOld = mon.dex;
|
|
|
|
if (ac > getTotalAc()) mon.dex += 2;
|
|
else mon.dex -= 2;
|
|
} else {
|
|
if (ac < minFromArmor) fromArmor -= 1;
|
|
else fromArmor += 1;
|
|
if (fromArmor < 12 || fromArmor > 18) throw Error("Should never occur!"); // sanity check
|
|
maxFromArmor = fromArmor + 2;
|
|
minFromArmor = fromArmor - 5;
|
|
}
|
|
|
|
if (getTotalAc() === ac) break;
|
|
loops++;
|
|
}
|
|
|
|
return getByBase(fromArmor);
|
|
} else {
|
|
const dexOffset = Math.min(Parser.getAbilityModNumber(mon.dex), 2);
|
|
return getByBase(ac - dexOffset);
|
|
}
|
|
};
|
|
|
|
if (acItem.from) {
|
|
for (let i = 0; i < acItem.from.length; ++i) {
|
|
const mediumTag = this._isStringContainsTag(mediumTags, acItem.from[i]._);
|
|
if (mediumTag) {
|
|
const [tagBase, tagMod] = this._getAcBaseAndMod(this._MEDIUM, mediumTag);
|
|
const tagAc = tagBase + tagMod;
|
|
if (isMedium(targetNoShield)) {
|
|
acItem.from[i]._ = this._replaceTag(acItem.from[i]._, mediumTag, getMedium(targetNoShield, {tag: mediumTag, ac: tagAc}));
|
|
acItem.ac = target;
|
|
delete acItem._acBeforePreAdjustment;
|
|
return true;
|
|
} else if (this._canDropShield(mon) && isMedium(target)) {
|
|
acItem.from[i]._ = this._replaceTag(acItem.from[i]._, mediumTag, getMedium(target, {tag: mediumTag, ac: tagAc}));
|
|
acItem.ac = target;
|
|
delete acItem._acBeforePreAdjustment;
|
|
this._dropShield(acItem);
|
|
return true;
|
|
} else if (canAdjustDex && isMedium(targetNoShield, true) === -1) { // drop to light
|
|
acItem.from[i]._ = this._replaceTag(acItem.from[i]._, mediumTag, `studded leather armor|phb`);
|
|
acItem.ac = (acItem.ac - tagAc - Math.min(2, Parser.getAbilityModNumber(mon.dex))) + 12 + Parser.getAbilityModNumber(mon.dex);
|
|
delete acItem._acBeforePreAdjustment;
|
|
return false;
|
|
} else {
|
|
// if we need more AC, switch to heavy, and restart the conversion
|
|
acItem.from[i]._ = this._replaceTag(acItem.from[i]._, mediumTag, `ring mail|phb`);
|
|
acItem.ac = 14;
|
|
delete acItem._acBeforePreAdjustment;
|
|
return -1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const handleLightArmor = () => {
|
|
// if there's light armor, try adjusting dex, then try adjusting it
|
|
const lightTags = this._ALL_LIGHT_VARIANTS.map(it => it.tag);
|
|
|
|
const isLight = (ac, asPos) => {
|
|
const min = 11 + (canAdjustDex ? -5 : Parser.getAbilityModNumber(mon.dex)); // padded/leather; 11
|
|
const max = 15 + (canAdjustDex ? 100 : Parser.getAbilityModNumber(mon.dex)); // studded leather +3; 15
|
|
if (asPos) return ac < min ? -1 : ac > max ? 1 : 0;
|
|
return ac >= min && ac <= max;
|
|
};
|
|
|
|
const getLight = (ac, curArmor) => {
|
|
const getByBase = (base) => {
|
|
switch (base) {
|
|
case 11:
|
|
return [`padded armor|phb`, `leather armor|phb`][RollerUtil.roll(1, ScaleCreature._rng)];
|
|
case 12:
|
|
return `studded leather armor|phb`;
|
|
case 13:
|
|
return [`+1 padded armor|dmg`, `+1 leather armor|dmg`][RollerUtil.roll(1, ScaleCreature._rng)];
|
|
case 14:
|
|
return [`+2 padded armor|dmg`, `+2 leather armor|dmg`, `+1 studded leather armor|dmg`][RollerUtil.roll(2, ScaleCreature._rng)];
|
|
case 15:
|
|
return `+2 studded leather armor|dmg`;
|
|
}
|
|
};
|
|
|
|
if (canAdjustDex) {
|
|
let fromArmor = curArmor.ac;
|
|
let minFromArmor = fromArmor - 5;
|
|
|
|
const withinDexRange = () => {
|
|
return ac >= minFromArmor;
|
|
};
|
|
|
|
const getTotalAc = () => {
|
|
return fromArmor + Parser.getAbilityModNumber(mon.dex);
|
|
};
|
|
|
|
let loops = 0;
|
|
while (1) {
|
|
if (loops > 1000) throw new Error(`Failed to find valid light armor!`);
|
|
|
|
if (withinDexRange()) {
|
|
canAdjustDex = false;
|
|
if (mon.dexOld == null) mon.dexOld = mon.dex;
|
|
|
|
if (ac > getTotalAc()) mon.dex += 2;
|
|
else mon.dex -= 2;
|
|
} else {
|
|
if (ac < minFromArmor) fromArmor -= 1;
|
|
else fromArmor += 1;
|
|
if (fromArmor < 11 || fromArmor > 15) throw Error("Should never occur!"); // sanity check
|
|
minFromArmor = fromArmor - 5;
|
|
}
|
|
|
|
if (getTotalAc() === ac) break;
|
|
loops++;
|
|
}
|
|
|
|
return getByBase(fromArmor);
|
|
} else {
|
|
const dexOffset = Parser.getAbilityModNumber(mon.dex);
|
|
return getByBase(ac - dexOffset);
|
|
}
|
|
};
|
|
|
|
if (acItem.from) {
|
|
for (let i = 0; i < acItem.from.length; ++i) {
|
|
const lightTag = this._isStringContainsTag(lightTags, acItem.from[i]._);
|
|
if (lightTag) {
|
|
const [tagBase, tagMod] = this._getAcBaseAndMod(this._LIGHT, lightTag);
|
|
const tagAc = tagBase + tagMod;
|
|
if (isLight(targetNoShield)) {
|
|
acItem.from[i]._ = this._replaceTag(acItem.from[i]._, lightTag, getLight(targetNoShield, {tag: lightTag, ac: tagAc}));
|
|
acItem.ac = target;
|
|
delete acItem._acBeforePreAdjustment;
|
|
return true;
|
|
} else if (this._canDropShield(mon) && isLight(target)) {
|
|
acItem.from[i]._ = this._replaceTag(acItem.from[i]._, lightTag, getLight(target, {tag: lightTag, ac: tagAc}));
|
|
acItem.ac = target;
|
|
delete acItem._acBeforePreAdjustment;
|
|
this._dropShield(acItem);
|
|
return true;
|
|
} else if (!canAdjustDex && isLight(targetNoShield, true) === -1) { // drop armor
|
|
if (acItem.from.length === 1) { // revert to pure numerical
|
|
acItem._droppedArmor = true;
|
|
return -1;
|
|
} else { // revert to base 10
|
|
acItem.from.splice(i, 1);
|
|
acItem.ac = (acItem.ac - tagAc) + 10;
|
|
delete acItem._acBeforePreAdjustment;
|
|
return -1;
|
|
}
|
|
} else {
|
|
// if we need more, switch to medium, and restart the conversion
|
|
acItem.from[i]._ = this._replaceTag(acItem.from[i]._, lightTag, `chain shirt|phb`);
|
|
acItem.ac = (acItem.ac - tagAc - Parser.getAbilityModNumber(mon.dex)) + 13 + Math.min(2, Parser.getAbilityModNumber(mon.dex));
|
|
delete acItem._acBeforePreAdjustment;
|
|
return -1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const handleNaturalArmor = () => {
|
|
// if there's natural armor, try adjusting dex, then try adjusting it
|
|
|
|
if (acItem.from && acItem.from.map(it => it._).includes("natural armor")) {
|
|
if (canAdjustDex) {
|
|
acItem.ac = target;
|
|
delete acItem._acBeforePreAdjustment;
|
|
return adjustDex();
|
|
} else {
|
|
acItem.ac = target; // natural armor of all modifiers is still just "natural armor," so this works
|
|
delete acItem._acBeforePreAdjustment;
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
if (acItem.ac && !acItem._droppedArmor) {
|
|
const toRun = [
|
|
handleMageArmor,
|
|
handleShield,
|
|
handleHeavyArmor,
|
|
handleMediumArmor,
|
|
handleLightArmor,
|
|
handleNaturalArmor,
|
|
];
|
|
let lastVal = 0;
|
|
for (let i = 0; i < toRun.length; ++i) {
|
|
lastVal = toRun[i]();
|
|
if (lastVal === -1) return null;
|
|
else if (lastVal) break;
|
|
}
|
|
|
|
// if there was no reasonable way to adjust the AC, forcibly set it here as a fallback
|
|
if (!lastVal) {
|
|
acItem.ac = target;
|
|
delete acItem._acBeforePreAdjustment;
|
|
}
|
|
return acItem;
|
|
} else {
|
|
return handleNoArmor();
|
|
}
|
|
},
|
|
},
|
|
|
|
_adjustHp (mon, crIn, crOut) {
|
|
if (mon.hp.special) return; // could be anything; best to just leave it
|
|
|
|
const hpInAvg = ScaleCreatureConsts.CR_HP_RANGES[crIn].mean();
|
|
const hpOutRange = ScaleCreatureConsts.CR_HP_RANGES[crOut];
|
|
const hpOutAvg = hpOutRange.mean();
|
|
const targetHpOut = ScaleCreatureUtils.getScaledToRatio(mon.hp.average, hpInAvg, hpOutAvg);
|
|
const targetHpDeviation = (hpOutRange[1] - hpOutRange[0]) / 2;
|
|
const targetHpRange = [Math.floor(targetHpOut - targetHpDeviation), Math.ceil(targetHpOut + targetHpDeviation)];
|
|
|
|
const origFormula = mon.hp.formula.replace(/\s*/g, "");
|
|
mon.hp.average = Math.floor(Math.max(1, targetHpOut));
|
|
|
|
const fSplit = origFormula.split(/([-+])/);
|
|
const mDice = /(\d+)d(\d+)/i.exec(fSplit[0]);
|
|
const hdFaces = Number(mDice[2]);
|
|
const hdAvg = (hdFaces + 1) / 2;
|
|
const numHd = Number(mDice[1]);
|
|
const modTotal = fSplit.length === 3 ? Number(`${fSplit[1]}${fSplit[2]}`) : 0;
|
|
const modPerHd = Math.floor(modTotal / numHd);
|
|
|
|
const getAdjustedConMod = () => {
|
|
const outRange = this._crToEstimatedConModRange[crOut];
|
|
if (outRange[0] === outRange[1]) return outRange[0]; // handle CR 30, which is always 10
|
|
return ScaleCreatureUtils.interpAndTranslateToSpace(modPerHd, this._crToEstimatedConModRange[crIn], outRange);
|
|
};
|
|
|
|
let numHdOut = numHd;
|
|
let hpModOut = getAdjustedConMod();
|
|
|
|
const getAvg = (numHd = numHdOut, hpMod = hpModOut) => {
|
|
return (numHd * hdAvg) + (numHd * hpMod);
|
|
};
|
|
|
|
const inRange = (num) => {
|
|
return num >= targetHpRange[0] && num <= targetHpRange[1];
|
|
};
|
|
|
|
let loops = 0;
|
|
while (1) {
|
|
if (inRange(getAvg(numHdOut))) break;
|
|
if (loops > 100) throw new Error(`Failed to find new HP! Current formula is: ${numHd}d${hpModOut}`);
|
|
|
|
const tryAdjustNumDice = () => {
|
|
let numDiceTemp = numHdOut;
|
|
let tempTotalHp = getAvg();
|
|
let found = false;
|
|
|
|
if (tempTotalHp > targetHpRange[1]) { // too high
|
|
while (numDiceTemp > 1) {
|
|
numDiceTemp -= 1;
|
|
tempTotalHp -= hdAvg;
|
|
|
|
if (inRange(getAvg(numDiceTemp))) {
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
} else { // too low
|
|
while (tempTotalHp <= targetHpRange[1]) {
|
|
numDiceTemp += 1;
|
|
tempTotalHp += hdAvg;
|
|
|
|
if (inRange(getAvg(numDiceTemp))) {
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (found) {
|
|
numHdOut = numDiceTemp;
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const tryAdjustMod = () => {
|
|
// alternating sequence, going further from origin each time.
|
|
// E.g. original modOut == 0 => 1, -1, 2, -2, 3, -3, ... modOut+n, modOut-n
|
|
hpModOut += (1 - ((loops % 2) * 2)) * (loops + 1);
|
|
};
|
|
|
|
// order of preference for scaling:
|
|
// - adjusting number of dice
|
|
// - adjusting modifier
|
|
if (tryAdjustNumDice()) break;
|
|
tryAdjustMod();
|
|
|
|
loops++;
|
|
}
|
|
|
|
mon.hp.average = Math.floor(getAvg(numHdOut));
|
|
const outModTotal = numHdOut * hpModOut;
|
|
mon.hp.formula = `${numHdOut}d${hdFaces}${outModTotal === 0 ? "" : `${outModTotal >= 0 ? "+" : ""}${outModTotal}`}`
|
|
.replace(/([-+])\s*(\d+)$/g, " $1 $2"); // add spaces around the operator
|
|
|
|
if (hpModOut !== modPerHd) {
|
|
const conOut = this._calcNewAbility(mon, "con", hpModOut);
|
|
if (conOut !== mon.con && mon.save && mon.save.con) {
|
|
const conDelta = Parser.getAbilityModifier(conOut) - Parser.getAbilityModifier(mon.con);
|
|
const conSaveOut = Number(mon.save.con) + conDelta;
|
|
mon.save.con = `${conSaveOut >= 0 ? "+" : ""}${conSaveOut}`;
|
|
}
|
|
mon.con = conOut;
|
|
}
|
|
},
|
|
|
|
_getEnchantmentBonus (str) {
|
|
const m = /\+(\d+)/.exec(str);
|
|
if (m) return Number(m[1]);
|
|
else return 0;
|
|
},
|
|
|
|
_wepThrownFinesse: ["dagger", "dart"],
|
|
_wepFinesse: ["dagger", "dart", "rapier", "scimitar", "shortsword", "whip"],
|
|
_wepThrown: ["handaxe", "javelin", "light hammer", "spear", "trident", "net"],
|
|
_getAbilBeingScaled ({strMod, dexMod, modFromAbil, name, content}) {
|
|
if (modFromAbil == null) return null;
|
|
|
|
const guessMod = () => {
|
|
name = name.toLowerCase();
|
|
content = content.replace(/{@atk ([A-Za-z,]+)}/gi, (_, p1) => Renderer.attackTagToFull(p1)).toLowerCase();
|
|
|
|
const isMeleeOrRangedWep = content.includes("melee or ranged weapon attack:");
|
|
if (isMeleeOrRangedWep) {
|
|
const wtf = this._wepThrownFinesse.find(it => content.includes(it));
|
|
if (wtf) return "dex";
|
|
|
|
const wf = this._wepFinesse.find(it => content.includes(it));
|
|
if (wf) return "dex";
|
|
|
|
const wt = this._wepThrown.find(it => content.includes(it));
|
|
if (wt) return "str";
|
|
|
|
return null;
|
|
}
|
|
|
|
const isMeleeWep = content.includes("melee weapon attack:");
|
|
if (isMeleeWep) {
|
|
const wf = this._wepFinesse.find(it => content.includes(it));
|
|
if (wf) return "dex";
|
|
return "str";
|
|
}
|
|
|
|
const isRangedWep = content.includes("ranged weapon attack:");
|
|
if (isRangedWep) {
|
|
const wt = this._wepThrown.find(it => content.includes(it));
|
|
if (wt) return "str"; // this should realistically only catch Nets
|
|
return "dex";
|
|
}
|
|
};
|
|
|
|
if (strMod === dexMod && strMod === modFromAbil) return guessMod();
|
|
return strMod === modFromAbil ? "str" : dexMod === modFromAbil ? "dex" : null;
|
|
},
|
|
|
|
_adjustAtkBonusAndSaveDc (mon, crIn, crOut, pbIn, pbOut) {
|
|
const idealHitIn = Number(this._crToAtk(crIn));
|
|
const idealHitOut = Number(this._crToAtk(crOut));
|
|
|
|
const strMod = Parser.getAbilityModNumber(mon.str);
|
|
const dexMod = Parser.getAbilityModNumber(mon.dex);
|
|
|
|
const getAdjustedHitFlat = toHitIn => {
|
|
// For low CR -> high CR,
|
|
// prefer scaling to-hits by a flat difference, rather than using a ratio
|
|
// this keeps ability scores more sane, and better maintains bounded accuracy.
|
|
if (crIn < crOut) return toHitIn + (idealHitOut - idealHitIn);
|
|
|
|
// Otherwise, for high CR -> low CR
|
|
return ScaleCreatureUtils.getScaledToRatio(toHitIn, idealHitIn, idealHitOut);
|
|
};
|
|
|
|
const handleHit = (str, name) => {
|
|
const offsetEnchant = name != null ? this._getEnchantmentBonus(name) : 0;
|
|
|
|
return str.replace(/{@hit ([-+]?\d+)}/g, (m0, m1) => {
|
|
const curToHit = Number(m1);
|
|
|
|
const modFromAbil = curToHit - (offsetEnchant + pbOut);
|
|
// Handle e.g. "Hobgoblin Warlord" expertise on attacks
|
|
const modFromAbilExpertise = curToHit - (offsetEnchant + (pbOut * 2));
|
|
// Handle e.g. "Ghast" lack of proficiency on attacks
|
|
const modFromAbilNoProf = curToHit - offsetEnchant;
|
|
|
|
// ignore spell attacks here, as they'll be scaled using DCs later
|
|
const abilBeingScaled = name != null
|
|
? this._getAbilBeingScaled({strMod, dexMod, modFromAbil, name, content: str})
|
|
: null;
|
|
const abilBeingScaledExpertise = name != null
|
|
? this._getAbilBeingScaled({strMod, dexMod, modFromAbil: modFromAbilExpertise, name, content: str})
|
|
: null;
|
|
const abilBeingScaledNoProf = name != null
|
|
? this._getAbilBeingScaled({strMod, dexMod, modFromAbil: modFromAbilNoProf, name, content: str})
|
|
: null;
|
|
|
|
const {abil, profMult} = [
|
|
abilBeingScaled ? {abil: abilBeingScaled, profMult: 1} : null,
|
|
abilBeingScaledExpertise ? {abil: abilBeingScaledExpertise, profMult: 2} : null,
|
|
abilBeingScaledNoProf ? {abil: abilBeingScaledNoProf, profMult: 0} : null,
|
|
].filter(Boolean)[0] || {abil: null, profMult: 1};
|
|
|
|
const pbInMult = profMult * pbIn;
|
|
const pbOutMult = profMult * pbOut;
|
|
|
|
const origToHitNoEnch = curToHit + (pbInMult - pbOutMult) - offsetEnchant;
|
|
const targetToHitNoEnch = getAdjustedHitFlat(origToHitNoEnch);
|
|
|
|
if (origToHitNoEnch === targetToHitNoEnch) return m0; // this includes updated PB, so just return it
|
|
|
|
if (abil != null) {
|
|
const modDiff = (targetToHitNoEnch - pbOutMult) - (origToHitNoEnch - pbInMult);
|
|
const modFromAbilOut = modFromAbil + modDiff;
|
|
|
|
// Written out in full to make ctrl-F easier
|
|
const tmpModListProp = {
|
|
"str": `_strTmpMods`,
|
|
"dex": `_dexTmpMods`,
|
|
}[abil];
|
|
|
|
mon[tmpModListProp] = mon[tmpModListProp] || [];
|
|
mon[tmpModListProp].push(modFromAbilOut);
|
|
}
|
|
|
|
return `{@hit ${targetToHitNoEnch + offsetEnchant}}`;
|
|
});
|
|
};
|
|
|
|
const idealDcIn = this._crToDc(crIn);
|
|
const idealDcOut = this._crToDc(crOut);
|
|
|
|
const getAdjustedDcFlat = (dcIn) => dcIn + (idealDcOut - idealDcIn);
|
|
|
|
const handleDc = (str, castingAbility) => {
|
|
return str
|
|
.replace(/DC (\d+)/g, (m0, m1) => `{@dc ${m1}}`)
|
|
.replace(/{@dc (\d+)(?:\|[^}]+)?}/g, (m0, m1) => {
|
|
const curDc = Number(m1);
|
|
const origDc = curDc + pbIn - pbOut;
|
|
const outDc = Math.max(10, getAdjustedDcFlat(origDc));
|
|
if (curDc === outDc) return m0;
|
|
|
|
if (["int", "wis", "cha"].includes(castingAbility)) {
|
|
// Written out in long-form to make ctrl-F easier
|
|
const oldKey = (() => {
|
|
switch (castingAbility) {
|
|
case "int": return "intOld";
|
|
case "wis": return "wisOld";
|
|
case "cha": return "chaOld";
|
|
default: throw new Error(`Unimplemented!`);
|
|
}
|
|
})();
|
|
if (mon[oldKey] == null) {
|
|
mon[oldKey] = mon[castingAbility];
|
|
const dcDiff = outDc - origDc;
|
|
const curMod = Parser.getAbilityModNumber(mon[castingAbility]);
|
|
mon[castingAbility] = this._calcNewAbility(mon, castingAbility, curMod + dcDiff + pbIn - pbOut);
|
|
}
|
|
}
|
|
return `{@dc ${outDc}}`;
|
|
});
|
|
};
|
|
|
|
if (mon.spellcasting) {
|
|
mon.spellcasting.forEach(sc => {
|
|
if (sc.headerEntries) {
|
|
const toUpdate = JSON.stringify(sc.headerEntries);
|
|
const out = handleHit(handleDc(toUpdate, sc.ability));
|
|
sc.headerEntries = JSON.parse(out);
|
|
}
|
|
});
|
|
}
|
|
|
|
const handleGenericEntries = (prop) => {
|
|
if (mon[prop]) {
|
|
mon[prop].forEach(it => {
|
|
const toUpdate = JSON.stringify(it.entries);
|
|
const out = handleDc(handleHit(toUpdate, it.name));
|
|
it.entries = JSON.parse(out);
|
|
});
|
|
}
|
|
};
|
|
|
|
handleGenericEntries("trait");
|
|
handleGenericEntries("action");
|
|
handleGenericEntries("bonus");
|
|
handleGenericEntries("reaction");
|
|
handleGenericEntries("legendary");
|
|
handleGenericEntries("mythic");
|
|
handleGenericEntries("variant");
|
|
|
|
// Apply any changes required by the to-hit adjustment to our ability scores
|
|
const checkSetTempMod = (abil) => {
|
|
// Written out in full to make ctrl-F easier
|
|
const tmpModListProp = {
|
|
"str": `_strTmpMods`,
|
|
"dex": `_dexTmpMods`,
|
|
}[abil];
|
|
|
|
if (!mon[tmpModListProp]) return;
|
|
|
|
const nxtK = `_${abil}TmpMod`;
|
|
if (mon[tmpModListProp].length === 0) throw new Error("Should never occur!");
|
|
else if (mon[tmpModListProp].length > 1) {
|
|
const cntEachMod = {};
|
|
mon[tmpModListProp].forEach(mod => cntEachMod[mod] = (cntEachMod[mod] || 0) + 1);
|
|
|
|
// If all changes are equal, apply the first
|
|
if (Object.keys(cntEachMod).length === 1) mon[nxtK] = mon[tmpModListProp][0];
|
|
// Otherwise, apply the one we found the most. Failing that, apply the first one.
|
|
else {
|
|
const maxCount = Math.max(...Object.values(cntEachMod));
|
|
const mostPopularMods = Object.entries(cntEachMod)
|
|
.filter(([, cnt]) => cnt === maxCount)
|
|
.map(([mod]) => Number(mod));
|
|
mon[nxtK] = mostPopularMods[0];
|
|
}
|
|
} else {
|
|
mon[nxtK] = mon[tmpModListProp][0];
|
|
}
|
|
|
|
delete mon[tmpModListProp];
|
|
};
|
|
|
|
checkSetTempMod("str");
|
|
checkSetTempMod("dex");
|
|
},
|
|
|
|
_adjustDpr (mon, crIn, crOut) {
|
|
const {dprAverageIn, dprAverageOut, crOutDprVariance} = ScaleCreatureDamageExpression.getCreatureDamageScaleMeta({crInNumber: crIn, crOutNumber: crOut});
|
|
|
|
let dprAdjustmentComplete = false;
|
|
let scaledEntries = [];
|
|
while (!dprAdjustmentComplete) {
|
|
scaledEntries = []; // reset any previous processing
|
|
|
|
const originalStrMod = Parser.getAbilityModNumber(mon.str);
|
|
const originalDexMod = Parser.getAbilityModNumber(mon.dex);
|
|
const strMod = mon._strTmpMod || originalStrMod;
|
|
const dexMod = mon._dexTmpMod || originalDexMod;
|
|
|
|
const handleDpr = (prop) => {
|
|
if (!mon[prop]) return true; // if there was nothing to do, the operation was a success
|
|
|
|
let allSucceeded = true;
|
|
|
|
mon[prop].forEach((it, idxProp) => {
|
|
const toUpdate = JSON.stringify(it.entries);
|
|
|
|
// handle flat values first, as we may convert dice values to flats
|
|
let out = toUpdate.replace(RollerUtil.REGEX_DAMAGE_FLAT, (m0, prefix, flatVal, suffix) => {
|
|
const adjDpr = ScaleCreatureUtils.getScaledDpr({dprIn: flatVal, crInNumber: crIn, dprTargetIn: dprAverageIn, dprTargetOut: dprAverageOut});
|
|
return `${prefix}${adjDpr}${suffix}`;
|
|
});
|
|
|
|
// track attribute adjustment requirements (unused except for dbgging)
|
|
const reqAbilAdjust = [];
|
|
|
|
// pre-calculate enchanted weapon offsets
|
|
const offsetEnchant = this._getEnchantmentBonus(it.name);
|
|
|
|
out = out.replace(RollerUtil.REGEX_DAMAGE_DICE, (m0, average, prefix, diceExp, suffix) => {
|
|
const {
|
|
dprTargetRange,
|
|
numDice,
|
|
dprAdjusted,
|
|
diceFaces,
|
|
modFromAbil,
|
|
} = ScaleCreatureDamageExpression.getExpressionDamageScaleMeta({
|
|
diceExp,
|
|
|
|
crInNumber: crIn,
|
|
crOutNumber: crOut,
|
|
|
|
dprAverageIn,
|
|
dprAverageOut,
|
|
crOutDprVariance,
|
|
});
|
|
|
|
// try to figure out which mod we're going to be scaling
|
|
const abilBeingScaled = this._getAbilBeingScaled({
|
|
strMod: originalStrMod,
|
|
dexMod: originalDexMod,
|
|
modFromAbil,
|
|
name: it.name,
|
|
content: toUpdate,
|
|
});
|
|
|
|
const modOut = ScaleCreatureDamageExpression.getAdjustedDamageMod({
|
|
crInNumber: crIn,
|
|
crOutNumber: crOut,
|
|
|
|
abilBeingScaled,
|
|
strTmpMod: mon._strTmpMod,
|
|
dexTmpMod: mon._dexTmpMod,
|
|
|
|
modFromAbil,
|
|
|
|
offsetEnchant,
|
|
});
|
|
|
|
const doPostCalc = ({modOutScaled}) => {
|
|
// prevent ability scores going below zero
|
|
// should be mathematically impossible, if the recalculation is working correctly as:
|
|
// - minimum damage dice is a d4
|
|
// - minimum number of dice is 1
|
|
// - minimum DPR range is 0-1, which can be achieved with e.g. 1d4-1 (avg 1) or 1d4-2 (avg 0)
|
|
// therefore, this provides a sanity check: this should only occur when something's broken
|
|
if (modOutScaled < -5) throw new Error(`Ability modifier ${abilBeingScaled != null ? `(${abilBeingScaled})` : ""} was below -5 (${modOutScaled})! Original dice expression was ${diceExp}.`);
|
|
|
|
if (abilBeingScaled == null) return;
|
|
|
|
const originalAbilMod = abilBeingScaled === "str" ? strMod : abilBeingScaled === "dex" ? dexMod : null;
|
|
|
|
// Written out in full to make ctrl-F easier
|
|
const [tmpModProp, maxDprKey] = {
|
|
"str": [`_strTmpMod`, `_maxDprStr`],
|
|
"dex": [`_dexTmpMod`, `_maxDprDex`],
|
|
}[abilBeingScaled];
|
|
|
|
if (originalAbilMod != null) {
|
|
if (mon[tmpModProp] != null && mon[tmpModProp] !== modOutScaled) {
|
|
if (mon[maxDprKey] < dprAdjusted) {
|
|
// TODO test this -- none of the official monsters require attribute re-calculation but homebrew might. The story so far:
|
|
// - A previous damage roll required an adjusted ability modifier to make the numbers line up
|
|
// - This damage roll requires a _different_ adjustment to the same modifier to make the numbers line up
|
|
// - This damage roll has a bigger average DPR, so should be prioritised. Update the modifier using this roll's requirements.
|
|
// - Since this will effectively invalidate the previous roll adjustments, break out of whatever we're doing here, and restart the entire adjustment process
|
|
// - As we've set our new attribute modifier on the creature, the next loop will respect it, and use it by default
|
|
// - Additionally, track the largest DPR, so we don't get stuck in a loop doing this on the next DPR adjustment iteration
|
|
mon[tmpModProp] = modOutScaled;
|
|
mon[maxDprKey] = dprAdjusted;
|
|
allSucceeded = false;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Always update the ability score key if one was used, to avoid later rolls clobbering our
|
|
// values. We do this for e.g. Young White Dragon's "Bite" attack being scaled from CR6 to 7,
|
|
// which would otherwise cause the 1d8 (mod 0) to calculate a new Strength value.
|
|
mon[maxDprKey] = Math.max((mon[maxDprKey] || 0), dprAdjusted);
|
|
mon[tmpModProp] = modOutScaled;
|
|
}
|
|
|
|
// Track dbg data
|
|
reqAbilAdjust.push({
|
|
ability: abilBeingScaled,
|
|
mod: modOutScaled,
|
|
dprAdjusted,
|
|
});
|
|
};
|
|
|
|
const {expression, modOut: modOutScaled} = ScaleCreatureDamageExpression.getScaled({
|
|
dprTargetRange,
|
|
prefix,
|
|
suffix,
|
|
|
|
numDice,
|
|
dprAdjusted,
|
|
diceFaces,
|
|
offsetEnchant,
|
|
|
|
modOut,
|
|
|
|
isAllowAdjustingMod: modFromAbil != null,
|
|
});
|
|
|
|
doPostCalc({modOutScaled});
|
|
|
|
return expression;
|
|
});
|
|
|
|
// skip remaining entries, to let the outer loop continue
|
|
if (!allSucceeded) return false;
|
|
|
|
if (toUpdate !== out) {
|
|
scaledEntries.push({
|
|
prop,
|
|
idxProp,
|
|
entriesStrOriginal: toUpdate, // unused/debug
|
|
entriesStr: out,
|
|
reqAbilAdjust, // unused/debug
|
|
});
|
|
}
|
|
});
|
|
|
|
return allSucceeded;
|
|
};
|
|
|
|
if (!handleDpr("trait")) continue;
|
|
if (!handleDpr("action")) continue;
|
|
if (!handleDpr("bonus")) continue;
|
|
if (!handleDpr("reaction")) continue;
|
|
if (!handleDpr("legendary")) continue;
|
|
if (!handleDpr("mythic")) continue;
|
|
if (!handleDpr("variant")) continue;
|
|
dprAdjustmentComplete = true;
|
|
}
|
|
|
|
// overwrite originals with scaled versions
|
|
scaledEntries.forEach(it => {
|
|
mon[it.prop][it.idxProp].entries = JSON.parse(it.entriesStr);
|
|
});
|
|
|
|
// update ability scores, as required
|
|
const updateAbility = (prop) => {
|
|
// Written out in full to make ctrl-F easier
|
|
const [tmpModProp, oldScoreProp] = {
|
|
"str": [`_strTmpMod`, `strOld`],
|
|
"dex": [`_dexTmpMod`, `dexOld`],
|
|
}[prop];
|
|
|
|
if (mon[tmpModProp] != null) {
|
|
mon[oldScoreProp] = mon[prop];
|
|
mon[prop] = this._calcNewAbility(mon, prop, mon[tmpModProp]);
|
|
}
|
|
delete mon[tmpModProp];
|
|
};
|
|
updateAbility("str");
|
|
updateAbility("dex");
|
|
},
|
|
|
|
_handleUpdateAbilityScoresSkillsSaves (mon) {
|
|
const TO_HANDLE = ["str", "dex", "int", "wis", "con"];
|
|
|
|
const getModString = (mod) => {
|
|
return `${mod >= 0 ? "+" : ""}${mod}`;
|
|
};
|
|
|
|
TO_HANDLE.forEach(abil => {
|
|
const abilOld = (() => {
|
|
// Written out in full to make ctrl-F easier
|
|
switch (abil) {
|
|
case "str": return `strOld`;
|
|
case "dex": return `dexOld`;
|
|
case "int": return `intOld`;
|
|
case "wis": return `wisOld`;
|
|
case "con": return `conOld`;
|
|
default: throw new Error(`Unimplemented!`);
|
|
}
|
|
})();
|
|
if (mon[abilOld] != null) {
|
|
const diff = Parser.getAbilityModNumber(mon[abil]) - Parser.getAbilityModNumber(mon[abilOld]);
|
|
|
|
if (mon.save && mon.save[abil] != null) {
|
|
const out = Number(mon.save[abil]) + diff;
|
|
mon.save[abil] = UiUtil.intToBonus(out);
|
|
}
|
|
|
|
this._handleUpdateAbilityScoresSkillsSaves_handleSkills(mon.skill, abil, diff);
|
|
|
|
if (abil === "wis" && mon.passive != null) {
|
|
if (typeof mon.passive === "number") {
|
|
mon.passive = mon.passive + diff;
|
|
} else {
|
|
// Passive perception can be a string in e.g. the case of Artificer Steel Defender
|
|
delete mon.passive;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
},
|
|
|
|
_handleUpdateAbilityScoresSkillsSaves_handleSkills (monSkill, abil, diff) {
|
|
if (!monSkill) return;
|
|
|
|
Object.keys(monSkill).forEach(skill => {
|
|
if (skill === "other") {
|
|
monSkill[skill].forEach(block => {
|
|
if (block.oneOf) {
|
|
this._handleUpdateAbilityScoresSkillsSaves_handleSkills(block.oneOf.oneOf, abil, diff);
|
|
} else throw new Error(`Unhandled "other" skill keys: ${Object.keys(block)}`);
|
|
});
|
|
return;
|
|
}
|
|
|
|
const skillAbil = Parser.skillToAbilityAbv(skill);
|
|
if (skillAbil !== abil) return;
|
|
const out = Number(monSkill[skill]) + diff;
|
|
monSkill[skill] = UiUtil.intToBonus(out);
|
|
});
|
|
},
|
|
|
|
_spells: null,
|
|
async _pInitSpellCache () {
|
|
if (this._spells) return Promise.resolve();
|
|
|
|
this._spells = {};
|
|
|
|
this.__initSpellCache({
|
|
spell: (await DataUtil.spell.loadJSON()).spell.filter(sp => sp.source === Parser.SRC_PHB),
|
|
});
|
|
},
|
|
|
|
__initSpellCache (data) {
|
|
data.spell.forEach(s => {
|
|
Renderer.spell.getCombinedClasses(s, "fromClassList")
|
|
.forEach(c => {
|
|
let it = (this._spells[c.source] = this._spells[c.source] || {});
|
|
const lowName = c.name.toLowerCase();
|
|
it = (it[lowName] = it[lowName] || {});
|
|
it = (it[s.level] = it[s.level] || {});
|
|
it[s.name] = 1;
|
|
});
|
|
});
|
|
},
|
|
|
|
_adjustSpellcasting (mon, crIn, crOut) {
|
|
const getSlotsAtLevel = (casterLvl, slotLvl) => {
|
|
// there's probably a nice equation for this somewhere
|
|
if (casterLvl < (slotLvl * 2) - 1) return 0;
|
|
switch (slotLvl) {
|
|
case 1: return casterLvl === 1 ? 2 : casterLvl === 2 ? 3 : 4;
|
|
case 2: return casterLvl === 3 ? 2 : 3;
|
|
case 3: return casterLvl === 5 ? 2 : 3;
|
|
case 4: return casterLvl === 7 ? 1 : casterLvl === 8 ? 2 : 3;
|
|
case 5: return casterLvl === 9 ? 1 : casterLvl < 18 ? 2 : 3;
|
|
case 6: return casterLvl >= 19 ? 2 : 1;
|
|
case 7: return casterLvl === 20 ? 2 : 1;
|
|
case 8: return 1;
|
|
case 9: return 1;
|
|
}
|
|
};
|
|
|
|
if (!mon.spellcasting) return;
|
|
|
|
const idealClvlIn = this._crToCasterLevel(crIn);
|
|
const idealClvlOut = this._crToCasterLevel(crOut);
|
|
|
|
const isWarlock = this._adjustSpellcasting_isWarlock(mon);
|
|
// favor the first result as primary
|
|
let primaryInLevel = null;
|
|
let primaryOutLevel = null;
|
|
|
|
mon.spellcasting.forEach(sc => {
|
|
// attempt to ascertain class spells
|
|
let spellsFromClass = null;
|
|
|
|
if (sc.headerEntries) {
|
|
const inStr = JSON.stringify(sc.headerEntries);
|
|
|
|
let anyChange = false;
|
|
const outStr = inStr.replace(/(an?) (\d+)[A-Za-z]+-level/i, (...m) => {
|
|
const level = Number(m[2]);
|
|
const outLevel = Math.max(1, Math.min(20, ScaleCreatureUtils.getScaledToRatio(level, idealClvlIn, idealClvlOut)));
|
|
anyChange = level !== outLevel;
|
|
if (anyChange) {
|
|
if (primaryInLevel == null) primaryInLevel = level;
|
|
if (primaryOutLevel == null) primaryOutLevel = outLevel;
|
|
return `${Parser.getArticle(outLevel)} ${Parser.spLevelToFull(outLevel)}-level`;
|
|
} else return m[0];
|
|
});
|
|
|
|
const mClasses = /(artificer|bard|cleric|druid|paladin|ranger|sorcerer|warlock|wizard) spells?/i.exec(outStr);
|
|
if (mClasses) spellsFromClass = mClasses[1];
|
|
else {
|
|
const mClasses2 = /(artificer|bard|cleric|druid|paladin|ranger|sorcerer|warlock|wizard)(?:'s)? spell list/i.exec(outStr);
|
|
if (mClasses2) spellsFromClass = mClasses2[1];
|
|
}
|
|
|
|
if (anyChange) sc.headerEntries = JSON.parse(outStr);
|
|
}
|
|
|
|
// calculate spell level from caster levels
|
|
let maxSpellLevel = null;
|
|
if (primaryOutLevel) {
|
|
maxSpellLevel = Math.min(9, Math.ceil(primaryOutLevel / 2));
|
|
|
|
// cap half-caster slots at 5
|
|
if (/paladin|ranger|warlock/i.exec(spellsFromClass)) {
|
|
maxSpellLevel = Math.min(5, primaryOutLevel);
|
|
}
|
|
}
|
|
|
|
if (sc.spells && primaryOutLevel != null) {
|
|
const spells = sc.spells;
|
|
|
|
// "lower" is the property defining a set of spell slots as having a lower bound, e.g. "1st-5th level"
|
|
const isWarlockCasting = /warlock/i.exec(spellsFromClass) && Object.values(spells).filter(it => it.slots && it.lower).length === 1;
|
|
|
|
// cantrips
|
|
if (spells[0]) {
|
|
const curCantrips = spells[0].spells.length;
|
|
const idealCantripsIn = this._casterLevelAndClassToCantrips(primaryInLevel, spellsFromClass);
|
|
const idealCantripsOut = this._casterLevelAndClassToCantrips(primaryOutLevel, spellsFromClass);
|
|
const targetCantripCount = ScaleCreatureUtils.getScaledToRatio(curCantrips, idealCantripsIn, idealCantripsOut);
|
|
|
|
if (curCantrips < targetCantripCount) {
|
|
const cantrips = Object.keys((this._spells[Parser.SRC_PHB][spellsFromClass.toLowerCase()] || {})[0]).map(it => it.toLowerCase());
|
|
if (cantrips.length) {
|
|
const extraCantrips = [];
|
|
const numNew = Math.min(targetCantripCount - curCantrips, cantrips.length);
|
|
for (let n = 0; n < numNew; ++n) {
|
|
const ix = RollerUtil.roll(cantrips.length, this._rng);
|
|
extraCantrips.push(cantrips[ix]);
|
|
cantrips.splice(ix, 1);
|
|
}
|
|
spells[0].spells = spells[0].spells.concat(extraCantrips.map(it => `{@spell ${it}}`));
|
|
}
|
|
} else {
|
|
const keepThese = this._protectedCantrips.map(it => `@spell ${it}`);
|
|
while (spells[0].spells.length > targetCantripCount) {
|
|
const ixs = spells[0].spells.filterIndex(it => !~keepThese.findIndex(x => it.includes(x)));
|
|
if (ixs.length) {
|
|
const ix = RollerUtil.roll(ixs.length, this._rng);
|
|
spells[0].spells.splice(ix, 1);
|
|
} else spells[0].spells.pop();
|
|
}
|
|
}
|
|
}
|
|
|
|
// spells
|
|
if (isWarlockCasting) {
|
|
const curCastingLevel = Object.keys(spells).find(k => spells[k].lower);
|
|
if (maxSpellLevel === Number(curCastingLevel)) return;
|
|
if (maxSpellLevel === 0) {
|
|
Object.keys(spells).filter(lvl => lvl !== "0").forEach(lvl => delete spells[lvl]);
|
|
return;
|
|
}
|
|
|
|
const numSpellsKnown = this._adjustSpellcasting_getWarlockNumSpellsKnown(primaryOutLevel);
|
|
const warlockSpells = this._spells[Parser.SRC_PHB].warlock;
|
|
let spellList = [];
|
|
for (let i = 1; i < maxSpellLevel + 1; ++i) {
|
|
spellList = spellList.concat(Object.keys(warlockSpells[i]).map(sp => sp.toSpellCase()));
|
|
}
|
|
const spellsKnown = []; // TODO maintain original spell list if possible -- add them to this list, and remove them from the list being rolled against
|
|
for (let i = 0; i < numSpellsKnown; ++i) {
|
|
const ix = RollerUtil.roll(spellList.length, this._rng);
|
|
spellsKnown.push(spellList[ix]);
|
|
spellList.splice(ix, 1);
|
|
}
|
|
Object.keys(spells).filter(lvl => lvl !== "0").forEach(lvl => delete spells[lvl]);
|
|
const slots = this._adjustSpellcasting_getWarlockNumSpellSlots(maxSpellLevel);
|
|
spells[maxSpellLevel] = {
|
|
slots,
|
|
lower: 1,
|
|
spells: [
|
|
`A selection of ${maxSpellLevel === 1 ? `{@filter 1st-level warlock spells|spells|level=${1}|class=warlock}.` : `{@filter 1st- to ${Parser.spLevelToFull(maxSpellLevel)}-level warlock spells|spells|level=${[...new Array(maxSpellLevel)].map((_, i) => i + 1).join(";")}|class=warlock}.`} Examples include: ${spellsKnown.sort(SortUtil.ascSortLower).map(it => `{@spell ${it}}`).joinConjunct(", ", " and ")}`,
|
|
],
|
|
};
|
|
} else {
|
|
let lastRatio = 1; // adjust for higher/lower than regular spell slot counts
|
|
for (let i = 1; i < 10; ++i) {
|
|
const atLevel = spells[i];
|
|
const idealSlotsIn = getSlotsAtLevel(primaryInLevel, i);
|
|
const idealSlotsOut = getSlotsAtLevel(primaryOutLevel, i);
|
|
|
|
if (atLevel) {
|
|
// TODO grow/shrink the spell list at this level as required
|
|
if (atLevel.slots) { // no "slots" signifies at-wills
|
|
const adjustedSlotsOut = ScaleCreatureUtils.getScaledToRatio(atLevel.slots, idealSlotsIn, idealSlotsOut);
|
|
lastRatio = adjustedSlotsOut / idealSlotsOut;
|
|
|
|
atLevel.slots = adjustedSlotsOut;
|
|
if (adjustedSlotsOut <= 0) {
|
|
delete spells[i];
|
|
}
|
|
}
|
|
} else if (i <= maxSpellLevel) {
|
|
const slots = Math.max(1, Math.round(idealSlotsOut * lastRatio));
|
|
if (spellsFromClass && (this._spells[Parser.SRC_PHB][spellsFromClass.toLowerCase()] || {})[i]) {
|
|
const examples = [];
|
|
const levelSpells = Object.keys(this._spells[Parser.SRC_PHB][spellsFromClass.toLowerCase()][i]).map(it => it.toSpellCase());
|
|
const numExamples = Math.min(5, levelSpells.length);
|
|
for (let n = 0; n < numExamples; ++n) {
|
|
const ix = RollerUtil.roll(levelSpells.length, this._rng);
|
|
examples.push(levelSpells[ix]);
|
|
levelSpells.splice(ix, 1);
|
|
}
|
|
spells[i] = {
|
|
slots,
|
|
spells: [
|
|
`A selection of {@filter ${Parser.spLevelToFull(i)}-level ${spellsFromClass} spells|spells|level=${i}|class=${spellsFromClass}}. Examples include: ${examples.sort(SortUtil.ascSortLower).map(it => `{@spell ${it}}`).joinConjunct(", ", " and ")}`,
|
|
],
|
|
};
|
|
} else {
|
|
spells[i] = {
|
|
slots,
|
|
spells: [
|
|
`A selection of {@filter ${Parser.spLevelToFull(i)}-level spells|spells|level=${i}}`,
|
|
],
|
|
};
|
|
}
|
|
} else {
|
|
delete spells[i];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
mon.spellcasting.forEach(sc => {
|
|
// adjust Mystic Arcanum spells
|
|
if (isWarlock && sc.daily && sc.daily["1e"]) {
|
|
const numArcanum = this._adjustSpellcasting_getWarlockNumArcanum(primaryOutLevel);
|
|
|
|
const curNumSpells = sc.daily["1e"].length;
|
|
|
|
if (sc.daily["1e"].length === numArcanum) return;
|
|
if (numArcanum === 0) return delete sc.daily["1e"];
|
|
|
|
if (curNumSpells > numArcanum) {
|
|
// map each existing spell e.g. `{@spell gate}` to an object of the form `{original: "{@spell gate}", level: 9}`
|
|
const curSpells = sc.daily["1e"].map(it => {
|
|
const m = /{@spell ([^|}]+)(?:\|([^|}]+))?[|}]/.exec(it);
|
|
if (m) {
|
|
const nameTag = m[1].toLowerCase();
|
|
const srcTag = (m[2] || Parser.SRC_PHB).toLowerCase();
|
|
|
|
const src = Object.keys(this._spells).find(it => it.toLowerCase() === srcTag);
|
|
if (src) {
|
|
const levelStr = Object.keys(this._spells[src].warlock || {}).find(lvl => Object.keys((this._spells[src].warlock || {})[lvl]).some(nm => nm.toLowerCase() === nameTag));
|
|
|
|
if (levelStr) return {original: it, level: Number(levelStr)};
|
|
}
|
|
}
|
|
return {original: it, level: null};
|
|
});
|
|
|
|
for (let i = 9; i > 5; --i) {
|
|
const ixToRemove = curSpells.map(it => it.level === i ? curSpells.indexOf(it) : -1).filter(it => ~it);
|
|
while (ixToRemove.length && curSpells.length > numArcanum) {
|
|
curSpells.splice(ixToRemove.pop(), 1);
|
|
}
|
|
if (curSpells.length === numArcanum) break;
|
|
}
|
|
|
|
sc.daily["1e"] = curSpells.map(it => it.original);
|
|
} else {
|
|
for (let i = 5 + curNumSpells; i < 5 + numArcanum; ++i) {
|
|
const rollOn = Object.keys(this._spells[Parser.SRC_PHB].warlock[i]);
|
|
const ix = RollerUtil.roll(rollOn.length, this._rng);
|
|
sc.daily["1e"].push(`{@spell ${rollOn[ix].toSpellCase()}}`);
|
|
}
|
|
|
|
sc.daily["1e"].sort(SortUtil.ascSortLower);
|
|
}
|
|
}
|
|
});
|
|
},
|
|
|
|
_adjustSpellcasting_isWarlock (mon) {
|
|
if (mon.spellcasting) {
|
|
return mon.spellcasting.some(sc => sc.headerEntries && /warlock spells?|warlock('s)? spell list/i.test(JSON.stringify(sc.headerEntries)));
|
|
}
|
|
},
|
|
|
|
_adjustSpellcasting_getWarlockNumSpellsKnown (level) {
|
|
return level <= 9 ? level + 1 : 10 + Math.ceil((level - 10) / 2);
|
|
},
|
|
|
|
_adjustSpellcasting_getWarlockNumSpellSlots (level) {
|
|
return level === 1 ? 1 : level < 11 ? 2 : level < 17 ? 3 : 4;
|
|
},
|
|
|
|
_adjustSpellcasting_getWarlockNumArcanum (level) {
|
|
return level < 11 ? 0 : level < 13 ? 1 : level < 15 ? 2 : level < 17 ? 3 : 4;
|
|
},
|
|
};
|
|
|
|
globalThis.ScaleSummonedCreature = {
|
|
_mutSimpleSpecialAcItem (acItem) {
|
|
// Try to convert to "from" AC
|
|
const mSimpleNatural = /^(\d+) \(natural armor\)$/i.exec(acItem.special);
|
|
if (mSimpleNatural) {
|
|
delete acItem.special;
|
|
acItem.ac = Number(mSimpleNatural[1]);
|
|
acItem.from = ["natural armor"];
|
|
}
|
|
},
|
|
|
|
/** */
|
|
_mutSimpleSpecialHp (mon) {
|
|
if (!mon.hp?.special) return;
|
|
|
|
const cleanHp = mon.hp.special.toLowerCase().replace(/ /g, "");
|
|
const mHp = /^(?<averagePart>\d+)(?<hdPart>\((?<dicePart>\d+d\d+)(?<bonusPart>[-+]\d+)?\))?$/.exec(cleanHp);
|
|
|
|
if (!mHp) return;
|
|
|
|
if (!mHp.groups.hdPart) return {average: Number(mHp.groups.averagePart)};
|
|
|
|
mon.hp = {
|
|
average: Number(mHp.groups.averagePart),
|
|
formula: `${mHp.groups.dicePart}${mHp.groups.bonusPart ? mHp.groups.bonusPart.replace(/[-+]/g, " $0 ") : ""}`,
|
|
};
|
|
},
|
|
};
|
|
|
|
globalThis.ScaleSpellSummonedCreature = {
|
|
async scale (mon, toSpellLevel) {
|
|
mon = MiscUtil.copyFast(mon);
|
|
|
|
if (!mon.summonedBySpell || mon.summonedBySpellLevel == null) return mon;
|
|
|
|
ScaleSpellSummonedCreature._WALKER = ScaleSpellSummonedCreature._WALKER || MiscUtil.getWalker({keyBlocklist: MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST});
|
|
|
|
const state = new ScaleSpellSummonedCreature.State({});
|
|
|
|
mon._displayName = `${mon.name} (${Parser.getOrdinalForm(toSpellLevel)}-Level Spell)`;
|
|
|
|
this._scale_ac(mon, toSpellLevel, state);
|
|
this._scale_hp(mon, toSpellLevel, state);
|
|
|
|
this._scale_traits(mon, toSpellLevel, state);
|
|
this._scale_actions(mon, toSpellLevel, state);
|
|
this._scale_bonusActions(mon, toSpellLevel, state);
|
|
this._scale_reactions(mon, toSpellLevel, state);
|
|
|
|
mon._summonedBySpell_level = toSpellLevel;
|
|
mon._scaledSpellSummonLevel = toSpellLevel;
|
|
mon._isScaledSpellSummon = true;
|
|
|
|
return mon;
|
|
},
|
|
|
|
_scale_ac (mon, toSpellLevel, state) {
|
|
if (!mon.ac) return;
|
|
|
|
mon.ac = mon.ac.map(it => {
|
|
if (!it.special) return it;
|
|
|
|
it.special = it.special
|
|
// "11 + the level of the spell (natural armor)"
|
|
.replace(/(\d+)\s*\+\s*the level of the spell/g, (...m) => Number(m[1]) + toSpellLevel)
|
|
;
|
|
|
|
ScaleSummonedCreature._mutSimpleSpecialAcItem(it);
|
|
|
|
return it;
|
|
});
|
|
},
|
|
|
|
_scale_hp (mon, toSpellLevel, state) {
|
|
if (!mon.hp?.special) return;
|
|
|
|
mon.hp.special = mon.hp.special
|
|
// "40 + 10 for each spell level above 4th"
|
|
.replace(/(\d+)\s*\+\s*(\d+) for each spell level above (\d+)(?:st|nd|rd|th)/g, (...m) => {
|
|
const [, hpBase, hpPlus, spLevelMin] = m;
|
|
return Number(hpBase) + (Number(hpPlus) * (toSpellLevel - Number(spLevelMin)));
|
|
})
|
|
// "equal the aberration's Constitution modifier + your spellcasting ability modifier + ten times the spell's level"
|
|
.replace(/(ten) times the spell's level/g, (...m) => {
|
|
const [, numMult] = m;
|
|
return Parser.textToNumber(numMult) * toSpellLevel;
|
|
})
|
|
;
|
|
|
|
ScaleSummonedCreature._mutSimpleSpecialHp(mon);
|
|
},
|
|
|
|
_scale_genericEntries (mon, toSpellLevel, state, prop) {
|
|
if (!mon[prop]) return;
|
|
mon[prop] = ScaleSpellSummonedCreature._WALKER.walk(
|
|
mon[prop],
|
|
{
|
|
string: (str) => {
|
|
str = str
|
|
// "The aberration makes a number of attacks equal to half this spell's level (rounded down)."
|
|
.replace(/a number of attacks equal to half this spell's level \(rounded down\)/g, (...m) => {
|
|
const count = Math.floor(toSpellLevel / 2);
|
|
return `${Parser.numberToText(count)} attack${count === 1 ? "" : "s"}`;
|
|
})
|
|
// "{@damage 1d8 + 3 + summonSpellLevel}"
|
|
.replace(/{@(?:dice|damage|hit|d20) [^}]+}/g, (...m) => {
|
|
return m[0]
|
|
.replace(/\bsummonSpellLevel\b/g, (...n) => toSpellLevel)
|
|
;
|
|
})
|
|
;
|
|
|
|
return str;
|
|
},
|
|
},
|
|
);
|
|
},
|
|
|
|
_scale_traits (mon, toSpellLevel, state) { this._scale_genericEntries(mon, toSpellLevel, state, "trait"); },
|
|
_scale_actions (mon, toSpellLevel, state) { this._scale_genericEntries(mon, toSpellLevel, state, "action"); },
|
|
_scale_bonusActions (mon, toSpellLevel, state) { this._scale_genericEntries(mon, toSpellLevel, state, "bonus"); },
|
|
_scale_reactions (mon, toSpellLevel, state) { this._scale_genericEntries(mon, toSpellLevel, state, "reaction"); },
|
|
|
|
State: function () {
|
|
// (Implement as required)
|
|
// this.whatever = null;
|
|
},
|
|
|
|
_WALKER: null,
|
|
};
|
|
|
|
globalThis.ScaleClassSummonedCreature = {
|
|
async scale (mon, toClassLevel) {
|
|
mon = MiscUtil.copyFast(mon);
|
|
|
|
if (!mon.summonedByClass || toClassLevel < 1) return mon;
|
|
|
|
ScaleClassSummonedCreature._WALKER = ScaleClassSummonedCreature._WALKER || MiscUtil.getWalker({keyBlocklist: MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST});
|
|
|
|
const className = mon.summonedByClass.split("|")[0].toTitleCase();
|
|
const state = new ScaleClassSummonedCreature.State({
|
|
className,
|
|
proficiencyBonus: Parser.levelToPb(toClassLevel),
|
|
});
|
|
|
|
mon._displayName = `${mon.name} (Level ${toClassLevel} ${className})`;
|
|
|
|
this._scale_ac(mon, toClassLevel, state);
|
|
this._scale_hp(mon, toClassLevel, state);
|
|
|
|
this._scale_saves(mon, toClassLevel, state);
|
|
this._scale_skills(mon, toClassLevel, state);
|
|
|
|
this._scale_pbNote(mon, toClassLevel, state);
|
|
|
|
this._scale_traits(mon, toClassLevel, state);
|
|
this._scale_actions(mon, toClassLevel, state);
|
|
this._scale_bonusActions(mon, toClassLevel, state);
|
|
this._scale_reactions(mon, toClassLevel, state);
|
|
|
|
mon._summonedByClass_level = toClassLevel;
|
|
mon._scaledClassSummonLevel = toClassLevel;
|
|
mon._isScaledClassSummon = true;
|
|
|
|
return mon;
|
|
},
|
|
|
|
_scale_ac (mon, toClassLevel, state) {
|
|
if (!mon.ac) return;
|
|
|
|
mon.ac = mon.ac.map(it => {
|
|
if (!it.special) return it;
|
|
|
|
it.special = it.special
|
|
// "13 + PB (natural armor)"
|
|
// "13 plus PB (natural armor)"
|
|
.replace(/(\d+)\s*(\+|plus)\s*PB\b/g, (...m) => Number(m[1]) + state.proficiencyBonus)
|
|
;
|
|
|
|
ScaleSummonedCreature._mutSimpleSpecialAcItem(it);
|
|
|
|
return it;
|
|
});
|
|
},
|
|
|
|
_scale_getConvertedPbString (state, str, {isBonus = false} = {}) {
|
|
let out = str
|
|
.replace(/\bplus\b/gi, "+")
|
|
.replace(/(\b|[-+])PB\b/g, `$1${state.proficiencyBonus}`)
|
|
// eslint-disable-next-line no-eval
|
|
.replace(/[-+]\s*\d+\s*[-+]\s*\d+\b/g, (...n) => eval(n[0]))
|
|
;
|
|
|
|
const reDice = /(\b(?:\d+)?d\d+\b)/g;
|
|
let ix = 0;
|
|
const outSimplified = out.split(reDice)
|
|
.map(pt => {
|
|
// Don't increase index for empty strings
|
|
if (!pt.trim()) return pt;
|
|
|
|
if (reDice.test(pt)) {
|
|
ix++;
|
|
return pt;
|
|
}
|
|
|
|
const simplified = Renderer.dice.parseRandomise2(pt);
|
|
if (simplified != null) {
|
|
if (ix) {
|
|
ix++;
|
|
return UiUtil.intToBonus(simplified);
|
|
}
|
|
ix++;
|
|
return simplified;
|
|
}
|
|
|
|
ix++;
|
|
return pt;
|
|
})
|
|
.join("")
|
|
.replace(/\s*[-+]\s*/g, (...m) => ` ${m[0].trim()} `);
|
|
|
|
if (!isNaN(outSimplified) && isBonus) return UiUtil.intToBonus(outSimplified);
|
|
return outSimplified;
|
|
},
|
|
|
|
_scale_savesSkills (mon, toClassLevel, state, prop) {
|
|
mon[prop] = Object.entries(mon[prop])
|
|
.mergeMap(([k, v]) => {
|
|
if (typeof v !== "string") return {[k]: v};
|
|
return {[k]: this._scale_getConvertedPbString(state, v, {isBonus: true})};
|
|
});
|
|
},
|
|
|
|
_scale_saves (mon, toClassLevel, state) {
|
|
if (!mon.save) return;
|
|
this._scale_savesSkills(mon, toClassLevel, state, "save");
|
|
},
|
|
|
|
_scale_skills (mon, toClassLevel, state) {
|
|
if (mon.passive != null) mon.passive = this._scale_getConvertedPbString(state, `${mon.passive}`);
|
|
|
|
if (!mon.skill) return;
|
|
this._scale_savesSkills(mon, toClassLevel, state, "skill");
|
|
},
|
|
|
|
_scale_hp (mon, toClassLevel, state) {
|
|
if (!mon.hp?.special) return;
|
|
|
|
let basePart = mon.hp.special; let hdPart = ""; let yourAbilModPart = "";
|
|
if (mon.hp.special.includes("(")) {
|
|
let [start, ...rest] = mon.hp.special.split("(");
|
|
rest = rest.join("(");
|
|
if (rest.toLowerCase().includes("hit dice")) {
|
|
basePart = start.trim();
|
|
hdPart = rest.trimAnyChar("() ");
|
|
}
|
|
}
|
|
|
|
basePart = basePart
|
|
.replace(/\+\s*your (?:Strength|Dexterity|Constitution|Intelligence|Wisdom|Charisma) modifier/i, (...m) => {
|
|
yourAbilModPart = m[0];
|
|
return "";
|
|
})
|
|
.replace(/ +/g, " ")
|
|
.trim();
|
|
|
|
basePart = basePart
|
|
// "5 + five times your ranger level"
|
|
.replace(/(?<base>\d+)\s*\+\s*(?<perLevel>\d+|[a-z]+) times your (?:(?<className>[^(]*) )?level/g, (...m) => {
|
|
const numTimes = isNaN(m.last().perLevel) ? Parser.textToNumber(m.last().perLevel) : Number(m.last().perLevel);
|
|
return `${Number(m.last().base) + (numTimes * toClassLevel)}`;
|
|
})
|
|
// "1 + <...> + your artificer level"
|
|
.replace(/(?<base>\d+)\s*\+\s*your (?:(?<className>[^(]*) )?level/g, (...m) => {
|
|
return `${Number(m.last().base) + toClassLevel}`;
|
|
})
|
|
// "equal the beast's Constitution modifier + five times your ranger level"
|
|
.replace(/equal .*? Constitution modifier\s*\+\s*(?<perLevel>\d+|[a-z]+) times your (?:(?<className>[^(]*) )?level/g, (...m) => {
|
|
const numTimes = isNaN(m.last().perLevel) ? Parser.textToNumber(m.last().perLevel) : Number(m.last().perLevel);
|
|
return `${Parser.getAbilityModNumber(mon.con) + (numTimes * toClassLevel)}`;
|
|
})
|
|
;
|
|
|
|
basePart = this._scale_getConvertedPbString(state, basePart);
|
|
|
|
// "the beast has a number of Hit Dice [d8s] equal to your ranger level"
|
|
if (hdPart) {
|
|
hdPart = hdPart.replace(/(?<intro>.*) a number of hit dice \[d(?<hdSides>\d+)s?] equal to your (?:(?<className>[^(]*) )?level/i, (...m) => {
|
|
const hdFormula = `${toClassLevel}d${m.last().hdSides}`;
|
|
if (!yourAbilModPart) return hdFormula;
|
|
|
|
return `${m.last().intro} {@dice ${hdFormula}} Hit Dice`;
|
|
});
|
|
}
|
|
|
|
// If there is an ability modifier part, we cannot scale purely by level--display an expression instead.
|
|
if (yourAbilModPart) {
|
|
mon.hp.special = `${basePart} ${yourAbilModPart}${hdPart ? ` (${hdPart})` : ""}`.trim();
|
|
} else {
|
|
mon.hp.special = `${basePart}${hdPart ? ` (${hdPart})` : ""}`.trim();
|
|
}
|
|
|
|
ScaleSummonedCreature._mutSimpleSpecialHp(mon);
|
|
},
|
|
|
|
_scale_genericEntries (mon, toClassLevel, state, prop) {
|
|
if (!mon[prop]) return;
|
|
mon[prop] = ScaleClassSummonedCreature._WALKER.walk(
|
|
mon[prop],
|
|
{
|
|
string: (str) => {
|
|
str = str
|
|
// "add your proficiency bonus"
|
|
.replace(/add your proficiency bonus/gi, (...m) => {
|
|
return `${m[0]} (${UiUtil.intToBonus(state.proficiencyBonus)})`;
|
|
})
|
|
// "{@damage 1d8 + 2 + PB}"
|
|
.replace(/{@(?<tag>dice|damage|hit|d20|dc) (?<text>[^}]+)}/g, (...m) => {
|
|
const {tag, text} = m.last();
|
|
const [ptNumber, ...ptsRest] = text.split("|");
|
|
|
|
const ptNumberOut = this._scale_getConvertedPbString(state, ptNumber);
|
|
|
|
return `{@${tag} ${[ptNumberOut, ...ptsRest].join("|")}}`;
|
|
})
|
|
;
|
|
|
|
return str;
|
|
},
|
|
},
|
|
);
|
|
},
|
|
|
|
_scale_traits (mon, toClassLevel, state) { this._scale_genericEntries(mon, toClassLevel, state, "trait"); },
|
|
_scale_actions (mon, toClassLevel, state) { this._scale_genericEntries(mon, toClassLevel, state, "action"); },
|
|
_scale_bonusActions (mon, toClassLevel, state) { this._scale_genericEntries(mon, toClassLevel, state, "bonus"); },
|
|
_scale_reactions (mon, toClassLevel, state) { this._scale_genericEntries(mon, toClassLevel, state, "reaction"); },
|
|
|
|
_scale_pbNote (mon, toClassLevel, state) {
|
|
if (!mon.pbNote) return;
|
|
|
|
mon.pbNote = mon.pbNote.replace(/equals your bonus\b/, (...m) => `${m[0]} (${UiUtil.intToBonus(state.proficiencyBonus, {isPretty: true})})`);
|
|
},
|
|
|
|
State: function ({className, proficiencyBonus}) {
|
|
this.className = className;
|
|
this.proficiencyBonus = proficiencyBonus;
|
|
},
|
|
|
|
_WALKER: null,
|
|
};
|