@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.
@@ -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();
@@ -2472,6 +2496,84 @@ var _PMF = class _PMF {
2472
2496
  _PMF.__anonIdCounter = 1;
2473
2497
  var PMF = _PMF;
2474
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
+
2475
2577
  // src/pmf/mixture.ts
2476
2578
  var Mixture = class _Mixture {
2477
2579
  constructor(eps = EPS) {
@@ -3570,16 +3672,17 @@ var defaultConfig = {
3570
3672
  modifier: 0,
3571
3673
  reroll: 0,
3572
3674
  explode: 0,
3675
+ explodePoolBudget: 0,
3573
3676
  minimum: 0,
3574
3677
  bestOf: 0,
3575
3678
  keep: void 0,
3576
3679
  rollType: "flat"
3577
3680
  };
3578
3681
  var rollConfigsEqual = (a, b) => {
3579
- 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;
3580
3683
  };
3581
3684
  var configComplexityScore = (config) => {
3582
- 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);
3583
3686
  };
3584
3687
  var RollBuilder = class _RollBuilder {
3585
3688
  constructor(countOrConfigs = 1) {
@@ -3761,10 +3864,35 @@ var RollBuilder = class _RollBuilder {
3761
3864
  if (count === void 0) return this;
3762
3865
  if (count === 0) return this;
3763
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
+ }
3764
3872
  const newConfigs = this.getSubRollConfigs();
3765
3873
  newConfigs[newConfigs.length - 1].explode = count;
3766
3874
  return this.create(newConfigs);
3767
3875
  }
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
+ }
3768
3896
  /** Apply per-die minimum value (floors each die roll at `val`, e.g. `minimum(3)` treats a 1 or
3769
3897
  * 2 as a 3 -- the 2024 Great Weapon Fighting style). */
3770
3898
  minimum(val) {
@@ -4003,6 +4131,11 @@ var RollBuilder = class _RollBuilder {
4003
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().`
4004
4132
  );
4005
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
+ }
4006
4139
  let baseDie = `d${config.sides}`;
4007
4140
  const rerollClause = config.reroll > 0 ? config.reroll === 1 ? " reroll 1" : ` reroll d${config.reroll}` : "";
4008
4141
  if (config.reroll > 0) {
@@ -4501,6 +4634,9 @@ var PooledRollBuilder = class _PooledRollBuilder extends RollBuilder {
4501
4634
  explode(_count = Infinity) {
4502
4635
  throw new Error("Cannot set explode on a pooled roll.");
4503
4636
  }
4637
+ explodePool(_budget) {
4638
+ throw new Error("Cannot set explodePool on a pooled roll.");
4639
+ }
4504
4640
  minimum(_val) {
4505
4641
  throw new Error("Cannot set minimum on a pooled roll.");
4506
4642
  }
@@ -4750,6 +4886,11 @@ function astFromRollConfigs(configs) {
4750
4886
  appliedRollType = true;
4751
4887
  }
4752
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
+ }
4753
4894
  const baseCount = Math.max(1, Math.floor(Math.abs(count || 1)));
4754
4895
  const trials = Math.max(1, Math.floor(effectiveKeep.total));
4755
4896
  const k = Math.max(0, Math.floor(effectiveKeep.count));
@@ -4800,7 +4941,17 @@ function astFromRollConfigs(configs) {
4800
4941
  }
4801
4942
  } else {
4802
4943
  const c = appliedRollType ? 1 : Math.max(1, count || 1);
4803
- node = { type: "sum", count: c, child: node };
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
+ };
4804
4955
  }
4805
4956
  children.push({ node, sign });
4806
4957
  }
@@ -4831,6 +4982,13 @@ function resolve(node, eps = defaultEps) {
4831
4982
  const base = resolve(node.child, eps);
4832
4983
  const n = Math.max(0, Math.floor(node.count));
4833
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
+ }
4834
4992
  if (n === 1) return base;
4835
4993
  return base.power(n, eps);
4836
4994
  }
@@ -4968,6 +5126,31 @@ function resolveSingleDie(die, eps = defaultEps) {
4968
5126
  singleDiePMFCache.set(cacheKey, pmf);
4969
5127
  return pmf;
4970
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
+ }
4971
5154
  function findDie(node) {
4972
5155
  switch (node.type) {
4973
5156
  case "die":
@@ -5152,7 +5335,7 @@ function getASTSignature(node) {
5152
5335
  return `d{${parts.join(",")}}`;
5153
5336
  }
5154
5337
  case "sum":
5155
- return `sum{c:${node.count},ch:${getASTSignature(node.child)}}`;
5338
+ return `sum{c:${node.count},b:${node.explodePoolBudget || 0},ch:${getASTSignature(node.child)}}`;
5156
5339
  case "d20Roll":
5157
5340
  return `d20{t:${node.rollType},ch:${getASTSignature(node.child)}}`;
5158
5341
  case "keep":
@@ -5373,6 +5556,55 @@ var AttackBuilder = class _AttackBuilder {
5373
5556
  weights: { hit: phit, crit: pcrit, miss: pmiss }
5374
5557
  };
5375
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
+ }
5376
5608
  /**
5377
5609
  * A cheap, complete key for this attack's resolved PMF, or `null` when it can't be cached soundly (an
5378
5610
  * effect whose PMF isn't captured by its {@link RollConfig}s — see {@link RollBuilder.cacheKey}). Composed
@@ -5697,14 +5929,15 @@ var TurnSpecError = class extends Error {
5697
5929
  this.name = "TurnSpecError";
5698
5930
  }
5699
5931
  };
5700
- var MAX_TRIGGER_GROUPS = 4;
5932
+ var MAX_TRIGGER_GROUPS = 9;
5701
5933
 
5702
5934
  // src/turn/plan.ts
5703
- var IS_HIT_TRIGGER = {
5935
+ var READS_GROUP = {
5704
5936
  "first-hit": true,
5705
5937
  "any-crit": true,
5706
5938
  "any-miss": true,
5707
- "every-hit": true
5939
+ "every-hit": true,
5940
+ "dice-match": true
5708
5941
  };
5709
5942
  function toPMF(damage, eps, id = "") {
5710
5943
  const parts = Array.isArray(damage) ? damage : [damage];
@@ -5748,20 +5981,49 @@ function critPMF(rider, base, eps) {
5748
5981
  if (doubled.length === 0) return base;
5749
5982
  return PMF.convolveMany(doubled, eps);
5750
5983
  }
5751
- function sliceSource(pmf) {
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) {
5752
5992
  const labels = pmf.outcomes();
5753
5993
  if (!labels.includes("hit") && !labels.includes("crit")) return null;
5754
5994
  const missParts = ["missNone", "missDamage"].filter((label) => labels.includes(label)).map((label) => pmf.filterOutcome(label));
5755
- return {
5756
- hit: labels.includes("hit") ? pmf.filterOutcome("hit") : PMF.emptyMass(),
5757
- crit: labels.includes("crit") ? pmf.filterOutcome("crit") : PMF.emptyMass(),
5758
- miss: missParts.length ? missParts.reduce((all, part) => all.add(part)) : PMF.emptyMass()
5759
- };
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 };
5760
6015
  }
5761
6016
  function buildPlan(spec, eps = EPS) {
5762
6017
  const fail = (code, id, message) => {
5763
6018
  throw new TurnSpecError(code, id, message);
5764
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
+ }
5765
6027
  const attackIds = [];
5766
6028
  const attackPMFs = [];
5767
6029
  const attackSlices = [];
@@ -5771,11 +6033,11 @@ function buildPlan(spec, eps = EPS) {
5771
6033
  const id = hasWrapper ? named.id : `attack ${index + 1}`;
5772
6034
  const source = hasWrapper ? named.source : entry;
5773
6035
  const pmf = toPMF(source, eps, id);
6036
+ const matchInfo = matchNeededSourceIds.has(id) ? diceMatchInfoOf(source, eps) : null;
5774
6037
  attackIds.push(id);
5775
6038
  attackPMFs.push(pmf);
5776
- attackSlices.push(sliceSource(pmf));
6039
+ attackSlices.push(sliceSource(pmf, matchInfo));
5777
6040
  });
5778
- const riders = spec.riders ?? [];
5779
6041
  const riderIds = riders.map((rider, index) => rider.id ?? `rider ${index + 1}`);
5780
6042
  const seen = /* @__PURE__ */ new Set();
5781
6043
  for (const id of [...attackIds, ...riderIds]) {
@@ -5784,6 +6046,17 @@ function buildPlan(spec, eps = EPS) {
5784
6046
  }
5785
6047
  const attackIndexById = new Map(attackIds.map((id, index) => [id, index]));
5786
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
+ };
5787
6060
  const sourceIdsByRider = riders.map((rider, index) => {
5788
6061
  const id = riderIds[index];
5789
6062
  if (rider.on === "not-fired") {
@@ -5808,7 +6081,7 @@ function buildPlan(spec, eps = EPS) {
5808
6081
  }
5809
6082
  return [target];
5810
6083
  }
5811
- const of = [...new Set(rider.of ?? attackIds)];
6084
+ const of = [...new Set(rider.on === "dice-match" ? rider.of : rider.of ?? attackIds)];
5812
6085
  if (of.length === 0) {
5813
6086
  fail("unknown-id", id, `Rider "${id}" has no sources.`);
5814
6087
  }
@@ -5832,8 +6105,11 @@ function buildPlan(spec, eps = EPS) {
5832
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.`
5833
6106
  );
5834
6107
  }
6108
+ const damageSource = isAttack ? void 0 : riders[riderIndex].damage;
6109
+ const singleDamageSource = damageSource !== void 0 && !Array.isArray(damageSource) ? damageSource : void 0;
5835
6110
  const slices = isAttack ? attackSlices[attackIndexById.get(sourceId)] : sliceSource(
5836
- toPMF(riders[riderIndex].damage, eps, sourceId)
6111
+ toPMF(damageSource, eps, sourceId),
6112
+ matchNeededSourceIds.has(sourceId) && singleDamageSource !== void 0 ? diceMatchInfoOf(singleDamageSource, eps) : null
5837
6113
  );
5838
6114
  if (!slices) {
5839
6115
  fail(
@@ -5841,6 +6117,8 @@ function buildPlan(spec, eps = EPS) {
5841
6117
  sourceId,
5842
6118
  `Rider "${id}" triggers on "${sourceId}", which has no hit/crit outcomes.`
5843
6119
  );
6120
+ } else if (rider.on === "dice-match") {
6121
+ checkMatchable(id, sourceId, slices);
5844
6122
  }
5845
6123
  }
5846
6124
  return [...of];
@@ -5886,7 +6164,7 @@ function buildPlan(spec, eps = EPS) {
5886
6164
  const perHitGroups = /* @__PURE__ */ new Map();
5887
6165
  for (const index of order) {
5888
6166
  const rider = riders[index];
5889
- if (!IS_HIT_TRIGGER[rider.on]) continue;
6167
+ if (!READS_GROUP[rider.on]) continue;
5890
6168
  const group = groupOf(sourceIdsByRider[index]);
5891
6169
  readsByRider.set(index, group);
5892
6170
  if (rider.on === "every-hit") perHitGroups.set(riderIds[index], group);
@@ -5916,21 +6194,34 @@ function buildPlan(spec, eps = EPS) {
5916
6194
  if (!payloads) return slices;
5917
6195
  let hit = slices.hit;
5918
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;
5919
6201
  for (const payload of payloads) {
5920
6202
  hit = hit.convolve(payload.hit, eps, true);
5921
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);
5922
6208
  }
5923
- 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
5924
6219
  };
5925
6220
  const steps = attackIds.map((id, index) => ({
5926
6221
  id,
5927
6222
  trigger: null,
5928
6223
  slices: withPerHit(
5929
- attackSlices[index] ?? {
5930
- hit: attackPMFs[index],
5931
- crit: PMF.emptyMass(),
5932
- miss: PMF.emptyMass()
5933
- },
6224
+ attackSlices[index] ?? { ...emptySlices, hit: attackPMFs[index] },
5934
6225
  id
5935
6226
  ),
5936
6227
  damage: null,
@@ -5945,7 +6236,9 @@ function buildPlan(spec, eps = EPS) {
5945
6236
  if (rider.on === "every-hit") continue;
5946
6237
  const id = riderIds[index];
5947
6238
  const hit = toPMF(rider.damage, eps, id);
5948
- const slices = sliceSource(hit);
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);
5949
6242
  if (slices && rider.critDamage !== void 0) {
5950
6243
  fail(
5951
6244
  "unused-crit-damage",
@@ -5967,6 +6260,15 @@ function buildPlan(spec, eps = EPS) {
5967
6260
  stepIndexByRider.set(index, steps.length - 1);
5968
6261
  riderSteps.set(id, steps.length - 1);
5969
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
+ }
5970
6272
  return {
5971
6273
  steps,
5972
6274
  groupCount: groupSources.length,
@@ -5974,7 +6276,8 @@ function buildPlan(spec, eps = EPS) {
5974
6276
  attackIds,
5975
6277
  riderIds,
5976
6278
  riderSteps,
5977
- perHitGroups
6279
+ perHitGroups,
6280
+ groupLastReadStep
5978
6281
  };
5979
6282
  }
5980
6283
 
@@ -5984,18 +6287,36 @@ var FIRST_HIT = 1;
5984
6287
  var FIRST_CRIT = 2;
5985
6288
  var CRIT_BIT = 2;
5986
6289
  var MISS_BIT = 1;
6290
+ var MATCH_BIT = 16;
5987
6291
  var START_CODE = FIRST_NONE << 2;
5988
- function advance(code, outcome) {
6292
+ function advance(code, outcome, matched = false) {
5989
6293
  if (outcome === "miss") return code | MISS_BIT;
5990
- const first = code >> 2;
6294
+ const first = code >> 2 & 3;
5991
6295
  const withCrit = outcome === "crit" ? code | CRIT_BIT : code;
5992
- if (first !== FIRST_NONE) return withCrit;
6296
+ const withMatch = matched ? withCrit | MATCH_BIT : withCrit;
6297
+ if (first !== FIRST_NONE) return withMatch;
5993
6298
  const nextFirst = outcome === "crit" ? FIRST_CRIT : FIRST_HIT;
5994
- return nextFirst << 2 | withCrit & (CRIT_BIT | MISS_BIT);
6299
+ return nextFirst << 2 | withMatch & (MATCH_BIT | CRIT_BIT | MISS_BIT);
5995
6300
  }
5996
6301
 
5997
6302
  // src/turn/turn.ts
5998
- var OUTCOMES = ["hit", "crit", "miss"];
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
+ }
5999
6320
  function fireMode(step, codes, firedByStep) {
6000
6321
  const trigger = step.trigger;
6001
6322
  if (!trigger) return "hit";
@@ -6003,7 +6324,7 @@ function fireMode(step, codes, firedByStep) {
6003
6324
  return firedByStep[step.negates] === null ? "hit" : null;
6004
6325
  }
6005
6326
  const code = codes[step.reads];
6006
- const first = code >> 2;
6327
+ const first = code >> 2 & 3;
6007
6328
  switch (trigger.on) {
6008
6329
  case "first-hit":
6009
6330
  if (first === FIRST_NONE) return null;
@@ -6012,6 +6333,8 @@ function fireMode(step, codes, firedByStep) {
6012
6333
  return (code & CRIT_BIT) !== 0 ? "crit" : null;
6013
6334
  case "any-miss":
6014
6335
  return (code & MISS_BIT) !== 0 ? "hit" : null;
6336
+ case "dice-match":
6337
+ return (code & MATCH_BIT) !== 0 ? "hit" : null;
6015
6338
  default:
6016
6339
  return null;
6017
6340
  }
@@ -6139,6 +6462,20 @@ var Turn = class _Turn {
6139
6462
  riders.push({ ...options, damage, on: "not-fired", of: target });
6140
6463
  return new _Turn(this.declaredAttacks, riders, this.eps);
6141
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
+ }
6142
6479
  /**
6143
6480
  * The exact joint distribution: mass 1, outcome-labelled. Resolved once and
6144
6481
  * cached.
@@ -6211,8 +6548,14 @@ var Turn = class _Turn {
6211
6548
  let states = /* @__PURE__ */ new Map([[String.fromCharCode(), start]]);
6212
6549
  plan.steps.forEach((step, stepIndex) => {
6213
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
+ }
6214
6555
  const merge = (state) => {
6215
- const key = String.fromCharCode(...state.codes) + "" + state.fired.map((mode) => mode === null ? "-" : "+").join("");
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("");
6216
6559
  const existing = next.get(key);
6217
6560
  if (existing) existing.pmf = existing.pmf.add(state.pmf);
6218
6561
  else next.set(key, state);
@@ -6238,13 +6581,12 @@ var Turn = class _Turn {
6238
6581
  });
6239
6582
  continue;
6240
6583
  }
6241
- for (const outcome of OUTCOMES) {
6242
- const slice = step.slices[outcome];
6584
+ for (const { outcome, matched, slice } of stepDraws(step.slices)) {
6243
6585
  const sliceMass = slice.mass();
6244
6586
  if (sliceMass <= eps) continue;
6245
6587
  const codes = [...state.codes];
6246
6588
  for (const group of step.updates) {
6247
- codes[group] = advance(codes[group], outcome);
6589
+ codes[group] = advance(codes[group], outcome, matched);
6248
6590
  }
6249
6591
  merge({
6250
6592
  codes,
@@ -6268,7 +6610,7 @@ var Turn = class _Turn {
6268
6610
  }
6269
6611
  }
6270
6612
  for (const [id, group] of plan.perHitGroups) {
6271
- if (state.codes[group] >> 2 !== FIRST_NONE) {
6613
+ if ((state.codes[group] >> 2 & 3) !== FIRST_NONE) {
6272
6614
  fireMass.set(id, fireMass.get(id) + mass);
6273
6615
  }
6274
6616
  }
@@ -6292,7 +6634,20 @@ function turn(attacks = [], eps = EPS) {
6292
6634
  eps
6293
6635
  );
6294
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
+ }
6295
6650
 
6296
- export { ACBuilder, AlwaysCritBuilder, AlwaysHitBuilder, AttackBuilder, DCBuilder, HalfRollBuilder, MAX_TRIGGER_GROUPS, MaxOfRollBuilder, ParsedRollBuilder, PooledRollBuilder, RollBuilder, SaveBuilder, ScaleRollBuilder, Turn, TurnSpecError, builderPMFCache, clearAttackCache, clearDCCache, clearRollCache, clearSaveCache, d, d10, d100, d12, d20, d4, d6, d8, defaultConfig, flat, hd20, roll, sumRolls, turn };
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 };
6297
6652
  //# sourceMappingURL=index.js.map
6298
6653
  //# sourceMappingURL=index.js.map