@yipe/dice 0.3.0 → 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.
package/dist/index.d.cts CHANGED
@@ -1,5 +1,37 @@
1
- import { P as PMF } from './pmf-DqUCnYN9.cjs';
2
- export { B as Bin, C as CritConfig, a as DamageDistribution, D as DiceQuery, E as EPS, L as LRUCache, O as OutcomeLabelMap, b as OutcomeSnapshot, c as OutcomeType, R as Rounding, S as Snapshot, o as onAnyHit, d as onCritOnly, e as onHitOnly, f as onMissDamageOnly, g as onMissOnly, h as onPotentCantripOnly, i as onSaveFailOnly, j as onSaveHalfOnly, p as pmfCache } from './pmf-DqUCnYN9.cjs';
1
+ import { P as PMF } from './pmf-D5VRghZI.cjs';
2
+ export { A as ALL_OUTCOME_TYPES, B as Bin, C as CritConfig, D as DamageDistribution, l as DiceQuery, E as EPS, L as LRUCache, M as MISS_NONE_OUTCOME, d as OUTCOME_DISPLAY_ORDER, O as OutcomeLabelMap, m as OutcomeSnapshot, a as OutcomeType, b as RollType, R as Rounding, S as Snapshot, c as critProbability, o as onAnyHit, e as onCritOnly, f as onHitOnly, h as onMissDamageOnly, g as onMissOnly, k as onPotentCantripOnly, j as onSaveFailOnly, i as onSaveHalfOnly, p as pmfCache, s as sortOutcomes } from './pmf-D5VRghZI.cjs';
3
+
4
+ /**
5
+ * Bounce odds — the "birthday problem" for bouncing damage dice (e.g. Chromatic
6
+ * Orb): the probability that at least two of K dice with S faces show the same
7
+ * value, which is what lets the spell jump to another target.
8
+ *
9
+ * Accounts for two modifiers:
10
+ * - **Elemental Adept** (`minimumDieRoll >= 2`): rolls below the minimum are
11
+ * bumped up to it, collapsing the low faces onto a single heavier value.
12
+ * - **Empowered Spell** (`rerollDamageDice > 0`): a number of dice may be
13
+ * rerolled once, giving a second chance at a match.
14
+ *
15
+ * The base and Elemental-Adept cases are computed exactly (see
16
+ * {@link pAllDistinct}); the Empowered-Spell reroll is an explicit model layered
17
+ * on the exact base match probability.
18
+ */
19
+ /** Options that modify bounce odds via metamagic / feats. */
20
+ interface BounceOddsOptions {
21
+ /** Minimum die roll — e.g. 2 for Elemental Adept, 3 for Great Weapon Fighting 2024. */
22
+ minimumDieRoll?: number;
23
+ /** Number of dice that may be rerolled once — e.g. CHA modifier for Empowered Spell. */
24
+ rerollDamageDice?: number;
25
+ }
26
+ /**
27
+ * P(at least two of `diceCount` dice with `dieFaces` faces match), honoring
28
+ * Elemental Adept and Empowered Spell. Returns a probability in [0, 1].
29
+ *
30
+ * @param diceCount Number of dice rolled.
31
+ * @param dieFaces Faces per die (e.g. 8 for d8).
32
+ * @param options Optional metamagic / feat modifiers.
33
+ */
34
+ declare function calculateBounceOdds(diceCount: number, dieFaces: number, options?: BounceOddsOptions): number;
3
35
 
