@yipe/dice 0.2.23 → 0.4.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.
@@ -36,6 +36,41 @@ type DamageDistribution = Record<number, number>;
36
36
  /** Canonical outcome labels supported by the query helpers. */
37
37
  type OutcomeType = "crit" | "hit" | "missNone" | "missDamage" | "saveHalf" | "saveFail" | "pc";
38
38
  type Rounding = "none" | "floor" | "round" | "ceil";
39
+ /** How a d20 attack roll resolves: single die, keep-highest of 2/3, or keep-lowest of 2. */
40
+ type RollType = "flat" | "advantage" | "disadvantage" | "elven accuracy";
41
+ /**
42
+ * P(critical hit) for the given crit window and d20 {@link RollType}.
43
+ *
44
+ * `critRange` is the number of top faces that crit (1 for a natural 20, 2 for
45
+ * 19–20, …), so a single die crits with probability `critRange / 20`. Advantage
46
+ * rolls two d20s / elven accuracy three, keeping the best; disadvantage keeps
47
+ * the worst of two.
48
+ */
49
+ declare function critProbability(critRange: number, rollType?: RollType): number;
50
+ /**
51
+ * The canonical "clean miss" outcome — a point of zero damage with no rider.
52
+ * This is the {@link OutcomeType} that attribution charts and outcome stats key
53
+ * on, and is distinct from the builder's attack-resolution `miss` weight label.
54
+ */
55
+ declare const MISS_NONE_OUTCOME: OutcomeType;
56
+ /**
57
+ * All outcome types in canonical severity order — clean miss → crit. This is
58
+ * also the natural stacking order for attribution charts (least- to
59
+ * most-impactful, bottom → top). Enumerates every {@link OutcomeType} exactly
60
+ * once; use it instead of hand-maintained per-consumer outcome tables.
61
+ */
62
+ declare const ALL_OUTCOME_TYPES: OutcomeType[];
63
+ /**
64
+ * Outcome types in display order for stats / breakdown rows — most prominent
65
+ * first (crit, hit, …) down to the clean miss.
66
+ */
67
+ declare const OUTCOME_DISPLAY_ORDER: OutcomeType[];
68
+ /**
69
+ * Sort outcome labels by a canonical order (defaults to {@link ALL_OUTCOME_TYPES}).
70
+ * Labels not present in `order` sort after known ones, alphabetically — so
71
+ * ad-hoc/test labels outside the {@link OutcomeType} union stay stable.
72
+ */
73
+ declare function sortOutcomes<T extends string>(outcomes: Iterable<T>, order?: readonly string[]): T[];
39
74
  declare const onAnyHit: OutcomeType[];
40
75
  declare const onCritOnly: OutcomeType[];
41
76
  declare const onHitOnly: OutcomeType[];
@@ -57,9 +92,20 @@ declare const onPotentCantripOnly: OutcomeType[];
57
92
  */
