@yipe/dice 0.8.1 → 0.9.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.
@@ -117,6 +117,10 @@ var _DiceQuery = class _DiceQuery {
117
117
  if (this._combinedWithAttr) {
118
118
  return this._combinedWithAttr;
119
119
  }
120
+ if (this._combinedProvided) {
121
+ this._combinedWithAttr = this.combined.withAttribution();
122
+ return this._combinedWithAttr;
123
+ }
120
124
  if (this.singles.every((pmf) => pmf.hasAttribution())) {
121
125
  this._combinedWithAttr = this.combined;
122
126
  return this._combinedWithAttr;
@@ -887,6 +891,55 @@ var _DiceQuery = class _DiceQuery {
887
891
  }
888
892
  return out;
889
893
  }
894
+ /**
895
+ * Per-outcome probabilities and damage ranges, aggregated over the individual
896
+ * singles rather than read off the combined distribution.
897
+ *
898
+ * `damageRange` is the sum, over every single that can produce the outcome, of
899
+ * that single's own conditional damage range: "what this outcome contributes
900
+ * across the whole turn when every attack that can produce it does". Linear in
901
+ * the number of attacks by construction.
902
+ *
903
+ * Prefer this over {@link DiceQuery.snapshot} for a multi-attack query.
904
+ * `snapshot` reads `damageRange` off the combined PMF's `count`, which the
905
+ * convolution accumulates as an expected count, so its `avg` is size-biased
906
+ * for N≥2 (its own doc comment says so). The two agree for a single attack.
907
+ *
908
+ * Only outcomes that actually occur appear in the result.
909
+ *
910
+ * Like every `singles`-based helper on this class, it describes the singles
911
+ * and not an explicitly provided `combined`. `Turn.toQuery()` supplies one whose
912
+ * distribution also contains rider attacks that are absent from `singles`
913
+ * (an `otherwise([unarmed, unarmed])` flurry, say), so those attacks do not
914
+ * appear here. For rider-inclusive figures read the combined distribution
915
+ * directly: {@link DiceQuery.outcomeTotals}, {@link DiceQuery.outcomeDamageRanges}.
916
+ *
917
+ * @param outcomes Which outcomes to consider; defaults to every canonical one.
918
+ */
919
+ outcomeStats(outcomes = ALL_OUTCOME_TYPES) {
920
+ const stats = /* @__PURE__ */ new Map();
921
+ const perSingle = this.singles.map((pmf) => new _DiceQuery([pmf], void 0, this._eps));
922
+ for (const outcome of outcomes) {
923
+ const atLeastOneProbability = this.probAtLeastOne(outcome);
924
+ if (atLeastOneProbability <= 0) continue;
925
+ const damageRange = { min: 0, avg: 0, max: 0 };
926
+ let contributors = 0;
927
+ for (const single of perSingle) {
928
+ if (single.probAtLeastOne(outcome) <= 0) continue;
929
+ contributors++;
930
+ const stat = single.damageStatsFrom(outcome);
931
+ damageRange.min += stat.min;
932
+ damageRange.avg += stat.avg;
933
+ damageRange.max += stat.max;
934
+ }
935
+ stats.set(outcome, {
936
+ atLeastOneProbability,
937
+ allProbability: contributors > 0 ? this.probExactlyK(outcome, contributors) : 0,
938
+ damageRange
939
+ });
940
+ }
941
+ return stats;
942
+ }
890
943
  /**
891
944
  * Snapshot of the distribution in the exact shape the UI consumes.
892
945
  * - outcome probabilities are "at least one" (and equal to "all" for a single PMF)
@@ -5139,11 +5192,11 @@ var AttackBuilder = class _AttackBuilder {
5139
5192
  pMiss: pmiss
5140
5193
  } = this.resolveProbabilities(this.check, eps);
5141
5194
  const hitPMF = this.hitEffect ? this.hitEffect instanceof ParsedRollBuilder ? this.hitEffect.toPMF(eps) : pmfFromRollBuilder(this.hitEffect, eps) : PMF.delta(0, eps);
5142
- let critPMF = null;
5195
+ let critPMF2 = null;
5143
5196
  let phit = pHit;
5144
5197
  let pcrit = pCrit;
5145
5198
  if (this.critEffect === null) {
5146
- critPMF = null;
5199
+ critPMF2 = null;
5147
5200
  phit += pcrit;
5148
5201
  pcrit = 0;
5149
5202
  } else {
@@ -5151,7 +5204,7 @@ var AttackBuilder = class _AttackBuilder {
5151
5204
  if (this.critEffect) {
5152
5205
  critBuilder = this.critEffect;
5153
5206
  } else if (this.hitEffect instanceof ParsedRollBuilder) {
5154
- critPMF = null;
5207
+ critPMF2 = null;
5155
5208
  phit += pcrit;
5156
5209
  pcrit = 0;
5157
5210
  critBuilder = void 0;
@@ -5159,20 +5212,20 @@ var AttackBuilder = class _AttackBuilder {
5159
5212
  critBuilder = this.hitEffect?.copy().doubleDice();
5160
5213
  }
5161
5214
  if (critBuilder) {
5162
- critPMF = critBuilder instanceof ParsedRollBuilder ? critBuilder.toPMF(eps) : pmfFromRollBuilder(critBuilder, eps);
5215
+ critPMF2 = critBuilder instanceof ParsedRollBuilder ? critBuilder.toPMF(eps) : pmfFromRollBuilder(critBuilder, eps);
5163
5216
  }
5164
5217
  }
5165
5218
  const missPMF = this.missEffect ? this.missEffect instanceof ParsedRollBuilder ? this.missEffect.toPMF(eps) : pmfFromRollBuilder(this.missEffect, eps) : PMF.delta(0, eps);
5166
5219
  const mix = new Mixture(eps);
5167
5220
  if (phit > 0) mix.add("hit", hitPMF, phit);
5168
- if (critPMF && pcrit > 0) mix.add("crit", critPMF, pcrit);
5221
+ if (critPMF2 && pcrit > 0) mix.add("crit", critPMF2, pcrit);
5169
5222
  if (pmiss > 0)
5170
5223
  mix.add(this.missEffect ? "missDamage" : "missNone", missPMF, pmiss);
5171
5224
  return {
5172
5225
  pmf: mix.buildPMF(eps) ?? PMF.delta(0, eps),
5173
5226
  check: this.check.toPMF(eps) ?? PMF.delta(0, eps),
5174
5227
  hit: hitPMF ?? PMF.delta(0, eps),
5175
- crit: critPMF ?? PMF.delta(0, eps),
5228
+ crit: critPMF2 ?? PMF.delta(0, eps),
5176
5229
  miss: missPMF ?? PMF.delta(0, eps),
5177
5230
  weights: { hit: phit, crit: pcrit, miss: pmiss }
5178
5231
  };
@@ -5500,18 +5553,626 @@ RollBuilder.prototype.dc = function(saveDC) {
5500
5553
  return new DCBuilder(this).dc(saveDC);
5501
5554
  };
5502
5555
 
5556
+ // src/turn/types.ts
5557
+ var TurnSpecError = class extends Error {
5558
+ constructor(code, id, message) {
5559
+ super(message);
5560
+ this.code = code;
5561
+ this.id = id;
5562
+ this.name = "TurnSpecError";
5563
+ }
5564
+ };
5565
+ var MAX_TRIGGER_GROUPS = 4;
5566
+
5567
+ // src/turn/plan.ts
5568
+ var IS_HIT_TRIGGER = {
5569
+ "first-hit": true,
5570
+ "any-crit": true,
5571
+ "any-miss": true,
5572
+ "every-hit": true
5573
+ };
5574
+ function toPMF(damage, eps, id = "") {
5575
+ const parts = Array.isArray(damage) ? damage : [damage];
5576
+ if (parts.length === 0) return PMF.delta(0, eps);
5577
+ const pmfs = parts.map((part) => {
5578
+ if (part instanceof PMF) return part;
5579
+ if (typeof part.toPMF !== "function") {
5580
+ throw new TurnSpecError(
5581
+ "not-an-attack",
5582
+ id,
5583
+ `"${id}" is neither a PMF nor a builder with toPMF().`
5584
+ );
5585
+ }
5586
+ const resolved = part.toPMF(eps);
5587
+ if (!(resolved instanceof PMF)) {
5588
+ throw new TurnSpecError(
5589
+ "not-an-attack",
5590
+ id,
5591
+ `"${id}" has a toPMF() that did not return a PMF.`
5592
+ );
5593
+ }
5594
+ return resolved;
5595
+ });
5596
+ return PMF.convolveMany(pmfs, eps);
5597
+ }
5598
+ function critPMF(rider, base, eps) {
5599
+ if (rider.critDamage !== void 0) return toPMF(rider.critDamage, eps);
5600
+ const parts = Array.isArray(rider.damage) ? rider.damage : [rider.damage];
5601
+ const doubled = [];
5602
+ for (const part of parts) {
5603
+ const doublable = part;
5604
+ if (part instanceof PMF || typeof doublable.doubleDice !== "function") {
5605
+ return base;
5606
+ }
5607
+ try {
5608
+ doubled.push(toPMF(doublable.doubleDice(), eps));
5609
+ } catch {
5610
+ return base;
5611
+ }
5612
+ }
5613
+ if (doubled.length === 0) return base;
5614
+ return PMF.convolveMany(doubled, eps);
5615
+ }
5616
+ function sliceSource(pmf) {
5617
+ const labels = pmf.outcomes();
5618
+ if (!labels.includes("hit") && !labels.includes("crit")) return null;
5619
+ const missParts = ["missNone", "missDamage"].filter((label) => labels.includes(label)).map((label) => pmf.filterOutcome(label));
5620
+ return {
5621
+ hit: labels.includes("hit") ? pmf.filterOutcome("hit") : PMF.emptyMass(),
5622
+ crit: labels.includes("crit") ? pmf.filterOutcome("crit") : PMF.emptyMass(),
5623
+ miss: missParts.length ? missParts.reduce((all, part) => all.add(part)) : PMF.emptyMass()
5624
+ };
5625
+ }
5626
+ function buildPlan(spec, eps = EPS) {
5627
+ const fail = (code, id, message) => {
5628
+ throw new TurnSpecError(code, id, message);
5629
+ };
5630
+ const attackIds = [];
5631
+ const attackPMFs = [];
5632
+ const attackSlices = [];
5633
+ spec.attacks.forEach((entry, index) => {
5634
+ const named = entry;
5635
+ const hasWrapper = typeof named.id === "string" && named.source !== void 0;
5636
+ const id = hasWrapper ? named.id : `attack ${index + 1}`;
5637
+ const source = hasWrapper ? named.source : entry;
5638
+ const pmf = toPMF(source, eps, id);
5639
+ attackIds.push(id);
5640
+ attackPMFs.push(pmf);
5641
+ attackSlices.push(sliceSource(pmf));
5642
+ });
5643
+ const riders = spec.riders ?? [];
5644
+ const riderIds = riders.map((rider, index) => rider.id ?? `rider ${index + 1}`);
5645
+ const seen = /* @__PURE__ */ new Set();
5646
+ for (const id of [...attackIds, ...riderIds]) {
5647
+ if (seen.has(id)) fail("duplicate-id", id, `Duplicate id "${id}".`);
5648
+ seen.add(id);
5649
+ }
5650
+ const attackIndexById = new Map(attackIds.map((id, index) => [id, index]));
5651
+ const riderIndexById = new Map(riderIds.map((id, index) => [id, index]));
5652
+ const sourceIdsByRider = riders.map((rider, index) => {
5653
+ const id = riderIds[index];
5654
+ if (rider.on === "not-fired") {
5655
+ const target = rider.of;
5656
+ if (target === id) {
5657
+ fail("self-reference", id, `Rider "${id}" cannot depend on itself.`);
5658
+ }
5659
+ const targetIndex = riderIndexById.get(target);
5660
+ if (targetIndex === void 0) {
5661
+ fail(
5662
+ "unknown-id",
5663
+ target,
5664
+ `Rider "${id}" negates "${target}", which is not a rider in this turn.`
5665
+ );
5666
+ }
5667
+ if (riders[targetIndex].on === "every-hit") {
5668
+ fail(
5669
+ "not-an-attack",
5670
+ target,
5671
+ `Rider "${id}" negates "${target}", an every-hit rider, which can fire more than once and so has no single "did not fire" branch.`
5672
+ );
5673
+ }
5674
+ return [target];
5675
+ }
5676
+ const of = [...new Set(rider.of ?? attackIds)];
5677
+ if (of.length === 0) {
5678
+ fail("unknown-id", id, `Rider "${id}" has no sources.`);
5679
+ }
5680
+ for (const sourceId of of) {
5681
+ if (sourceId === id) {
5682
+ fail("self-reference", id, `Rider "${id}" cannot depend on itself.`);
5683
+ }
5684
+ const riderIndex = riderIndexById.get(sourceId);
5685
+ const isAttack = attackIndexById.has(sourceId);
5686
+ if (!isAttack && riderIndex === void 0) {
5687
+ fail(
5688
+ "unknown-id",
5689
+ sourceId,
5690
+ `Rider "${id}" depends on "${sourceId}", which is not in this turn.`
5691
+ );
5692
+ }
5693
+ if (riderIndex !== void 0 && riders[riderIndex].on === "every-hit") {
5694
+ fail(
5695
+ "not-an-attack",
5696
+ sourceId,
5697
+ `Rider "${id}" triggers on "${sourceId}", an every-hit rider. Those are folded into their own sources rather than resolved separately, so they cannot be triggered on \u2014 point at the attacks instead.`
5698
+ );
5699
+ }
5700
+ const slices = isAttack ? attackSlices[attackIndexById.get(sourceId)] : sliceSource(
5701
+ toPMF(riders[riderIndex].damage, eps, sourceId)
5702
+ );
5703
+ if (!slices) {
5704
+ fail(
5705
+ "not-an-attack",
5706
+ sourceId,
5707
+ `Rider "${id}" triggers on "${sourceId}", which has no hit/crit outcomes.`
5708
+ );
5709
+ }
5710
+ }
5711
+ return [...of];
5712
+ });
5713
+ const order = [];
5714
+ const visiting = /* @__PURE__ */ new Set();
5715
+ const done = /* @__PURE__ */ new Set();
5716
+ const visit = (index) => {
5717
+ if (done.has(index)) return;
5718
+ const id = riderIds[index];
5719
+ if (visiting.has(index)) {
5720
+ fail("cycle", id, `Rider "${id}" is part of a dependency cycle.`);
5721
+ }
5722
+ visiting.add(index);
5723
+ for (const sourceId of sourceIdsByRider[index]) {
5724
+ const dependency = riderIndexById.get(sourceId);
5725
+ if (dependency !== void 0) visit(dependency);
5726
+ }
5727
+ visiting.delete(index);
5728
+ done.add(index);
5729
+ order.push(index);
5730
+ };
5731
+ riders.forEach((_, index) => visit(index));
5732
+ const groupIndexByKey = /* @__PURE__ */ new Map();
5733
+ const groupSources = [];
5734
+ const groupOf = (sourceIds) => {
5735
+ const key = JSON.stringify([...sourceIds].sort());
5736
+ const existing = groupIndexByKey.get(key);
5737
+ if (existing !== void 0) return existing;
5738
+ if (groupSources.length >= MAX_TRIGGER_GROUPS) {
5739
+ fail(
5740
+ "too-many-groups",
5741
+ key,
5742
+ `A turn may track at most ${MAX_TRIGGER_GROUPS} distinct trigger source sets.`
5743
+ );
5744
+ }
5745
+ const index = groupSources.length;
5746
+ groupIndexByKey.set(key, index);
5747
+ groupSources.push([...sourceIds]);
5748
+ return index;
5749
+ };
5750
+ const readsByRider = /* @__PURE__ */ new Map();
5751
+ const perHitGroups = /* @__PURE__ */ new Map();
5752
+ for (const index of order) {
5753
+ const rider = riders[index];
5754
+ if (!IS_HIT_TRIGGER[rider.on]) continue;
5755
+ const group = groupOf(sourceIdsByRider[index]);
5756
+ readsByRider.set(index, group);
5757
+ if (rider.on === "every-hit") perHitGroups.set(riderIds[index], group);
5758
+ }
5759
+ const perHitBySource = /* @__PURE__ */ new Map();
5760
+ for (const index of order) {
5761
+ const rider = riders[index];
5762
+ if (rider.on !== "every-hit") continue;
5763
+ const hit = toPMF(rider.damage, eps, riderIds[index]);
5764
+ const payload = { hit, crit: critPMF(rider, hit, eps) };
5765
+ for (const sourceId of sourceIdsByRider[index]) {
5766
+ const existing = perHitBySource.get(sourceId);
5767
+ if (existing) existing.push(payload);
5768
+ else perHitBySource.set(sourceId, [payload]);
5769
+ }
5770
+ }
5771
+ const updatesById = /* @__PURE__ */ new Map();
5772
+ groupSources.forEach((sourceIds, groupIndex) => {
5773
+ for (const sourceId of sourceIds) {
5774
+ const existing = updatesById.get(sourceId);
5775
+ if (existing) existing.push(groupIndex);
5776
+ else updatesById.set(sourceId, [groupIndex]);
5777
+ }
5778
+ });
5779
+ const withPerHit = (slices, id) => {
5780
+ const payloads = perHitBySource.get(id);
5781
+ if (!payloads) return slices;
5782
+ let hit = slices.hit;
5783
+ let crit = slices.crit;
5784
+ for (const payload of payloads) {
5785
+ hit = hit.convolve(payload.hit, eps, true);
5786
+ crit = crit.convolve(payload.crit, eps, true);
5787
+ }
5788
+ return { hit, crit, miss: slices.miss };
5789
+ };
5790
+ const steps = attackIds.map((id, index) => ({
5791
+ id,
5792
+ trigger: null,
5793
+ slices: withPerHit(
5794
+ attackSlices[index] ?? {
5795
+ hit: attackPMFs[index],
5796
+ crit: PMF.emptyMass(),
5797
+ miss: PMF.emptyMass()
5798
+ },
5799
+ id
5800
+ ),
5801
+ damage: null,
5802
+ updates: updatesById.get(id) ?? [],
5803
+ reads: -1,
5804
+ negates: -1
5805
+ }));
5806
+ const stepIndexByRider = /* @__PURE__ */ new Map();
5807
+ const riderSteps = /* @__PURE__ */ new Map();
5808
+ for (const index of order) {
5809
+ const rider = riders[index];
5810
+ if (rider.on === "every-hit") continue;
5811
+ const id = riderIds[index];
5812
+ const hit = toPMF(rider.damage, eps, id);
5813
+ const slices = sliceSource(hit);
5814
+ if (slices && rider.critDamage !== void 0) {
5815
+ fail(
5816
+ "unused-crit-damage",
5817
+ id,
5818
+ `Rider "${id}" rolls its own attack, so its critDamage would never be used. Remove it, or pass plain damage dice instead.`
5819
+ );
5820
+ }
5821
+ const negatedRider = rider.on === "not-fired" ? riderIndexById.get(rider.of) : void 0;
5822
+ const step = {
5823
+ id,
5824
+ trigger: rider,
5825
+ slices: slices ? withPerHit(slices, id) : null,
5826
+ damage: slices ? null : { hit, crit: critPMF(rider, hit, eps) },
5827
+ updates: updatesById.get(id) ?? [],
5828
+ reads: readsByRider.get(index) ?? -1,
5829
+ negates: negatedRider === void 0 ? -1 : stepIndexByRider.get(negatedRider)
5830
+ };
5831
+ steps.push(step);
5832
+ stepIndexByRider.set(index, steps.length - 1);
5833
+ riderSteps.set(id, steps.length - 1);
5834
+ }
5835
+ return {
5836
+ steps,
5837
+ groupCount: groupSources.length,
5838
+ attackPMFs,
5839
+ attackIds,
5840
+ riderIds,
5841
+ riderSteps,
5842
+ perHitGroups
5843
+ };
5844
+ }
5845
+
5846
+ // src/turn/state.ts
5847
+ var FIRST_NONE = 0;
5848
+ var FIRST_HIT = 1;
5849
+ var FIRST_CRIT = 2;
5850
+ var CRIT_BIT = 2;
5851
+ var MISS_BIT = 1;
5852
+ var START_CODE = FIRST_NONE << 2;
5853
+ function advance(code, outcome) {
5854
+ if (outcome === "miss") return code | MISS_BIT;
5855
+ const first = code >> 2;
5856
+ const withCrit = outcome === "crit" ? code | CRIT_BIT : code;
5857
+ if (first !== FIRST_NONE) return withCrit;
5858
+ const nextFirst = outcome === "crit" ? FIRST_CRIT : FIRST_HIT;
5859
+ return nextFirst << 2 | withCrit & (CRIT_BIT | MISS_BIT);
5860
+ }
5861
+
5862
+ // src/turn/turn.ts
5863
+ var OUTCOMES = ["hit", "crit", "miss"];
5864
+ function fireMode(step, codes, firedByStep) {
5865
+ const trigger = step.trigger;
5866
+ if (!trigger) return "hit";
5867
+ if (trigger.on === "not-fired") {
5868
+ return firedByStep[step.negates] === null ? "hit" : null;
5869
+ }
5870
+ const code = codes[step.reads];
5871
+ const first = code >> 2;
5872
+ switch (trigger.on) {
5873
+ case "first-hit":
5874
+ if (first === FIRST_NONE) return null;
5875
+ return first === FIRST_CRIT ? "crit" : "hit";
5876
+ case "any-crit":
5877
+ return (code & CRIT_BIT) !== 0 ? "crit" : null;
5878
+ case "any-miss":
5879
+ return (code & MISS_BIT) !== 0 ? "hit" : null;
5880
+ default:
5881
+ return null;
5882
+ }
5883
+ }
5884
+ var Turn = class _Turn {
5885
+ constructor(attacks, riders, eps) {
5886
+ this.declaredAttacks = [...attacks];
5887
+ this.riders = [...riders];
5888
+ this.eps = eps;
5889
+ this.plan = buildPlan({ attacks: this.declaredAttacks, riders: this.riders }, eps);
5890
+ }
5891
+ /**
5892
+ * Builds a turn from plain data, throwing {@link TurnSpecError} if it is
5893
+ * malformed. Use this from a UI, where `error.code` maps to the field state to
5894
+ * show.
5895
+ */
5896
+ static from(spec, eps = EPS) {
5897
+ return new _Turn(spec.attacks, spec.riders ?? [], eps);
5898
+ }
5899
+ /**
5900
+ * Appends an attack, throwing {@link TurnSpecError} if that makes the turn
5901
+ * invalid.
5902
+ *
5903
+ * A rider with no explicit `of` watches every declared attack *including ones
5904
+ * appended after it*, because `of` is resolved when the plan is built rather
5905
+ * than when the rider is added. Pass an explicit `of` to pin a rider to the
5906
+ * attacks it already saw. One attack must exist before a rider with a default
5907
+ * `of` is added, or the build fails `unknown-id`.
5908
+ */
5909
+ attack(source, id) {
5910
+ const entry = id === void 0 ? source : { id, source };
5911
+ return new _Turn([...this.declaredAttacks, entry], this.riders, this.eps);
5912
+ }
5913
+ /**
5914
+ * Appends `count` copies of the same attack — the Extra Attack case, which is
5915
+ * most of 5e. Argument order mirrors `roll(count, die)`.
5916
+ *
5917
+ * ```ts
5918
+ * turn().attacks(4, greatsword).onEveryHit(d6); // fighter 20 + hunter's mark
5919
+ * ```
5920
+ *
5921
+ * @throws {RangeError} if `count` is not a positive integer.
5922
+ */
5923
+ attacks(count, source) {
5924
+ if (!Number.isInteger(count) || count < 1) {
5925
+ throw new RangeError(
5926
+ `attacks(count) needs a positive integer, got ${count}.`
5927
+ );
5928
+ }
5929
+ const added = new Array(count).fill(source);
5930
+ return new _Turn([...this.declaredAttacks, ...added], this.riders, this.eps);
5931
+ }
5932
+ /**
5933
+ * Appends a rider, throwing {@link TurnSpecError} if that makes the turn
5934
+ * invalid. The `onX` methods below are the readable way to call this.
5935
+ */
5936
+ rider(rider) {
5937
+ return new _Turn(this.declaredAttacks, [...this.riders, rider], this.eps);
5938
+ }
5939
+ /**
5940
+ * Fires once, on the first source that lands, in that source's mode — so a
5941
+ * crit on the first landing attack doubles the rider's dice. Sneak Attack.
5942
+ */
5943
+ onFirstHit(damage, options = {}) {
5944
+ return this.rider({ ...options, damage, on: "first-hit" });
5945
+ }
5946
+ /**
5947
+ * Fires once if any source crit, always in crit mode. Divine Smite: nothing is
5948
+ * lost by holding it for a crit, so this is "any", not "first".
5949
+ */
5950
+ onAnyCrit(damage, options = {}) {
5951
+ return this.rider({ ...options, damage, on: "any-crit" });
5952
+ }
5953
+ /**
5954
+ * Fires once if any source missed. The reroll gate: a reroll is a fresh attack,
5955
+ * so pass one as the damage. Kensei's Unerring Accuracy, Lucky.
5956
+ */
5957
+ onAnyMiss(damage, options = {}) {
5958
+ return this.rider({ ...options, damage, on: "any-miss" });
5959
+ }
5960
+ /**
5961
+ * Fires once per source that lands, in that hit's mode — so it can fire several
5962
+ * times in a turn. Hunter's Mark, Hex, Rage.
5963
+ */
5964
+ onEveryHit(damage, options = {}) {
5965
+ return this.rider({ ...options, damage, on: "every-hit" });
5966
+ }
5967
+ /**
5968
+ * Damage for the turns where the rider added just before this one did *not*
5969
+ * fire: "flurry of blows if I didn't smite".
5970
+ *
5971
+ * ```ts
5972
+ * turn([dagger, dagger])
5973
+ * .onAnyCrit(roll(2, d8)) // smite
5974
+ * .otherwise([flurry, flurry]) // ... or two more attacks
5975
+ * ```
5976
+ *
5977
+ * Always binds to the *immediately* preceding rider, so the two are branches of
5978
+ * one decision and can never both land. Note that chaining it therefore
5979
+ * alternates rather than laddering: `a.otherwise(b).otherwise(c)` makes `c`
5980
+ * fire whenever `b` did not, which is exactly when `a` did. For a genuine
5981
+ * three-way priority chain, name the riders and use explicit `not-fired`
5982
+ * triggers against the right one.
5983
+ */
5984
+ otherwise(damage, options = {}) {
5985
+ const index = this.riders.length - 1;
5986
+ if (index < 0) {
5987
+ throw new TurnSpecError(
5988
+ "unknown-id",
5989
+ "",
5990
+ "otherwise() needs a preceding rider to negate."
5991
+ );
5992
+ }
5993
+ const previous = this.riders[index];
5994
+ if (previous.on === "every-hit") {
5995
+ throw new TurnSpecError(
5996
+ "not-an-attack",
5997
+ previous.id ?? `rider ${index + 1}`,
5998
+ "otherwise() cannot negate an every-hit rider: it can fire more than once."
5999
+ );
6000
+ }
6001
+ const target = previous.id ?? `rider ${index + 1}`;
6002
+ const riders = [...this.riders];
6003
+ riders[index] = { ...previous, id: target };
6004
+ riders.push({ ...options, damage, on: "not-fired", of: target });
6005
+ return new _Turn(this.declaredAttacks, riders, this.eps);
6006
+ }
6007
+ /**
6008
+ * The exact joint distribution: mass 1, outcome-labelled. Resolved once and
6009
+ * cached.
6010
+ *
6011
+ * There is no `toPMF(eps)` to match the builders: a turn's epsilon is fixed
6012
+ * when it is constructed, because the plan is validated and its sources are
6013
+ * resolved at that point.
6014
+ */
6015
+ get pmf() {
6016
+ return this.resolve().pmf;
6017
+ }
6018
+ /** Mean damage for the turn. */
6019
+ mean() {
6020
+ return this.pmf.mean();
6021
+ }
6022
+ /**
6023
+ * A query whose `singles` are the **declared attacks** and whose combined
6024
+ * distribution is the exact turn PMF.
6025
+ *
6026
+ * Riders are inside the combined PMF, not in `singles`, so singles-based
6027
+ * helpers (`probAtLeastOne`, `countSinglesWith`, `outcomeStats`) describe the
6028
+ * attacks only. Read rider-inclusive statistics off the combined PMF —
6029
+ * `outcomeTotals`, `outcomeDamageRanges`, `damageAttributionChartModel`.
6030
+ */
6031
+ toQuery() {
6032
+ return new DiceQuery([...this.plan.attackPMFs], this.pmf, this.eps);
6033
+ }
6034
+ /**
6035
+ * Attack ids in declaration order, including the `attack 1`, `attack 2`, …
6036
+ * defaults given to bare sources. These are the names `of` accepts.
6037
+ */
6038
+ get attackIds() {
6039
+ return this.plan.attackIds;
6040
+ }
6041
+ /**
6042
+ * Rider ids in declaration order, including the `rider 1`, `rider 2`, …
6043
+ * defaults. These are the names {@link Turn.fireProbability} accepts.
6044
+ */
6045
+ get riderIds() {
6046
+ return this.plan.riderIds;
6047
+ }
6048
+ /**
6049
+ * P(this rider fired). For an `every-hit` rider it is P(at least one source
6050
+ * hit), since that rider can fire more than once in a turn.
6051
+ *
6052
+ * @throws {TurnSpecError} `unknown-id` if `id` is not a rider — attack ids
6053
+ * included, since attacks always happen and have no firing probability.
6054
+ */
6055
+ fireProbability(id) {
6056
+ const mass = this.resolve().fireMass.get(id);
6057
+ if (mass === void 0) {
6058
+ throw new TurnSpecError(
6059
+ "unknown-id",
6060
+ id,
6061
+ `"${id}" is not a rider in this turn. Riders: ${this.plan.riderIds.map((each) => `"${each}"`).join(", ")}.`
6062
+ );
6063
+ }
6064
+ return mass;
6065
+ }
6066
+ resolve() {
6067
+ if (this.resolved) return this.resolved;
6068
+ const plan = this.plan;
6069
+ const eps = this.eps;
6070
+ const width = plan.groupCount;
6071
+ const start = {
6072
+ codes: new Array(width).fill(START_CODE),
6073
+ pmf: PMF.delta(0, eps),
6074
+ fired: new Array(plan.steps.length).fill(null)
6075
+ };
6076
+ let states = /* @__PURE__ */ new Map([[String.fromCharCode(), start]]);
6077
+ plan.steps.forEach((step, stepIndex) => {
6078
+ const next = /* @__PURE__ */ new Map();
6079
+ const merge = (state) => {
6080
+ const key = String.fromCharCode(...state.codes) + "" + state.fired.map((mode) => mode === null ? "-" : "+").join("");
6081
+ const existing = next.get(key);
6082
+ if (existing) existing.pmf = existing.pmf.add(state.pmf);
6083
+ else next.set(key, state);
6084
+ };
6085
+ for (const state of states.values()) {
6086
+ const mode = fireMode(step, state.codes, state.fired);
6087
+ const fired = [...state.fired];
6088
+ fired[stepIndex] = mode;
6089
+ if (mode === null) {
6090
+ merge({ codes: state.codes, pmf: state.pmf, fired });
6091
+ continue;
6092
+ }
6093
+ if (!step.slices) {
6094
+ const payload = step.damage;
6095
+ merge({
6096
+ codes: state.codes,
6097
+ pmf: state.pmf.convolve(
6098
+ mode === "crit" ? payload.crit : payload.hit,
6099
+ eps,
6100
+ true
6101
+ ),
6102
+ fired
6103
+ });
6104
+ continue;
6105
+ }
6106
+ for (const outcome of OUTCOMES) {
6107
+ const slice = step.slices[outcome];
6108
+ const sliceMass = slice.mass();
6109
+ if (sliceMass <= eps) continue;
6110
+ const codes = [...state.codes];
6111
+ for (const group of step.updates) {
6112
+ codes[group] = advance(codes[group], outcome);
6113
+ }
6114
+ merge({
6115
+ codes,
6116
+ pmf: state.pmf.convolve(slice, eps, true),
6117
+ fired
6118
+ });
6119
+ }
6120
+ }
6121
+ states = next;
6122
+ });
6123
+ const fireMass = /* @__PURE__ */ new Map();
6124
+ for (const id of plan.riderSteps.keys()) fireMass.set(id, 0);
6125
+ for (const id of plan.perHitGroups.keys()) fireMass.set(id, 0);
6126
+ let total;
6127
+ for (const state of states.values()) {
6128
+ total = total ? total.add(state.pmf) : state.pmf;
6129
+ const mass = state.pmf.mass();
6130
+ for (const [id, stepIndex] of plan.riderSteps) {
6131
+ if (state.fired[stepIndex] !== null) {
6132
+ fireMass.set(id, fireMass.get(id) + mass);
6133
+ }
6134
+ }
6135
+ for (const [id, group] of plan.perHitGroups) {
6136
+ if (state.codes[group] >> 2 !== FIRST_NONE) {
6137
+ fireMass.set(id, fireMass.get(id) + mass);
6138
+ }
6139
+ }
6140
+ }
6141
+ const pmf = total ?? PMF.delta(0, eps);
6142
+ const totalMass = pmf.mass();
6143
+ const needsNormalizing = Math.abs(totalMass - 1) > eps && totalMass > 0;
6144
+ if (needsNormalizing) {
6145
+ for (const [id, mass] of fireMass) fireMass.set(id, mass / totalMass);
6146
+ }
6147
+ this.resolved = {
6148
+ pmf: needsNormalizing ? pmf.normalize() : pmf,
6149
+ fireMass
6150
+ };
6151
+ return this.resolved;
6152
+ }
6153
+ };
6154
+ function turn(attacks = [], eps = EPS) {
6155
+ return Turn.from(
6156
+ { attacks: Array.isArray(attacks) ? attacks : [attacks] },
6157
+ eps
6158
+ );
6159
+ }
6160
+
5503
6161
  exports.ACBuilder = ACBuilder;