4
36
  /**
5
37
  * Error thrown when a dice expression cannot be parsed.
@@ -76,4 +108,4 @@ declare class Mixture<L extends string = string> {
76
108
  static mix<L extends string = string>(items: Array<[label: L, pmf: PMF, weight: number]>, eps?: number): PMF;
77
109
  }
78
110
 
79
- export { DiceParseError, Mixture, PMF, clearParserCache, getCachingEnabled, parse, setCachingEnabled };
111
+ export { type BounceOddsOptions, DiceParseError, Mixture, PMF, calculateBounceOdds, clearParserCache, getCachingEnabled, parse, setCachingEnabled };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,37 @@
1
- import { P as PMF } from './pmf-DqUCnYN9.js';
2
- export { B as Bin, C as CritConfig, a as DamageDistribution, D as DiceQuery, E as EPS, L as LRUCache, O as OutcomeLabelMap, b as OutcomeSnapshot, c as OutcomeType, R as Rounding, S as Snapshot, o as onAnyHit, d as onCritOnly, e as onHitOnly, f as onMissDamageOnly, g as onMissOnly, h as onPotentCantripOnly, i as onSaveFailOnly, j as onSaveHalfOnly, p as pmfCache } from './pmf-DqUCnYN9.js';
1
+ import { P as PMF } from './pmf-D5VRghZI.js';
2
+ export { A as ALL_OUTCOME_TYPES, B as Bin, C as CritConfig, D as DamageDistribution, l as DiceQuery, E as EPS, L as LRUCache, M as MISS_NONE_OUTCOME, d as OUTCOME_DISPLAY_ORDER, O as OutcomeLabelMap, m as OutcomeSnapshot, a as OutcomeType, b as RollType, R as Rounding, S as Snapshot, c as critProbability, o as onAnyHit, e as onCritOnly, f as onHitOnly, h as onMissDamageOnly, g as onMissOnly, k as onPotentCantripOnly, j as onSaveFailOnly, i as onSaveHalfOnly, p as pmfCache, s as sortOutcomes } from './pmf-D5VRghZI.js';
3
+
4
+ /**
5
+ * Bounce odds — the "birthday problem" for bouncing damage dice (e.g. Chromatic
6
+ * Orb): the probability that at least two of K dice with S faces show the same
7
+ * value, which is what lets the spell jump to another target.
8
+ *
9
+ * Accounts for two modifiers:
10
+ * - **Elemental Adept** (`minimumDieRoll >= 2`): rolls below the minimum are
11
+ * bumped up to it, collapsing the low faces onto a single heavier value.
12
+ * - **Empowered Spell** (`rerollDamageDice > 0`): a number of dice may be
13
+ * rerolled once, giving a second chance at a match.
14
+ *
15
+ * The base and Elemental-Adept cases are computed exactly (see
16
+ * {@link pAllDistinct}); the Empowered-Spell reroll is an explicit model layered
17
+ * on the exact base match probability.
18
+ */
19
+ /** Options that modify bounce odds via metamagic / feats. */
20
+ interface BounceOddsOptions {
21
+ /** Minimum die roll — e.g. 2 for Elemental Adept, 3 for Great Weapon Fighting 2024. */
22
+ minimumDieRoll?: number;
23
+ /** Number of dice that may be rerolled once — e.g. CHA modifier for Empowered Spell. */
24
+ rerollDamageDice?: number;
25
+ }
26
+ /**
27
+ * P(at least two of `diceCount` dice with `dieFaces` faces match), honoring
28
+ * Elemental Adept and Empowered Spell. Returns a probability in [0, 1].
29
+ *
30
+ * @param diceCount Number of dice rolled.
31
+ * @param dieFaces Faces per die (e.g. 8 for d8).
32
+ * @param options Optional metamagic / feat modifiers.
33
+ */
34
+ declare function calculateBounceOdds(diceCount: number, dieFaces: number, options?: BounceOddsOptions): number;
3
35
 
