@yipe/dice 0.8.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +169 -19
- package/dist/builder/ac.d.ts.map +1 -1
- package/dist/builder/ast.d.ts +32 -2
- package/dist/builder/ast.d.ts.map +1 -1
- package/dist/builder/attack.d.ts.map +1 -1
- package/dist/builder/dc.d.ts.map +1 -1
- package/dist/builder/example.d.ts +4 -4
- package/dist/builder/example.d.ts.map +1 -1
- package/dist/builder/index.cjs +889 -90
- package/dist/builder/index.cjs.map +1 -1
- package/dist/builder/index.d.ts +1 -0
- package/dist/builder/index.d.ts.map +1 -1
- package/dist/builder/index.js +886 -91
- package/dist/builder/index.js.map +1 -1
- package/dist/builder/roll.d.ts +5 -1
- package/dist/builder/roll.d.ts.map +1 -1
- package/dist/builder/save.d.ts.map +1 -1
- package/dist/index.cjs +178 -15
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +177 -16
- package/dist/index.js.map +1 -1
- package/dist/parser/dice.d.ts +14 -0
- package/dist/parser/dice.d.ts.map +1 -1
- package/dist/parser/rollType.d.ts +58 -0
- package/dist/parser/rollType.d.ts.map +1 -0
- package/dist/pmf/pmf.d.ts +10 -4
- package/dist/pmf/pmf.d.ts.map +1 -1
- package/dist/pmf/query.d.ts +26 -0
- package/dist/pmf/query.d.ts.map +1 -1
- package/dist/turn/index.d.ts +3 -0
- package/dist/turn/index.d.ts.map +1 -0
- package/dist/turn/plan.d.ts +55 -0
- package/dist/turn/plan.d.ts.map +1 -0
- package/dist/turn/state.d.ts +23 -0
- package/dist/turn/state.d.ts.map +1 -0
- package/dist/turn/turn.d.ts +149 -0
- package/dist/turn/turn.d.ts.map +1 -0
- package/dist/turn/types.d.ts +99 -0
- package/dist/turn/types.d.ts.map +1 -0
- package/package.json +1 -1
package/dist/builder/index.js
CHANGED
|
@@ -115,6 +115,10 @@ var _DiceQuery = class _DiceQuery {
|
|
|
115
115
|
if (this._combinedWithAttr) {
|
|
116
116
|
return this._combinedWithAttr;
|
|
117
117
|
}
|
|
118
|
+
if (this._combinedProvided) {
|
|
119
|
+
this._combinedWithAttr = this.combined.withAttribution();
|
|
120
|
+
return this._combinedWithAttr;
|
|
121
|
+
}
|
|
118
122
|
if (this.singles.every((pmf) => pmf.hasAttribution())) {
|
|
119
123
|
this._combinedWithAttr = this.combined;
|
|
120
124
|
return this._combinedWithAttr;
|
|
@@ -885,6 +889,55 @@ var _DiceQuery = class _DiceQuery {
|
|
|
885
889
|
}
|
|
886
890
|
return out;
|
|
887
891
|
}
|
|
892
|
+
/**
|
|
893
|
+
* Per-outcome probabilities and damage ranges, aggregated over the individual
|
|
894
|
+
* singles rather than read off the combined distribution.
|
|
895
|
+
*
|
|
896
|
+
* `damageRange` is the sum, over every single that can produce the outcome, of
|
|
897
|
+
* that single's own conditional damage range: "what this outcome contributes
|
|
898
|
+
* across the whole turn when every attack that can produce it does". Linear in
|
|
899
|
+
* the number of attacks by construction.
|
|
900
|
+
*
|
|
901
|
+
* Prefer this over {@link DiceQuery.snapshot} for a multi-attack query.
|
|
902
|
+
* `snapshot` reads `damageRange` off the combined PMF's `count`, which the
|
|
903
|
+
* convolution accumulates as an expected count, so its `avg` is size-biased
|
|
904
|
+
* for N≥2 (its own doc comment says so). The two agree for a single attack.
|
|
905
|
+
*
|
|
906
|
+
* Only outcomes that actually occur appear in the result.
|
|
907
|
+
*
|
|
908
|
+
* Like every `singles`-based helper on this class, it describes the singles
|
|
909
|
+
* and not an explicitly provided `combined`. `Turn.toQuery()` supplies one whose
|
|
910
|
+
* distribution also contains rider attacks that are absent from `singles`
|
|
911
|
+
* (an `otherwise([unarmed, unarmed])` flurry, say), so those attacks do not
|
|
912
|
+
* appear here. For rider-inclusive figures read the combined distribution
|
|
913
|
+
* directly: {@link DiceQuery.outcomeTotals}, {@link DiceQuery.outcomeDamageRanges}.
|
|
914
|
+
*
|
|
915
|
+
* @param outcomes Which outcomes to consider; defaults to every canonical one.
|
|
916
|
+
*/
|
|
917
|
+
outcomeStats(outcomes = ALL_OUTCOME_TYPES) {
|
|
918
|
+
const stats = /* @__PURE__ */ new Map();
|
|
919
|
+
const perSingle = this.singles.map((pmf) => new _DiceQuery([pmf], void 0, this._eps));
|
|
920
|
+
for (const outcome of outcomes) {
|
|
921
|
+
const atLeastOneProbability = this.probAtLeastOne(outcome);
|
|
922
|
+
if (atLeastOneProbability <= 0) continue;
|
|
923
|
+
const damageRange = { min: 0, avg: 0, max: 0 };
|
|
924
|
+
let contributors = 0;
|
|
925
|
+
for (const single of perSingle) {
|
|
926
|
+
if (single.probAtLeastOne(outcome) <= 0) continue;
|
|
927
|
+
contributors++;
|
|
928
|
+
const stat = single.damageStatsFrom(outcome);
|
|
929
|
+
damageRange.min += stat.min;
|
|
930
|
+
damageRange.avg += stat.avg;
|
|
931
|
+
damageRange.max += stat.max;
|
|
932
|
+
}
|
|
933
|
+
stats.set(outcome, {
|
|
934
|
+
atLeastOneProbability,
|
|
935
|
+
allProbability: contributors > 0 ? this.probExactlyK(outcome, contributors) : 0,
|
|
936
|
+
damageRange
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
return stats;
|
|
940
|
+
}
|
|
888
941
|
/**
|
|
889
942
|
* Snapshot of the distribution in the exact shape the UI consumes.
|
|
890
943
|
* - outcome probabilities are "at least one" (and equal to "all" for a single PMF)
|
|
@@ -1421,7 +1474,7 @@ var _PMF = class _PMF {
|
|
|
1421
1474
|
const id = this.identifier;
|
|
1422
1475
|
let key = `${id}`;
|
|
1423
1476
|
for (let i = 1; i < n; i++) key += `+${id}`;
|
|
1424
|
-
return `${key}@${eps}`;
|
|
1477
|
+
return `${key}@${eps}|${this.fingerprint()}`;
|
|
1425
1478
|
}
|
|
1426
1479
|
/**
|
|
1427
1480
|
* Efficiently computes this PMF convolved with itself `n` times.
|
|
@@ -1772,16 +1825,27 @@ var _PMF = class _PMF {
|
|
|
1772
1825
|
return `v4:${raw ? "RAW" : "N"}:${id1}+${id2}@${eps}|${p1.fingerprint()}|${p2.fingerprint()}`;
|
|
1773
1826
|
}
|
|
1774
1827
|
/**
|
|
1775
|
-
* A
|
|
1776
|
-
* cache keys change
|
|
1777
|
-
*
|
|
1778
|
-
*
|
|
1828
|
+
* A content fingerprint of every bin (probability, per-label `count`, per-label `attr`) plus
|
|
1829
|
+
* the `normalized` flag, so convolution/power cache keys change whenever the underlying
|
|
1830
|
+
* numbers do. Mass/bin-count/face-sum alone are not content-unique: `mapDamage` variants can
|
|
1831
|
+
* keep the same identifier, support, mass, and face sum while differing in per-bin
|
|
1832
|
+
* probabilities or in the `count`/`attr` channels `convolve()`/`power()` actually propagate --
|
|
1833
|
+
* that previously let `power()` return one PMF's cached result for a different PMF. Memoized
|
|
1834
|
+
* because a PMF is immutable once constructed -- this avoids re-deriving the key on every
|
|
1835
|
+
* convolve()/power() call (including cache hits). Bin order is sorted by damage value (and
|
|
1836
|
+
* label keys sorted within each bin) so two equal-content PMFs built via different code paths
|
|
1837
|
+
* fingerprint identically regardless of Map insertion order.
|
|
1779
1838
|
*/
|
|
1780
1839
|
fingerprint() {
|
|
1781
1840
|
if (this._fingerprint === void 0) {
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1841
|
+
const bins = [...this.map.entries()].sort((a, b) => a[0] - b[0]);
|
|
1842
|
+
const parts = [];
|
|
1843
|
+
for (const [damageValue, bin] of bins) {
|
|
1844
|
+
const countStr = Object.keys(bin.count).sort().map((k) => `${k}:${bin.count[k]}`).join(",");
|
|
1845
|
+
const attrStr = bin.attr ? Object.keys(bin.attr).sort().map((k) => `${k}:${bin.attr[k]}`).join(",") : "";
|
|
1846
|
+
parts.push(`${damageValue}:${bin.p}[${countStr}]{${attrStr}}`);
|
|
1847
|
+
}
|
|
1848
|
+
this._fingerprint = `${this.normalized ? 1 : 0}|${parts.join(";")}`;
|
|
1785
1849
|
}
|
|
1786
1850
|
return this._fingerprint;
|
|
1787
1851
|
}
|
|
@@ -2013,11 +2077,13 @@ var _PMF = class _PMF {
|
|
|
2013
2077
|
/** Quantile / inverse CDF for p in [0,1]. Returns smallest x with CDF ≥ p. */
|
|
2014
2078
|
quantile(p) {
|
|
2015
2079
|
if (this.map.size === 0) return 0;
|
|
2080
|
+
const totalMass = this.mass();
|
|
2081
|
+
if (totalMass <= 0) return 0;
|
|
2016
2082
|
const s = this.support().sort((a, b) => a - b);
|
|
2017
2083
|
let acc = 0;
|
|
2018
2084
|
for (const x of s) {
|
|
2019
2085
|
acc += this.pAt(x);
|
|
2020
|
-
if (acc >= p) return x;
|
|
2086
|
+
if (acc / totalMass >= p) return x;
|
|
2021
2087
|
}
|
|
2022
2088
|
return s[s.length - 1];
|
|
2023
2089
|
}
|
|
@@ -2996,6 +3062,12 @@ function combineDiceWithNormalization(dice, normValue, outcomeType, currentNorm,
|
|
|
2996
3062
|
finalResult = finalResult.combine(dice);
|
|
2997
3063
|
return { newNorm: currentNorm * normValue, updatedResult: finalResult };
|
|
2998
3064
|
}
|
|
3065
|
+
function subtractCounts(a, b) {
|
|
3066
|
+
const result = new Dice();
|
|
3067
|
+
for (const [key, value] of a.getFaceEntries()) result.increment(key, value);
|
|
3068
|
+
for (const [key, value] of b.getFaceEntries()) result.increment(key, -value);
|
|
3069
|
+
return result;
|
|
3070
|
+
}
|
|
2999
3071
|
function parseExpression(arr, n) {
|
|
3000
3072
|
const result = (() => {
|
|
3001
3073
|
const res = parseArgument(arr, n);
|
|
@@ -3003,8 +3075,29 @@ function parseExpression(arr, n) {
|
|
|
3003
3075
|
})();
|
|
3004
3076
|
let op = parseOperation(arr);
|
|
3005
3077
|
let finalResult = result;
|
|
3078
|
+
let baseDieMeta = result.privateData?.checkDie && !result.privateData.checkDie.rerollOne ? result.privateData.checkDie : void 0;
|
|
3079
|
+
let bonusOnly = Dice.scalar(0);
|
|
3006
3080
|
while (op != null) {
|
|
3007
3081
|
const arg = !op.unary ? parseArgument(arr, n) : finalResult;
|
|
3082
|
+
let acAlreadyApplied = false;
|
|
3083
|
+
if (baseDieMeta) {
|
|
3084
|
+
if (op === Dice.prototype.addNonZero) {
|
|
3085
|
+
bonusOnly = bonusOnly.add(arg);
|
|
3086
|
+
} else if (op === Dice.prototype.subtract) {
|
|
3087
|
+
bonusOnly = bonusOnly.subtract(arg);
|
|
3088
|
+
} else if (op === Dice.prototype.ac && typeof arg === "number") {
|
|
3089
|
+
const natMaxSlice = bonusOnly.add(baseDieMeta.sides);
|
|
3090
|
+
const restSlice = subtractCounts(finalResult, natMaxSlice);
|
|
3091
|
+
const gatedNatMaxSlice = natMaxSlice.ac(arg);
|
|
3092
|
+
finalResult = restSlice.ac(arg).combine(gatedNatMaxSlice);
|
|
3093
|
+
finalResult.privateData.checkDie = baseDieMeta;
|
|
3094
|
+
finalResult.privateData.natMaxCritSlice = gatedNatMaxSlice;
|
|
3095
|
+
acAlreadyApplied = true;
|
|
3096
|
+
baseDieMeta = void 0;
|
|
3097
|
+
} else {
|
|
3098
|
+
baseDieMeta = void 0;
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
3008
3101
|
let crit;
|
|
3009
3102
|
let critNorm = 1;
|
|
3010
3103
|
if (arr[0] === "x" || arr[0] === "c") {
|
|
@@ -3015,11 +3108,17 @@ function parseExpression(arr, n) {
|
|
|
3015
3108
|
assertToken(arr, "i");
|
|
3016
3109
|
assertToken(arr, "t");
|
|
3017
3110
|
const count = isXcrit ? parseNumber(arr, n) : 1;
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3111
|
+
const trackedCritSlice = finalResult.privateData?.natMaxCritSlice;
|
|
3112
|
+
if (count === 1 && trackedCritSlice) {
|
|
3113
|
+
crit = trackedCritSlice;
|
|
3114
|
+
finalResult = subtractCounts(finalResult, trackedCritSlice);
|
|
3115
|
+
} else {
|
|
3116
|
+
crit = new Dice();
|
|
3117
|
+
for (let i = 0; i < count; i++) {
|
|
3118
|
+
const max = finalResult.maxFace();
|
|
3119
|
+
crit.setFace(max, finalResult.get(max));
|
|
3120
|
+
finalResult = finalResult.deleteFace(max);
|
|
3121
|
+
}
|
|
3023
3122
|
}
|
|
3024
3123
|
critNorm = crit.total();
|
|
3025
3124
|
crit = op.call(crit, parseBinaryArgument(arg, arr, n));
|
|
@@ -3070,7 +3169,9 @@ function parseExpression(arr, n) {
|
|
|
3070
3169
|
missNorm = miss && missNorm ? miss.total() / missNorm : 1;
|
|
3071
3170
|
}
|
|
3072
3171
|
let norm = finalResult.total();
|
|
3073
|
-
|
|
3172
|
+
if (!acAlreadyApplied) {
|
|
3173
|
+
finalResult = op.call(finalResult, arg);
|
|
3174
|
+
}
|
|
3074
3175
|
norm = norm ? finalResult.total() / norm : 1;
|
|
3075
3176
|
if (crit) {
|
|
3076
3177
|
const result2 = combineDiceWithNormalization(
|
|
@@ -3261,6 +3362,7 @@ function parseDice(s, n) {
|
|
|
3261
3362
|
if (rerollOne) {
|
|
3262
3363
|
result = result.reroll(1);
|
|
3263
3364
|
}
|
|
3365
|
+
result.privateData.checkDie = { sides, rerollOne };
|
|
3264
3366
|
return result;
|
|
3265
3367
|
}
|
|
3266
3368
|
function peek(arr, expected) {
|
|
@@ -3452,6 +3554,12 @@ function d20PMF(rerollOne) {
|
|
|
3452
3554
|
}
|
|
3453
3555
|
|
|
3454
3556
|
// src/builder/roll.ts
|
|
3557
|
+
function validateScaleInt(scale) {
|
|
3558
|
+
const scaleInt = Math.floor(scale);
|
|
3559
|
+
if (scaleInt !== scale) throw new Error("Scale must be an integer");
|
|
3560
|
+
if (scaleInt <= 0) throw new Error("Scale must be > 0");
|
|
3561
|
+
return scaleInt;
|
|
3562
|
+
}
|
|
3455
3563
|
var rollPMFCache = new LRUCache(4e3);
|
|
3456
3564
|
function clearRollCache() {
|
|
3457
3565
|
rollPMFCache.clear();
|
|
@@ -3657,7 +3765,8 @@ var RollBuilder = class _RollBuilder {
|
|
|
3657
3765
|
newConfigs[newConfigs.length - 1].explode = count;
|
|
3658
3766
|
return this.create(newConfigs);
|
|
3659
3767
|
}
|
|
3660
|
-
/** Apply per-die minimum value (
|
|
3768
|
+
/** Apply per-die minimum value (floors each die roll at `val`, e.g. `minimum(3)` treats a 1 or
|
|
3769
|
+
* 2 as a 3 -- the 2024 Great Weapon Fighting style). */
|
|
3661
3770
|
minimum(val) {
|
|
3662
3771
|
if (val !== void 0 && isNaN(val))
|
|
3663
3772
|
throw new Error("Invalid NaN value for minimum");
|
|
@@ -3665,7 +3774,7 @@ var RollBuilder = class _RollBuilder {
|
|
|
3665
3774
|
if (val === 0) return this;
|
|
3666
3775
|
if (val < 0) throw new Error("Minimum value must be >= 0");
|
|
3667
3776
|
const newConfigs = this.getSubRollConfigs();
|
|
3668
|
-
newConfigs[newConfigs.length - 1].minimum = val
|
|
3777
|
+
newConfigs[newConfigs.length - 1].minimum = val;
|
|
3669
3778
|
return this.create(newConfigs);
|
|
3670
3779
|
}
|
|
3671
3780
|
bestOf(count) {
|
|
@@ -3766,9 +3875,7 @@ var RollBuilder = class _RollBuilder {
|
|
|
3766
3875
|
return this.create(configs);
|
|
3767
3876
|
}
|
|
3768
3877
|
scaleDice(scale) {
|
|
3769
|
-
const scaleInt =
|
|
3770
|
-
if (scaleInt !== scale) throw new Error("Scale must be an integer");
|
|
3771
|
-
if (scaleInt <= 0) throw new Error("Scale must be > 0");
|
|
3878
|
+
const scaleInt = validateScaleInt(scale);
|
|
3772
3879
|
const newConfigs = this.getSubRollConfigs().map((config) => {
|
|
3773
3880
|
if (!config.sides || config.sides <= 0) return config;
|
|
3774
3881
|
return { ...config, count: config.count * scaleInt };
|
|
@@ -3891,12 +3998,16 @@ var RollBuilder = class _RollBuilder {
|
|
|
3891
3998
|
}
|
|
3892
3999
|
configToSingleExpressionWithoutModifier(config, isRootDie) {
|
|
3893
4000
|
if (!config.sides || config.sides <= 0) return "";
|
|
4001
|
+
if (config.explode && Number.isFinite(config.explode) && config.explode > 0) {
|
|
4002
|
+
throw new Error(
|
|
4003
|
+
`toExpression() cannot represent an exploding die (d${config.sides} explode(${config.explode})): the string grammar has no explode syntax. Use the builder's own PMF (.toPMF()/.pmf) instead of round-tripping through toExpression()/parse().`
|
|
4004
|
+
);
|
|
4005
|
+
}
|
|
3894
4006
|
let baseDie = `d${config.sides}`;
|
|
4007
|
+
const rerollClause = config.reroll > 0 ? config.reroll === 1 ? " reroll 1" : ` reroll d${config.reroll}` : "";
|
|
3895
4008
|
if (config.reroll > 0) {
|
|
3896
|
-
if (config.minimum > 0 && config.explode > 0) ; else
|
|
3897
|
-
|
|
3898
|
-
} else {
|
|
3899
|
-
for (let i = 1; i <= config.reroll; i++) baseDie += ` reroll ${i}`;
|
|
4009
|
+
if (config.minimum > 0 && config.explode > 0) ; else {
|
|
4010
|
+
baseDie += rerollClause;
|
|
3900
4011
|
}
|
|
3901
4012
|
}
|
|
3902
4013
|
if (config.minimum > 0) {
|
|
@@ -3906,9 +4017,7 @@ var RollBuilder = class _RollBuilder {
|
|
|
3906
4017
|
baseDie = `${config.minimum}>${baseDie}`;
|
|
3907
4018
|
}
|
|
3908
4019
|
if (config.reroll > 0 && config.explode > 0) {
|
|
3909
|
-
|
|
3910
|
-
baseDie += ` reroll ${i}`;
|
|
3911
|
-
}
|
|
4020
|
+
baseDie += rerollClause;
|
|
3912
4021
|
}
|
|
3913
4022
|
}
|
|
3914
4023
|
if (baseDie === "d20 reroll 1" && config.minimum <= 1) baseDie = "hd20";
|
|
@@ -3926,10 +4035,14 @@ var RollBuilder = class _RollBuilder {
|
|
|
3926
4035
|
case "flat":
|
|
3927
4036
|
if (config.keep) {
|
|
3928
4037
|
const mode = config.keep.mode === "highest" ? "kh" : "kl";
|
|
4038
|
+
const baseCount = Math.max(1, Math.floor(Math.abs(config.count || 1)));
|
|
4039
|
+
const trials = Math.max(1, Math.floor(config.keep.total));
|
|
4040
|
+
const isMaxOfShape = config.keep.count === 1 && config.keep.mode === "highest";
|
|
4041
|
+
const innerCount = trials === baseCount && !isMaxOfShape ? 1 : baseCount;
|
|
3929
4042
|
const baseDieExpression = this.configToSingleExpressionWithoutModifier(
|
|
3930
4043
|
{
|
|
3931
4044
|
...config,
|
|
3932
|
-
count:
|
|
4045
|
+
count: innerCount,
|
|
3933
4046
|
modifier: 0,
|
|
3934
4047
|
rollType: "flat",
|
|
3935
4048
|
keep: void 0
|
|
@@ -3961,7 +4074,19 @@ var RollBuilder = class _RollBuilder {
|
|
|
3961
4074
|
}
|
|
3962
4075
|
}
|
|
3963
4076
|
if (config.bestOf && config.count && config.bestOf < config.count) {
|
|
3964
|
-
|
|
4077
|
+
const pool = Math.max(1, Math.floor(Math.abs(config.count)));
|
|
4078
|
+
const baseDieExpression = this.configToSingleExpressionWithoutModifier(
|
|
4079
|
+
{
|
|
4080
|
+
...config,
|
|
4081
|
+
count: 1,
|
|
4082
|
+
modifier: 0,
|
|
4083
|
+
bestOf: 0,
|
|
4084
|
+
keep: void 0,
|
|
4085
|
+
rollType: "flat"
|
|
4086
|
+
},
|
|
4087
|
+
false
|
|
4088
|
+
);
|
|
4089
|
+
mainExpression = `${pool}kh${Math.floor(config.bestOf)}(${baseDieExpression})`;
|
|
3965
4090
|
}
|
|
3966
4091
|
break;
|
|
3967
4092
|
}
|
|
@@ -4064,6 +4189,12 @@ var HalfRollBuilder = class _HalfRollBuilder extends RollBuilder {
|
|
|
4064
4189
|
toPMF(eps = 0) {
|
|
4065
4190
|
return pmfFromRollBuilder(this, eps);
|
|
4066
4191
|
}
|
|
4192
|
+
// Scale the dice, keep the same // 2 (half) transform applied on top -- delegating to the
|
|
4193
|
+
// base class's `create()`-based scaleDice would drop the halving entirely, e.g. a doubled-dice
|
|
4194
|
+
// crit on a resisted hit payload silently losing the resistance.
|
|
4195
|
+
scaleDice(scale) {
|
|
4196
|
+
return new _HalfRollBuilder(this.innerRoll.scaleDice(scale));
|
|
4197
|
+
}
|
|
4067
4198
|
copy() {
|
|
4068
4199
|
return new _HalfRollBuilder(this.innerRoll.copy());
|
|
4069
4200
|
}
|
|
@@ -4090,9 +4221,16 @@ var ScaleRollBuilder = class _ScaleRollBuilder extends RollBuilder {
|
|
|
4090
4221
|
}
|
|
4091
4222
|
toExpression() {
|
|
4092
4223
|
const inner = this.innerRoll.toExpression();
|
|
4093
|
-
|
|
4094
|
-
if (
|
|
4095
|
-
|
|
4224
|
+
const denominator = this.denominator === 0 ? 1 : this.denominator;
|
|
4225
|
+
if (denominator === 1) return `${this.numerator} ** (${inner})`;
|
|
4226
|
+
if (this.rounding === "round") {
|
|
4227
|
+
throw new Error(
|
|
4228
|
+
`toExpression() cannot represent scaleResult(${this.numerator}, ${this.denominator}, "round"): the string grammar has only floor (//) and ceil (/) division. Use the builder's own PMF (.toPMF()/.pmf) instead.`
|
|
4229
|
+
);
|
|
4230
|
+
}
|
|
4231
|
+
const div = this.rounding === "ceil" ? "/" : "//";
|
|
4232
|
+
if (this.numerator === 1) return `(${inner}) ${div} ${denominator}`;
|
|
4233
|
+
return `(${inner}) ** ${this.numerator} ${div} ${denominator}`;
|
|
4096
4234
|
}
|
|
4097
4235
|
toAST() {
|
|
4098
4236
|
return {
|
|
@@ -4106,6 +4244,17 @@ var ScaleRollBuilder = class _ScaleRollBuilder extends RollBuilder {
|
|
|
4106
4244
|
toPMF(eps = 0) {
|
|
4107
4245
|
return pmfFromRollBuilder(this, eps);
|
|
4108
4246
|
}
|
|
4247
|
+
// Scale the dice, keep the same numerator/denominator/rounding transform applied on top --
|
|
4248
|
+
// delegating to the base class's `create()`-based scaleDice would drop the scale entirely,
|
|
4249
|
+
// e.g. a doubled-dice crit on a vulnerable hit payload silently losing the vulnerability.
|
|
4250
|
+
scaleDice(scale) {
|
|
4251
|
+
return new _ScaleRollBuilder(
|
|
4252
|
+
this.innerRoll.scaleDice(scale),
|
|
4253
|
+
this.numerator,
|
|
4254
|
+
this.denominator,
|
|
4255
|
+
this.rounding
|
|
4256
|
+
);
|
|
4257
|
+
}
|
|
4109
4258
|
copy() {
|
|
4110
4259
|
return new _ScaleRollBuilder(
|
|
4111
4260
|
this.innerRoll.copy(),
|
|
@@ -4178,6 +4327,18 @@ var MaxOfRollBuilder = class _MaxOfRollBuilder extends RollBuilder {
|
|
|
4178
4327
|
toPMF(eps = 0) {
|
|
4179
4328
|
return pmfFromRollBuilder(this, eps);
|
|
4180
4329
|
}
|
|
4330
|
+
// Scale the dice INSIDE each trial (e.g. maxOf(2, 1d12) -> maxOf(2, 2d12)), keeping the same
|
|
4331
|
+
// trial count -- delegating to the base class's `create()`-based scaleDice would collapse
|
|
4332
|
+
// straight to plain dice, losing the "take the highest of N trials" semantics entirely.
|
|
4333
|
+
scaleDice(scale) {
|
|
4334
|
+
const scaleInt = validateScaleInt(scale);
|
|
4335
|
+
return new _MaxOfRollBuilder(
|
|
4336
|
+
this.innerRoll.scaleDice(scaleInt),
|
|
4337
|
+
this.count,
|
|
4338
|
+
this.diceCount ? this.diceCount * scaleInt : void 0,
|
|
4339
|
+
this.diceSides
|
|
4340
|
+
);
|
|
4341
|
+
}
|
|
4181
4342
|
copy() {
|
|
4182
4343
|
return new _MaxOfRollBuilder(this.innerRoll.copy(), this.count);
|
|
4183
4344
|
}
|
|
@@ -4224,9 +4385,7 @@ var AlwaysHitBuilder = class _AlwaysHitBuilder extends RollBuilder {
|
|
|
4224
4385
|
return new RollBuilder(configs).toExpression();
|
|
4225
4386
|
}
|
|
4226
4387
|
toPMF() {
|
|
4227
|
-
|
|
4228
|
-
const rerollOne = this.baseReroll > 0;
|
|
4229
|
-
return d20RollPMF(rollType, rerollOne);
|
|
4388
|
+
return resolveRootD20(this);
|
|
4230
4389
|
}
|
|
4231
4390
|
copy() {
|
|
4232
4391
|
const baseCopy = new RollBuilder(this.getSubRollConfigs());
|
|
@@ -4274,9 +4433,7 @@ var AlwaysCritBuilder = class _AlwaysCritBuilder extends RollBuilder {
|
|
|
4274
4433
|
return new RollBuilder(configs).toExpression();
|
|
4275
4434
|
}
|
|
4276
4435
|
toPMF() {
|
|
4277
|
-
|
|
4278
|
-
const rerollOne = this.baseReroll > 0;
|
|
4279
|
-
return d20RollPMF(rollType, rerollOne);
|
|
4436
|
+
return resolveRootD20(this);
|
|
4280
4437
|
}
|
|
4281
4438
|
copy() {
|
|
4282
4439
|
const baseCopy = new RollBuilder(this.getSubRollConfigs());
|
|
@@ -4466,6 +4623,17 @@ var CompositeSumRollBuilder = class _CompositeSumRollBuilder extends RollBuilder
|
|
|
4466
4623
|
toPMF(eps = 0) {
|
|
4467
4624
|
return pmfFromRollBuilder(this, eps);
|
|
4468
4625
|
}
|
|
4626
|
+
// Scaling a composite (mixed damage types, e.g. base + resisted) must scale each PART's own
|
|
4627
|
+
// dice while preserving its own half/scale wrapper -- delegating to the base class's
|
|
4628
|
+
// `create()`-based scaleDice would lose every part's transform, collapsing straight to plain
|
|
4629
|
+
// dice. This is what auto-crit doubling (attack.ts's `hitEffect.copy().doubleDice()`) relies
|
|
4630
|
+
// on for a mixed-resistance hit payload.
|
|
4631
|
+
scaleDice(scale) {
|
|
4632
|
+
validateScaleInt(scale);
|
|
4633
|
+
return new _CompositeSumRollBuilder(
|
|
4634
|
+
this.parts.map((p) => p.scaleDice(scale))
|
|
4635
|
+
);
|
|
4636
|
+
}
|
|
4469
4637
|
copy() {
|
|
4470
4638
|
return new _CompositeSumRollBuilder(this.parts.map((p) => p.copy()));
|
|
4471
4639
|
}
|
|
@@ -4543,6 +4711,15 @@ var builderPMFCache = new LRUCache(1e3);
|
|
|
4543
4711
|
// src/builder/ast.ts
|
|
4544
4712
|
var defaultEps = 0;
|
|
4545
4713
|
var singleDiePMFCache = new LRUCache(1e3);
|
|
4714
|
+
function dieNodeFromConfig(cfg) {
|
|
4715
|
+
return {
|
|
4716
|
+
type: "die",
|
|
4717
|
+
sides: cfg.sides,
|
|
4718
|
+
reroll: cfg.reroll > 0 ? cfg.reroll : void 0,
|
|
4719
|
+
minimum: cfg.minimum > 0 ? cfg.minimum : void 0,
|
|
4720
|
+
explode: cfg.explode && Number.isFinite(cfg.explode) && cfg.explode > 0 ? cfg.explode : void 0
|
|
4721
|
+
};
|
|
4722
|
+
}
|
|
4546
4723
|
function astFromRollConfigs(configs) {
|
|
4547
4724
|
if (!configs || configs.length === 0) return void 0;
|
|
4548
4725
|
const children = [];
|
|
@@ -4552,13 +4729,9 @@ function astFromRollConfigs(configs) {
|
|
|
4552
4729
|
const count = Math.abs(cfg.count || 0);
|
|
4553
4730
|
constantSum += cfg.modifier || 0;
|
|
4554
4731
|
if ((cfg.sides || 0) <= 0) continue;
|
|
4555
|
-
const
|
|
4556
|
-
|
|
4557
|
-
|
|
4558
|
-
reroll: cfg.reroll > 0 ? cfg.reroll : void 0,
|
|
4559
|
-
minimum: cfg.minimum > 0 ? cfg.minimum : void 0,
|
|
4560
|
-
explode: cfg.explode && Number.isFinite(cfg.explode) && cfg.explode > 0 ? cfg.explode : void 0
|
|
4561
|
-
};
|
|
4732
|
+
const isSynthesizedBestOf = !cfg.keep && cfg.bestOf > 0 && cfg.bestOf < count;
|
|
4733
|
+
const effectiveKeep = isSynthesizedBestOf ? { total: count, count: Math.floor(cfg.bestOf), mode: "highest" } : cfg.keep;
|
|
4734
|
+
const die = dieNodeFromConfig(cfg);
|
|
4562
4735
|
let node = die;
|
|
4563
4736
|
let appliedRollType = false;
|
|
4564
4737
|
if (cfg.rollType && cfg.rollType !== "flat") {
|
|
@@ -4576,11 +4749,11 @@ function astFromRollConfigs(configs) {
|
|
|
4576
4749
|
}
|
|
4577
4750
|
appliedRollType = true;
|
|
4578
4751
|
}
|
|
4579
|
-
if (cfg.rollType === "flat" &&
|
|
4752
|
+
if (cfg.rollType === "flat" && effectiveKeep && effectiveKeep.total > 0) {
|
|
4580
4753
|
const baseCount = Math.max(1, Math.floor(Math.abs(count || 1)));
|
|
4581
|
-
const trials = Math.max(1, Math.floor(
|
|
4582
|
-
const k = Math.max(0, Math.floor(
|
|
4583
|
-
if (k === 1 &&
|
|
4754
|
+
const trials = Math.max(1, Math.floor(effectiveKeep.total));
|
|
4755
|
+
const k = Math.max(0, Math.floor(effectiveKeep.count));
|
|
4756
|
+
if (k === 1 && effectiveKeep.mode === "highest" && !isSynthesizedBestOf) {
|
|
4584
4757
|
const perTrial = {
|
|
4585
4758
|
type: "sum",
|
|
4586
4759
|
count: baseCount,
|
|
@@ -4599,7 +4772,7 @@ function astFromRollConfigs(configs) {
|
|
|
4599
4772
|
const base = { type: "sum", count: trials, child: node };
|
|
4600
4773
|
node = {
|
|
4601
4774
|
type: "keep",
|
|
4602
|
-
mode:
|
|
4775
|
+
mode: effectiveKeep.mode,
|
|
4603
4776
|
count: k,
|
|
4604
4777
|
child: base
|
|
4605
4778
|
};
|
|
@@ -4619,7 +4792,7 @@ function astFromRollConfigs(configs) {
|
|
|
4619
4792
|
};
|
|
4620
4793
|
node = {
|
|
4621
4794
|
type: "keep",
|
|
4622
|
-
mode:
|
|
4795
|
+
mode: effectiveKeep.mode,
|
|
4623
4796
|
count: k,
|
|
4624
4797
|
child: trialPool
|
|
4625
4798
|
};
|
|
@@ -4693,8 +4866,8 @@ function resolve(node, eps = defaultEps) {
|
|
|
4693
4866
|
}
|
|
4694
4867
|
case "d20Roll": {
|
|
4695
4868
|
const childDie = findDie(node.child);
|
|
4696
|
-
|
|
4697
|
-
return
|
|
4869
|
+
if (!childDie) return d20RollPMF(node.rollType, false);
|
|
4870
|
+
return resolveD20Roll(childDie, node.rollType);
|
|
4698
4871
|
}
|
|
4699
4872
|
case "half": {
|
|
4700
4873
|
const childPMF = resolve(node.child, eps);
|
|
@@ -4720,6 +4893,37 @@ function pmfFromRollBuilder(rb, eps = defaultEps) {
|
|
|
4720
4893
|
const ast = rb.toAST();
|
|
4721
4894
|
return resolve(ast, eps);
|
|
4722
4895
|
}
|
|
4896
|
+
var d20RollLiftCache = new LRUCache(500);
|
|
4897
|
+
function resolveD20Roll(die, rollType) {
|
|
4898
|
+
const base = resolveSingleDie(die, defaultEps);
|
|
4899
|
+
const type = rollType || "flat";
|
|
4900
|
+
if (type === "flat") return base;
|
|
4901
|
+
const cacheKey = `${getASTSignature(die)}|${type}`;
|
|
4902
|
+
const cached = d20RollLiftCache.get(cacheKey);
|
|
4903
|
+
if (cached) return cached;
|
|
4904
|
+
const support = [...base.support()].sort((a, b) => a - b);
|
|
4905
|
+
const out = /* @__PURE__ */ new Map();
|
|
4906
|
+
let cum = 0;
|
|
4907
|
+
let prevLifted = 0;
|
|
4908
|
+
for (const k of support) {
|
|
4909
|
+
cum += base.pAt(k);
|
|
4910
|
+
const curLifted = type === "advantage" ? cum * cum : type === "elven accuracy" ? cum * cum * cum : 1 - (1 - cum) * (1 - cum);
|
|
4911
|
+
const pk = curLifted - prevLifted;
|
|
4912
|
+
if (pk > 0) out.set(k, pk);
|
|
4913
|
+
prevLifted = curLifted;
|
|
4914
|
+
}
|
|
4915
|
+
const result = PMF.fromMap(out, defaultEps);
|
|
4916
|
+
d20RollLiftCache.set(cacheKey, result);
|
|
4917
|
+
return result;
|
|
4918
|
+
}
|
|
4919
|
+
function resolveRootD20(check) {
|
|
4920
|
+
const rootConfig = check.getRootDieConfig();
|
|
4921
|
+
const rollType = check.rollType;
|
|
4922
|
+
if (!rootConfig || !(rootConfig.sides > 0)) {
|
|
4923
|
+
return d20RollPMF(rollType, check.baseReroll > 0);
|
|
4924
|
+
}
|
|
4925
|
+
return resolveD20Roll(dieNodeFromConfig(rootConfig), rollType);
|
|
4926
|
+
}
|
|
4723
4927
|
function resolveSingleDie(die, eps = defaultEps) {
|
|
4724
4928
|
const signature = getASTSignature(die);
|
|
4725
4929
|
const cacheKey = `${signature}_${eps}`;
|
|
@@ -4753,20 +4957,12 @@ function resolveSingleDie(die, eps = defaultEps) {
|
|
|
4753
4957
|
for (const v of pmf.support()) {
|
|
4754
4958
|
if (v !== maxFace) nonMax.set(v, pmf.pAt(v));
|
|
4755
4959
|
}
|
|
4756
|
-
|
|
4757
|
-
|
|
4758
|
-
|
|
4759
|
-
|
|
4760
|
-
let tail = PMF.delta(0, eps);
|
|
4761
|
-
const addOnce = pmf;
|
|
4762
|
-
for (let t = 1; t <= times; t++) {
|
|
4763
|
-
tail = tail.convolve(addOnce, eps);
|
|
4960
|
+
const nonMaxPMF = PMF.fromMap(nonMax, eps);
|
|
4961
|
+
let chain = pmf;
|
|
4962
|
+
for (let remaining = 1; remaining <= times - 1; remaining++) {
|
|
4963
|
+
chain = PMF.branch(chain.mapDamage((v) => v + maxFace), nonMaxPMF, pMax);
|
|
4764
4964
|
}
|
|
4765
|
-
const exploded = PMF.branch(
|
|
4766
|
-
tail.mapDamage((v) => v + maxFace),
|
|
4767
|
-
nonMaxPMF,
|
|
4768
|
-
pMax
|
|
4769
|
-
);
|
|
4965
|
+
const exploded = PMF.branch(chain.mapDamage((v) => v + maxFace), nonMaxPMF, pMax);
|
|
4770
4966
|
pmf = exploded;
|
|
4771
4967
|
}
|
|
4772
4968
|
singleDiePMFCache.set(cacheKey, pmf);
|
|
@@ -5065,10 +5261,8 @@ var AttackBuilder = class _AttackBuilder {
|
|
|
5065
5261
|
return `${checkPart} * ${effectPart}`;
|
|
5066
5262
|
}
|
|
5067
5263
|
resolveProbabilities(check, eps = 0) {
|
|
5068
|
-
const rollType = check.rollType;
|
|
5069
|
-
const rerollOne = check.baseReroll > 0;
|
|
5070
5264
|
const critThreshold = check.critThreshold;
|
|
5071
|
-
const d202 =
|
|
5265
|
+
const d202 = resolveRootD20(check);
|
|
5072
5266
|
if (check instanceof AlwaysCritBuilder) {
|
|
5073
5267
|
if (check.fromAlwaysHit) {
|
|
5074
5268
|
return { pSuccess: 1, pHit: 0, pCrit: 1, pMiss: 0 };
|
|
@@ -5118,13 +5312,17 @@ var AttackBuilder = class _AttackBuilder {
|
|
|
5118
5312
|
pmiss += pr;
|
|
5119
5313
|
continue;
|
|
5120
5314
|
}
|
|
5121
|
-
if (r
|
|
5315
|
+
if (r === 20) {
|
|
5122
5316
|
pcrit += pr;
|
|
5123
5317
|
continue;
|
|
5124
5318
|
}
|
|
5125
5319
|
const need = ac - staticMod - r;
|
|
5126
5320
|
const pBonusHit = bonusPMF.tailProbGE(need);
|
|
5127
|
-
|
|
5321
|
+
if (r >= critThreshold) {
|
|
5322
|
+
pcrit += pr * pBonusHit;
|
|
5323
|
+
} else {
|
|
5324
|
+
phit += pr * pBonusHit;
|
|
5325
|
+
}
|
|
5128
5326
|
pmiss += pr * (1 - pBonusHit);
|
|
5129
5327
|
}
|
|
5130
5328
|
const psuccess = phit + pcrit;
|
|
@@ -5137,11 +5335,11 @@ var AttackBuilder = class _AttackBuilder {
|
|
|
5137
5335
|
pMiss: pmiss
|
|
5138
5336
|
} = this.resolveProbabilities(this.check, eps);
|
|
5139
5337
|
const hitPMF = this.hitEffect ? this.hitEffect instanceof ParsedRollBuilder ? this.hitEffect.toPMF(eps) : pmfFromRollBuilder(this.hitEffect, eps) : PMF.delta(0, eps);
|
|
5140
|
-
let
|
|
5338
|
+
let critPMF2 = null;
|
|
5141
5339
|
let phit = pHit;
|
|
5142
5340
|
let pcrit = pCrit;
|
|
5143
5341
|
if (this.critEffect === null) {
|
|
5144
|
-
|
|
5342
|
+
critPMF2 = null;
|
|
5145
5343
|
phit += pcrit;
|
|
5146
5344
|
pcrit = 0;
|
|
5147
5345
|
} else {
|
|
@@ -5149,7 +5347,7 @@ var AttackBuilder = class _AttackBuilder {
|
|
|
5149
5347
|
if (this.critEffect) {
|
|
5150
5348
|
critBuilder = this.critEffect;
|
|
5151
5349
|
} else if (this.hitEffect instanceof ParsedRollBuilder) {
|
|
5152
|
-
|
|
5350
|
+
critPMF2 = null;
|
|
5153
5351
|
phit += pcrit;
|
|
5154
5352
|
pcrit = 0;
|
|
5155
5353
|
critBuilder = void 0;
|
|
@@ -5157,20 +5355,20 @@ var AttackBuilder = class _AttackBuilder {
|
|
|
5157
5355
|
critBuilder = this.hitEffect?.copy().doubleDice();
|
|
5158
5356
|
}
|
|
5159
5357
|
if (critBuilder) {
|
|
5160
|
-
|
|
5358
|
+
critPMF2 = critBuilder instanceof ParsedRollBuilder ? critBuilder.toPMF(eps) : pmfFromRollBuilder(critBuilder, eps);
|
|
5161
5359
|
}
|
|
5162
5360
|
}
|
|
5163
5361
|
const missPMF = this.missEffect ? this.missEffect instanceof ParsedRollBuilder ? this.missEffect.toPMF(eps) : pmfFromRollBuilder(this.missEffect, eps) : PMF.delta(0, eps);
|
|
5164
5362
|
const mix = new Mixture(eps);
|
|
5165
5363
|
if (phit > 0) mix.add("hit", hitPMF, phit);
|
|
5166
|
-
if (
|
|
5364
|
+
if (critPMF2 && pcrit > 0) mix.add("crit", critPMF2, pcrit);
|
|
5167
5365
|
if (pmiss > 0)
|
|
5168
5366
|
mix.add(this.missEffect ? "missDamage" : "missNone", missPMF, pmiss);
|
|
5169
5367
|
return {
|
|
5170
5368
|
pmf: mix.buildPMF(eps) ?? PMF.delta(0, eps),
|
|
5171
5369
|
check: this.check.toPMF(eps) ?? PMF.delta(0, eps),
|
|
5172
5370
|
hit: hitPMF ?? PMF.delta(0, eps),
|
|
5173
|
-
crit:
|
|
5371
|
+
crit: critPMF2 ?? PMF.delta(0, eps),
|
|
5174
5372
|
miss: missPMF ?? PMF.delta(0, eps),
|
|
5175
5373
|
weights: { hit: phit, crit: pcrit, miss: pmiss }
|
|
5176
5374
|
};
|
|
@@ -5272,9 +5470,7 @@ var ACBuilder = class _ACBuilder extends RollBuilder {
|
|
|
5272
5470
|
}
|
|
5273
5471
|
toPMF(eps = 0) {
|
|
5274
5472
|
const ac = this.attackConfig.ac;
|
|
5275
|
-
const
|
|
5276
|
-
const rerollOne = this.baseReroll > 0;
|
|
5277
|
-
const d202 = d20RollPMF(rollType, rerollOne);
|
|
5473
|
+
const d202 = resolveRootD20(this);
|
|
5278
5474
|
const staticMod = this.modifier;
|
|
5279
5475
|
const bonusPMFs = this.getBonusDicePMFs(this, eps);
|
|
5280
5476
|
const parts = [d202, ...bonusPMFs];
|
|
@@ -5384,21 +5580,17 @@ var SaveBuilder = class _SaveBuilder {
|
|
|
5384
5580
|
function resolveProbabilities(check) {
|
|
5385
5581
|
const saveBonus = check.modifier;
|
|
5386
5582
|
const dc = check.saveDC;
|
|
5387
|
-
const
|
|
5388
|
-
const
|
|
5389
|
-
const die = d20RollPMF(d20Type, baseReroll > 0);
|
|
5583
|
+
const eps = 0;
|
|
5584
|
+
const die = resolveRootD20(check);
|
|
5390
5585
|
const faceP = /* @__PURE__ */ new Map();
|
|
5391
5586
|
for (const [r, bin] of die) {
|
|
5392
5587
|
const pr = bin.p;
|
|
5393
5588
|
if (pr > 0) faceP.set(r, pr);
|
|
5394
5589
|
}
|
|
5395
|
-
const eps = 0;
|
|
5396
5590
|
const bonusDicePMFs = check.getBonusDicePMFs(check, eps);
|
|
5397
5591
|
const bonusPMF = bonusDicePMFs.length > 0 ? PMF.convolveMany(bonusDicePMFs, eps) : PMF.zero(eps);
|
|
5398
5592
|
let pSuccess = 0;
|
|
5399
|
-
for (
|
|
5400
|
-
const pr = faceP.get(r);
|
|
5401
|
-
if (!pr) continue;
|
|
5593
|
+
for (const [r, pr] of faceP) {
|
|
5402
5594
|
const need = dc - saveBonus - r;
|
|
5403
5595
|
pSuccess += pr * bonusPMF.tailProbGE(need);
|
|
5404
5596
|
}
|
|
@@ -5468,9 +5660,7 @@ var DCBuilder = class _DCBuilder extends RollBuilder {
|
|
|
5468
5660
|
if (cached) return cached;
|
|
5469
5661
|
}
|
|
5470
5662
|
const saveDC = this.saveDC;
|
|
5471
|
-
const
|
|
5472
|
-
const rerollOne = this.baseReroll > 0;
|
|
5473
|
-
const d202 = d20RollPMF(rollType, rerollOne);
|
|
5663
|
+
const d202 = resolveRootD20(this);
|
|
5474
5664
|
const staticMod = this.modifier;
|
|
5475
5665
|
const bonusDicePMFs = this.getBonusDiceConfigs().map(
|
|
5476
5666
|
(cfg) => pmfFromRollBuilder(RollBuilder.fromConfigs([cfg]), eps)
|
|
@@ -5498,6 +5688,611 @@ RollBuilder.prototype.dc = function(saveDC) {
|
|
|
5498
5688
|
return new DCBuilder(this).dc(saveDC);
|
|
5499
5689
|
};
|
|
5500
5690
|
|
|
5501
|
-
|
|
5691
|
+
// src/turn/types.ts
|
|
5692
|
+
var TurnSpecError = class extends Error {
|
|
5693
|
+
constructor(code, id, message) {
|
|
5694
|
+
super(message);
|
|
5695
|
+
this.code = code;
|
|
5696
|
+
this.id = id;
|
|
5697
|
+
this.name = "TurnSpecError";
|
|
5698
|
+
}
|
|
5699
|
+
};
|
|
5700
|
+
var MAX_TRIGGER_GROUPS = 4;
|
|
5701
|
+
|
|
5702
|
+
// src/turn/plan.ts
|
|
5703
|
+
var IS_HIT_TRIGGER = {
|
|
5704
|
+
"first-hit": true,
|
|
5705
|
+
"any-crit": true,
|
|
5706
|
+
"any-miss": true,
|
|
5707
|
+
"every-hit": true
|
|
5708
|
+
};
|
|
5709
|
+
function toPMF(damage, eps, id = "") {
|
|
5710
|
+
const parts = Array.isArray(damage) ? damage : [damage];
|
|
5711
|
+
if (parts.length === 0) return PMF.delta(0, eps);
|
|
5712
|
+
const pmfs = parts.map((part) => {
|
|
5713
|
+
if (part instanceof PMF) return part;
|
|
5714
|
+
if (typeof part.toPMF !== "function") {
|
|
5715
|
+
throw new TurnSpecError(
|
|
5716
|
+
"not-an-attack",
|
|
5717
|
+
id,
|
|
5718
|
+
`"${id}" is neither a PMF nor a builder with toPMF().`
|
|
5719
|
+
);
|
|
5720
|
+
}
|
|
5721
|
+
const resolved = part.toPMF(eps);
|
|
5722
|
+
if (!(resolved instanceof PMF)) {
|
|
5723
|
+
throw new TurnSpecError(
|
|
5724
|
+
"not-an-attack",
|
|
5725
|
+
id,
|
|
5726
|
+
`"${id}" has a toPMF() that did not return a PMF.`
|
|
5727
|
+
);
|
|
5728
|
+
}
|
|
5729
|
+
return resolved;
|
|
5730
|
+
});
|
|
5731
|
+
return PMF.convolveMany(pmfs, eps);
|
|
5732
|
+
}
|
|
5733
|
+
function critPMF(rider, base, eps) {
|
|
5734
|
+
if (rider.critDamage !== void 0) return toPMF(rider.critDamage, eps);
|
|
5735
|
+
const parts = Array.isArray(rider.damage) ? rider.damage : [rider.damage];
|
|
5736
|
+
const doubled = [];
|
|
5737
|
+
for (const part of parts) {
|
|
5738
|
+
const doublable = part;
|
|
5739
|
+
if (part instanceof PMF || typeof doublable.doubleDice !== "function") {
|
|
5740
|
+
return base;
|
|
5741
|
+
}
|
|
5742
|
+
try {
|
|
5743
|
+
doubled.push(toPMF(doublable.doubleDice(), eps));
|
|
5744
|
+
} catch {
|
|
5745
|
+
return base;
|
|
5746
|
+
}
|
|
5747
|
+
}
|
|
5748
|
+
if (doubled.length === 0) return base;
|
|
5749
|
+
return PMF.convolveMany(doubled, eps);
|
|
5750
|
+
}
|
|
5751
|
+
function sliceSource(pmf) {
|
|
5752
|
+
const labels = pmf.outcomes();
|
|
5753
|
+
if (!labels.includes("hit") && !labels.includes("crit")) return null;
|
|
5754
|
+
const missParts = ["missNone", "missDamage"].filter((label) => labels.includes(label)).map((label) => pmf.filterOutcome(label));
|
|
5755
|
+
return {
|
|
5756
|
+
hit: labels.includes("hit") ? pmf.filterOutcome("hit") : PMF.emptyMass(),
|
|
5757
|
+
crit: labels.includes("crit") ? pmf.filterOutcome("crit") : PMF.emptyMass(),
|
|
5758
|
+
miss: missParts.length ? missParts.reduce((all, part) => all.add(part)) : PMF.emptyMass()
|
|
5759
|
+
};
|
|
5760
|
+
}
|
|
5761
|
+
function buildPlan(spec, eps = EPS) {
|
|
5762
|
+
const fail = (code, id, message) => {
|
|
5763
|
+
throw new TurnSpecError(code, id, message);
|
|
5764
|
+
};
|
|
5765
|
+
const attackIds = [];
|
|
5766
|
+
const attackPMFs = [];
|
|
5767
|
+
const attackSlices = [];
|
|
5768
|
+
spec.attacks.forEach((entry, index) => {
|
|
5769
|
+
const named = entry;
|
|
5770
|
+
const hasWrapper = typeof named.id === "string" && named.source !== void 0;
|
|
5771
|
+
const id = hasWrapper ? named.id : `attack ${index + 1}`;
|
|
5772
|
+
const source = hasWrapper ? named.source : entry;
|
|
5773
|
+
const pmf = toPMF(source, eps, id);
|
|
5774
|
+
attackIds.push(id);
|
|
5775
|
+
attackPMFs.push(pmf);
|
|
5776
|
+
attackSlices.push(sliceSource(pmf));
|
|
5777
|
+
});
|
|
5778
|
+
const riders = spec.riders ?? [];
|
|
5779
|
+
const riderIds = riders.map((rider, index) => rider.id ?? `rider ${index + 1}`);
|
|
5780
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5781
|
+
for (const id of [...attackIds, ...riderIds]) {
|
|
5782
|
+
if (seen.has(id)) fail("duplicate-id", id, `Duplicate id "${id}".`);
|
|
5783
|
+
seen.add(id);
|
|
5784
|
+
}
|
|
5785
|
+
const attackIndexById = new Map(attackIds.map((id, index) => [id, index]));
|
|
5786
|
+
const riderIndexById = new Map(riderIds.map((id, index) => [id, index]));
|
|
5787
|
+
const sourceIdsByRider = riders.map((rider, index) => {
|
|
5788
|
+
const id = riderIds[index];
|
|
5789
|
+
if (rider.on === "not-fired") {
|
|
5790
|
+
const target = rider.of;
|
|
5791
|
+
if (target === id) {
|
|
5792
|
+
fail("self-reference", id, `Rider "${id}" cannot depend on itself.`);
|
|
5793
|
+
}
|
|
5794
|
+
const targetIndex = riderIndexById.get(target);
|
|
5795
|
+
if (targetIndex === void 0) {
|
|
5796
|
+
fail(
|
|
5797
|
+
"unknown-id",
|
|
5798
|
+
target,
|
|
5799
|
+
`Rider "${id}" negates "${target}", which is not a rider in this turn.`
|
|
5800
|
+
);
|
|
5801
|
+
}
|
|
5802
|
+
if (riders[targetIndex].on === "every-hit") {
|
|
5803
|
+
fail(
|
|
5804
|
+
"not-an-attack",
|
|
5805
|
+
target,
|
|
5806
|
+
`Rider "${id}" negates "${target}", an every-hit rider, which can fire more than once and so has no single "did not fire" branch.`
|
|
5807
|
+
);
|
|
5808
|
+
}
|
|
5809
|
+
return [target];
|
|
5810
|
+
}
|
|
5811
|
+
const of = [...new Set(rider.of ?? attackIds)];
|
|
5812
|
+
if (of.length === 0) {
|
|
5813
|
+
fail("unknown-id", id, `Rider "${id}" has no sources.`);
|
|
5814
|
+
}
|
|
5815
|
+
for (const sourceId of of) {
|
|
5816
|
+
if (sourceId === id) {
|
|
5817
|
+
fail("self-reference", id, `Rider "${id}" cannot depend on itself.`);
|
|
5818
|
+
}
|
|
5819
|
+
const riderIndex = riderIndexById.get(sourceId);
|
|
5820
|
+
const isAttack = attackIndexById.has(sourceId);
|
|
5821
|
+
if (!isAttack && riderIndex === void 0) {
|
|
5822
|
+
fail(
|
|
5823
|
+
"unknown-id",
|
|
5824
|
+
sourceId,
|
|
5825
|
+
`Rider "${id}" depends on "${sourceId}", which is not in this turn.`
|
|
5826
|
+
);
|
|
5827
|
+
}
|
|
5828
|
+
if (riderIndex !== void 0 && riders[riderIndex].on === "every-hit") {
|
|
5829
|
+
fail(
|
|
5830
|
+
"not-an-attack",
|
|
5831
|
+
sourceId,
|
|
5832
|
+
`Rider "${id}" triggers on "${sourceId}", an every-hit rider. Those are folded into their own sources rather than resolved separately, so they cannot be triggered on \u2014 point at the attacks instead.`
|
|
5833
|
+
);
|
|
5834
|
+
}
|
|
5835
|
+
const slices = isAttack ? attackSlices[attackIndexById.get(sourceId)] : sliceSource(
|
|
5836
|
+
toPMF(riders[riderIndex].damage, eps, sourceId)
|
|
5837
|
+
);
|
|
5838
|
+
if (!slices) {
|
|
5839
|
+
fail(
|
|
5840
|
+
"not-an-attack",
|
|
5841
|
+
sourceId,
|
|
5842
|
+
`Rider "${id}" triggers on "${sourceId}", which has no hit/crit outcomes.`
|
|
5843
|
+
);
|
|
5844
|
+
}
|
|
5845
|
+
}
|
|
5846
|
+
return [...of];
|
|
5847
|
+
});
|
|
5848
|
+
const order = [];
|
|
5849
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
5850
|
+
const done = /* @__PURE__ */ new Set();
|
|
5851
|
+
const visit = (index) => {
|
|
5852
|
+
if (done.has(index)) return;
|
|
5853
|
+
const id = riderIds[index];
|
|
5854
|
+
if (visiting.has(index)) {
|
|
5855
|
+
fail("cycle", id, `Rider "${id}" is part of a dependency cycle.`);
|
|
5856
|
+
}
|
|
5857
|
+
visiting.add(index);
|
|
5858
|
+
for (const sourceId of sourceIdsByRider[index]) {
|
|
5859
|
+
const dependency = riderIndexById.get(sourceId);
|
|
5860
|
+
if (dependency !== void 0) visit(dependency);
|
|
5861
|
+
}
|
|
5862
|
+
visiting.delete(index);
|
|
5863
|
+
done.add(index);
|
|
5864
|
+
order.push(index);
|
|
5865
|
+
};
|
|
5866
|
+
riders.forEach((_, index) => visit(index));
|
|
5867
|
+
const groupIndexByKey = /* @__PURE__ */ new Map();
|
|
5868
|
+
const groupSources = [];
|
|
5869
|
+
const groupOf = (sourceIds) => {
|
|
5870
|
+
const key = JSON.stringify([...sourceIds].sort());
|
|
5871
|
+
const existing = groupIndexByKey.get(key);
|
|
5872
|
+
if (existing !== void 0) return existing;
|
|
5873
|
+
if (groupSources.length >= MAX_TRIGGER_GROUPS) {
|
|
5874
|
+
fail(
|
|
5875
|
+
"too-many-groups",
|
|
5876
|
+
key,
|
|
5877
|
+
`A turn may track at most ${MAX_TRIGGER_GROUPS} distinct trigger source sets.`
|
|
5878
|
+
);
|
|
5879
|
+
}
|
|
5880
|
+
const index = groupSources.length;
|
|
5881
|
+
groupIndexByKey.set(key, index);
|
|
5882
|
+
groupSources.push([...sourceIds]);
|
|
5883
|
+
return index;
|
|
5884
|
+
};
|
|
5885
|
+
const readsByRider = /* @__PURE__ */ new Map();
|
|
5886
|
+
const perHitGroups = /* @__PURE__ */ new Map();
|
|
5887
|
+
for (const index of order) {
|
|
5888
|
+
const rider = riders[index];
|
|
5889
|
+
if (!IS_HIT_TRIGGER[rider.on]) continue;
|
|
5890
|
+
const group = groupOf(sourceIdsByRider[index]);
|
|
5891
|
+
readsByRider.set(index, group);
|
|
5892
|
+
if (rider.on === "every-hit") perHitGroups.set(riderIds[index], group);
|
|
5893
|
+
}
|
|
5894
|
+
const perHitBySource = /* @__PURE__ */ new Map();
|
|
5895
|
+
for (const index of order) {
|
|
5896
|
+
const rider = riders[index];
|
|
5897
|
+
if (rider.on !== "every-hit") continue;
|
|
5898
|
+
const hit = toPMF(rider.damage, eps, riderIds[index]);
|
|
5899
|
+
const payload = { hit, crit: critPMF(rider, hit, eps) };
|
|
5900
|
+
for (const sourceId of sourceIdsByRider[index]) {
|
|
5901
|
+
const existing = perHitBySource.get(sourceId);
|
|
5902
|
+
if (existing) existing.push(payload);
|
|
5903
|
+
else perHitBySource.set(sourceId, [payload]);
|
|
5904
|
+
}
|
|
5905
|
+
}
|
|
5906
|
+
const updatesById = /* @__PURE__ */ new Map();
|
|
5907
|
+
groupSources.forEach((sourceIds, groupIndex) => {
|
|
5908
|
+
for (const sourceId of sourceIds) {
|
|
5909
|
+
const existing = updatesById.get(sourceId);
|
|
5910
|
+
if (existing) existing.push(groupIndex);
|
|
5911
|
+
else updatesById.set(sourceId, [groupIndex]);
|
|
5912
|
+
}
|
|
5913
|
+
});
|
|
5914
|
+
const withPerHit = (slices, id) => {
|
|
5915
|
+
const payloads = perHitBySource.get(id);
|
|
5916
|
+
if (!payloads) return slices;
|
|
5917
|
+
let hit = slices.hit;
|
|
5918
|
+
let crit = slices.crit;
|
|
5919
|
+
for (const payload of payloads) {
|
|
5920
|
+
hit = hit.convolve(payload.hit, eps, true);
|
|
5921
|
+
crit = crit.convolve(payload.crit, eps, true);
|
|
5922
|
+
}
|
|
5923
|
+
return { hit, crit, miss: slices.miss };
|
|
5924
|
+
};
|
|
5925
|
+
const steps = attackIds.map((id, index) => ({
|
|
5926
|
+
id,
|
|
5927
|
+
trigger: null,
|
|
5928
|
+
slices: withPerHit(
|
|
5929
|
+
attackSlices[index] ?? {
|
|
5930
|
+
hit: attackPMFs[index],
|
|
5931
|
+
crit: PMF.emptyMass(),
|
|
5932
|
+
miss: PMF.emptyMass()
|
|
5933
|
+
},
|
|
5934
|
+
id
|
|
5935
|
+
),
|
|
5936
|
+
damage: null,
|
|
5937
|
+
updates: updatesById.get(id) ?? [],
|
|
5938
|
+
reads: -1,
|
|
5939
|
+
negates: -1
|
|
5940
|
+
}));
|
|
5941
|
+
const stepIndexByRider = /* @__PURE__ */ new Map();
|
|
5942
|
+
const riderSteps = /* @__PURE__ */ new Map();
|
|
5943
|
+
for (const index of order) {
|
|
5944
|
+
const rider = riders[index];
|
|
5945
|
+
if (rider.on === "every-hit") continue;
|
|
5946
|
+
const id = riderIds[index];
|
|
5947
|
+
const hit = toPMF(rider.damage, eps, id);
|
|
5948
|
+
const slices = sliceSource(hit);
|
|
5949
|
+
if (slices && rider.critDamage !== void 0) {
|
|
5950
|
+
fail(
|
|
5951
|
+
"unused-crit-damage",
|
|
5952
|
+
id,
|
|
5953
|
+
`Rider "${id}" rolls its own attack, so its critDamage would never be used. Remove it, or pass plain damage dice instead.`
|
|
5954
|
+
);
|
|
5955
|
+
}
|
|
5956
|
+
const negatedRider = rider.on === "not-fired" ? riderIndexById.get(rider.of) : void 0;
|
|
5957
|
+
const step = {
|
|
5958
|
+
id,
|
|
5959
|
+
trigger: rider,
|
|
5960
|
+
slices: slices ? withPerHit(slices, id) : null,
|
|
5961
|
+
damage: slices ? null : { hit, crit: critPMF(rider, hit, eps) },
|
|
5962
|
+
updates: updatesById.get(id) ?? [],
|
|
5963
|
+
reads: readsByRider.get(index) ?? -1,
|
|
5964
|
+
negates: negatedRider === void 0 ? -1 : stepIndexByRider.get(negatedRider)
|
|
5965
|
+
};
|
|
5966
|
+
steps.push(step);
|
|
5967
|
+
stepIndexByRider.set(index, steps.length - 1);
|
|
5968
|
+
riderSteps.set(id, steps.length - 1);
|
|
5969
|
+
}
|
|
5970
|
+
return {
|
|
5971
|
+
steps,
|
|
5972
|
+
groupCount: groupSources.length,
|
|
5973
|
+
attackPMFs,
|
|
5974
|
+
attackIds,
|
|
5975
|
+
riderIds,
|
|
5976
|
+
riderSteps,
|
|
5977
|
+
perHitGroups
|
|
5978
|
+
};
|
|
5979
|
+
}
|
|
5980
|
+
|
|
5981
|
+
// src/turn/state.ts
|
|
5982
|
+
var FIRST_NONE = 0;
|
|
5983
|
+
var FIRST_HIT = 1;
|
|
5984
|
+
var FIRST_CRIT = 2;
|
|
5985
|
+
var CRIT_BIT = 2;
|
|
5986
|
+
var MISS_BIT = 1;
|
|
5987
|
+
var START_CODE = FIRST_NONE << 2;
|
|
5988
|
+
function advance(code, outcome) {
|
|
5989
|
+
if (outcome === "miss") return code | MISS_BIT;
|
|
5990
|
+
const first = code >> 2;
|
|
5991
|
+
const withCrit = outcome === "crit" ? code | CRIT_BIT : code;
|
|
5992
|
+
if (first !== FIRST_NONE) return withCrit;
|
|
5993
|
+
const nextFirst = outcome === "crit" ? FIRST_CRIT : FIRST_HIT;
|
|
5994
|
+
return nextFirst << 2 | withCrit & (CRIT_BIT | MISS_BIT);
|
|
5995
|
+
}
|
|
5996
|
+
|
|
5997
|
+
// src/turn/turn.ts
|
|
5998
|
+
var OUTCOMES = ["hit", "crit", "miss"];
|
|
5999
|
+
function fireMode(step, codes, firedByStep) {
|
|
6000
|
+
const trigger = step.trigger;
|
|
6001
|
+
if (!trigger) return "hit";
|
|
6002
|
+
if (trigger.on === "not-fired") {
|
|
6003
|
+
return firedByStep[step.negates] === null ? "hit" : null;
|
|
6004
|
+
}
|
|
6005
|
+
const code = codes[step.reads];
|
|
6006
|
+
const first = code >> 2;
|
|
6007
|
+
switch (trigger.on) {
|
|
6008
|
+
case "first-hit":
|
|
6009
|
+
if (first === FIRST_NONE) return null;
|
|
6010
|
+
return first === FIRST_CRIT ? "crit" : "hit";
|
|
6011
|
+
case "any-crit":
|
|
6012
|
+
return (code & CRIT_BIT) !== 0 ? "crit" : null;
|
|
6013
|
+
case "any-miss":
|
|
6014
|
+
return (code & MISS_BIT) !== 0 ? "hit" : null;
|
|
6015
|
+
default:
|
|
6016
|
+
return null;
|
|
6017
|
+
}
|
|
6018
|
+
}
|
|
6019
|
+
var Turn = class _Turn {
|
|
6020
|
+
constructor(attacks, riders, eps) {
|
|
6021
|
+
this.declaredAttacks = [...attacks];
|
|
6022
|
+
this.riders = [...riders];
|
|
6023
|
+
this.eps = eps;
|
|
6024
|
+
this.plan = buildPlan({ attacks: this.declaredAttacks, riders: this.riders }, eps);
|
|
6025
|
+
}
|
|
6026
|
+
/**
|
|
6027
|
+
* Builds a turn from plain data, throwing {@link TurnSpecError} if it is
|
|
6028
|
+
* malformed. Use this from a UI, where `error.code` maps to the field state to
|
|
6029
|
+
* show.
|
|
6030
|
+
*/
|
|
6031
|
+
static from(spec, eps = EPS) {
|
|
6032
|
+
return new _Turn(spec.attacks, spec.riders ?? [], eps);
|
|
6033
|
+
}
|
|
6034
|
+
/**
|
|
6035
|
+
* Appends an attack, throwing {@link TurnSpecError} if that makes the turn
|
|
6036
|
+
* invalid.
|
|
6037
|
+
*
|
|
6038
|
+
* A rider with no explicit `of` watches every declared attack *including ones
|
|
6039
|
+
* appended after it*, because `of` is resolved when the plan is built rather
|
|
6040
|
+
* than when the rider is added. Pass an explicit `of` to pin a rider to the
|
|
6041
|
+
* attacks it already saw. One attack must exist before a rider with a default
|
|
6042
|
+
* `of` is added, or the build fails `unknown-id`.
|
|
6043
|
+
*/
|
|
6044
|
+
attack(source, id) {
|
|
6045
|
+
const entry = id === void 0 ? source : { id, source };
|
|
6046
|
+
return new _Turn([...this.declaredAttacks, entry], this.riders, this.eps);
|
|
6047
|
+
}
|
|
6048
|
+
/**
|
|
6049
|
+
* Appends `count` copies of the same attack — the Extra Attack case, which is
|
|
6050
|
+
* most of 5e. Argument order mirrors `roll(count, die)`.
|
|
6051
|
+
*
|
|
6052
|
+
* ```ts
|
|
6053
|
+
* turn().attacks(4, greatsword).onEveryHit(d6); // fighter 20 + hunter's mark
|
|
6054
|
+
* ```
|
|
6055
|
+
*
|
|
6056
|
+
* @throws {RangeError} if `count` is not a positive integer.
|
|
6057
|
+
*/
|
|
6058
|
+
attacks(count, source) {
|
|
6059
|
+
if (!Number.isInteger(count) || count < 1) {
|
|
6060
|
+
throw new RangeError(
|
|
6061
|
+
`attacks(count) needs a positive integer, got ${count}.`
|
|
6062
|
+
);
|
|
6063
|
+
}
|
|
6064
|
+
const added = new Array(count).fill(source);
|
|
6065
|
+
return new _Turn([...this.declaredAttacks, ...added], this.riders, this.eps);
|
|
6066
|
+
}
|
|
6067
|
+
/**
|
|
6068
|
+
* Appends a rider, throwing {@link TurnSpecError} if that makes the turn
|
|
6069
|
+
* invalid. The `onX` methods below are the readable way to call this.
|
|
6070
|
+
*/
|
|
6071
|
+
rider(rider) {
|
|
6072
|
+
return new _Turn(this.declaredAttacks, [...this.riders, rider], this.eps);
|
|
6073
|
+
}
|
|
6074
|
+
/**
|
|
6075
|
+
* Fires once, on the first source that lands, in that source's mode — so a
|
|
6076
|
+
* crit on the first landing attack doubles the rider's dice. Sneak Attack.
|
|
6077
|
+
*/
|
|
6078
|
+
onFirstHit(damage, options = {}) {
|
|
6079
|
+
return this.rider({ ...options, damage, on: "first-hit" });
|
|
6080
|
+
}
|
|
6081
|
+
/**
|
|
6082
|
+
* Fires once if any source crit, always in crit mode. Divine Smite: nothing is
|
|
6083
|
+
* lost by holding it for a crit, so this is "any", not "first".
|
|
6084
|
+
*/
|
|
6085
|
+
onAnyCrit(damage, options = {}) {
|
|
6086
|
+
return this.rider({ ...options, damage, on: "any-crit" });
|
|
6087
|
+
}
|
|
6088
|
+
/**
|
|
6089
|
+
* Fires once if any source missed. The reroll gate: a reroll is a fresh attack,
|
|
6090
|
+
* so pass one as the damage. Kensei's Unerring Accuracy, Lucky.
|
|
6091
|
+
*/
|
|
6092
|
+
onAnyMiss(damage, options = {}) {
|
|
6093
|
+
return this.rider({ ...options, damage, on: "any-miss" });
|
|
6094
|
+
}
|
|
6095
|
+
/**
|
|
6096
|
+
* Fires once per source that lands, in that hit's mode — so it can fire several
|
|
6097
|
+
* times in a turn. Hunter's Mark, Hex, Rage.
|
|
6098
|
+
*/
|
|
6099
|
+
onEveryHit(damage, options = {}) {
|
|
6100
|
+
return this.rider({ ...options, damage, on: "every-hit" });
|
|
6101
|
+
}
|
|
6102
|
+
/**
|
|
6103
|
+
* Damage for the turns where the rider added just before this one did *not*
|
|
6104
|
+
* fire: "flurry of blows if I didn't smite".
|
|
6105
|
+
*
|
|
6106
|
+
* ```ts
|
|
6107
|
+
* turn([dagger, dagger])
|
|
6108
|
+
* .onAnyCrit(roll(2, d8)) // smite
|
|
6109
|
+
* .otherwise([flurry, flurry]) // ... or two more attacks
|
|
6110
|
+
* ```
|
|
6111
|
+
*
|
|
6112
|
+
* Always binds to the *immediately* preceding rider, so the two are branches of
|
|
6113
|
+
* one decision and can never both land. Note that chaining it therefore
|
|
6114
|
+
* alternates rather than laddering: `a.otherwise(b).otherwise(c)` makes `c`
|
|
6115
|
+
* fire whenever `b` did not, which is exactly when `a` did. For a genuine
|
|
6116
|
+
* three-way priority chain, name the riders and use explicit `not-fired`
|
|
6117
|
+
* triggers against the right one.
|
|
6118
|
+
*/
|
|
6119
|
+
otherwise(damage, options = {}) {
|
|
6120
|
+
const index = this.riders.length - 1;
|
|
6121
|
+
if (index < 0) {
|
|
6122
|
+
throw new TurnSpecError(
|
|
6123
|
+
"unknown-id",
|
|
6124
|
+
"",
|
|
6125
|
+
"otherwise() needs a preceding rider to negate."
|
|
6126
|
+
);
|
|
6127
|
+
}
|
|
6128
|
+
const previous = this.riders[index];
|
|
6129
|
+
if (previous.on === "every-hit") {
|
|
6130
|
+
throw new TurnSpecError(
|
|
6131
|
+
"not-an-attack",
|
|
6132
|
+
previous.id ?? `rider ${index + 1}`,
|
|
6133
|
+
"otherwise() cannot negate an every-hit rider: it can fire more than once."
|
|
6134
|
+
);
|
|
6135
|
+
}
|
|
6136
|
+
const target = previous.id ?? `rider ${index + 1}`;
|
|
6137
|
+
const riders = [...this.riders];
|
|
6138
|
+
riders[index] = { ...previous, id: target };
|
|
6139
|
+
riders.push({ ...options, damage, on: "not-fired", of: target });
|
|
6140
|
+
return new _Turn(this.declaredAttacks, riders, this.eps);
|
|
6141
|
+
}
|
|
6142
|
+
/**
|
|
6143
|
+
* The exact joint distribution: mass 1, outcome-labelled. Resolved once and
|
|
6144
|
+
* cached.
|
|
6145
|
+
*
|
|
6146
|
+
* There is no `toPMF(eps)` to match the builders: a turn's epsilon is fixed
|
|
6147
|
+
* when it is constructed, because the plan is validated and its sources are
|
|
6148
|
+
* resolved at that point.
|
|
6149
|
+
*/
|
|
6150
|
+
get pmf() {
|
|
6151
|
+
return this.resolve().pmf;
|
|
6152
|
+
}
|
|
6153
|
+
/** Mean damage for the turn. */
|
|
6154
|
+
mean() {
|
|
6155
|
+
return this.pmf.mean();
|
|
6156
|
+
}
|
|
6157
|
+
/**
|
|
6158
|
+
* A query whose `singles` are the **declared attacks** and whose combined
|
|
6159
|
+
* distribution is the exact turn PMF.
|
|
6160
|
+
*
|
|
6161
|
+
* Riders are inside the combined PMF, not in `singles`, so singles-based
|
|
6162
|
+
* helpers (`probAtLeastOne`, `countSinglesWith`, `outcomeStats`) describe the
|
|
6163
|
+
* attacks only. Read rider-inclusive statistics off the combined PMF —
|
|
6164
|
+
* `outcomeTotals`, `outcomeDamageRanges`, `damageAttributionChartModel`.
|
|
6165
|
+
*/
|
|
6166
|
+
toQuery() {
|
|
6167
|
+
return new DiceQuery([...this.plan.attackPMFs], this.pmf, this.eps);
|
|
6168
|
+
}
|
|
6169
|
+
/**
|
|
6170
|
+
* Attack ids in declaration order, including the `attack 1`, `attack 2`, …
|
|
6171
|
+
* defaults given to bare sources. These are the names `of` accepts.
|
|
6172
|
+
*/
|
|
6173
|
+
get attackIds() {
|
|
6174
|
+
return this.plan.attackIds;
|
|
6175
|
+
}
|
|
6176
|
+
/**
|
|
6177
|
+
* Rider ids in declaration order, including the `rider 1`, `rider 2`, …
|
|
6178
|
+
* defaults. These are the names {@link Turn.fireProbability} accepts.
|
|
6179
|
+
*/
|
|
6180
|
+
get riderIds() {
|
|
6181
|
+
return this.plan.riderIds;
|
|
6182
|
+
}
|
|
6183
|
+
/**
|
|
6184
|
+
* P(this rider fired). For an `every-hit` rider it is P(at least one source
|
|
6185
|
+
* hit), since that rider can fire more than once in a turn.
|
|
6186
|
+
*
|
|
6187
|
+
* @throws {TurnSpecError} `unknown-id` if `id` is not a rider — attack ids
|
|
6188
|
+
* included, since attacks always happen and have no firing probability.
|
|
6189
|
+
*/
|
|
6190
|
+
fireProbability(id) {
|
|
6191
|
+
const mass = this.resolve().fireMass.get(id);
|
|
6192
|
+
if (mass === void 0) {
|
|
6193
|
+
throw new TurnSpecError(
|
|
6194
|
+
"unknown-id",
|
|
6195
|
+
id,
|
|
6196
|
+
`"${id}" is not a rider in this turn. Riders: ${this.plan.riderIds.map((each) => `"${each}"`).join(", ")}.`
|
|
6197
|
+
);
|
|
6198
|
+
}
|
|
6199
|
+
return mass;
|
|
6200
|
+
}
|
|
6201
|
+
resolve() {
|
|
6202
|
+
if (this.resolved) return this.resolved;
|
|
6203
|
+
const plan = this.plan;
|
|
6204
|
+
const eps = this.eps;
|
|
6205
|
+
const width = plan.groupCount;
|
|
6206
|
+
const start = {
|
|
6207
|
+
codes: new Array(width).fill(START_CODE),
|
|
6208
|
+
pmf: PMF.delta(0, eps),
|
|
6209
|
+
fired: new Array(plan.steps.length).fill(null)
|
|
6210
|
+
};
|
|
6211
|
+
let states = /* @__PURE__ */ new Map([[String.fromCharCode(), start]]);
|
|
6212
|
+
plan.steps.forEach((step, stepIndex) => {
|
|
6213
|
+
const next = /* @__PURE__ */ new Map();
|
|
6214
|
+
const merge = (state) => {
|
|
6215
|
+
const key = String.fromCharCode(...state.codes) + "" + state.fired.map((mode) => mode === null ? "-" : "+").join("");
|
|
6216
|
+
const existing = next.get(key);
|
|
6217
|
+
if (existing) existing.pmf = existing.pmf.add(state.pmf);
|
|
6218
|
+
else next.set(key, state);
|
|
6219
|
+
};
|
|
6220
|
+
for (const state of states.values()) {
|
|
6221
|
+
const mode = fireMode(step, state.codes, state.fired);
|
|
6222
|
+
const fired = [...state.fired];
|
|
6223
|
+
fired[stepIndex] = mode;
|
|
6224
|
+
if (mode === null) {
|
|
6225
|
+
merge({ codes: state.codes, pmf: state.pmf, fired });
|
|
6226
|
+
continue;
|
|
6227
|
+
}
|
|
6228
|
+
if (!step.slices) {
|
|
6229
|
+
const payload = step.damage;
|
|
6230
|
+
merge({
|
|
6231
|
+
codes: state.codes,
|
|
6232
|
+
pmf: state.pmf.convolve(
|
|
6233
|
+
mode === "crit" ? payload.crit : payload.hit,
|
|
6234
|
+
eps,
|
|
6235
|
+
true
|
|
6236
|
+
),
|
|
6237
|
+
fired
|
|
6238
|
+
});
|
|
6239
|
+
continue;
|
|
6240
|
+
}
|
|
6241
|
+
for (const outcome of OUTCOMES) {
|
|
6242
|
+
const slice = step.slices[outcome];
|
|
6243
|
+
const sliceMass = slice.mass();
|
|
6244
|
+
if (sliceMass <= eps) continue;
|
|
6245
|
+
const codes = [...state.codes];
|
|
6246
|
+
for (const group of step.updates) {
|
|
6247
|
+
codes[group] = advance(codes[group], outcome);
|
|
6248
|
+
}
|
|
6249
|
+
merge({
|
|
6250
|
+
codes,
|
|
6251
|
+
pmf: state.pmf.convolve(slice, eps, true),
|
|
6252
|
+
fired
|
|
6253
|
+
});
|
|
6254
|
+
}
|
|
6255
|
+
}
|
|
6256
|
+
states = next;
|
|
6257
|
+
});
|
|
6258
|
+
const fireMass = /* @__PURE__ */ new Map();
|
|
6259
|
+
for (const id of plan.riderSteps.keys()) fireMass.set(id, 0);
|
|
6260
|
+
for (const id of plan.perHitGroups.keys()) fireMass.set(id, 0);
|
|
6261
|
+
let total;
|
|
6262
|
+
for (const state of states.values()) {
|
|
6263
|
+
total = total ? total.add(state.pmf) : state.pmf;
|
|
6264
|
+
const mass = state.pmf.mass();
|
|
6265
|
+
for (const [id, stepIndex] of plan.riderSteps) {
|
|
6266
|
+
if (state.fired[stepIndex] !== null) {
|
|
6267
|
+
fireMass.set(id, fireMass.get(id) + mass);
|
|
6268
|
+
}
|
|
6269
|
+
}
|
|
6270
|
+
for (const [id, group] of plan.perHitGroups) {
|
|
6271
|
+
if (state.codes[group] >> 2 !== FIRST_NONE) {
|
|
6272
|
+
fireMass.set(id, fireMass.get(id) + mass);
|
|
6273
|
+
}
|
|
6274
|
+
}
|
|
6275
|
+
}
|
|
6276
|
+
const pmf = total ?? PMF.delta(0, eps);
|
|
6277
|
+
const totalMass = pmf.mass();
|
|
6278
|
+
const needsNormalizing = Math.abs(totalMass - 1) > eps && totalMass > 0;
|
|
6279
|
+
if (needsNormalizing) {
|
|
6280
|
+
for (const [id, mass] of fireMass) fireMass.set(id, mass / totalMass);
|
|
6281
|
+
}
|
|
6282
|
+
this.resolved = {
|
|
6283
|
+
pmf: needsNormalizing ? pmf.normalize() : pmf,
|
|
6284
|
+
fireMass
|
|
6285
|
+
};
|
|
6286
|
+
return this.resolved;
|
|
6287
|
+
}
|
|
6288
|
+
};
|
|
6289
|
+
function turn(attacks = [], eps = EPS) {
|
|
6290
|
+
return Turn.from(
|
|
6291
|
+
{ attacks: Array.isArray(attacks) ? attacks : [attacks] },
|
|
6292
|
+
eps
|
|
6293
|
+
);
|
|
6294
|
+
}
|
|
6295
|
+
|
|
6296
|
+
export { ACBuilder, AlwaysCritBuilder, AlwaysHitBuilder, AttackBuilder, DCBuilder, HalfRollBuilder, MAX_TRIGGER_GROUPS, MaxOfRollBuilder, ParsedRollBuilder, PooledRollBuilder, RollBuilder, SaveBuilder, ScaleRollBuilder, Turn, TurnSpecError, builderPMFCache, clearAttackCache, clearDCCache, clearRollCache, clearSaveCache, d, d10, d100, d12, d20, d4, d6, d8, defaultConfig, flat, hd20, roll, sumRolls, turn };
|
|
5502
6297
|
//# sourceMappingURL=index.js.map
|
|
5503
6298
|
//# sourceMappingURL=index.js.map
|