5504
6162
  exports.AlwaysCritBuilder = AlwaysCritBuilder;
5505
6163
  exports.AlwaysHitBuilder = AlwaysHitBuilder;
5506
6164
  exports.AttackBuilder = AttackBuilder;
5507
6165
  exports.DCBuilder = DCBuilder;
5508
6166
  exports.HalfRollBuilder = HalfRollBuilder;
6167
+ exports.MAX_TRIGGER_GROUPS = MAX_TRIGGER_GROUPS;
5509
6168
  exports.MaxOfRollBuilder = MaxOfRollBuilder;
5510
6169
  exports.ParsedRollBuilder = ParsedRollBuilder;
5511
6170
  exports.PooledRollBuilder = PooledRollBuilder;
5512
6171
  exports.RollBuilder = RollBuilder;
5513
6172
  exports.SaveBuilder = SaveBuilder;
5514
6173
  exports.ScaleRollBuilder = ScaleRollBuilder;
6174
+ exports.Turn = Turn;
6175
+ exports.TurnSpecError = TurnSpecError;
5515
6176
  exports.builderPMFCache = builderPMFCache;
5516
6177
  exports.clearAttackCache = clearAttackCache;
5517
6178
  exports.clearDCCache = clearDCCache;
@@ -5530,5 +6191,6 @@ exports.flat = flat;
5530
6191
  exports.hd20 = hd20;
5531
6192
  exports.roll = roll;
5532
6193
  exports.sumRolls = sumRolls;
6194
+ exports.turn = turn;
5533
6195
  //# sourceMappingURL=index.cjs.map
5534
6196
  //# sourceMappingURL=index.cjs.map