4
36
  /**
5
37
  * Error thrown when a dice expression cannot be parsed.
@@ -76,4 +108,4 @@ declare class Mixture<L extends string = string> {
76
108
  static mix<L extends string = string>(items: Array<[label: L, pmf: PMF, weight: number]>, eps?: number): PMF;
77
109
  }
78
110
 
79
- export { DiceParseError, Mixture, PMF, clearParserCache, getCachingEnabled, parse, setCachingEnabled };
111
+ export { type BounceOddsOptions, DiceParseError, Mixture, PMF, calculateBounceOdds, clearParserCache, getCachingEnabled, parse, setCachingEnabled };
package/dist/index.js CHANGED
@@ -1,3 +1,53 @@
1
+ // src/common/bounce.ts
2
+ function binom(n, k) {
3
+ if (k < 0 || k > n) return 0;
4
+ let result = 1;
5
+ for (let i = 0; i < k; i++) result = result * (n - i) / (i + 1);
6
+ return result;
7
+ }
8
+ function pAllDistinct(dice, faces, uniformCount, heavyWeight) {
9
+ const light = 1 / faces;
10
+ const eK = binom(uniformCount, dice) * Math.pow(light, dice) + heavyWeight * binom(uniformCount, dice - 1) * Math.pow(light, dice - 1);
11
+ let kFactorial = 1;
12
+ for (let i = 2; i <= dice; i++) kFactorial *= i;
13
+ return kFactorial * eK;
14
+ }
15
+ function pMatch(dice, faces, minimumDieRoll) {
16
+ if (dice <= 1) return 0;
17
+ if (dice > faces) return 1;
18
+ if (minimumDieRoll >= 2) {
19
+ const uniformCount = faces - minimumDieRoll;
20
+ const effectiveValues = uniformCount + 1;
21
+ if (dice > effectiveValues) return 1;
22
+ const heavyWeight = minimumDieRoll / faces;
23
+ const distinct = pAllDistinct(dice, faces, uniformCount, heavyWeight);
24
+ return Math.min(1, Math.max(0, 1 - distinct));
25
+ }
26
+ let pDistinct = 1;
27
+ for (let i = 0; i < dice; i++) pDistinct *= (faces - i) / faces;
28
+ return 1 - pDistinct;
29
+ }
30
+ function calculateBounceOdds(diceCount, dieFaces, options) {
31
+ if (diceCount <= 1) return 0;
32
+ if (diceCount > dieFaces) return 1;
33
+ const minimumDieRoll = options?.minimumDieRoll ?? 0;
34
+ const rerollDamageDice = options?.rerollDamageDice ?? 0;
35
+ const pMatchFirst = pMatch(diceCount, dieFaces, minimumDieRoll);
36
+ const rerollCount = Math.min(rerollDamageDice, diceCount);
37
+ if (rerollCount <= 0) return pMatchFirst;
38
+ const pNoMatchFirst = 1 - pMatchFirst;
39
+ const keptDice = diceCount - rerollCount;
40
+ const effectiveFaces = minimumDieRoll >= 2 ? dieFaces - (minimumDieRoll - 1) : dieFaces;
41
+ const pRerollDieMissesAll = keptDice > 0 ? Math.pow((effectiveFaces - keptDice) / effectiveFaces, rerollCount) : 1;
42
+ const pAtLeastOneRerollMatches = 1 - pRerollDieMissesAll;
43
+ const pRerolledMatch = rerollCount >= 2 ? pMatch(rerollCount, dieFaces, minimumDieRoll) : 0;
44
+ const pMatchAfterReroll = Math.min(
45
+ 1,
46
+ pAtLeastOneRerollMatches + pRerolledMatch * (1 - pAtLeastOneRerollMatches)
47
+ );
48
+ return Math.min(1, pMatchFirst + pNoMatchFirst * pMatchAfterReroll);
49
+ }
50
+
1
51
  // src/common/errors.ts
2
52
  var DiceParseError = class _DiceParseError extends Error {
3
53
  constructor(message, options) {
@@ -53,6 +103,50 @@ var LRUCache = class {
53
103
 
54
104
  // src/common/types.ts
55
105
  var EPS = 1e-12;
106
+ function critProbability(critRange, rollType = "flat") {
107
+ const base = critRange / 20;
108
+ switch (rollType) {
109
+ case "advantage":
110
+ return 1 - (1 - base) ** 2;
111
+ case "elven accuracy":
112
+ return 1 - (1 - base) ** 3;
113
+ case "disadvantage":
114
+ return base ** 2;
115
+ case "flat":
116
+ default:
117
+ return base;
118
+ }
119
+ }
120
+ var MISS_NONE_OUTCOME = "missNone";
121
+ var ALL_OUTCOME_TYPES = [
122
+ "missNone",
123
+ "missDamage",
124
+ "saveFail",
125
+ "saveHalf",
126
+ "pc",
127
+ "hit",
128
+ "crit"
129
+ ];
130
+ var OUTCOME_DISPLAY_ORDER = [
131
+ "crit",
132
+ "hit",
133
+ "missDamage",
134
+ "saveHalf",
135
+ "saveFail",
136
+ "pc",
137
+ "missNone"
138
+ ];
139
+ function sortOutcomes(outcomes, order = ALL_OUTCOME_TYPES) {
140
+ const rank = new Map(order.map((o, i) => [o, i]));
141
+ return [...outcomes].sort((a, b) => {
142
+ const ra = rank.get(a);
143
+ const rb = rank.get(b);
144
+ if (ra !== void 0 && rb !== void 0) return ra - rb;
145
+ if (ra !== void 0) return -1;
146
+ if (rb !== void 0) return 1;
147
+ return a.localeCompare(b);
148
+ });
149
+ }
56
150
  var onAnyHit = ["hit", "crit"];
57
151
  var onCritOnly = ["crit"];
58
152
  var onHitOnly = ["hit"];
@@ -123,6 +217,29 @@ var _DiceQuery = class _DiceQuery {
123
217
  this._combinedWithAttr = normalized;
124
218
  return normalized;
125
219
  }
220
+ /**
221
+ * Per-label `damage value → probability mass` series for the combined,
222
+ * attribution-carrying distribution — the provenance core of the stacked
223
+ * damage-attribution chart. Convenience for
224
+ * `combinedWithAttribution().attributionByValue()`; see
225
+ * {@link PMF.attributionByValue}.
226
+ */
227
+ attributionByValue() {
228
+ return this.combinedWithAttribution().attributionByValue();
229
+ }
230
+ /**
231
+ * How many of the independent single PMFs can produce the given outcome
232
+ * label. Useful for "all of them succeeded" style probabilities where the
233
+ * exponent is the number of contributing attacks (see
234
+ * {@link DiceQuery.probExactlyK}).
235
+ */
236
+ countSinglesWith(label) {
237
+ let count = 0;
238
+ for (const single of this.singles) {
239
+ if (single.hasOutcome(label)) count++;
240
+ }
241
+ return count;
242
+ }
126
243
  /**
127
244
  * Returns the expected damage across all possible outcomes.
128
245
  *
@@ -1406,6 +1523,20 @@ var _PMF = class _PMF {
1406
1523
  static delta(value, epsilon = EPS) {
1407
1524
  return _PMF.fromMap(/* @__PURE__ */ new Map([[value, 1]]), epsilon);
