@yipe/dice 0.9.0 → 0.11.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/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 +19 -0
- 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 +614 -121
- package/dist/builder/index.cjs.map +1 -1
- package/dist/builder/index.js +614 -122
- package/dist/builder/index.js.map +1 -1
- package/dist/builder/nodes.d.ts +1 -0
- package/dist/builder/nodes.d.ts.map +1 -1
- package/dist/builder/roll.d.ts +13 -1
- package/dist/builder/roll.d.ts.map +1 -1
- package/dist/builder/save.d.ts.map +1 -1
- package/dist/builder/types.d.ts +1 -0
- package/dist/builder/types.d.ts.map +1 -1
- package/dist/common/bounce.d.ts +45 -0
- package/dist/common/bounce.d.ts.map +1 -1
- package/dist/common/types.d.ts +33 -0
- package/dist/common/types.d.ts.map +1 -1
- package/dist/index.cjs +235 -15
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +232 -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/pmf/pmf.d.ts +22 -4
- package/dist/pmf/pmf.d.ts.map +1 -1
- package/dist/turn/plan.d.ts +29 -6
- package/dist/turn/plan.d.ts.map +1 -1
- package/dist/turn/state.d.ts +16 -8
- package/dist/turn/state.d.ts.map +1 -1
- package/dist/turn/turn.d.ts +35 -0
- package/dist/turn/turn.d.ts.map +1 -1
- package/dist/turn/types.d.ts +32 -10
- package/dist/turn/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/CHANGELOG.md +0 -456
package/dist/builder/index.cjs
CHANGED
|
@@ -1476,7 +1476,7 @@ var _PMF = class _PMF {
|
|
|
1476
1476
|
const id = this.identifier;
|
|
1477
1477
|
let key = `${id}`;
|
|
1478
1478
|
for (let i = 1; i < n; i++) key += `+${id}`;
|
|
1479
|
-
return `${key}@${eps}`;
|
|
1479
|
+
return `${key}@${eps}|${this.fingerprint()}`;
|
|
1480
1480
|
}
|
|
1481
1481
|
/**
|
|
1482
1482
|
* Efficiently computes this PMF convolved with itself `n` times.
|
|
@@ -1788,6 +1788,30 @@ var _PMF = class _PMF {
|
|
|
1788
1788
|
`freq(${this.identifier},${freq})`
|
|
1789
1789
|
);
|
|
1790
1790
|
}
|
|
1791
|
+
/**
|
|
1792
|
+
* Splits this PMF into two complementary PMFs by an arbitrary per-damage-value factor in
|
|
1793
|
+
* `[0, 1]` — bin `d`'s mass, `count`, and `attr` split `factor(d)` / `1 - factor(d)` between the
|
|
1794
|
+
* two results (via the same proportional scaling {@link applyHitFrequency} uses, `scaleBin`), so
|
|
1795
|
+
* `a.add(b)` recovers this PMF exactly and both halves stay chart-attributable. Unlike
|
|
1796
|
+
* {@link applyHitFrequency}, mass is NOT redistributed to a miss bin at 0 — each bin stays at its
|
|
1797
|
+
* own damage value in whichever half it lands in. `factor` outside `[0, 1]` is clamped.
|
|
1798
|
+
*
|
|
1799
|
+
* Built for `dice-match` trigger slicing: splitting a hit/crit sub-PMF into "matched" and
|
|
1800
|
+
* "did not match" halves by the exact per-damage-value match probability.
|
|
1801
|
+
*/
|
|
1802
|
+
splitByFactor(factor) {
|
|
1803
|
+
const a = /* @__PURE__ */ new Map();
|
|
1804
|
+
const b = /* @__PURE__ */ new Map();
|
|
1805
|
+
for (const [damage, bin] of this.map) {
|
|
1806
|
+
const f = Math.min(1, Math.max(0, factor(damage)));
|
|
1807
|
+
if (f > 0) a.set(damage, _PMF.scaleBin(bin, f));
|
|
1808
|
+
if (f < 1) b.set(damage, _PMF.scaleBin(bin, 1 - f));
|
|
1809
|
+
}
|
|
1810
|
+
return [
|
|
1811
|
+
new _PMF(a, this.epsilon, false, `split+(${this.identifier})`),
|
|
1812
|
+
new _PMF(b, this.epsilon, false, `split-(${this.identifier})`)
|
|
1813
|
+
];
|
|
1814
|
+
}
|
|
1791
1815
|
scaleMass(factor) {
|
|
1792
1816
|
if (factor === 1) return this;
|
|
1793
1817
|
const scaledMap = /* @__PURE__ */ new Map();
|
|
@@ -1827,16 +1851,27 @@ var _PMF = class _PMF {
|
|
|
1827
1851
|
return `v4:${raw ? "RAW" : "N"}:${id1}+${id2}@${eps}|${p1.fingerprint()}|${p2.fingerprint()}`;
|
|
1828
1852
|
}
|
|
1829
1853
|
/**
|
|
1830
|
-
* A
|
|
1831
|
-
* cache keys change
|
|
1832
|
-
*
|
|
1833
|
-
*
|
|
1854
|
+
* A content fingerprint of every bin (probability, per-label `count`, per-label `attr`) plus
|
|
1855
|
+
* the `normalized` flag, so convolution/power cache keys change whenever the underlying
|
|
1856
|
+
* numbers do. Mass/bin-count/face-sum alone are not content-unique: `mapDamage` variants can
|
|
1857
|
+
* keep the same identifier, support, mass, and face sum while differing in per-bin
|
|
1858
|
+
* probabilities or in the `count`/`attr` channels `convolve()`/`power()` actually propagate --
|
|
1859
|
+
* that previously let `power()` return one PMF's cached result for a different PMF. Memoized
|
|
1860
|
+
* because a PMF is immutable once constructed -- this avoids re-deriving the key on every
|
|
1861
|
+
* convolve()/power() call (including cache hits). Bin order is sorted by damage value (and
|
|
1862
|
+
* label keys sorted within each bin) so two equal-content PMFs built via different code paths
|
|
1863
|
+
* fingerprint identically regardless of Map insertion order.
|
|
1834
1864
|
*/
|
|
1835
1865
|
fingerprint() {
|
|
1836
1866
|
if (this._fingerprint === void 0) {
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1867
|
+
const bins = [...this.map.entries()].sort((a, b) => a[0] - b[0]);
|
|
1868
|
+
const parts = [];
|
|
1869
|
+
for (const [damageValue, bin] of bins) {
|
|
1870
|
+
const countStr = Object.keys(bin.count).sort().map((k) => `${k}:${bin.count[k]}`).join(",");
|
|
1871
|
+
const attrStr = bin.attr ? Object.keys(bin.attr).sort().map((k) => `${k}:${bin.attr[k]}`).join(",") : "";
|
|
1872
|
+
parts.push(`${damageValue}:${bin.p}[${countStr}]{${attrStr}}`);
|
|
1873
|
+
}
|
|
1874
|
+
this._fingerprint = `${this.normalized ? 1 : 0}|${parts.join(";")}`;
|
|
1840
1875
|
}
|
|
1841
1876
|
return this._fingerprint;
|
|
1842
1877
|
}
|
|
@@ -2068,11 +2103,13 @@ var _PMF = class _PMF {
|
|
|
2068
2103
|
/** Quantile / inverse CDF for p in [0,1]. Returns smallest x with CDF ≥ p. */
|
|
2069
2104
|
quantile(p) {
|
|
2070
2105
|
if (this.map.size === 0) return 0;
|
|
2106
|
+
const totalMass = this.mass();
|
|
2107
|
+
if (totalMass <= 0) return 0;
|
|
2071
2108
|
const s = this.support().sort((a, b) => a - b);
|
|
2072
2109
|
let acc = 0;
|
|
2073
2110
|
for (const x of s) {
|
|
2074
2111
|
acc += this.pAt(x);
|
|
2075
|
-
if (acc >= p) return x;
|
|
2112
|
+
if (acc / totalMass >= p) return x;
|
|
2076
2113
|
}
|
|
2077
2114
|
return s[s.length - 1];
|
|
2078
2115
|
}
|
|
@@ -2461,6 +2498,84 @@ var _PMF = class _PMF {
|
|
|
2461
2498
|
_PMF.__anonIdCounter = 1;
|
|
2462
2499
|
var PMF = _PMF;
|
|
2463
2500
|
|
|
2501
|
+
// src/common/bounce.ts
|
|
2502
|
+
function faceWeights(faces, minimum = 0, reroll = 0) {
|
|
2503
|
+
const f = Math.max(0, Math.floor(faces));
|
|
2504
|
+
if (f <= 0) return [];
|
|
2505
|
+
let weights = new Array(f).fill(1 / f);
|
|
2506
|
+
const r = Math.max(0, Math.min(Math.floor(reroll), f));
|
|
2507
|
+
if (r > 0) {
|
|
2508
|
+
const rerollMass = r / f;
|
|
2509
|
+
const uniformReroll = rerollMass / f;
|
|
2510
|
+
weights = weights.map((_, i) => (i < r ? 0 : 1 / f) + uniformReroll);
|
|
2511
|
+
}
|
|
2512
|
+
const minV = Math.max(0, Math.floor(minimum));
|
|
2513
|
+
if (minV > 1) {
|
|
2514
|
+
const collapsed = new Array(f).fill(0);
|
|
2515
|
+
for (let v = 1; v <= f; v++) {
|
|
2516
|
+
const target = Math.min(f, Math.max(v, minV));
|
|
2517
|
+
collapsed[target - 1] += weights[v - 1];
|
|
2518
|
+
}
|
|
2519
|
+
weights = collapsed;
|
|
2520
|
+
}
|
|
2521
|
+
return weights;
|
|
2522
|
+
}
|
|
2523
|
+
function diceSumDistribution(dice, weights) {
|
|
2524
|
+
let dist = /* @__PURE__ */ new Map([[0, 1]]);
|
|
2525
|
+
for (let die = 0; die < dice; die++) {
|
|
2526
|
+
const next = /* @__PURE__ */ new Map();
|
|
2527
|
+
for (const [sum, mass] of dist) {
|
|
2528
|
+
for (let face = 1; face <= weights.length; face++) {
|
|
2529
|
+
const w = weights[face - 1] ?? 0;
|
|
2530
|
+
if (w <= 0) continue;
|
|
2531
|
+
const s = sum + face;
|
|
2532
|
+
next.set(s, (next.get(s) ?? 0) + mass * w);
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
dist = next;
|
|
2536
|
+
}
|
|
2537
|
+
return dist;
|
|
2538
|
+
}
|
|
2539
|
+
function sumAllDistinctDistribution(dice, weights) {
|
|
2540
|
+
const faceCount = weights.length;
|
|
2541
|
+
let dp = /* @__PURE__ */ new Map([[0, /* @__PURE__ */ new Map([[0, 1]])]]);
|
|
2542
|
+
for (let face = 1; face <= faceCount; face++) {
|
|
2543
|
+
const w = weights[face - 1] ?? 0;
|
|
2544
|
+
const next = /* @__PURE__ */ new Map();
|
|
2545
|
+
for (const [count, sumMap] of dp) next.set(count, new Map(sumMap));
|
|
2546
|
+
if (w > 0) {
|
|
2547
|
+
for (const [count, sumMap] of dp) {
|
|
2548
|
+
const nextCount = count + 1;
|
|
2549
|
+
if (nextCount > dice) continue;
|
|
2550
|
+
const target = next.get(nextCount) ?? /* @__PURE__ */ new Map();
|
|
2551
|
+
for (const [sum, mass] of sumMap) {
|
|
2552
|
+
const s = sum + face;
|
|
2553
|
+
target.set(s, (target.get(s) ?? 0) + mass * w);
|
|
2554
|
+
}
|
|
2555
|
+
next.set(nextCount, target);
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
dp = next;
|
|
2559
|
+
}
|
|
2560
|
+
let factorial = 1;
|
|
2561
|
+
for (let i = 2; i <= dice; i++) factorial *= i;
|
|
2562
|
+
const chosen = dp.get(dice) ?? /* @__PURE__ */ new Map();
|
|
2563
|
+
const result = /* @__PURE__ */ new Map();
|
|
2564
|
+
for (const [sum, mass] of chosen) result.set(sum, mass * factorial);
|
|
2565
|
+
return result;
|
|
2566
|
+
}
|
|
2567
|
+
function jointSumAndMatch(dice, weights) {
|
|
2568
|
+
if (dice <= 1) return /* @__PURE__ */ new Map();
|
|
2569
|
+
const total = diceSumDistribution(dice, weights);
|
|
2570
|
+
const distinct = sumAllDistinctDistribution(dice, weights);
|
|
2571
|
+
const result = /* @__PURE__ */ new Map();
|
|
2572
|
+
for (const [sum, mass] of total) {
|
|
2573
|
+
const matchMass = Math.max(0, mass - (distinct.get(sum) ?? 0));
|
|
2574
|
+
if (matchMass > 0) result.set(sum, matchMass);
|
|
2575
|
+
}
|
|
2576
|
+
return result;
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2464
2579
|
// src/pmf/mixture.ts
|
|
2465
2580
|
var Mixture = class _Mixture {
|
|
2466
2581
|
constructor(eps = EPS) {
|
|
@@ -3051,6 +3166,12 @@ function combineDiceWithNormalization(dice, normValue, outcomeType, currentNorm,
|
|
|
3051
3166
|
finalResult = finalResult.combine(dice);
|
|
3052
3167
|
return { newNorm: currentNorm * normValue, updatedResult: finalResult };
|
|
3053
3168
|
}
|
|
3169
|
+
function subtractCounts(a, b) {
|
|
3170
|
+
const result = new Dice();
|
|
3171
|
+
for (const [key, value] of a.getFaceEntries()) result.increment(key, value);
|
|
3172
|
+
for (const [key, value] of b.getFaceEntries()) result.increment(key, -value);
|
|
3173
|
+
return result;
|
|
3174
|
+
}
|
|
3054
3175
|
function parseExpression(arr, n) {
|
|
3055
3176
|
const result = (() => {
|
|
3056
3177
|
const res = parseArgument(arr, n);
|
|
@@ -3058,8 +3179,29 @@ function parseExpression(arr, n) {
|
|
|
3058
3179
|
})();
|
|
3059
3180
|
let op = parseOperation(arr);
|
|
3060
3181
|
let finalResult = result;
|
|
3182
|
+
let baseDieMeta = result.privateData?.checkDie && !result.privateData.checkDie.rerollOne ? result.privateData.checkDie : void 0;
|
|
3183
|
+
let bonusOnly = Dice.scalar(0);
|
|
3061
3184
|
while (op != null) {
|
|
3062
3185
|
const arg = !op.unary ? parseArgument(arr, n) : finalResult;
|
|
3186
|
+
let acAlreadyApplied = false;
|
|
3187
|
+
if (baseDieMeta) {
|
|
3188
|
+
if (op === Dice.prototype.addNonZero) {
|
|
3189
|
+
bonusOnly = bonusOnly.add(arg);
|
|
3190
|
+
} else if (op === Dice.prototype.subtract) {
|
|
3191
|
+
bonusOnly = bonusOnly.subtract(arg);
|
|
3192
|
+
} else if (op === Dice.prototype.ac && typeof arg === "number") {
|
|
3193
|
+
const natMaxSlice = bonusOnly.add(baseDieMeta.sides);
|
|
3194
|
+
const restSlice = subtractCounts(finalResult, natMaxSlice);
|
|
3195
|
+
const gatedNatMaxSlice = natMaxSlice.ac(arg);
|
|
3196
|
+
finalResult = restSlice.ac(arg).combine(gatedNatMaxSlice);
|
|
3197
|
+
finalResult.privateData.checkDie = baseDieMeta;
|
|
3198
|
+
finalResult.privateData.natMaxCritSlice = gatedNatMaxSlice;
|
|
3199
|
+
acAlreadyApplied = true;
|
|
3200
|
+
baseDieMeta = void 0;
|
|
3201
|
+
} else {
|
|
3202
|
+
baseDieMeta = void 0;
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3063
3205
|
let crit;
|
|
3064
3206
|
let critNorm = 1;
|
|
3065
3207
|
if (arr[0] === "x" || arr[0] === "c") {
|
|
@@ -3070,11 +3212,17 @@ function parseExpression(arr, n) {
|
|
|
3070
3212
|
assertToken(arr, "i");
|
|
3071
3213
|
assertToken(arr, "t");
|
|
3072
3214
|
const count = isXcrit ? parseNumber(arr, n) : 1;
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3215
|
+
const trackedCritSlice = finalResult.privateData?.natMaxCritSlice;
|
|
3216
|
+
if (count === 1 && trackedCritSlice) {
|
|
3217
|
+
crit = trackedCritSlice;
|
|
3218
|
+
finalResult = subtractCounts(finalResult, trackedCritSlice);
|
|
3219
|
+
} else {
|
|
3220
|
+
crit = new Dice();
|
|
3221
|
+
for (let i = 0; i < count; i++) {
|
|
3222
|
+
const max = finalResult.maxFace();
|
|
3223
|
+
crit.setFace(max, finalResult.get(max));
|
|
3224
|
+
finalResult = finalResult.deleteFace(max);
|
|
3225
|
+
}
|
|
3078
3226
|
}
|
|
3079
3227
|
critNorm = crit.total();
|
|
3080
3228
|
crit = op.call(crit, parseBinaryArgument(arg, arr, n));
|
|
@@ -3125,7 +3273,9 @@ function parseExpression(arr, n) {
|
|
|
3125
3273
|
missNorm = miss && missNorm ? miss.total() / missNorm : 1;
|
|
3126
3274
|
}
|
|
3127
3275
|
let norm = finalResult.total();
|
|
3128
|
-
|
|
3276
|
+
if (!acAlreadyApplied) {
|
|
3277
|
+
finalResult = op.call(finalResult, arg);
|
|
3278
|
+
}
|
|
3129
3279
|
norm = norm ? finalResult.total() / norm : 1;
|
|
3130
3280
|
if (crit) {
|
|
3131
3281
|
const result2 = combineDiceWithNormalization(
|
|
@@ -3316,6 +3466,7 @@ function parseDice(s, n) {
|
|
|
3316
3466
|
if (rerollOne) {
|
|
3317
3467
|
result = result.reroll(1);
|
|
3318
3468
|
}
|
|
3469
|
+
result.privateData.checkDie = { sides, rerollOne };
|
|
3319
3470
|
return result;
|
|
3320
3471
|
}
|
|
3321
3472
|
function peek(arr, expected) {
|
|
@@ -3507,6 +3658,12 @@ function d20PMF(rerollOne) {
|
|
|
3507
3658
|
}
|
|
3508
3659
|
|
|
3509
3660
|
// src/builder/roll.ts
|
|
3661
|
+
function validateScaleInt(scale) {
|
|
3662
|
+
const scaleInt = Math.floor(scale);
|
|
3663
|
+
if (scaleInt !== scale) throw new Error("Scale must be an integer");
|
|
3664
|
+
if (scaleInt <= 0) throw new Error("Scale must be > 0");
|
|
3665
|
+
return scaleInt;
|
|
3666
|
+
}
|
|
3510
3667
|
var rollPMFCache = new LRUCache(4e3);
|
|
3511
3668
|
function clearRollCache() {
|
|
3512
3669
|
rollPMFCache.clear();
|
|
@@ -3517,16 +3674,17 @@ var defaultConfig = {
|
|
|
3517
3674
|
modifier: 0,
|
|
3518
3675
|
reroll: 0,
|
|
3519
3676
|
explode: 0,
|
|
3677
|
+
explodePoolBudget: 0,
|
|
3520
3678
|
minimum: 0,
|
|
3521
3679
|
bestOf: 0,
|
|
3522
3680
|
keep: void 0,
|
|
3523
3681
|
rollType: "flat"
|
|
3524
3682
|
};
|
|
3525
3683
|
var rollConfigsEqual = (a, b) => {
|
|
3526
|
-
return a.count === b.count && a.sides === b.sides && a.modifier === b.modifier && a.reroll === b.reroll && a.explode === b.explode && a.minimum === b.minimum && a.bestOf === b.bestOf && a.keep === b.keep && a.rollType === b.rollType;
|
|
3684
|
+
return a.count === b.count && a.sides === b.sides && a.modifier === b.modifier && a.reroll === b.reroll && a.explode === b.explode && a.explodePoolBudget === b.explodePoolBudget && a.minimum === b.minimum && a.bestOf === b.bestOf && a.keep === b.keep && a.rollType === b.rollType;
|
|
3527
3685
|
};
|
|
3528
3686
|
var configComplexityScore = (config) => {
|
|
3529
|
-
return (config.reroll > 0 ? 1 : 0) + (config.explode > 0 ? 1 : 0) + (config.minimum > 0 ? 1 : 0) + (config.bestOf > 0 ? 1 : 0) + (config.keep !== void 0 ? 1 : 0) + (config.rollType !== "flat" ? 1 : 0);
|
|
3687
|
+
return (config.reroll > 0 ? 1 : 0) + (config.explode > 0 ? 1 : 0) + (config.explodePoolBudget > 0 ? 1 : 0) + (config.minimum > 0 ? 1 : 0) + (config.bestOf > 0 ? 1 : 0) + (config.keep !== void 0 ? 1 : 0) + (config.rollType !== "flat" ? 1 : 0);
|
|
3530
3688
|
};
|
|
3531
3689
|
var RollBuilder = class _RollBuilder {
|
|
3532
3690
|
constructor(countOrConfigs = 1) {
|
|
@@ -3708,11 +3866,37 @@ var RollBuilder = class _RollBuilder {
|
|
|
3708
3866
|
if (count === void 0) return this;
|
|
3709
3867
|
if (count === 0) return this;
|
|
3710
3868
|
if (count < 0) throw new Error("Explode count must be >= 0");
|
|
3869
|
+
if (this.lastConfig.explodePoolBudget > 0) {
|
|
3870
|
+
throw new Error(
|
|
3871
|
+
"Cannot set explode() on a config that already has a pool-wide explodePool() budget \u2014 the two exploding-dice semantics (per-die vs pool-wide) are mutually exclusive on one config."
|
|
3872
|
+
);
|
|
3873
|
+
}
|
|
3711
3874
|
const newConfigs = this.getSubRollConfigs();
|
|
3712
3875
|
newConfigs[newConfigs.length - 1].explode = count;
|
|
3713
3876
|
return this.create(newConfigs);
|
|
3714
3877
|
}
|
|
3715
|
-
/**
|
|
3878
|
+
/**
|
|
3879
|
+
* Set a pool-wide exploding-dice budget: at most `budget` extra dice may be added across the
|
|
3880
|
+
* WHOLE pool (shared), as opposed to {@link explode}'s per-die cap (`n` dice each individually
|
|
3881
|
+
* allowed up to `explode(k)` extra dice). `budget` must be a finite non-negative integer —
|
|
3882
|
+
* unlike `explode()`, `Infinity` is not accepted (it would make the pool-wide DP non-terminating).
|
|
3883
|
+
*/
|
|
3884
|
+
explodePool(budget) {
|
|
3885
|
+
if (isNaN(budget)) throw new Error("Invalid NaN value for explodePool budget");
|
|
3886
|
+
if (!Number.isFinite(budget)) throw new Error("explodePool budget must be finite");
|
|
3887
|
+
if (budget < 0) throw new Error("explodePool budget must be >= 0");
|
|
3888
|
+
if (budget === 0) return this;
|
|
3889
|
+
if (this.lastConfig.explode > 0) {
|
|
3890
|
+
throw new Error(
|
|
3891
|
+
"Cannot set explodePool() on a config that already has a per-die explode() cap \u2014 the two exploding-dice semantics (per-die vs pool-wide) are mutually exclusive on one config."
|
|
3892
|
+
);
|
|
3893
|
+
}
|
|
3894
|
+
const newConfigs = this.getSubRollConfigs();
|
|
3895
|
+
newConfigs[newConfigs.length - 1].explodePoolBudget = Math.floor(budget);
|
|
3896
|
+
return this.create(newConfigs);
|
|
3897
|
+
}
|
|
3898
|
+
/** Apply per-die minimum value (floors each die roll at `val`, e.g. `minimum(3)` treats a 1 or
|
|
3899
|
+
* 2 as a 3 -- the 2024 Great Weapon Fighting style). */
|
|
3716
3900
|
minimum(val) {
|
|
3717
3901
|
if (val !== void 0 && isNaN(val))
|
|
3718
3902
|
throw new Error("Invalid NaN value for minimum");
|
|
@@ -3720,7 +3904,7 @@ var RollBuilder = class _RollBuilder {
|
|
|
3720
3904
|
if (val === 0) return this;
|
|
3721
3905
|
if (val < 0) throw new Error("Minimum value must be >= 0");
|
|
3722
3906
|
const newConfigs = this.getSubRollConfigs();
|
|
3723
|
-
newConfigs[newConfigs.length - 1].minimum = val
|
|
3907
|
+
newConfigs[newConfigs.length - 1].minimum = val;
|
|
3724
3908
|
return this.create(newConfigs);
|
|
3725
3909
|
}
|
|
3726
3910
|
bestOf(count) {
|
|
@@ -3821,9 +4005,7 @@ var RollBuilder = class _RollBuilder {
|
|
|
3821
4005
|
return this.create(configs);
|
|
3822
4006
|
}
|
|
3823
4007
|
scaleDice(scale) {
|
|
3824
|
-
const scaleInt =
|
|
3825
|
-
if (scaleInt !== scale) throw new Error("Scale must be an integer");
|
|
3826
|
-
if (scaleInt <= 0) throw new Error("Scale must be > 0");
|
|
4008
|
+
const scaleInt = validateScaleInt(scale);
|
|
3827
4009
|
const newConfigs = this.getSubRollConfigs().map((config) => {
|
|
3828
4010
|
if (!config.sides || config.sides <= 0) return config;
|
|
3829
4011
|
return { ...config, count: config.count * scaleInt };
|
|
@@ -3946,12 +4128,21 @@ var RollBuilder = class _RollBuilder {
|
|
|
3946
4128
|
}
|
|
3947
4129
|
configToSingleExpressionWithoutModifier(config, isRootDie) {
|
|
3948
4130
|
if (!config.sides || config.sides <= 0) return "";
|
|
4131
|
+
if (config.explode && Number.isFinite(config.explode) && config.explode > 0) {
|
|
4132
|
+
throw new Error(
|
|
4133
|
+
`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().`
|
|
4134
|
+
);
|
|
4135
|
+
}
|
|
4136
|
+
if (config.explodePoolBudget && config.explodePoolBudget > 0) {
|
|
4137
|
+
throw new Error(
|
|
4138
|
+
`toExpression() cannot represent a pool-wide exploding-dice budget (d${config.sides} explodePool(${config.explodePoolBudget})): the string grammar has no explode syntax. Use the builder's own PMF (.toPMF()/.pmf) instead of round-tripping through toExpression()/parse().`
|
|
4139
|
+
);
|
|
4140
|
+
}
|
|
3949
4141
|
let baseDie = `d${config.sides}`;
|
|
4142
|
+
const rerollClause = config.reroll > 0 ? config.reroll === 1 ? " reroll 1" : ` reroll d${config.reroll}` : "";
|
|
3950
4143
|
if (config.reroll > 0) {
|
|
3951
|
-
if (config.minimum > 0 && config.explode > 0) ; else
|
|
3952
|
-
|
|
3953
|
-
} else {
|
|
3954
|
-
for (let i = 1; i <= config.reroll; i++) baseDie += ` reroll ${i}`;
|
|
4144
|
+
if (config.minimum > 0 && config.explode > 0) ; else {
|
|
4145
|
+
baseDie += rerollClause;
|
|
3955
4146
|
}
|
|
3956
4147
|
}
|
|
3957
4148
|
if (config.minimum > 0) {
|
|
@@ -3961,9 +4152,7 @@ var RollBuilder = class _RollBuilder {
|
|
|
3961
4152
|
baseDie = `${config.minimum}>${baseDie}`;
|
|
3962
4153
|
}
|
|
3963
4154
|
if (config.reroll > 0 && config.explode > 0) {
|
|
3964
|
-
|
|
3965
|
-
baseDie += ` reroll ${i}`;
|
|
3966
|
-
}
|
|
4155
|
+
baseDie += rerollClause;
|
|
3967
4156
|
}
|
|
3968
4157
|
}
|
|
3969
4158
|
if (baseDie === "d20 reroll 1" && config.minimum <= 1) baseDie = "hd20";
|
|
@@ -3981,10 +4170,14 @@ var RollBuilder = class _RollBuilder {
|
|
|
3981
4170
|
case "flat":
|
|
3982
4171
|
if (config.keep) {
|
|
3983
4172
|
const mode = config.keep.mode === "highest" ? "kh" : "kl";
|
|
4173
|
+
const baseCount = Math.max(1, Math.floor(Math.abs(config.count || 1)));
|
|
4174
|
+
const trials = Math.max(1, Math.floor(config.keep.total));
|
|
4175
|
+
const isMaxOfShape = config.keep.count === 1 && config.keep.mode === "highest";
|
|
4176
|
+
const innerCount = trials === baseCount && !isMaxOfShape ? 1 : baseCount;
|
|
3984
4177
|
const baseDieExpression = this.configToSingleExpressionWithoutModifier(
|
|
3985
4178
|
{
|
|
3986
4179
|
...config,
|
|
3987
|
-
count:
|
|
4180
|
+
count: innerCount,
|
|
3988
4181
|
modifier: 0,
|
|
3989
4182
|
rollType: "flat",
|
|
3990
4183
|
keep: void 0
|
|
@@ -4016,7 +4209,19 @@ var RollBuilder = class _RollBuilder {
|
|
|
4016
4209
|
}
|
|
4017
4210
|
}
|
|
4018
4211
|
if (config.bestOf && config.count && config.bestOf < config.count) {
|
|
4019
|
-
|
|
4212
|
+
const pool = Math.max(1, Math.floor(Math.abs(config.count)));
|
|
4213
|
+
const baseDieExpression = this.configToSingleExpressionWithoutModifier(
|
|
4214
|
+
{
|
|
4215
|
+
...config,
|
|
4216
|
+
count: 1,
|
|
4217
|
+
modifier: 0,
|
|
4218
|
+
bestOf: 0,
|
|
4219
|
+
keep: void 0,
|
|
4220
|
+
rollType: "flat"
|
|
4221
|
+
},
|
|
4222
|
+
false
|
|
4223
|
+
);
|
|
4224
|
+
mainExpression = `${pool}kh${Math.floor(config.bestOf)}(${baseDieExpression})`;
|
|
4020
4225
|
}
|
|
4021
4226
|
break;
|
|
4022
4227
|
}
|
|
@@ -4119,6 +4324,12 @@ var HalfRollBuilder = class _HalfRollBuilder extends RollBuilder {
|
|
|
4119
4324
|
toPMF(eps = 0) {
|
|
4120
4325
|
return pmfFromRollBuilder(this, eps);
|
|
4121
4326
|
}
|
|
4327
|
+
// Scale the dice, keep the same // 2 (half) transform applied on top -- delegating to the
|
|
4328
|
+
// base class's `create()`-based scaleDice would drop the halving entirely, e.g. a doubled-dice
|
|
4329
|
+
// crit on a resisted hit payload silently losing the resistance.
|
|
4330
|
+
scaleDice(scale) {
|
|
4331
|
+
return new _HalfRollBuilder(this.innerRoll.scaleDice(scale));
|
|
4332
|
+
}
|
|
4122
4333
|
copy() {
|
|
4123
4334
|
return new _HalfRollBuilder(this.innerRoll.copy());
|
|
4124
4335
|
}
|
|
@@ -4145,9 +4356,16 @@ var ScaleRollBuilder = class _ScaleRollBuilder extends RollBuilder {
|
|
|
4145
4356
|
}
|
|
4146
4357
|
toExpression() {
|
|
4147
4358
|
const inner = this.innerRoll.toExpression();
|
|
4148
|
-
|
|
4149
|
-
if (
|
|
4150
|
-
|
|
4359
|
+
const denominator = this.denominator === 0 ? 1 : this.denominator;
|
|
4360
|
+
if (denominator === 1) return `${this.numerator} ** (${inner})`;
|
|
4361
|
+
if (this.rounding === "round") {
|
|
4362
|
+
throw new Error(
|
|
4363
|
+
`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.`
|
|
4364
|
+
);
|
|
4365
|
+
}
|
|
4366
|
+
const div = this.rounding === "ceil" ? "/" : "//";
|
|
4367
|
+
if (this.numerator === 1) return `(${inner}) ${div} ${denominator}`;
|
|
4368
|
+
return `(${inner}) ** ${this.numerator} ${div} ${denominator}`;
|
|
4151
4369
|
}
|
|
4152
4370
|
toAST() {
|
|
4153
4371
|
return {
|
|
@@ -4161,6 +4379,17 @@ var ScaleRollBuilder = class _ScaleRollBuilder extends RollBuilder {
|
|
|
4161
4379
|
toPMF(eps = 0) {
|
|
4162
4380
|
return pmfFromRollBuilder(this, eps);
|
|
4163
4381
|
}
|
|
4382
|
+
// Scale the dice, keep the same numerator/denominator/rounding transform applied on top --
|
|
4383
|
+
// delegating to the base class's `create()`-based scaleDice would drop the scale entirely,
|
|
4384
|
+
// e.g. a doubled-dice crit on a vulnerable hit payload silently losing the vulnerability.
|
|
4385
|
+
scaleDice(scale) {
|
|
4386
|
+
return new _ScaleRollBuilder(
|
|
4387
|
+
this.innerRoll.scaleDice(scale),
|
|
4388
|
+
this.numerator,
|
|
4389
|
+
this.denominator,
|
|
4390
|
+
this.rounding
|
|
4391
|
+
);
|
|
4392
|
+
}
|
|
4164
4393
|
copy() {
|
|
4165
4394
|
return new _ScaleRollBuilder(
|
|
4166
4395
|
this.innerRoll.copy(),
|
|
@@ -4233,6 +4462,18 @@ var MaxOfRollBuilder = class _MaxOfRollBuilder extends RollBuilder {
|
|
|
4233
4462
|
toPMF(eps = 0) {
|
|
4234
4463
|
return pmfFromRollBuilder(this, eps);
|
|
4235
4464
|
}
|
|
4465
|
+
// Scale the dice INSIDE each trial (e.g. maxOf(2, 1d12) -> maxOf(2, 2d12)), keeping the same
|
|
4466
|
+
// trial count -- delegating to the base class's `create()`-based scaleDice would collapse
|
|
4467
|
+
// straight to plain dice, losing the "take the highest of N trials" semantics entirely.
|
|
4468
|
+
scaleDice(scale) {
|
|
4469
|
+
const scaleInt = validateScaleInt(scale);
|
|
4470
|
+
return new _MaxOfRollBuilder(
|
|
4471
|
+
this.innerRoll.scaleDice(scaleInt),
|
|
4472
|
+
this.count,
|
|
4473
|
+
this.diceCount ? this.diceCount * scaleInt : void 0,
|
|
4474
|
+
this.diceSides
|
|
4475
|
+
);
|
|
4476
|
+
}
|
|
4236
4477
|
copy() {
|
|
4237
4478
|
return new _MaxOfRollBuilder(this.innerRoll.copy(), this.count);
|
|
4238
4479
|
}
|
|
@@ -4279,9 +4520,7 @@ var AlwaysHitBuilder = class _AlwaysHitBuilder extends RollBuilder {
|
|
|
4279
4520
|
return new RollBuilder(configs).toExpression();
|
|
4280
4521
|
}
|
|
4281
4522
|
toPMF() {
|
|
4282
|
-
|
|
4283
|
-
const rerollOne = this.baseReroll > 0;
|
|
4284
|
-
return d20RollPMF(rollType, rerollOne);
|
|
4523
|
+
return resolveRootD20(this);
|
|
4285
4524
|
}
|
|
4286
4525
|
copy() {
|
|
4287
4526
|
const baseCopy = new RollBuilder(this.getSubRollConfigs());
|
|
@@ -4329,9 +4568,7 @@ var AlwaysCritBuilder = class _AlwaysCritBuilder extends RollBuilder {
|
|
|
4329
4568
|
return new RollBuilder(configs).toExpression();
|
|
4330
4569
|
}
|
|
4331
4570
|
toPMF() {
|
|
4332
|
-
|
|
4333
|
-
const rerollOne = this.baseReroll > 0;
|
|
4334
|
-
return d20RollPMF(rollType, rerollOne);
|
|
4571
|
+
return resolveRootD20(this);
|
|
4335
4572
|
}
|
|
4336
4573
|
copy() {
|
|
4337
4574
|
const baseCopy = new RollBuilder(this.getSubRollConfigs());
|
|
@@ -4399,6 +4636,9 @@ var PooledRollBuilder = class _PooledRollBuilder extends RollBuilder {
|
|
|
4399
4636
|
explode(_count = Infinity) {
|
|
4400
4637
|
throw new Error("Cannot set explode on a pooled roll.");
|
|
4401
4638
|
}
|
|
4639
|
+
explodePool(_budget) {
|
|
4640
|
+
throw new Error("Cannot set explodePool on a pooled roll.");
|
|
4641
|
+
}
|
|
4402
4642
|
minimum(_val) {
|
|
4403
4643
|
throw new Error("Cannot set minimum on a pooled roll.");
|
|
4404
4644
|
}
|
|
@@ -4521,6 +4761,17 @@ var CompositeSumRollBuilder = class _CompositeSumRollBuilder extends RollBuilder
|
|
|
4521
4761
|
toPMF(eps = 0) {
|
|
4522
4762
|
return pmfFromRollBuilder(this, eps);
|
|
4523
4763
|
}
|
|
4764
|
+
// Scaling a composite (mixed damage types, e.g. base + resisted) must scale each PART's own
|
|
4765
|
+
// dice while preserving its own half/scale wrapper -- delegating to the base class's
|
|
4766
|
+
// `create()`-based scaleDice would lose every part's transform, collapsing straight to plain
|
|
4767
|
+
// dice. This is what auto-crit doubling (attack.ts's `hitEffect.copy().doubleDice()`) relies
|
|
4768
|
+
// on for a mixed-resistance hit payload.
|
|
4769
|
+
scaleDice(scale) {
|
|
4770
|
+
validateScaleInt(scale);
|
|
4771
|
+
return new _CompositeSumRollBuilder(
|
|
4772
|
+
this.parts.map((p) => p.scaleDice(scale))
|
|
4773
|
+
);
|
|
4774
|
+
}
|
|
4524
4775
|
copy() {
|
|
4525
4776
|
return new _CompositeSumRollBuilder(this.parts.map((p) => p.copy()));
|
|
4526
4777
|
}
|
|
@@ -4598,6 +4849,15 @@ var builderPMFCache = new LRUCache(1e3);
|
|
|
4598
4849
|
// src/builder/ast.ts
|
|
4599
4850
|
var defaultEps = 0;
|
|
4600
4851
|
var singleDiePMFCache = new LRUCache(1e3);
|
|
4852
|
+
function dieNodeFromConfig(cfg) {
|
|
4853
|
+
return {
|
|
4854
|
+
type: "die",
|
|
4855
|
+
sides: cfg.sides,
|
|
4856
|
+
reroll: cfg.reroll > 0 ? cfg.reroll : void 0,
|
|
4857
|
+
minimum: cfg.minimum > 0 ? cfg.minimum : void 0,
|
|
4858
|
+
explode: cfg.explode && Number.isFinite(cfg.explode) && cfg.explode > 0 ? cfg.explode : void 0
|
|
4859
|
+
};
|
|
4860
|
+
}
|
|
4601
4861
|
function astFromRollConfigs(configs) {
|
|
4602
4862
|
if (!configs || configs.length === 0) return void 0;
|
|
4603
4863
|
const children = [];
|
|
@@ -4607,13 +4867,9 @@ function astFromRollConfigs(configs) {
|
|
|
4607
4867
|
const count = Math.abs(cfg.count || 0);
|
|
4608
4868
|
constantSum += cfg.modifier || 0;
|
|
4609
4869
|
if ((cfg.sides || 0) <= 0) continue;
|
|
4610
|
-
const
|
|
4611
|
-
|
|
4612
|
-
|
|
4613
|
-
reroll: cfg.reroll > 0 ? cfg.reroll : void 0,
|
|
4614
|
-
minimum: cfg.minimum > 0 ? cfg.minimum : void 0,
|
|
4615
|
-
explode: cfg.explode && Number.isFinite(cfg.explode) && cfg.explode > 0 ? cfg.explode : void 0
|
|
4616
|
-
};
|
|
4870
|
+
const isSynthesizedBestOf = !cfg.keep && cfg.bestOf > 0 && cfg.bestOf < count;
|
|
4871
|
+
const effectiveKeep = isSynthesizedBestOf ? { total: count, count: Math.floor(cfg.bestOf), mode: "highest" } : cfg.keep;
|
|
4872
|
+
const die = dieNodeFromConfig(cfg);
|
|
4617
4873
|
let node = die;
|
|
4618
4874
|
let appliedRollType = false;
|
|
4619
4875
|
if (cfg.rollType && cfg.rollType !== "flat") {
|
|
@@ -4631,11 +4887,16 @@ function astFromRollConfigs(configs) {
|
|
|
4631
4887
|
}
|
|
4632
4888
|
appliedRollType = true;
|
|
4633
4889
|
}
|
|
4634
|
-
if (cfg.rollType === "flat" &&
|
|
4890
|
+
if (cfg.rollType === "flat" && effectiveKeep && effectiveKeep.total > 0) {
|
|
4891
|
+
if (cfg.explodePoolBudget > 0) {
|
|
4892
|
+
throw new Error(
|
|
4893
|
+
"explodePool() cannot be combined with keep()/bestOf() on the same config \u2014 the match/keep pool is ambiguous once dice can be added mid-resolution. Use explodePool() on a plain (non-keep) pool."
|
|
4894
|
+
);
|
|
4895
|
+
}
|
|
4635
4896
|
const baseCount = Math.max(1, Math.floor(Math.abs(count || 1)));
|
|
4636
|
-
const trials = Math.max(1, Math.floor(
|
|
4637
|
-
const k = Math.max(0, Math.floor(
|
|
4638
|
-
if (k === 1 &&
|
|
4897
|
+
const trials = Math.max(1, Math.floor(effectiveKeep.total));
|
|
4898
|
+
const k = Math.max(0, Math.floor(effectiveKeep.count));
|
|
4899
|
+
if (k === 1 && effectiveKeep.mode === "highest" && !isSynthesizedBestOf) {
|
|
4639
4900
|
const perTrial = {
|
|
4640
4901
|
type: "sum",
|
|
4641
4902
|
count: baseCount,
|
|
@@ -4654,7 +4915,7 @@ function astFromRollConfigs(configs) {
|
|
|
4654
4915
|
const base = { type: "sum", count: trials, child: node };
|
|
4655
4916
|
node = {
|
|
4656
4917
|
type: "keep",
|
|
4657
|
-
mode:
|
|
4918
|
+
mode: effectiveKeep.mode,
|
|
4658
4919
|
count: k,
|
|
4659
4920
|
child: base
|
|
4660
4921
|
};
|
|
@@ -4674,7 +4935,7 @@ function astFromRollConfigs(configs) {
|
|
|
4674
4935
|
};
|
|
4675
4936
|
node = {
|
|
4676
4937
|
type: "keep",
|
|
4677
|
-
mode:
|
|
4938
|
+
mode: effectiveKeep.mode,
|
|
4678
4939
|
count: k,
|
|
4679
4940
|
child: trialPool
|
|
4680
4941
|
};
|
|
@@ -4682,7 +4943,17 @@ function astFromRollConfigs(configs) {
|
|
|
4682
4943
|
}
|
|
4683
4944
|
} else {
|
|
4684
4945
|
const c = appliedRollType ? 1 : Math.max(1, count || 1);
|
|
4685
|
-
|
|
4946
|
+
if (cfg.explodePoolBudget > 0 && appliedRollType) {
|
|
4947
|
+
throw new Error(
|
|
4948
|
+
"explodePool() cannot be combined with advantage/disadvantage/elven-accuracy on the same config \u2014 pool-wide explosion is for damage dice pools, not d20 rolls."
|
|
4949
|
+
);
|
|
4950
|
+
}
|
|
4951
|
+
node = {
|
|
4952
|
+
type: "sum",
|
|
4953
|
+
count: c,
|
|
4954
|
+
child: node,
|
|
4955
|
+
explodePoolBudget: cfg.explodePoolBudget > 0 ? cfg.explodePoolBudget : void 0
|
|
4956
|
+
};
|
|
4686
4957
|
}
|
|
4687
4958
|
children.push({ node, sign });
|
|
4688
4959
|
}
|
|
@@ -4713,6 +4984,13 @@ function resolve(node, eps = defaultEps) {
|
|
|
4713
4984
|
const base = resolve(node.child, eps);
|
|
4714
4985
|
const n = Math.max(0, Math.floor(node.count));
|
|
4715
4986
|
if (n === 0) return PMF.delta(0, eps);
|
|
4987
|
+
if (node.explodePoolBudget && Number.isFinite(node.explodePoolBudget) && node.explodePoolBudget > 0) {
|
|
4988
|
+
const die = findDie(node.child);
|
|
4989
|
+
if (!die) {
|
|
4990
|
+
throw new Error("explodePool() requires the pool's child to be a plain die.");
|
|
4991
|
+
}
|
|
4992
|
+
return resolveExplodingPool(base, die.sides, n, Math.floor(node.explodePoolBudget), eps);
|
|
4993
|
+
}
|
|
4716
4994
|
if (n === 1) return base;
|
|
4717
4995
|
return base.power(n, eps);
|
|
4718
4996
|
}
|
|
@@ -4748,8 +5026,8 @@ function resolve(node, eps = defaultEps) {
|
|
|
4748
5026
|
}
|
|
4749
5027
|
case "d20Roll": {
|
|
4750
5028
|
const childDie = findDie(node.child);
|
|
4751
|
-
|
|
4752
|
-
return
|
|
5029
|
+
if (!childDie) return d20RollPMF(node.rollType, false);
|
|
5030
|
+
return resolveD20Roll(childDie, node.rollType);
|
|
4753
5031
|
}
|
|
4754
5032
|
case "half": {
|
|
4755
5033
|
const childPMF = resolve(node.child, eps);
|
|
@@ -4775,6 +5053,37 @@ function pmfFromRollBuilder(rb, eps = defaultEps) {
|
|
|
4775
5053
|
const ast = rb.toAST();
|
|
4776
5054
|
return resolve(ast, eps);
|
|
4777
5055
|
}
|
|
5056
|
+
var d20RollLiftCache = new LRUCache(500);
|
|
5057
|
+
function resolveD20Roll(die, rollType) {
|
|
5058
|
+
const base = resolveSingleDie(die, defaultEps);
|
|
5059
|
+
const type = rollType || "flat";
|
|
5060
|
+
if (type === "flat") return base;
|
|
5061
|
+
const cacheKey = `${getASTSignature(die)}|${type}`;
|
|
5062
|
+
const cached = d20RollLiftCache.get(cacheKey);
|
|
5063
|
+
if (cached) return cached;
|
|
5064
|
+
const support = [...base.support()].sort((a, b) => a - b);
|
|
5065
|
+
const out = /* @__PURE__ */ new Map();
|
|
5066
|
+
let cum = 0;
|
|
5067
|
+
let prevLifted = 0;
|
|
5068
|
+
for (const k of support) {
|
|
5069
|
+
cum += base.pAt(k);
|
|
5070
|
+
const curLifted = type === "advantage" ? cum * cum : type === "elven accuracy" ? cum * cum * cum : 1 - (1 - cum) * (1 - cum);
|
|
5071
|
+
const pk = curLifted - prevLifted;
|
|
5072
|
+
if (pk > 0) out.set(k, pk);
|
|
5073
|
+
prevLifted = curLifted;
|
|
5074
|
+
}
|
|
5075
|
+
const result = PMF.fromMap(out, defaultEps);
|
|
5076
|
+
d20RollLiftCache.set(cacheKey, result);
|
|
5077
|
+
return result;
|
|
5078
|
+
}
|
|
5079
|
+
function resolveRootD20(check) {
|
|
5080
|
+
const rootConfig = check.getRootDieConfig();
|
|
5081
|
+
const rollType = check.rollType;
|
|
5082
|
+
if (!rootConfig || !(rootConfig.sides > 0)) {
|
|
5083
|
+
return d20RollPMF(rollType, check.baseReroll > 0);
|
|
5084
|
+
}
|
|
5085
|
+
return resolveD20Roll(dieNodeFromConfig(rootConfig), rollType);
|
|
5086
|
+
}
|
|
4778
5087
|
function resolveSingleDie(die, eps = defaultEps) {
|
|
4779
5088
|
const signature = getASTSignature(die);
|
|
4780
5089
|
const cacheKey = `${signature}_${eps}`;
|
|
@@ -4808,25 +5117,42 @@ function resolveSingleDie(die, eps = defaultEps) {
|
|
|
4808
5117
|
for (const v of pmf.support()) {
|
|
4809
5118
|
if (v !== maxFace) nonMax.set(v, pmf.pAt(v));
|
|
4810
5119
|
}
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
5120
|
+
const nonMaxPMF = PMF.fromMap(nonMax, eps);
|
|
5121
|
+
let chain = pmf;
|
|
5122
|
+
for (let remaining = 1; remaining <= times - 1; remaining++) {
|
|
5123
|
+
chain = PMF.branch(chain.mapDamage((v) => v + maxFace), nonMaxPMF, pMax);
|
|
4814
5124
|
}
|
|
4815
|
-
|
|
4816
|
-
const addOnce = pmf;
|
|
4817
|
-
for (let t = 1; t <= times; t++) {
|
|
4818
|
-
tail = tail.convolve(addOnce, eps);
|
|
4819
|
-
}
|
|
4820
|
-
const exploded = PMF.branch(
|
|
4821
|
-
tail.mapDamage((v) => v + maxFace),
|
|
4822
|
-
nonMaxPMF,
|
|
4823
|
-
pMax
|
|
4824
|
-
);
|
|
5125
|
+
const exploded = PMF.branch(chain.mapDamage((v) => v + maxFace), nonMaxPMF, pMax);
|
|
4825
5126
|
pmf = exploded;
|
|
4826
5127
|
}
|
|
4827
5128
|
singleDiePMFCache.set(cacheKey, pmf);
|
|
4828
5129
|
return pmf;
|
|
4829
5130
|
}
|
|
5131
|
+
function resolveExplodingPool(diePMF, maxFace, count, budget, eps) {
|
|
5132
|
+
const pMax = diePMF.pAt(maxFace);
|
|
5133
|
+
const nonMax = /* @__PURE__ */ new Map();
|
|
5134
|
+
for (const v of diePMF.support()) {
|
|
5135
|
+
if (v !== maxFace) nonMax.set(v, diePMF.pAt(v));
|
|
5136
|
+
}
|
|
5137
|
+
if (nonMax.size === 0) {
|
|
5138
|
+
return PMF.delta((count + budget) * maxFace, eps);
|
|
5139
|
+
}
|
|
5140
|
+
const nonMaxPMF = PMF.fromMap(nonMax, eps);
|
|
5141
|
+
const memo = /* @__PURE__ */ new Map();
|
|
5142
|
+
const f = (p, b) => {
|
|
5143
|
+
if (p === 0) return PMF.delta(0, eps);
|
|
5144
|
+
if (b === 0) return diePMF.power(p, eps);
|
|
5145
|
+
const key = `${p},${b}`;
|
|
5146
|
+
const cached = memo.get(key);
|
|
5147
|
+
if (cached) return cached;
|
|
5148
|
+
const maxBranch = f(p, b - 1).mapDamage((v) => v + maxFace);
|
|
5149
|
+
const nonMaxBranch = nonMaxPMF.convolve(f(p - 1, b), eps);
|
|
5150
|
+
const result = PMF.branch(maxBranch, nonMaxBranch, pMax);
|
|
5151
|
+
memo.set(key, result);
|
|
5152
|
+
return result;
|
|
5153
|
+
};
|
|
5154
|
+
return f(count, budget);
|
|
5155
|
+
}
|
|
4830
5156
|
function findDie(node) {
|
|
4831
5157
|
switch (node.type) {
|
|
4832
5158
|
case "die":
|
|
@@ -5011,7 +5337,7 @@ function getASTSignature(node) {
|
|
|
5011
5337
|
return `d{${parts.join(",")}}`;
|
|
5012
5338
|
}
|
|
5013
5339
|
case "sum":
|
|
5014
|
-
return `sum{c:${node.count},ch:${getASTSignature(node.child)}}`;
|
|
5340
|
+
return `sum{c:${node.count},b:${node.explodePoolBudget || 0},ch:${getASTSignature(node.child)}}`;
|
|
5015
5341
|
case "d20Roll":
|
|
5016
5342
|
return `d20{t:${node.rollType},ch:${getASTSignature(node.child)}}`;
|
|
5017
5343
|
case "keep":
|
|
@@ -5120,10 +5446,8 @@ var AttackBuilder = class _AttackBuilder {
|
|
|
5120
5446
|
return `${checkPart} * ${effectPart}`;
|
|
5121
5447
|
}
|
|
5122
5448
|
resolveProbabilities(check, eps = 0) {
|
|
5123
|
-
const rollType = check.rollType;
|
|
5124
|
-
const rerollOne = check.baseReroll > 0;
|
|
5125
5449
|
const critThreshold = check.critThreshold;
|
|
5126
|
-
const d202 =
|
|
5450
|
+
const d202 = resolveRootD20(check);
|
|
5127
5451
|
if (check instanceof AlwaysCritBuilder) {
|
|
5128
5452
|
if (check.fromAlwaysHit) {
|
|
5129
5453
|
return { pSuccess: 1, pHit: 0, pCrit: 1, pMiss: 0 };
|
|
@@ -5173,13 +5497,17 @@ var AttackBuilder = class _AttackBuilder {
|
|
|
5173
5497
|
pmiss += pr;
|
|
5174
5498
|
continue;
|
|
5175
5499
|
}
|
|
5176
|
-
if (r
|
|
5500
|
+
if (r === 20) {
|
|
5177
5501
|
pcrit += pr;
|
|
5178
5502
|
continue;
|
|
5179
5503
|
}
|
|
5180
5504
|
const need = ac - staticMod - r;
|
|
5181
5505
|
const pBonusHit = bonusPMF.tailProbGE(need);
|
|
5182
|
-
|
|
5506
|
+
if (r >= critThreshold) {
|
|
5507
|
+
pcrit += pr * pBonusHit;
|
|
5508
|
+
} else {
|
|
5509
|
+
phit += pr * pBonusHit;
|
|
5510
|
+
}
|
|
5183
5511
|
pmiss += pr * (1 - pBonusHit);
|
|
5184
5512
|
}
|
|
5185
5513
|
const psuccess = phit + pcrit;
|
|
@@ -5230,6 +5558,55 @@ var AttackBuilder = class _AttackBuilder {
|
|
|
5230
5558
|
weights: { hit: phit, crit: pcrit, miss: pmiss }
|
|
5231
5559
|
};
|
|
5232
5560
|
}
|
|
5561
|
+
/**
|
|
5562
|
+
* For `dice-match` trigger slicing (`turn/types.ts`'s `HasDiceMatchInfo`): the exact
|
|
5563
|
+
* per-damage-value match probability for the hit and crit branches, or `null` per branch when no
|
|
5564
|
+
* descriptor is available — a string-parsed effect, a wrapped transform whose PMF isn't fully
|
|
5565
|
+
* captured by its `RollConfig`s (half/scale/maxOf/pooled — `cacheKey()` returns `null` for
|
|
5566
|
+
* exactly these), a `keep`/`bestOf` pool (ambiguous "the dice" under crit doubling), a
|
|
5567
|
+
* pool-wide exploding budget (composition with match not yet threaded through here), a
|
|
5568
|
+
* multi-die-type pool, a single die (can never match), or (crit) `noCrit()`.
|
|
5569
|
+
*
|
|
5570
|
+
* The crit branch is built from the SAME crit-effect selection `resolve()` uses (an explicit
|
|
5571
|
+
* `onCrit` roll, or the hit dice auto-doubled via `copy().doubleDice()`), so its descriptor
|
|
5572
|
+
* reflects the crit branch's REAL doubled pool, not the hit pool re-used blindly.
|
|
5573
|
+
*/
|
|
5574
|
+
diceMatchInfo(_eps = EPS) {
|
|
5575
|
+
const hit = this.matchInfoForEffect(this.hitEffect);
|
|
5576
|
+
let critEffect;
|
|
5577
|
+
if (this.critEffect === null) {
|
|
5578
|
+
critEffect = void 0;
|
|
5579
|
+
} else if (this.critEffect) {
|
|
5580
|
+
critEffect = this.critEffect;
|
|
5581
|
+
} else if (this.hitEffect instanceof ParsedRollBuilder) {
|
|
5582
|
+
critEffect = void 0;
|
|
5583
|
+
} else {
|
|
5584
|
+
critEffect = this.hitEffect?.copy().doubleDice();
|
|
5585
|
+
}
|
|
5586
|
+
const crit = this.matchInfoForEffect(critEffect);
|
|
5587
|
+
return { hit, crit };
|
|
5588
|
+
}
|
|
5589
|
+
matchInfoForEffect(effect) {
|
|
5590
|
+
if (!effect || effect instanceof ParsedRollBuilder) return null;
|
|
5591
|
+
if (effect.cacheKey() === null) return null;
|
|
5592
|
+
const diceConfigs = effect.getSubRollConfigs().filter((c) => c.sides > 0);
|
|
5593
|
+
if (diceConfigs.length !== 1) return null;
|
|
5594
|
+
const config = diceConfigs[0];
|
|
5595
|
+
if (config.keep || config.bestOf > 0) return null;
|
|
5596
|
+
if (config.explodePoolBudget > 0) return null;
|
|
5597
|
+
if (config.count <= 1) return { matchProbabilityByDamage: /* @__PURE__ */ new Map() };
|
|
5598
|
+
const weights = faceWeights(config.sides, config.minimum, config.reroll);
|
|
5599
|
+
const totalDist = diceSumDistribution(config.count, weights);
|
|
5600
|
+
const joint = jointSumAndMatch(config.count, weights);
|
|
5601
|
+
const modifier = effect.modifier;
|
|
5602
|
+
const matchProbabilityByDamage = /* @__PURE__ */ new Map();
|
|
5603
|
+
for (const [sum, totalMass] of totalDist) {
|
|
5604
|
+
if (totalMass <= 0) continue;
|
|
5605
|
+
const matchMass = joint.get(sum) ?? 0;
|
|
5606
|
+
matchProbabilityByDamage.set(sum + modifier, matchMass / totalMass);
|
|
5607
|
+
}
|
|
5608
|
+
return { matchProbabilityByDamage };
|
|
5609
|
+
}
|
|
5233
5610
|
/**
|
|
5234
5611
|
* A cheap, complete key for this attack's resolved PMF, or `null` when it can't be cached soundly (an
|
|
5235
5612
|
* effect whose PMF isn't captured by its {@link RollConfig}s — see {@link RollBuilder.cacheKey}). Composed
|
|
@@ -5327,9 +5704,7 @@ var ACBuilder = class _ACBuilder extends RollBuilder {
|
|
|
5327
5704
|
}
|
|
5328
5705
|
toPMF(eps = 0) {
|
|
5329
5706
|
const ac = this.attackConfig.ac;
|
|
5330
|
-
const
|
|
5331
|
-
const rerollOne = this.baseReroll > 0;
|
|
5332
|
-
const d202 = d20RollPMF(rollType, rerollOne);
|
|
5707
|
+
const d202 = resolveRootD20(this);
|
|
5333
5708
|
const staticMod = this.modifier;
|
|
5334
5709
|
const bonusPMFs = this.getBonusDicePMFs(this, eps);
|
|
5335
5710
|
const parts = [d202, ...bonusPMFs];
|
|
@@ -5439,21 +5814,17 @@ var SaveBuilder = class _SaveBuilder {
|
|
|
5439
5814
|
function resolveProbabilities(check) {
|
|
5440
5815
|
const saveBonus = check.modifier;
|
|
5441
5816
|
const dc = check.saveDC;
|
|
5442
|
-
const
|
|
5443
|
-
const
|
|
5444
|
-
const die = d20RollPMF(d20Type, baseReroll > 0);
|
|
5817
|
+
const eps = 0;
|
|
5818
|
+
const die = resolveRootD20(check);
|
|
5445
5819
|
const faceP = /* @__PURE__ */ new Map();
|
|
5446
5820
|
for (const [r, bin] of die) {
|
|
5447
5821
|
const pr = bin.p;
|
|
5448
5822
|
if (pr > 0) faceP.set(r, pr);
|
|
5449
5823
|
}
|
|
5450
|
-
const eps = 0;
|
|
5451
5824
|
const bonusDicePMFs = check.getBonusDicePMFs(check, eps);
|
|
5452
5825
|
const bonusPMF = bonusDicePMFs.length > 0 ? PMF.convolveMany(bonusDicePMFs, eps) : PMF.zero(eps);
|
|
5453
5826
|
let pSuccess = 0;
|
|
5454
|
-
for (
|
|
5455
|
-
const pr = faceP.get(r);
|
|
5456
|
-
if (!pr) continue;
|
|
5827
|
+
for (const [r, pr] of faceP) {
|
|
5457
5828
|
const need = dc - saveBonus - r;
|
|
5458
5829
|
pSuccess += pr * bonusPMF.tailProbGE(need);
|
|
5459
5830
|
}
|
|
@@ -5523,9 +5894,7 @@ var DCBuilder = class _DCBuilder extends RollBuilder {
|
|
|
5523
5894
|
if (cached) return cached;
|
|
5524
5895
|
}
|
|
5525
5896
|
const saveDC = this.saveDC;
|
|
5526
|
-
const
|
|
5527
|
-
const rerollOne = this.baseReroll > 0;
|
|
5528
|
-
const d202 = d20RollPMF(rollType, rerollOne);
|
|
5897
|
+
const d202 = resolveRootD20(this);
|
|
5529
5898
|
const staticMod = this.modifier;
|
|
5530
5899
|
const bonusDicePMFs = this.getBonusDiceConfigs().map(
|
|
5531
5900
|
(cfg) => pmfFromRollBuilder(RollBuilder.fromConfigs([cfg]), eps)
|
|
@@ -5562,14 +5931,15 @@ var TurnSpecError = class extends Error {
|
|
|
5562
5931
|
this.name = "TurnSpecError";
|
|
5563
5932
|
}
|
|
5564
5933
|
};
|
|
5565
|
-
var MAX_TRIGGER_GROUPS =
|
|
5934
|
+
var MAX_TRIGGER_GROUPS = 9;
|
|
5566
5935
|
|
|
5567
5936
|
// src/turn/plan.ts
|
|
5568
|
-
var
|
|
5937
|
+
var READS_GROUP = {
|
|
5569
5938
|
"first-hit": true,
|
|
5570
5939
|
"any-crit": true,
|
|
5571
5940
|
"any-miss": true,
|
|
5572
|
-
"every-hit": true
|
|
5941
|
+
"every-hit": true,
|
|
5942
|
+
"dice-match": true
|
|
5573
5943
|
};
|
|
5574
5944
|
function toPMF(damage, eps, id = "") {
|
|
5575
5945
|
const parts = Array.isArray(damage) ? damage : [damage];
|
|
@@ -5613,20 +5983,49 @@ function critPMF(rider, base, eps) {
|
|
|
5613
5983
|
if (doubled.length === 0) return base;
|
|
5614
5984
|
return PMF.convolveMany(doubled, eps);
|
|
5615
5985
|
}
|
|
5616
|
-
function
|
|
5986
|
+
function diceMatchInfoOf(source, eps) {
|
|
5987
|
+
const capable = source;
|
|
5988
|
+
if (typeof capable.diceMatchInfo === "function") {
|
|
5989
|
+
return capable.diceMatchInfo(eps);
|
|
5990
|
+
}
|
|
5991
|
+
return { hit: null, crit: null };
|
|
5992
|
+
}
|
|
5993
|
+
function sliceSource(pmf, matchInfo) {
|
|
5617
5994
|
const labels = pmf.outcomes();
|
|
5618
5995
|
if (!labels.includes("hit") && !labels.includes("crit")) return null;
|
|
5619
5996
|
const missParts = ["missNone", "missDamage"].filter((label) => labels.includes(label)).map((label) => pmf.filterOutcome(label));
|
|
5620
|
-
|
|
5621
|
-
|
|
5622
|
-
|
|
5623
|
-
|
|
5624
|
-
|
|
5997
|
+
const hit = labels.includes("hit") ? pmf.filterOutcome("hit") : PMF.emptyMass();
|
|
5998
|
+
const crit = labels.includes("crit") ? pmf.filterOutcome("crit") : PMF.emptyMass();
|
|
5999
|
+
const miss = missParts.length ? missParts.reduce((all, part) => all.add(part)) : PMF.emptyMass();
|
|
6000
|
+
let hitMatch = null;
|
|
6001
|
+
let hitNoMatch = null;
|
|
6002
|
+
let critMatch = null;
|
|
6003
|
+
let critNoMatch = null;
|
|
6004
|
+
if (matchInfo?.hit) {
|
|
6005
|
+
const info = matchInfo.hit;
|
|
6006
|
+
const [m, nm] = hit.splitByFactor((d2) => info.matchProbabilityByDamage.get(d2) ?? 0);
|
|
6007
|
+
hitMatch = m;
|
|
6008
|
+
hitNoMatch = nm;
|
|
6009
|
+
}
|
|
6010
|
+
if (matchInfo?.crit) {
|
|
6011
|
+
const info = matchInfo.crit;
|
|
6012
|
+
const [m, nm] = crit.splitByFactor((d2) => info.matchProbabilityByDamage.get(d2) ?? 0);
|
|
6013
|
+
critMatch = m;
|
|
6014
|
+
critNoMatch = nm;
|
|
6015
|
+
}
|
|
6016
|
+
return { hit, crit, miss, hitMatch, hitNoMatch, critMatch, critNoMatch };
|
|
5625
6017
|
}
|
|
5626
6018
|
function buildPlan(spec, eps = EPS) {
|
|
5627
6019
|
const fail = (code, id, message) => {
|
|
5628
6020
|
throw new TurnSpecError(code, id, message);
|
|
5629
6021
|
};
|
|
6022
|
+
const riders = spec.riders ?? [];
|
|
6023
|
+
const matchNeededSourceIds = /* @__PURE__ */ new Set();
|
|
6024
|
+
for (const rider of riders) {
|
|
6025
|
+
if (rider.on === "dice-match") {
|
|
6026
|
+
for (const sourceId of rider.of) matchNeededSourceIds.add(sourceId);
|
|
6027
|
+
}
|
|
6028
|
+
}
|
|
5630
6029
|
const attackIds = [];
|
|
5631
6030
|
const attackPMFs = [];
|
|
5632
6031
|
const attackSlices = [];
|
|
@@ -5636,11 +6035,11 @@ function buildPlan(spec, eps = EPS) {
|
|
|
5636
6035
|
const id = hasWrapper ? named.id : `attack ${index + 1}`;
|
|
5637
6036
|
const source = hasWrapper ? named.source : entry;
|
|
5638
6037
|
const pmf = toPMF(source, eps, id);
|
|
6038
|
+
const matchInfo = matchNeededSourceIds.has(id) ? diceMatchInfoOf(source, eps) : null;
|
|
5639
6039
|
attackIds.push(id);
|
|
5640
6040
|
attackPMFs.push(pmf);
|
|
5641
|
-
attackSlices.push(sliceSource(pmf));
|
|
6041
|
+
attackSlices.push(sliceSource(pmf, matchInfo));
|
|
5642
6042
|
});
|
|
5643
|
-
const riders = spec.riders ?? [];
|
|
5644
6043
|
const riderIds = riders.map((rider, index) => rider.id ?? `rider ${index + 1}`);
|
|
5645
6044
|
const seen = /* @__PURE__ */ new Set();
|
|
5646
6045
|
for (const id of [...attackIds, ...riderIds]) {
|
|
@@ -5649,6 +6048,17 @@ function buildPlan(spec, eps = EPS) {
|
|
|
5649
6048
|
}
|
|
5650
6049
|
const attackIndexById = new Map(attackIds.map((id, index) => [id, index]));
|
|
5651
6050
|
const riderIndexById = new Map(riderIds.map((id, index) => [id, index]));
|
|
6051
|
+
const checkMatchable = (riderId, sourceId, slices) => {
|
|
6052
|
+
const missingHit = slices.hit.mass() > 0 && slices.hitMatch === null;
|
|
6053
|
+
const missingCrit = slices.crit.mass() > 0 && slices.critMatch === null;
|
|
6054
|
+
if (missingHit || missingCrit) {
|
|
6055
|
+
fail(
|
|
6056
|
+
"no-dice-descriptor",
|
|
6057
|
+
sourceId,
|
|
6058
|
+
`Rider "${riderId}" reads "${sourceId}" for "dice-match", but "${sourceId}" has no dice descriptor to match against \u2014 a bare PMF, a string-parsed expression, or a keep()/bestOf() pool (ambiguous "the dice" under crit doubling) cannot be matched.`
|
|
6059
|
+
);
|
|
6060
|
+
}
|
|
6061
|
+
};
|
|
5652
6062
|
const sourceIdsByRider = riders.map((rider, index) => {
|
|
5653
6063
|
const id = riderIds[index];
|
|
5654
6064
|
if (rider.on === "not-fired") {
|
|
@@ -5673,7 +6083,7 @@ function buildPlan(spec, eps = EPS) {
|
|
|
5673
6083
|
}
|
|
5674
6084
|
return [target];
|
|
5675
6085
|
}
|
|
5676
|
-
const of = [...new Set(rider.of ?? attackIds)];
|
|
6086
|
+
const of = [...new Set(rider.on === "dice-match" ? rider.of : rider.of ?? attackIds)];
|
|
5677
6087
|
if (of.length === 0) {
|
|
5678
6088
|
fail("unknown-id", id, `Rider "${id}" has no sources.`);
|
|
5679
6089
|
}
|
|
@@ -5697,8 +6107,11 @@ function buildPlan(spec, eps = EPS) {
|
|
|
5697
6107
|
`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.`
|
|
5698
6108
|
);
|
|
5699
6109
|
}
|
|
6110
|
+
const damageSource = isAttack ? void 0 : riders[riderIndex].damage;
|
|
6111
|
+
const singleDamageSource = damageSource !== void 0 && !Array.isArray(damageSource) ? damageSource : void 0;
|
|
5700
6112
|
const slices = isAttack ? attackSlices[attackIndexById.get(sourceId)] : sliceSource(
|
|
5701
|
-
toPMF(
|
|
6113
|
+
toPMF(damageSource, eps, sourceId),
|
|
6114
|
+
matchNeededSourceIds.has(sourceId) && singleDamageSource !== void 0 ? diceMatchInfoOf(singleDamageSource, eps) : null
|
|
5702
6115
|
);
|
|
5703
6116
|
if (!slices) {
|
|
5704
6117
|
fail(
|
|
@@ -5706,6 +6119,8 @@ function buildPlan(spec, eps = EPS) {
|
|
|
5706
6119
|
sourceId,
|
|
5707
6120
|
`Rider "${id}" triggers on "${sourceId}", which has no hit/crit outcomes.`
|
|
5708
6121
|
);
|
|
6122
|
+
} else if (rider.on === "dice-match") {
|
|
6123
|
+
checkMatchable(id, sourceId, slices);
|
|
5709
6124
|
}
|
|
5710
6125
|
}
|
|
5711
6126
|
return [...of];
|
|
@@ -5751,7 +6166,7 @@ function buildPlan(spec, eps = EPS) {
|
|
|
5751
6166
|
const perHitGroups = /* @__PURE__ */ new Map();
|
|
5752
6167
|
for (const index of order) {
|
|
5753
6168
|
const rider = riders[index];
|
|
5754
|
-
if (!
|
|
6169
|
+
if (!READS_GROUP[rider.on]) continue;
|
|
5755
6170
|
const group = groupOf(sourceIdsByRider[index]);
|
|
5756
6171
|
readsByRider.set(index, group);
|
|
5757
6172
|
if (rider.on === "every-hit") perHitGroups.set(riderIds[index], group);
|
|
@@ -5781,21 +6196,34 @@ function buildPlan(spec, eps = EPS) {
|
|
|
5781
6196
|
if (!payloads) return slices;
|
|
5782
6197
|
let hit = slices.hit;
|
|
5783
6198
|
let crit = slices.crit;
|
|
6199
|
+
let hitMatch = slices.hitMatch;
|
|
6200
|
+
let hitNoMatch = slices.hitNoMatch;
|
|
6201
|
+
let critMatch = slices.critMatch;
|
|
6202
|
+
let critNoMatch = slices.critNoMatch;
|
|
5784
6203
|
for (const payload of payloads) {
|
|
5785
6204
|
hit = hit.convolve(payload.hit, eps, true);
|
|
5786
6205
|
crit = crit.convolve(payload.crit, eps, true);
|
|
6206
|
+
if (hitMatch) hitMatch = hitMatch.convolve(payload.hit, eps, true);
|
|
6207
|
+
if (hitNoMatch) hitNoMatch = hitNoMatch.convolve(payload.hit, eps, true);
|
|
6208
|
+
if (critMatch) critMatch = critMatch.convolve(payload.crit, eps, true);
|
|
6209
|
+
if (critNoMatch) critNoMatch = critNoMatch.convolve(payload.crit, eps, true);
|
|
5787
6210
|
}
|
|
5788
|
-
return { hit, crit, miss: slices.miss };
|
|
6211
|
+
return { hit, crit, miss: slices.miss, hitMatch, hitNoMatch, critMatch, critNoMatch };
|
|
6212
|
+
};
|
|
6213
|
+
const emptySlices = {
|
|
6214
|
+
hit: PMF.emptyMass(),
|
|
6215
|
+
crit: PMF.emptyMass(),
|
|
6216
|
+
miss: PMF.emptyMass(),
|
|
6217
|
+
hitMatch: null,
|
|
6218
|
+
hitNoMatch: null,
|
|
6219
|
+
critMatch: null,
|
|
6220
|
+
critNoMatch: null
|
|
5789
6221
|
};
|
|
5790
6222
|
const steps = attackIds.map((id, index) => ({
|
|
5791
6223
|
id,
|
|
5792
6224
|
trigger: null,
|
|
5793
6225
|
slices: withPerHit(
|
|
5794
|
-
attackSlices[index] ?? {
|
|
5795
|
-
hit: attackPMFs[index],
|
|
5796
|
-
crit: PMF.emptyMass(),
|
|
5797
|
-
miss: PMF.emptyMass()
|
|
5798
|
-
},
|
|
6226
|
+
attackSlices[index] ?? { ...emptySlices, hit: attackPMFs[index] },
|
|
5799
6227
|
id
|
|
5800
6228
|
),
|
|
5801
6229
|
damage: null,
|
|
@@ -5810,7 +6238,9 @@ function buildPlan(spec, eps = EPS) {
|
|
|
5810
6238
|
if (rider.on === "every-hit") continue;
|
|
5811
6239
|
const id = riderIds[index];
|
|
5812
6240
|
const hit = toPMF(rider.damage, eps, id);
|
|
5813
|
-
const
|
|
6241
|
+
const singleRiderDamage = !Array.isArray(rider.damage) ? rider.damage : void 0;
|
|
6242
|
+
const matchInfo = matchNeededSourceIds.has(id) && singleRiderDamage !== void 0 ? diceMatchInfoOf(singleRiderDamage, eps) : null;
|
|
6243
|
+
const slices = sliceSource(hit, matchInfo);
|
|
5814
6244
|
if (slices && rider.critDamage !== void 0) {
|
|
5815
6245
|
fail(
|
|
5816
6246
|
"unused-crit-damage",
|
|
@@ -5832,6 +6262,15 @@ function buildPlan(spec, eps = EPS) {
|
|
|
5832
6262
|
stepIndexByRider.set(index, steps.length - 1);
|
|
5833
6263
|
riderSteps.set(id, steps.length - 1);
|
|
5834
6264
|
}
|
|
6265
|
+
const groupLastReadStep = new Array(groupSources.length).fill(-1);
|
|
6266
|
+
steps.forEach((step, stepIndex) => {
|
|
6267
|
+
if (step.reads !== -1) {
|
|
6268
|
+
groupLastReadStep[step.reads] = Math.max(groupLastReadStep[step.reads], stepIndex);
|
|
6269
|
+
}
|
|
6270
|
+
});
|
|
6271
|
+
for (const group of perHitGroups.values()) {
|
|
6272
|
+
groupLastReadStep[group] = steps.length;
|
|
6273
|
+
}
|
|
5835
6274
|
return {
|
|
5836
6275
|
steps,
|
|
5837
6276
|
groupCount: groupSources.length,
|
|
@@ -5839,7 +6278,8 @@ function buildPlan(spec, eps = EPS) {
|
|
|
5839
6278
|
attackIds,
|
|
5840
6279
|
riderIds,
|
|
5841
6280
|
riderSteps,
|
|
5842
|
-
perHitGroups
|
|
6281
|
+
perHitGroups,
|
|
6282
|
+
groupLastReadStep
|
|
5843
6283
|
};
|
|
5844
6284
|
}
|
|
5845
6285
|
|
|
@@ -5849,18 +6289,36 @@ var FIRST_HIT = 1;
|
|
|
5849
6289
|
var FIRST_CRIT = 2;
|
|
5850
6290
|
var CRIT_BIT = 2;
|
|
5851
6291
|
var MISS_BIT = 1;
|
|
6292
|
+
var MATCH_BIT = 16;
|
|
5852
6293
|
var START_CODE = FIRST_NONE << 2;
|
|
5853
|
-
function advance(code, outcome) {
|
|
6294
|
+
function advance(code, outcome, matched = false) {
|
|
5854
6295
|
if (outcome === "miss") return code | MISS_BIT;
|
|
5855
|
-
const first = code >> 2;
|
|
6296
|
+
const first = code >> 2 & 3;
|
|
5856
6297
|
const withCrit = outcome === "crit" ? code | CRIT_BIT : code;
|
|
5857
|
-
|
|
6298
|
+
const withMatch = matched ? withCrit | MATCH_BIT : withCrit;
|
|
6299
|
+
if (first !== FIRST_NONE) return withMatch;
|
|
5858
6300
|
const nextFirst = outcome === "crit" ? FIRST_CRIT : FIRST_HIT;
|
|
5859
|
-
return nextFirst << 2 |
|
|
6301
|
+
return nextFirst << 2 | withMatch & (MATCH_BIT | CRIT_BIT | MISS_BIT);
|
|
5860
6302
|
}
|
|
5861
6303
|
|
|
5862
6304
|
// src/turn/turn.ts
|
|
5863
|
-
|
|
6305
|
+
function stepDraws(slices) {
|
|
6306
|
+
const draws = [];
|
|
6307
|
+
if (slices.hitMatch) {
|
|
6308
|
+
draws.push({ outcome: "hit", matched: true, slice: slices.hitMatch });
|
|
6309
|
+
draws.push({ outcome: "hit", matched: false, slice: slices.hitNoMatch });
|
|
6310
|
+
} else {
|
|
6311
|
+
draws.push({ outcome: "hit", matched: false, slice: slices.hit });
|
|
6312
|
+
}
|
|
6313
|
+
if (slices.critMatch) {
|
|
6314
|
+
draws.push({ outcome: "crit", matched: true, slice: slices.critMatch });
|
|
6315
|
+
draws.push({ outcome: "crit", matched: false, slice: slices.critNoMatch });
|
|
6316
|
+
} else {
|
|
6317
|
+
draws.push({ outcome: "crit", matched: false, slice: slices.crit });
|
|
6318
|
+
}
|
|
6319
|
+
draws.push({ outcome: "miss", matched: false, slice: slices.miss });
|
|
6320
|
+
return draws;
|
|
6321
|
+
}
|
|
5864
6322
|
function fireMode(step, codes, firedByStep) {
|
|
5865
6323
|
const trigger = step.trigger;
|
|
5866
6324
|
if (!trigger) return "hit";
|
|
@@ -5868,7 +6326,7 @@ function fireMode(step, codes, firedByStep) {
|
|
|
5868
6326
|
return firedByStep[step.negates] === null ? "hit" : null;
|
|
5869
6327
|
}
|
|
5870
6328
|
const code = codes[step.reads];
|
|
5871
|
-
const first = code >> 2;
|
|
6329
|
+
const first = code >> 2 & 3;
|
|
5872
6330
|
switch (trigger.on) {
|
|
5873
6331
|
case "first-hit":
|
|
5874
6332
|
if (first === FIRST_NONE) return null;
|
|
@@ -5877,6 +6335,8 @@ function fireMode(step, codes, firedByStep) {
|
|
|
5877
6335
|
return (code & CRIT_BIT) !== 0 ? "crit" : null;
|
|
5878
6336
|
case "any-miss":
|
|
5879
6337
|
return (code & MISS_BIT) !== 0 ? "hit" : null;
|
|
6338
|
+
case "dice-match":
|
|
6339
|
+
return (code & MATCH_BIT) !== 0 ? "hit" : null;
|
|
5880
6340
|
default:
|
|
5881
6341
|
return null;
|
|
5882
6342
|
}
|
|
@@ -6004,6 +6464,20 @@ var Turn = class _Turn {
|
|
|
6004
6464
|
riders.push({ ...options, damage, on: "not-fired", of: target });
|
|
6005
6465
|
return new _Turn(this.declaredAttacks, riders, this.eps);
|
|
6006
6466
|
}
|
|
6467
|
+
/**
|
|
6468
|
+
* Fires once if any of `of`'s named sources' own damage dice matched (showed a
|
|
6469
|
+
* duplicate value) on hit or crit — Chromatic Orb's bounce. Unlike the other
|
|
6470
|
+
* `onX` triggers, `of` is required: "the dice matched" has no coherent meaning
|
|
6471
|
+
* defaulted across every declared attack. Each named source must expose a
|
|
6472
|
+
* dice-match descriptor (an `AttackBuilder`-shaped source does); naming one
|
|
6473
|
+
* that doesn't is a `TurnSpecError("no-dice-descriptor", ...)`.
|
|
6474
|
+
*
|
|
6475
|
+
* Most callers want {@link bounce} instead of calling this directly — it
|
|
6476
|
+
* builds the whole depth-capped chain of attack-shaped riders.
|
|
6477
|
+
*/
|
|
6478
|
+
onDiceMatch(of, damage, options = {}) {
|
|
6479
|
+
return this.rider({ ...options, damage, on: "dice-match", of });
|
|
6480
|
+
}
|
|
6007
6481
|
/**
|
|
6008
6482
|
* The exact joint distribution: mass 1, outcome-labelled. Resolved once and
|
|
6009
6483
|
* cached.
|
|
@@ -6076,8 +6550,14 @@ var Turn = class _Turn {
|
|
|
6076
6550
|
let states = /* @__PURE__ */ new Map([[String.fromCharCode(), start]]);
|
|
6077
6551
|
plan.steps.forEach((step, stepIndex) => {
|
|
6078
6552
|
const next = /* @__PURE__ */ new Map();
|
|
6553
|
+
const liveGroups = [];
|
|
6554
|
+
for (let g = 0; g < plan.groupCount; g++) {
|
|
6555
|
+
if (plan.groupLastReadStep[g] > stepIndex) liveGroups.push(g);
|
|
6556
|
+
}
|
|
6079
6557
|
const merge = (state) => {
|
|
6080
|
-
|
|
6558
|
+
let codesKey = "";
|
|
6559
|
+
for (const g of liveGroups) codesKey += String.fromCharCode(state.codes[g]);
|
|
6560
|
+
const key = codesKey + "" + state.fired.map((mode) => mode === null ? "-" : "+").join("");
|
|
6081
6561
|
const existing = next.get(key);
|
|
6082
6562
|
if (existing) existing.pmf = existing.pmf.add(state.pmf);
|
|
6083
6563
|
else next.set(key, state);
|
|
@@ -6103,13 +6583,12 @@ var Turn = class _Turn {
|
|
|
6103
6583
|
});
|
|
6104
6584
|
continue;
|
|
6105
6585
|
}
|
|
6106
|
-
for (const outcome of
|
|
6107
|
-
const slice = step.slices[outcome];
|
|
6586
|
+
for (const { outcome, matched, slice } of stepDraws(step.slices)) {
|
|
6108
6587
|
const sliceMass = slice.mass();
|
|
6109
6588
|
if (sliceMass <= eps) continue;
|
|
6110
6589
|
const codes = [...state.codes];
|
|
6111
6590
|
for (const group of step.updates) {
|
|
6112
|
-
codes[group] = advance(codes[group], outcome);
|
|
6591
|
+
codes[group] = advance(codes[group], outcome, matched);
|
|
6113
6592
|
}
|
|
6114
6593
|
merge({
|
|
6115
6594
|
codes,
|
|
@@ -6133,7 +6612,7 @@ var Turn = class _Turn {
|
|
|
6133
6612
|
}
|
|
6134
6613
|
}
|
|
6135
6614
|
for (const [id, group] of plan.perHitGroups) {
|
|
6136
|
-
if (state.codes[group] >> 2 !== FIRST_NONE) {
|
|
6615
|
+
if ((state.codes[group] >> 2 & 3) !== FIRST_NONE) {
|
|
6137
6616
|
fireMass.set(id, fireMass.get(id) + mass);
|
|
6138
6617
|
}
|
|
6139
6618
|
}
|
|
@@ -6157,6 +6636,19 @@ function turn(attacks = [], eps = EPS) {
|
|
|
6157
6636
|
eps
|
|
6158
6637
|
);
|
|
6159
6638
|
}
|
|
6639
|
+
function bounce({ source, max }) {
|
|
6640
|
+
if (!Number.isInteger(max) || max < 0) {
|
|
6641
|
+
throw new RangeError(`bounce({ max }) needs a non-negative integer, got ${max}.`);
|
|
6642
|
+
}
|
|
6643
|
+
let result = turn(source);
|
|
6644
|
+
let previousId = result.attackIds[0];
|
|
6645
|
+
for (let i = 0; i < max; i++) {
|
|
6646
|
+
const riderId = `bounce ${i + 1}`;
|
|
6647
|
+
result = result.onDiceMatch([previousId], source, { id: riderId });
|
|
6648
|
+
previousId = riderId;
|
|
6649
|
+
}
|
|
6650
|
+
return result;
|
|
6651
|
+
}
|
|
6160
6652
|
|
|
6161
6653
|
exports.ACBuilder = ACBuilder;
|
|
6162
6654
|
exports.AlwaysCritBuilder = AlwaysCritBuilder;
|
|
@@ -6173,6 +6665,7 @@ exports.SaveBuilder = SaveBuilder;
|
|
|
6173
6665
|
exports.ScaleRollBuilder = ScaleRollBuilder;
|
|
6174
6666
|
exports.Turn = Turn;
|
|
6175
6667
|
exports.TurnSpecError = TurnSpecError;
|
|
6668
|
+
exports.bounce = bounce;
|
|
6176
6669
|
exports.builderPMFCache = builderPMFCache;
|
|
6177
6670
|
exports.clearAttackCache = clearAttackCache;
|
|
6178
6671
|
exports.clearDCCache = clearDCCache;
|