58
93
  declare class DiceQuery {
59
94
  readonly singles: PMF[];
60
- readonly combined: PMF;
95
+ private readonly _eps;
96
+ private readonly _combinedProvided;
97
+ private _combined?;
61
98
  private _combinedWithAttr?;
62
99
  constructor(singles: PMF | PMF[], combined?: PMF, eps?: number);
100
+ /**
101
+ * The combined damage distribution of all single PMFs (their convolution),
102
+ * normalized to total probability 1.
103
+ *
104
+ * Computed lazily on first access and cached. Queries that only need
105
+ * additive statistics — {@link DiceQuery.mean}, {@link DiceQuery.variance},
106
+ * {@link DiceQuery.stddev} — never trigger this convolution.
107
+ */
108
+ get combined(): PMF;
63
109
  private static readonly DEFAULT_OUTCOMES;
64
110
  /**
65
111
  * Returns a new PMF with damage attribution metadata populated.
@@ -81,6 +127,21 @@ declare class DiceQuery {
81
127
  * // Now pmf can be used with toDamageAttributionChartSeries()
82
128
  */
83
129
  combinedWithAttribution(): PMF;
130
+ /**
131
+ * Per-label `damage value → probability mass` series for the combined,
132
+ * attribution-carrying distribution — the provenance core of the stacked
133
+ * damage-attribution chart. Convenience for
134
+ * `combinedWithAttribution().attributionByValue()`; see
135
+ * {@link PMF.attributionByValue}.
136
+ */
137
+ attributionByValue(): Map<string, Map<number, number>>;
138
+ /**
139
+ * How many of the independent single PMFs can produce the given outcome
140
+ * label. Useful for "all of them succeeded" style probabilities where the
141
+ * exponent is the number of contributing attacks (see
142
+ * {@link DiceQuery.probExactlyK}).
143
+ */
144
+ countSinglesWith(label: string): number;
84
145
  /**
85
146
  * Returns the expected damage across all possible outcomes.
86
147
  *
@@ -104,6 +165,8 @@ declare class DiceQuery {
104
165
  * Used to determine how consistent the damage is.
105
166
  */
106
167
  stddev(): number;
168
+ /** Alias of {@link DiceQuery.stddev}, matching {@link PMF.stdev}. */
169
+ stdev(): number;
107
170
  /**
108
171
  * Returns the Cumulative Distribution Function.
109
172
  */
@@ -148,6 +211,18 @@ declare class DiceQuery {
148
211
  */
149
212
  max(): number;
150
213
  private singleProb;
214
+ /**
215
+ * Full count distribution [P(0), P(1), …, P(n)] for "an attack succeeds if it
216
+ * carries ANY of `labels`", over the n independent singles.
217
+ *
218
+ * Each single's per-event success probability is the Poisson-binomial
219
+ * marginal P(≥1 of labels) from {@link probabilityOf} (i.e. probAtLeastOne),
220
+ * computed exactly once. The binomial DP then runs once to produce the whole
221
+ * distribution, so the array-label paths of probExactlyK / probAtLeastK /
222
+ * probAtMostK can slice or sum from it instead of rebuilding a DiceQuery and
223
+ * re-running the DP per requested k.
224
+ */
225
+ private countDistribution;
151
226
  probAtLeastK(labels: OutcomeType | OutcomeType[], k: number): number;
152
227
  /**
153
228
  * Returns the probability that at least one attack has the specified outcome(s).
@@ -192,7 +267,7 @@ declare class DiceQuery {
192
267
  * Array examples:
193
268
  * - probExactlyK(['hit', 'crit'], 2) = probability exactly 2 attacks succeed
194
269
  * - probExactlyK(['hit', 'crit'], 1) = probability exactly 1 attack succeeds
195
- * - probExactlyK(['miss', 'missNone'], 0) = probability no attacks miss
270
+ * - probExactlyK(['missDamage', 'missNone'], 0) = probability no attacks miss
196
271
  *
197
272
  * Use cases:
198
273
  * - "What's the chance exactly one of my attacks hits?"
@@ -209,7 +284,7 @@ declare class DiceQuery {
209
284
  * Single label examples:
210
285
  * - probAtMostK('hit', 1) = probability 0 or 1 attacks hit (at most 1)
211
286
  * - probAtMostK('crit', 0) = probability no attacks crit
212
- * - probAtMostK('miss', 2) = probability at most 2 attacks miss
287
+ * - probAtMostK('missDamage', 2) = probability at most 2 attacks miss
213
288
  *
214
289
  * Array examples:
215
290
  * - probAtMostK(['hit', 'crit'], 1) = probability at most 1 attack succeeds
@@ -253,7 +328,7 @@ declare class DiceQuery {
253
328
  *
254
329
  * Array examples:
255
330
  * - damageStatsFrom(['hit', 'crit']) = damage range when at least one attack succeeds
256
- * - damageStatsFrom(['miss', 'missNone']) = damage range when at least one attack misses
331
+ * - damageStatsFrom(['missDamage', 'missNone']) = damage range when at least one attack misses
257
332
  *
258
333
  * Tactical Use Cases:
259
334
  * - "Given that I don't completely whiff (99% of turns), what damage should I expect?"
@@ -271,6 +346,12 @@ declare class DiceQuery {
271
346
  * This includes mixed scenarios (2 hits + 1 crit, 3 hits + 1 miss, etc.) which
272
347
  * occur far more frequently than pure scenarios. For pure scenarios, use combinedDamageStats.
273
348
  *
349
+ * KNOWN LIMITATION (multi-attack, single label): the returned `count` is an
350
+ * EXPECTED COUNT (E[#label], so > 1 for N≥2 attacks, not a probability), and
351
+ * `avg` is the size-biased conditional mean E[dmg·#label]/E[#label] rather than
352
+ * E[dmg | the label occurs]. For a single attack both are the plain
353
+ * conditional figures. Use {@link probAtLeastOne} for the scenario probability.
354
+ *
274
355
  * @example
275
356
  * // High-level tactical planning
276
357
  * const successStats = query.damageStatsFrom('hit')
@@ -335,7 +416,8 @@ declare class DiceQuery {
335
416
  count: number;
336
417
  };
337
418
  /**
338
- * Returns the probability that a result includes ANY of the specified labels.
419
+ * Returns the probability that at least one attack carries ANY of the
420
+ * specified labels (the marginal P(≥1) across the independent attacks).
339
421
  *
340
422
  * Examples:
341
423
  * - `query.probabilityOf('hit')` → 0.88 (probability at least one hit occurs)
@@ -344,6 +426,12 @@ declare class DiceQuery {
344
426
  * Use cases:
345
427
  * - "What's the chance my resolution includes a success label?"
346
428
  * - "How likely am I to get any hits or crits across all attacks?"
429
+ *
430
+ * Note: this must NOT be computed by summing `combined` bin probabilities. A
431
+ * single combined damage total is reachable by many outcome combinations and
432
+ * a bin can hold several labels at once, so summing `bin.p` over bins that
433
+ * contain a label over-counts. The correct marginal is the Poisson-binomial
434
+ * complement over the per-attack probabilities, i.e. {@link probAtLeastOne}.
347
435
  */
348
436
  probabilityOf(labels: OutcomeType | OutcomeType[]): number;
349
437
  /**
@@ -525,6 +613,16 @@ declare class DiceQuery {
525
613
  * Snapshot of the distribution in the exact shape the UI consumes.
526
614
  * - outcome probabilities are "at least one" (and equal to "all" for a single PMF)
527
615
  * - damageRange is conditional on the outcome occurring
616
+ *
617
+ * The outcome probabilities use the correct Poisson-binomial marginals
618
+ * (`atLeastOneProbability` = P(≥1 attack has it), `allProbability` = P(all do)),
619
+ * so they are always valid probabilities in [0,1].
620
+ *
621
+ * KNOWN LIMITATION (multi-attack): `damageRange.avg` is still aggregated from
622
+ * the combined PMF's `count`, which the convolution accumulates as an EXPECTED
623
+ * COUNT, so for N≥2 attacks it is the size-biased mean E[dmg·#label]/E[#label]
624
+ * rather than a clean conditional expectation. It is correct for a single
625
+ * attack.
528
626
  */
529
627
  snapshot(order?: readonly OutcomeType[]): Snapshot;
530
628
  /**
@@ -629,7 +727,7 @@ declare class DiceQuery {
629
727
  *
630
728
  * Returns tuple: [pFirstNonSubset, pFirstSubset, pAnySuccess, pNone]
631
729
  */
632
- firstSuccessSplit(successOutcome: string | string[], subsetOutcome: string | string[], eps?: number): readonly [pSuccess: number, pSubset: number, pAny: number, pNone: number];
730
+ firstSuccessSplit(successOutcome: OutcomeType | OutcomeType[], subsetOutcome: OutcomeType | OutcomeType[], eps?: number): readonly [pSuccess: number, pSubset: number, pAny: number, pNone: number];
633
731
  }
634
732
  type OutcomeSnapshot = {
635
733
  atLeastOneProbability: number;
@@ -669,10 +767,21 @@ declare class PMF {
669
767
  private _mean?;
670
768
  private _variance?;
671
769
  private _stdev?;
770
+ private _fingerprint?;
672
771
  constructor(map?: Map<number, Bin>, epsilon?: number, normalized?: boolean, identifier?: string, _preservedProvenance?: boolean);
673
772
  static empty(epsilon?: number, identifier?: string): PMF;
674
773
  static zero(epsilon?: number): PMF;
675
774
  static delta(value: number, epsilon?: number): PMF;
775
+ /**
776
+ * Point mass at damage 0 tagged with the canonical `missNone` outcome.
777
+ *
778
+ * Differs from {@link PMF.zero}, which labels its zero bin `miss` — the
779
+ * builder's attack-resolution vocabulary. This uses the `missNone`
780
+ * {@link OutcomeType} that the attribution charts and outcome stats key on,
781
+ * so it is the correct "clean miss / no damage" delta for provenance-aware
782
+ * mixtures feeding those consumers.
783
+ */
784
+ static missNone(epsilon?: number): PMF;
676
785
  static emptyMass(): PMF;
677
786
  [Symbol.iterator](): IterableIterator<[number, Bin]>;
678
787
  static clearCache(): void;
@@ -726,7 +835,7 @@ declare class PMF {
726
835
  * @param fallback PMF to apply when this PMF is *not* selected.
727
836
  * @returns A new PMF representing the weighted mixture of this PMF and the fallback.
728
837
  */
729
- gate(p: number, zero: PMF): PMF;
838
+ gate(p: number, fallback: PMF): PMF;
730
839
  /**
731
840
  * PMF.exclusive()
732
841
  *
@@ -773,6 +882,13 @@ declare class PMF {
773
882
  *
774
883
  * @returns New PMF with attr field populated in each bin
775
884
  */
885
+ /**
886
+ * Returns true if this PMF already carries damage attribution metadata.
887
+ *
888
+ * Only the first positive-damage bin is inspected (parser-generated PMFs
889
+ * populate `attr` uniformly), so this is O(1) in practice.
890
+ */
891
+ hasAttribution(): boolean;
776
892
  withAttribution(): PMF;
777
893
  /**
778
894
  * General-purpose N-way mixture.
@@ -827,6 +943,10 @@ declare class PMF {
827
943
  * Returns the standard deviation of the damage distribution.
828
944
  */
829
945
  stdev(): number;
946
+ /** Deep-copies a Bin, cloning its count and (optional) attr maps. */
947
+ private static cloneBin;
948
+ /** Returns a new Bin with p, count, and attr all multiplied by `factor`. */
949
+ private static scaleBin;
830
950
  private static mergeInto;
831
951
  add(other: PMF): PMF;
832
952
  /**
@@ -837,10 +957,38 @@ declare class PMF {
837
957
  * Example: `pmf.addScaled(critBranch, 0.05)` → PMF including 5% crit outcomes
838
958
  */
839
959
  addScaled(branch: PMF, probability: number): PMF;
960
+ /**
961
+ * Redistributes probability mass to model an effect that only occurs with
962
+ * probability `frequency` — a conditional attack, an on-hit rider, or a
963
+ * sub-one AoE target fraction.
964
+ *
965
+ * Every hit outcome (damage > 0) is scaled by `frequency` — probability mass,
966
+ * per-label `count`, AND per-label `attr` — and the freed mass is moved into
967
+ * the miss bin at damage 0, tagged with the canonical `missNone` outcome.
968
+ * Total probability mass is preserved.
969
+ *
970
+ * Unlike a bare {@link scaleMass} or {@link mapDamage}, this keeps damage
971
+ * attribution (`attr`) intact, so a frequency-scaled PMF still renders
972
+ * correctly in the damage-attribution charts.
973
+ *
974
+ * `frequency >= 1` (or non-finite) returns this PMF unchanged; `frequency <= 0`
975
+ * collapses all mass into the miss bin. The miss outcome is assumed to be
976
+ * encoded at damage value 0.
977
+ *
978
+ * @param frequency Probability in [0, 1] that the effect occurs.
979
+ */
980
+ applyHitFrequency(frequency: number): PMF;
840
981
  scaleMass(factor: number): PMF;
841
982
  mapDamage(damageTransformFunction: (damageValue: number) => number): PMF;
842
983
  scaleDamage(factor: number, rounding?: "floor" | "round" | "ceil"): PMF;
843
984
  private getPMFCombineCacheKey;
985
+ /**
986
+ * A small content fingerprint (mass + bin count + face sum) so convolution
987
+ * cache keys change if the underlying numbers do. Memoized because a PMF is
988
+ * immutable once constructed — this avoids re-summing every key on each
989
+ * convolve() call (including cache hits).
990
+ */
991
+ fingerprint(): string;
844
992
  convolve(other: PMF, eps?: number, raw?: boolean): PMF;
845
993
  combineRaw(other: PMF, eps?: number): PMF;
846
994
  private static reduceConvolveLeft;
@@ -855,7 +1003,20 @@ declare class PMF {
855
1003
  * - Order-independent cache keys work better with consistent build patterns
856
1004
  */
857
1005
  static convolveMany(pmfList: PMF[], eps?: number): PMF;
858
- toJSON(): string;
1006
+ /**
1007
+ * Returns a plain, JSON-serializable representation of this PMF.
1008
+ *
1009
+ * Follows the standard `toJSON` contract, so `JSON.stringify(pmf)` produces
1010
+ * the expected output (no double-encoding). Use {@link PMF.fromJSON} to
1011
+ * reconstruct, or {@link PMF.toJSONString} if you need the string directly.
1012
+ */
1013
+ toJSON(): {
1014
+ bins: Array<[number, Bin]>;
1015
+ normalized: boolean;
1016
+ identifier: string;
1017
+ };
1018
+ /** Serializes this PMF to a JSON string (equivalent to `JSON.stringify(pmf)`). */
1019
+ toJSONString(): string;
859
1020
  static fromJSON(jsonData: {
860
1021
  bins: Array<[number, Bin]>;
861
1022
  normalized?: boolean;
@@ -868,9 +1029,27 @@ declare class PMF {
868
1029
  * Returns a new, non-normalized PMF.
869
1030
  */
870
1031
  prune(epsRel: number, minBins?: number): PMF;
871
- /** NEW - REVIEW IF THESE ARE USEFUL OR DUPLCIATIVE? */
872
1032
  /** Probability mass at exactly x. */
873
1033
  pAt(x: number): number;
1034
+ /**
1035
+ * P(any damage) — the mass on all non-zero outcomes, i.e. `1 - P(0)`.
1036
+ * Assumes a miss is encoded as the damage-0 bin (the convention used across
1037
+ * attack/save PMFs). The dual of {@link missProbability}.
1038
+ */
1039
+ hitProbability(): number;
1040
+ /** P(no damage) — the mass at damage 0. The dual of {@link hitProbability}. */
1041
+ missProbability(): number;
1042
+ /**
1043
+ * Coarsen the distribution into at most `maxBuckets` contiguous, equal-width
1044
+ * damage buckets, aggregating probability mass (and `count`/`attr`
1045
+ * provenance) into each bucket's start value. Returns this PMF unchanged when
1046
+ * its integer support already fits within `maxBuckets`.
1047
+ *
1048
+ * This is a lossy display/downsampling transform (bucket start replaces the
1049
+ * exact damage value) — use it for charting wide distributions, not for DPR
1050
+ * math.
1051
+ */
1052
+ rebin(maxBuckets: number): PMF;
874
1053
  /** Dense integer support from min..max (inclusive).
875
1054
  * Useful for showing empty bars in charts.
876
1055
  */
@@ -895,6 +1074,21 @@ declare class PMF {
895
1074
  } | null;
896
1075
  /** Check if outcome exists in this PMF. */
897
1076
  hasOutcome(outcome: string): boolean;
1077
+ /**
1078
+ * Split each damage value's probability mass across outcome labels, returning
1079
+ * per-label maps of `damage value → probability mass attributable to that
1080
+ * label`. Summing over labels at a given value recovers that value's `p`.
1081
+ *
1082
+ * Damage-bearing bins are split by `attr` weight (the share of damage each
1083
+ * outcome contributed); the clean-miss bin at 0 is split by `count` weight
1084
+ * (there is no damage to attribute). Attribution is computed on demand via
1085
+ * {@link withAttribution} when absent, so builder-generated PMFs work too.
1086
+ *
1087
+ * This is the provenance core of the stacked damage-attribution chart — the
1088
+ * caller only maps these series into its rendering format (colors, binning,
1089
+ * axis labels).
1090
+ */
1091
+ attributionByValue(): Map<string, Map<number, number>>;
898
1092
  tailProbGE(t: number): number;
899
1093
  tailProbGT(t: number): number;
900
1094
  /**
@@ -932,4 +1126,4 @@ declare class PMF {
932
1126
  query(): DiceQuery;
933
1127
  }
934
1128
 
935
- export { type Bin as B, type CritConfig as C, type DamageDistribution as D, EPS as E, LRUCache as L, type OutcomeLabelMap as O, PMF as P, type Rounding as R, type Snapshot as S, type OutcomeType as a, onCritOnly as b, onHitOnly as c, onMissOnly as d, onMissDamageOnly as e, onSaveHalfOnly as f, onSaveFailOnly as g, onPotentCantripOnly as h, DiceQuery as i, type OutcomeSnapshot as j, onAnyHit as o, pmfCache as p };
1129
+ export { ALL_OUTCOME_TYPES as A, type Bin as B, type CritConfig as C, type DamageDistribution as D, EPS as E, LRUCache as L, MISS_NONE_OUTCOME as M, type OutcomeLabelMap as O, PMF as P, type Rounding as R, type Snapshot as S, type OutcomeType as a, type RollType as b, critProbability as c, OUTCOME_DISPLAY_ORDER as d, onCritOnly as e, onHitOnly as f, onMissOnly as g, onMissDamageOnly as h, onSaveHalfOnly as i, onSaveFailOnly as j, onPotentCantripOnly as k, DiceQuery as l, type OutcomeSnapshot as m, onAnyHit as o, pmfCache as p, sortOutcomes as s };