1408
1525
  }
1526
+ /**
1527
+ * Point mass at damage 0 tagged with the canonical `missNone` outcome.
1528
+ *
1529
+ * Differs from {@link PMF.zero}, which labels its zero bin `miss` — the
1530
+ * builder's attack-resolution vocabulary. This uses the `missNone`
1531
+ * {@link OutcomeType} that the attribution charts and outcome stats key on,
1532
+ * so it is the correct "clean miss / no damage" delta for provenance-aware
1533
+ * mixtures feeding those consumers.
1534
+ */
1535
+ static missNone(epsilon = EPS) {
1536
+ const m = /* @__PURE__ */ new Map();
1537
+ m.set(0, { p: 1, count: { [MISS_NONE_OUTCOME]: 1 }, attr: {} });
1538
+ return new _PMF(m, epsilon, false, "missNone");
1539
+ }
1409
1540
  // This creates a single bin at value 0, but with weight 0.
1410
1541
  static emptyMass() {
1411
1542
  return _PMF.zero().scaleMass(0);
@@ -1927,6 +2058,49 @@ var _PMF = class _PMF {
1927
2058
  `${this.identifier}+scaled(${branch.identifier},${probability})`
1928
2059
  );
1929
2060
  }
2061
+ /**
2062
+ * Redistributes probability mass to model an effect that only occurs with
2063
+ * probability `frequency` — a conditional attack, an on-hit rider, or a
2064
+ * sub-one AoE target fraction.
2065
+ *
2066
+ * Every hit outcome (damage > 0) is scaled by `frequency` — probability mass,
2067
+ * per-label `count`, AND per-label `attr` — and the freed mass is moved into
2068
+ * the miss bin at damage 0, tagged with the canonical `missNone` outcome.
2069
+ * Total probability mass is preserved.
2070
+ *
2071
+ * Unlike a bare {@link scaleMass} or {@link mapDamage}, this keeps damage
2072
+ * attribution (`attr`) intact, so a frequency-scaled PMF still renders
2073
+ * correctly in the damage-attribution charts.
2074
+ *
2075
+ * `frequency >= 1` (or non-finite) returns this PMF unchanged; `frequency <= 0`
2076
+ * collapses all mass into the miss bin. The miss outcome is assumed to be
2077
+ * encoded at damage value 0.
2078
+ *
2079
+ * @param frequency Probability in [0, 1] that the effect occurs.
2080
+ */
2081
+ applyHitFrequency(frequency) {
2082
+ if (!Number.isFinite(frequency) || frequency >= 1) return this;
2083
+ const freq = Math.max(0, frequency);
2084
+ const pMiss = this.pAt(0);
2085
+ const pHit = 1 - pMiss;
2086
+ const newMissMass = pMiss + (1 - freq) * pHit;
2087
+ const newMap = /* @__PURE__ */ new Map();
2088
+ newMap.set(0, {
2089
+ p: newMissMass,
2090
+ count: { [MISS_NONE_OUTCOME]: newMissMass },
2091
+ attr: {}
2092
+ });
2093
+ for (const [damage, bin] of this.map) {
2094
+ if (damage <= 0) continue;
2095
+ newMap.set(damage, _PMF.scaleBin(bin, freq));
2096
+ }
2097
+ return new _PMF(
2098
+ newMap,
2099
+ this.epsilon,
2100
+ false,
2101
+ `freq(${this.identifier},${freq})`
2102
+ );
2103
+ }
1930
2104
  scaleMass(factor) {
1931
2105
  if (factor === 1) return this;
1932
2106
  const scaledMap = /* @__PURE__ */ new Map();
@@ -2154,6 +2328,39 @@ var _PMF = class _PMF {
2154
2328
  pAt(x) {
2155
2329
  return this.map.get(x)?.p ?? 0;
2156
2330
  }
2331
+ /**
2332
+ * P(any damage) — the mass on all non-zero outcomes, i.e. `1 - P(0)`.
2333
+ * Assumes a miss is encoded as the damage-0 bin (the convention used across
2334
+ * attack/save PMFs). The dual of {@link missProbability}.
2335
+ */
2336
+ hitProbability() {
2337
+ return 1 - this.pAt(0);
2338
+ }
2339
+ /** P(no damage) — the mass at damage 0. The dual of {@link hitProbability}. */
2340
+ missProbability() {
2341
+ return this.pAt(0);
2342
+ }
2343
+ /**
2344
+ * Coarsen the distribution into at most `maxBuckets` contiguous, equal-width
2345
+ * damage buckets, aggregating probability mass (and `count`/`attr`
2346
+ * provenance) into each bucket's start value. Returns this PMF unchanged when
2347
+ * its integer support already fits within `maxBuckets`.
2348
+ *
2349
+ * This is a lossy display/downsampling transform (bucket start replaces the
2350
+ * exact damage value) — use it for charting wide distributions, not for DPR
2351
+ * math.
2352
+ */
2353
+ rebin(maxBuckets) {
2354
+ if (!(maxBuckets > 0)) return this;
2355
+ const support = this.support();
2356
+ if (support.length === 0) return this;
2357
+ const min = support[0];
2358
+ const max = support[support.length - 1];
2359
+ const range = max - min;
2360
+ if (range + 1 <= maxBuckets) return this;
2361
+ const binSize = Math.ceil((range + 1) / maxBuckets);
2362
+ return this.mapDamage((d) => min + Math.floor((d - min) / binSize) * binSize);
2363
+ }
2157
2364
  /** Dense integer support from min..max (inclusive).
2158
2365
  * Useful for showing empty bars in charts.
2159
2366
  */
@@ -2229,6 +2436,56 @@ var _PMF = class _PMF {
2229
2436
  }
2230
2437
  return false;
2231
2438
  }
2439
+ /**
2440
+ * Split each damage value's probability mass across outcome labels, returning
2441
+ * per-label maps of `damage value → probability mass attributable to that
2442
+ * label`. Summing over labels at a given value recovers that value's `p`.
2443
+ *
2444
+ * Damage-bearing bins are split by `attr` weight (the share of damage each
2445
+ * outcome contributed); the clean-miss bin at 0 is split by `count` weight
2446
+ * (there is no damage to attribute). Attribution is computed on demand via
2447
+ * {@link withAttribution} when absent, so builder-generated PMFs work too.
2448
+ *
2449
+ * This is the provenance core of the stacked damage-attribution chart — the
2450
+ * caller only maps these series into its rendering format (colors, binning,
2451
+ * axis labels).
2452
+ */
2453
+ attributionByValue() {
2454
+ const src = this.hasAttribution() ? this : this.withAttribution();
2455
+ const result = /* @__PURE__ */ new Map();
2456
+ const add = (label, damage, mass) => {
2457
+ if (!(mass > 0)) return;
2458
+ let series = result.get(label);
2459
+ if (!series) {
2460
+ series = /* @__PURE__ */ new Map();
2461
+ result.set(label, series);
2462
+ }
2463
+ series.set(damage, (series.get(damage) ?? 0) + mass);
2464
+ };
2465
+ for (const [damage, bin] of src.map) {
2466
+ const p = bin.p || 0;
2467
+ if (p <= 0) continue;
2468
+ const isMissBin = damage === 0;
2469
+ if (isMissBin) {
2470
+ let totalCount = 0;
2471
+ for (const k in bin.count) totalCount += bin.count[k] || 0;
2472
+ if (totalCount > 0) {
2473
+ const c = bin.count[MISS_NONE_OUTCOME] || 0;
2474
+ add(MISS_NONE_OUTCOME, damage, c / totalCount * p);
2475
+ }
2476
+ continue;
2477
+ }
2478
+ let totalAttr = 0;
2479
+ if (bin.attr) for (const k in bin.attr) totalAttr += bin.attr[k] || 0;
2480
+ if (bin.attr && totalAttr > 0) {
2481
+ for (const k in bin.attr) {
2482
+ if (k === MISS_NONE_OUTCOME) continue;
2483
+ add(k, damage, (bin.attr[k] || 0) / totalAttr * p);
2484
+ }
2485
+ }
2486
+ }
2487
+ return result;
2488
+ }
2232
2489
  tailProbGE(t) {
2233
2490
  let s = 0;
2234
2491
  for (const [x, bin] of this) {
@@ -3339,6 +3596,6 @@ var Mixture = class _Mixture {
3339
3596
  }
3340
3597
  };
3341
3598
 
3342
- export { DiceParseError, DiceQuery, EPS, LRUCache, Mixture, PMF, clearParserCache, getCachingEnabled, onAnyHit, onCritOnly, onHitOnly, onMissDamageOnly, onMissOnly, onPotentCantripOnly, onSaveFailOnly, onSaveHalfOnly, parse, pmfCache, setCachingEnabled };
3599
+ export { ALL_OUTCOME_TYPES, DiceParseError, DiceQuery, EPS, LRUCache, MISS_NONE_OUTCOME, Mixture, OUTCOME_DISPLAY_ORDER, PMF, calculateBounceOdds, clearParserCache, critProbability, getCachingEnabled, onAnyHit, onCritOnly, onHitOnly, onMissDamageOnly, onMissOnly, onPotentCantripOnly, onSaveFailOnly, onSaveHalfOnly, parse, pmfCache, setCachingEnabled, sortOutcomes };
3343
3600
  //# sourceMappingURL=index.js.map
3344
3601
  //# sourceMappingURL=index.js.map