@yipe/dice 0.10.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.
@@ -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();
@@ -2474,6 +2498,84 @@ var _PMF = class _PMF {
2474
2498
  _PMF.__anonIdCounter = 1;
2475
2499
  var PMF = _PMF;
2476
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
+
2477
2579
  // src/pmf/mixture.ts
2478
2580
  var Mixture = class _Mixture {
2479
2581
  constructor(eps = EPS) {
@@ -3572,16 +3674,17 @@ var defaultConfig = {
3572
3674
  modifier: 0,
3573
3675
  reroll: 0,
3574
3676
  explode: 0,
3677
+ explodePoolBudget: 0,
3575
3678
  minimum: 0,
3576
3679
  bestOf: 0,
3577
3680
  keep: void 0,
3578
3681
  rollType: "flat"
3579
3682
  };
3580
3683
  var rollConfigsEqual = (a, b) => {
3581
- 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;
3582
3685
  };
3583
3686
  var configComplexityScore = (config) => {
3584
- 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);
3585
3688
  };
3586
3689
  var RollBuilder = class _RollBuilder {
3587
3690
  constructor(countOrConfigs = 1) {
@@ -3763,10 +3866,35 @@ var RollBuilder = class _RollBuilder {
3763
3866
  if (count === void 0) return this;
3764
3867
  if (count === 0) return this;
3765
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
+ }
3766
3874
  const newConfigs = this.getSubRollConfigs();
3767
3875
  newConfigs[newConfigs.length - 1].explode = count;
3768
3876
  return this.create(newConfigs);
3769
3877
  }
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
+ }
3770
3898
  /** Apply per-die minimum value (floors each die roll at `val`, e.g. `minimum(3)` treats a 1 or
3771
3899
  * 2 as a 3 -- the 2024 Great Weapon Fighting style). */
3772
3900
  minimum(val) {
@@ -4005,6 +4133,11 @@ var RollBuilder = class _RollBuilder {
4005
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().`
4006
4134
  );
4007
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
+ }
4008
4141
  let baseDie = `d${config.sides}`;
4009
4142
  const rerollClause = config.reroll > 0 ? config.reroll === 1 ? " reroll 1" : ` reroll d${config.reroll}` : "";
4010
4143
  if (config.reroll > 0) {
@@ -4503,6 +4636,9 @@ var PooledRollBuilder = class _PooledRollBuilder extends RollBuilder {
4503
4636
  explode(_count = Infinity) {
4504
4637
  throw new Error("Cannot set explode on a pooled roll.");
4505
4638
  }
4639
+ explodePool(_budget) {
4640
+ throw new Error("Cannot set explodePool on a pooled roll.");
4641
+ }
4506
4642
  minimum(_val) {
4507
4643
  throw new Error("Cannot set minimum on a pooled roll.");
4508
4644
  }
@@ -4752,6 +4888,11 @@ function astFromRollConfigs(configs) {
4752
4888
  appliedRollType = true;
4753
4889
  }
4754
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
+ }
4755
4896
  const baseCount = Math.max(1, Math.floor(Math.abs(count || 1)));
4756
4897
  const trials = Math.max(1, Math.floor(effectiveKeep.total));
4757
4898
  const k = Math.max(0, Math.floor(effectiveKeep.count));
@@ -4802,7 +4943,17 @@ function astFromRollConfigs(configs) {
4802
4943
  }
4803
4944
  } else {
4804
4945
  const c = appliedRollType ? 1 : Math.max(1, count || 1);
4805
- node = { type: "sum", count: c, child: node };
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
+ };
4806
4957
  }
4807
4958
  children.push({ node, sign });
4808
4959
  }
@@ -4833,6 +4984,13 @@ function resolve(node, eps = defaultEps) {
4833
4984
  const base = resolve(node.child, eps);
4834
4985
  const n = Math.max(0, Math.floor(node.count));
4835
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
+ }
4836
4994
  if (n === 1) return base;
4837
4995
  return base.power(n, eps);
4838
4996
  }
@@ -4970,6 +5128,31 @@ function resolveSingleDie(die, eps = defaultEps) {
4970
5128
  singleDiePMFCache.set(cacheKey, pmf);
4971
5129
  return pmf;
4972
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
+ }
4973
5156
  function findDie(node) {
4974
5157
  switch (node.type) {
4975
5158
  case "die":
@@ -5154,7 +5337,7 @@ function getASTSignature(node) {
5154
5337
  return `d{${parts.join(",")}}`;
5155
5338
  }
5156
5339
  case "sum":
5157
- return `sum{c:${node.count},ch:${getASTSignature(node.child)}}`;
5340
+ return `sum{c:${node.count},b:${node.explodePoolBudget || 0},ch:${getASTSignature(node.child)}}`;
5158
5341
  case "d20Roll":
5159
5342
  return `d20{t:${node.rollType},ch:${getASTSignature(node.child)}}`;
5160
5343
  case "keep":
@@ -5375,6 +5558,55 @@ var AttackBuilder = class _AttackBuilder {
5375
5558
  weights: { hit: phit, crit: pcrit, miss: pmiss }
5376
5559
  };
5377
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
+ }
5378
5610
  /**
5379
5611
  * A cheap, complete key for this attack's resolved PMF, or `null` when it can't be cached soundly (an
5380
5612
  * effect whose PMF isn't captured by its {@link RollConfig}s — see {@link RollBuilder.cacheKey}). Composed
@@ -5699,14 +5931,15 @@ var TurnSpecError = class extends Error {
5699
5931
  this.name = "TurnSpecError";
5700
5932
  }
5701
5933
  };
5702
- var MAX_TRIGGER_GROUPS = 4;
5934
+ var MAX_TRIGGER_GROUPS = 9;
5703
5935
 
5704
5936
  // src/turn/plan.ts
5705
- var IS_HIT_TRIGGER = {
5937
+ var READS_GROUP = {
5706
5938
  "first-hit": true,
5707
5939
  "any-crit": true,
5708
5940
  "any-miss": true,
5709
- "every-hit": true
5941
+ "every-hit": true,
5942
+ "dice-match": true
5710
5943
  };
5711
5944
  function toPMF(damage, eps, id = "") {
5712
5945
  const parts = Array.isArray(damage) ? damage : [damage];
@@ -5750,20 +5983,49 @@ function critPMF(rider, base, eps) {
5750
5983
  if (doubled.length === 0) return base;
5751
5984
  return PMF.convolveMany(doubled, eps);
5752
5985
  }
5753
- function sliceSource(pmf) {
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) {
5754
5994
  const labels = pmf.outcomes();
5755
5995
  if (!labels.includes("hit") && !labels.includes("crit")) return null;
5756
5996
  const missParts = ["missNone", "missDamage"].filter((label) => labels.includes(label)).map((label) => pmf.filterOutcome(label));
5757
- return {
5758
- hit: labels.includes("hit") ? pmf.filterOutcome("hit") : PMF.emptyMass(),
5759
- crit: labels.includes("crit") ? pmf.filterOutcome("crit") : PMF.emptyMass(),
5760
- miss: missParts.length ? missParts.reduce((all, part) => all.add(part)) : PMF.emptyMass()
5761
- };
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 };
5762
6017
  }
5763
6018
  function buildPlan(spec, eps = EPS) {
5764
6019
  const fail = (code, id, message) => {
5765
6020
  throw new TurnSpecError(code, id, message);
5766
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
+ }
5767
6029
  const attackIds = [];
5768
6030
  const attackPMFs = [];
5769
6031
  const attackSlices = [];
@@ -5773,11 +6035,11 @@ function buildPlan(spec, eps = EPS) {
5773
6035
  const id = hasWrapper ? named.id : `attack ${index + 1}`;
5774
6036
  const source = hasWrapper ? named.source : entry;
5775
6037
  const pmf = toPMF(source, eps, id);
6038
+ const matchInfo = matchNeededSourceIds.has(id) ? diceMatchInfoOf(source, eps) : null;
5776
6039
  attackIds.push(id);
5777
6040
  attackPMFs.push(pmf);
5778
- attackSlices.push(sliceSource(pmf));
6041
+ attackSlices.push(sliceSource(pmf, matchInfo));
5779
6042
  });
5780
- const riders = spec.riders ?? [];
5781
6043
  const riderIds = riders.map((rider, index) => rider.id ?? `rider ${index + 1}`);
5782
6044
  const seen = /* @__PURE__ */ new Set();
5783
6045
  for (const id of [...attackIds, ...riderIds]) {
@@ -5786,6 +6048,17 @@ function buildPlan(spec, eps = EPS) {
5786
6048
  }
5787
6049
  const attackIndexById = new Map(attackIds.map((id, index) => [id, index]));
5788
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
+ };
5789
6062
  const sourceIdsByRider = riders.map((rider, index) => {
5790
6063
  const id = riderIds[index];
5791
6064
  if (rider.on === "not-fired") {
@@ -5810,7 +6083,7 @@ function buildPlan(spec, eps = EPS) {
5810
6083
  }
5811
6084
  return [target];
5812
6085
  }
5813
- const of = [...new Set(rider.of ?? attackIds)];
6086
+ const of = [...new Set(rider.on === "dice-match" ? rider.of : rider.of ?? attackIds)];
5814
6087
  if (of.length === 0) {
5815
6088
  fail("unknown-id", id, `Rider "${id}" has no sources.`);
5816
6089
  }
@@ -5834,8 +6107,11 @@ function buildPlan(spec, eps = EPS) {
5834
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.`
5835
6108
  );
5836
6109
  }
6110
+ const damageSource = isAttack ? void 0 : riders[riderIndex].damage;
6111
+ const singleDamageSource = damageSource !== void 0 && !Array.isArray(damageSource) ? damageSource : void 0;
5837
6112
  const slices = isAttack ? attackSlices[attackIndexById.get(sourceId)] : sliceSource(
5838
- toPMF(riders[riderIndex].damage, eps, sourceId)
6113
+ toPMF(damageSource, eps, sourceId),
6114
+ matchNeededSourceIds.has(sourceId) && singleDamageSource !== void 0 ? diceMatchInfoOf(singleDamageSource, eps) : null
5839
6115
  );
5840
6116
  if (!slices) {
5841
6117
  fail(
@@ -5843,6 +6119,8 @@ function buildPlan(spec, eps = EPS) {
5843
6119
  sourceId,
5844
6120
  `Rider "${id}" triggers on "${sourceId}", which has no hit/crit outcomes.`
5845
6121
  );
6122
+ } else if (rider.on === "dice-match") {
6123
+ checkMatchable(id, sourceId, slices);
5846
6124
  }
5847
6125
  }
5848
6126
  return [...of];
@@ -5888,7 +6166,7 @@ function buildPlan(spec, eps = EPS) {
5888
6166
  const perHitGroups = /* @__PURE__ */ new Map();
5889
6167
  for (const index of order) {
5890
6168
  const rider = riders[index];
5891
- if (!IS_HIT_TRIGGER[rider.on]) continue;
6169
+ if (!READS_GROUP[rider.on]) continue;
5892
6170
  const group = groupOf(sourceIdsByRider[index]);
5893
6171
  readsByRider.set(index, group);
5894
6172
  if (rider.on === "every-hit") perHitGroups.set(riderIds[index], group);
@@ -5918,21 +6196,34 @@ function buildPlan(spec, eps = EPS) {
5918
6196
  if (!payloads) return slices;
5919
6197
  let hit = slices.hit;
5920
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;
5921
6203
  for (const payload of payloads) {
5922
6204
  hit = hit.convolve(payload.hit, eps, true);
5923
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);
5924
6210
  }
5925
- 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
5926
6221
  };
5927
6222
  const steps = attackIds.map((id, index) => ({
5928
6223
  id,
5929
6224
  trigger: null,
5930
6225
  slices: withPerHit(
5931
- attackSlices[index] ?? {
5932
- hit: attackPMFs[index],
5933
- crit: PMF.emptyMass(),
5934
- miss: PMF.emptyMass()
5935
- },
6226
+ attackSlices[index] ?? { ...emptySlices, hit: attackPMFs[index] },
5936
6227
  id
5937
6228
  ),
5938
6229
  damage: null,
@@ -5947,7 +6238,9 @@ function buildPlan(spec, eps = EPS) {
5947
6238
  if (rider.on === "every-hit") continue;
5948
6239
  const id = riderIds[index];
5949
6240
  const hit = toPMF(rider.damage, eps, id);
5950
- const slices = sliceSource(hit);
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);
5951
6244
  if (slices && rider.critDamage !== void 0) {
5952
6245
  fail(
5953
6246
  "unused-crit-damage",
@@ -5969,6 +6262,15 @@ function buildPlan(spec, eps = EPS) {
5969
6262
  stepIndexByRider.set(index, steps.length - 1);
5970
6263
  riderSteps.set(id, steps.length - 1);
5971
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
+ }
5972
6274
  return {
5973
6275
  steps,
5974
6276
  groupCount: groupSources.length,
@@ -5976,7 +6278,8 @@ function buildPlan(spec, eps = EPS) {
5976
6278
  attackIds,
5977
6279
  riderIds,
5978
6280
  riderSteps,
5979
- perHitGroups
6281
+ perHitGroups,
6282
+ groupLastReadStep
5980
6283
  };
5981
6284
  }
5982
6285
 
@@ -5986,18 +6289,36 @@ var FIRST_HIT = 1;
5986
6289
  var FIRST_CRIT = 2;
5987
6290
  var CRIT_BIT = 2;
5988
6291
  var MISS_BIT = 1;
6292
+ var MATCH_BIT = 16;
5989
6293
  var START_CODE = FIRST_NONE << 2;
5990
- function advance(code, outcome) {
6294
+ function advance(code, outcome, matched = false) {
5991
6295
  if (outcome === "miss") return code | MISS_BIT;
5992
- const first = code >> 2;
6296
+ const first = code >> 2 & 3;
5993
6297
  const withCrit = outcome === "crit" ? code | CRIT_BIT : code;
5994
- if (first !== FIRST_NONE) return withCrit;
6298
+ const withMatch = matched ? withCrit | MATCH_BIT : withCrit;
6299
+ if (first !== FIRST_NONE) return withMatch;
5995
6300
  const nextFirst = outcome === "crit" ? FIRST_CRIT : FIRST_HIT;
5996
- return nextFirst << 2 | withCrit & (CRIT_BIT | MISS_BIT);
6301
+ return nextFirst << 2 | withMatch & (MATCH_BIT | CRIT_BIT | MISS_BIT);
5997
6302
  }
5998
6303
 
5999
6304
  // src/turn/turn.ts
6000
- var OUTCOMES = ["hit", "crit", "miss"];
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
+ }
6001
6322
  function fireMode(step, codes, firedByStep) {
6002
6323
  const trigger = step.trigger;
6003
6324
  if (!trigger) return "hit";
@@ -6005,7 +6326,7 @@ function fireMode(step, codes, firedByStep) {
6005
6326
  return firedByStep[step.negates] === null ? "hit" : null;
6006
6327
  }
6007
6328
  const code = codes[step.reads];
6008
- const first = code >> 2;
6329
+ const first = code >> 2 & 3;
6009
6330
  switch (trigger.on) {
6010
6331
  case "first-hit":
6011
6332
  if (first === FIRST_NONE) return null;
@@ -6014,6 +6335,8 @@ function fireMode(step, codes, firedByStep) {
6014
6335
  return (code & CRIT_BIT) !== 0 ? "crit" : null;
6015
6336
  case "any-miss":
6016
6337
  return (code & MISS_BIT) !== 0 ? "hit" : null;
6338
+ case "dice-match":
6339
+ return (code & MATCH_BIT) !== 0 ? "hit" : null;
6017
6340
  default:
6018
6341
  return null;
6019
6342
  }
@@ -6141,6 +6464,20 @@ var Turn = class _Turn {
6141
6464
  riders.push({ ...options, damage, on: "not-fired", of: target });
6142
6465
  return new _Turn(this.declaredAttacks, riders, this.eps);
6143
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
+ }
6144
6481
  /**
6145
6482
  * The exact joint distribution: mass 1, outcome-labelled. Resolved once and
6146
6483
  * cached.
@@ -6213,8 +6550,14 @@ var Turn = class _Turn {
6213
6550
  let states = /* @__PURE__ */ new Map([[String.fromCharCode(), start]]);
6214
6551
  plan.steps.forEach((step, stepIndex) => {
6215
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
+ }
6216
6557
  const merge = (state) => {
6217
- const key = String.fromCharCode(...state.codes) + "" + state.fired.map((mode) => mode === null ? "-" : "+").join("");
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("");
6218
6561
  const existing = next.get(key);
6219
6562
  if (existing) existing.pmf = existing.pmf.add(state.pmf);
6220
6563
  else next.set(key, state);
@@ -6240,13 +6583,12 @@ var Turn = class _Turn {
6240
6583
  });
6241
6584
  continue;
6242
6585
  }
6243
- for (const outcome of OUTCOMES) {
6244
- const slice = step.slices[outcome];
6586
+ for (const { outcome, matched, slice } of stepDraws(step.slices)) {
6245
6587
  const sliceMass = slice.mass();
6246
6588
  if (sliceMass <= eps) continue;
6247
6589
  const codes = [...state.codes];
6248
6590
  for (const group of step.updates) {
6249
- codes[group] = advance(codes[group], outcome);
6591
+ codes[group] = advance(codes[group], outcome, matched);
6250
6592
  }
6251
6593
  merge({
6252
6594
  codes,
@@ -6270,7 +6612,7 @@ var Turn = class _Turn {
6270
6612
  }
6271
6613
  }
6272
6614
  for (const [id, group] of plan.perHitGroups) {
6273
- if (state.codes[group] >> 2 !== FIRST_NONE) {
6615
+ if ((state.codes[group] >> 2 & 3) !== FIRST_NONE) {
6274
6616
  fireMass.set(id, fireMass.get(id) + mass);
6275
6617
  }
6276
6618
  }
@@ -6294,6 +6636,19 @@ function turn(attacks = [], eps = EPS) {
6294
6636
  eps
6295
6637
  );
6296
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
+ }
6297
6652
 
6298
6653
  exports.ACBuilder = ACBuilder;
6299
6654
  exports.AlwaysCritBuilder = AlwaysCritBuilder;
@@ -6310,6 +6665,7 @@ exports.SaveBuilder = SaveBuilder;
6310
6665
  exports.ScaleRollBuilder = ScaleRollBuilder;
6311
6666
  exports.Turn = Turn;
6312
6667
  exports.TurnSpecError = TurnSpecError;
6668
+ exports.bounce = bounce;
6313
6669
  exports.builderPMFCache = builderPMFCache;
6314
6670
  exports.clearAttackCache = clearAttackCache;
6315
6671
  exports.clearDCCache = clearDCCache;