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