@yipe/dice 0.3.0 → 0.5.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.cjs CHANGED
@@ -1,5 +1,55 @@
1
1
  'use strict';
2
2
 
3
+ // src/common/bounce.ts
4
+ function binom(n, k) {
5
+ if (k < 0 || k > n) return 0;
6
+ let result = 1;
7
+ for (let i = 0; i < k; i++) result = result * (n - i) / (i + 1);
8
+ return result;
9
+ }
10
+ function pAllDistinct(dice, faces, uniformCount, heavyWeight) {
11
+ const light = 1 / faces;
12
+ const eK = binom(uniformCount, dice) * Math.pow(light, dice) + heavyWeight * binom(uniformCount, dice - 1) * Math.pow(light, dice - 1);
13
+ let kFactorial = 1;
14
+ for (let i = 2; i <= dice; i++) kFactorial *= i;
15
+ return kFactorial * eK;
16
+ }
17
+ function pMatch(dice, faces, minimumDieRoll) {
18
+ if (dice <= 1) return 0;
19
+ if (dice > faces) return 1;
20
+ if (minimumDieRoll >= 2) {
21
+ const uniformCount = faces - minimumDieRoll;
22
+ const effectiveValues = uniformCount + 1;
23
+ if (dice > effectiveValues) return 1;
24
+ const heavyWeight = minimumDieRoll / faces;
25
+ const distinct = pAllDistinct(dice, faces, uniformCount, heavyWeight);
26
+ return Math.min(1, Math.max(0, 1 - distinct));
27
+ }
28
+ let pDistinct = 1;
29
+ for (let i = 0; i < dice; i++) pDistinct *= (faces - i) / faces;
30
+ return 1 - pDistinct;
31
+ }
32
+ function calculateBounceOdds(diceCount, dieFaces, options) {
33
+ if (diceCount <= 1) return 0;
34
+ if (diceCount > dieFaces) return 1;
35
+ const minimumDieRoll = options?.minimumDieRoll ?? 0;
36
+ const rerollDamageDice = options?.rerollDamageDice ?? 0;
37
+ const pMatchFirst = pMatch(diceCount, dieFaces, minimumDieRoll);
38
+ const rerollCount = Math.min(rerollDamageDice, diceCount);
39
+ if (rerollCount <= 0) return pMatchFirst;
40
+ const pNoMatchFirst = 1 - pMatchFirst;
41
+ const keptDice = diceCount - rerollCount;
42
+ const effectiveFaces = minimumDieRoll >= 2 ? dieFaces - (minimumDieRoll - 1) : dieFaces;
43
+ const pRerollDieMissesAll = keptDice > 0 ? Math.pow((effectiveFaces - keptDice) / effectiveFaces, rerollCount) : 1;
44
+ const pAtLeastOneRerollMatches = 1 - pRerollDieMissesAll;
45
+ const pRerolledMatch = rerollCount >= 2 ? pMatch(rerollCount, dieFaces, minimumDieRoll) : 0;
46
+ const pMatchAfterReroll = Math.min(
47
+ 1,
48
+ pAtLeastOneRerollMatches + pRerolledMatch * (1 - pAtLeastOneRerollMatches)
49
+ );
50
+ return Math.min(1, pMatchFirst + pNoMatchFirst * pMatchAfterReroll);
51
+ }
52
+
3
53
  // src/common/errors.ts
4
54
  var DiceParseError = class _DiceParseError extends Error {
5
55
  constructor(message, options) {
@@ -55,6 +105,50 @@ var LRUCache = class {
55
105
 
56
106
  // src/common/types.ts
57
107
  var EPS = 1e-12;
108
+ function critProbability(critRange, rollType = "flat") {
109
+ const base = critRange / 20;
110
+ switch (rollType) {
111
+ case "advantage":
112
+ return 1 - (1 - base) ** 2;
113
+ case "elven accuracy":
114
+ return 1 - (1 - base) ** 3;
115
+ case "disadvantage":
116
+ return base ** 2;
117
+ case "flat":
118
+ default:
119
+ return base;
120
+ }
121
+ }
122
+ var MISS_NONE_OUTCOME = "missNone";
123
+ var ALL_OUTCOME_TYPES = [
124
+ "missNone",
125
+ "missDamage",
126
+ "saveFail",
127
+ "saveHalf",
128
+ "pc",
129
+ "hit",
130
+ "crit"
131
+ ];
132
+ var OUTCOME_DISPLAY_ORDER = [
133
+ "crit",
134
+ "hit",
135
+ "missDamage",
136
+ "saveHalf",
137
+ "saveFail",
138
+ "pc",
139
+ "missNone"
140
+ ];
141
+ function sortOutcomes(outcomes, order = ALL_OUTCOME_TYPES) {
142
+ const rank = new Map(order.map((o, i) => [o, i]));
143
+ return [...outcomes].sort((a, b) => {
144
+ const ra = rank.get(a);
145
+ const rb = rank.get(b);
146
+ if (ra !== void 0 && rb !== void 0) return ra - rb;
147
+ if (ra !== void 0) return -1;
148
+ if (rb !== void 0) return 1;
149
+ return a.localeCompare(b);
150
+ });
151
+ }
58
152
  var onAnyHit = ["hit", "crit"];
59
153
  var onCritOnly = ["crit"];
60
154
  var onHitOnly = ["hit"];
@@ -125,6 +219,29 @@ var _DiceQuery = class _DiceQuery {
125
219
  this._combinedWithAttr = normalized;
126
220
  return normalized;
127
221
  }
222
+ /**
223
+ * Per-label `damage value → probability mass` series for the combined,
224
+ * attribution-carrying distribution — the provenance core of the stacked
225
+ * damage-attribution chart. Convenience for
226
+ * `combinedWithAttribution().attributionByValue()`; see
227
+ * {@link PMF.attributionByValue}.
228
+ */
229
+ attributionByValue() {
230
+ return this.combinedWithAttribution().attributionByValue();
231
+ }
232
+ /**
233
+ * How many of the independent single PMFs can produce the given outcome
234
+ * label. Useful for "all of them succeeded" style probabilities where the
235
+ * exponent is the number of contributing attacks (see
236
+ * {@link DiceQuery.probExactlyK}).
237
+ */
238
+ countSinglesWith(label) {
239
+ let count = 0;
240
+ for (const single of this.singles) {
241
+ if (single.hasOutcome(label)) count++;
242
+ }
243
+ return count;
244
+ }
128
245
  /**
129
246
  * Returns the expected damage across all possible outcomes.
130
247
  *
@@ -1408,6 +1525,20 @@ var _PMF = class _PMF {
1408
1525
  static delta(value, epsilon = EPS) {
1409
1526
  return _PMF.fromMap(/* @__PURE__ */ new Map([[value, 1]]), epsilon);
1410
1527
  }
1528
+ /**
1529
+ * Point mass at damage 0 tagged with the canonical `missNone` outcome.
1530
+ *
1531
+ * Differs from {@link PMF.zero}, which labels its zero bin `miss` — the
1532
+ * builder's attack-resolution vocabulary. This uses the `missNone`
1533
+ * {@link OutcomeType} that the attribution charts and outcome stats key on,
1534
+ * so it is the correct "clean miss / no damage" delta for provenance-aware
1535
+ * mixtures feeding those consumers.
1536
+ */
1537
+ static missNone(epsilon = EPS) {
1538
+ const m = /* @__PURE__ */ new Map();
1539
+ m.set(0, { p: 1, count: { [MISS_NONE_OUTCOME]: 1 }, attr: {} });
1540
+ return new _PMF(m, epsilon, false, "missNone");
1541
+ }
1411
1542
  // This creates a single bin at value 0, but with weight 0.
1412
1543
  static emptyMass() {
1413
1544
  return _PMF.zero().scaleMass(0);
@@ -1929,6 +2060,49 @@ var _PMF = class _PMF {
1929
2060
  `${this.identifier}+scaled(${branch.identifier},${probability})`
1930
2061
  );
1931
2062
  }
2063
+ /**
2064
+ * Redistributes probability mass to model an effect that only occurs with
2065
+ * probability `frequency` — a conditional attack, an on-hit rider, or a
2066
+ * sub-one AoE target fraction.
2067
+ *
2068
+ * Every hit outcome (damage > 0) is scaled by `frequency` — probability mass,
2069
+ * per-label `count`, AND per-label `attr` — and the freed mass is moved into
2070
+ * the miss bin at damage 0, tagged with the canonical `missNone` outcome.
2071
+ * Total probability mass is preserved.
2072
+ *
2073
+ * Unlike a bare {@link scaleMass} or {@link mapDamage}, this keeps damage
2074
+ * attribution (`attr`) intact, so a frequency-scaled PMF still renders
2075
+ * correctly in the damage-attribution charts.
2076
+ *
2077
+ * `frequency >= 1` (or non-finite) returns this PMF unchanged; `frequency <= 0`
2078
+ * collapses all mass into the miss bin. The miss outcome is assumed to be
2079
+ * encoded at damage value 0.
2080
+ *
2081
+ * @param frequency Probability in [0, 1] that the effect occurs.
2082
+ */
2083
+ applyHitFrequency(frequency) {
2084
+ if (!Number.isFinite(frequency) || frequency >= 1) return this;
2085
+ const freq = Math.max(0, frequency);
2086
+ const pMiss = this.pAt(0);
2087
+ const pHit = 1 - pMiss;
2088
+ const newMissMass = pMiss + (1 - freq) * pHit;
2089
+ const newMap = /* @__PURE__ */ new Map();
2090
+ newMap.set(0, {
2091
+ p: newMissMass,
2092
+ count: { [MISS_NONE_OUTCOME]: newMissMass },
2093
+ attr: {}
2094
+ });
2095
+ for (const [damage, bin] of this.map) {
2096
+ if (damage <= 0) continue;
2097
+ newMap.set(damage, _PMF.scaleBin(bin, freq));
2098
+ }
2099
+ return new _PMF(
2100
+ newMap,
2101
+ this.epsilon,
2102
+ false,
2103
+ `freq(${this.identifier},${freq})`
2104
+ );
2105
+ }
1932
2106
  scaleMass(factor) {
1933
2107
  if (factor === 1) return this;
1934
2108
  const scaledMap = /* @__PURE__ */ new Map();
@@ -2156,6 +2330,39 @@ var _PMF = class _PMF {
2156
2330
  pAt(x) {
2157
2331
  return this.map.get(x)?.p ?? 0;
2158
2332
  }
2333
+ /**
2334
+ * P(any damage) — the mass on all non-zero outcomes, i.e. `1 - P(0)`.
2335
+ * Assumes a miss is encoded as the damage-0 bin (the convention used across
2336
+ * attack/save PMFs). The dual of {@link missProbability}.
2337
+ */
2338
+ hitProbability() {
2339
+ return 1 - this.pAt(0);
2340
+ }
2341
+ /** P(no damage) — the mass at damage 0. The dual of {@link hitProbability}. */
2342
+ missProbability() {
2343
+ return this.pAt(0);
2344
+ }
2345
+ /**
2346
+ * Coarsen the distribution into at most `maxBuckets` contiguous, equal-width
2347
+ * damage buckets, aggregating probability mass (and `count`/`attr`
2348
+ * provenance) into each bucket's start value. Returns this PMF unchanged when
2349
+ * its integer support already fits within `maxBuckets`.
2350
+ *
2351
+ * This is a lossy display/downsampling transform (bucket start replaces the
2352
+ * exact damage value) — use it for charting wide distributions, not for DPR
2353
+ * math.
2354
+ */
2355
+ rebin(maxBuckets) {
2356
+ if (!(maxBuckets > 0)) return this;
2357
+ const support = this.support();
2358
+ if (support.length === 0) return this;
2359
+ const min = support[0];
2360
+ const max = support[support.length - 1];
2361
+ const range = max - min;
2362
+ if (range + 1 <= maxBuckets) return this;
2363
+ const binSize = Math.ceil((range + 1) / maxBuckets);
2364
+ return this.mapDamage((d) => min + Math.floor((d - min) / binSize) * binSize);
2365
+ }
2159
2366
  /** Dense integer support from min..max (inclusive).
2160
2367
  * Useful for showing empty bars in charts.
2161
2368
  */
@@ -2231,6 +2438,56 @@ var _PMF = class _PMF {
2231
2438
  }
2232
2439
  return false;
2233
2440
  }
2441
+ /**
2442
+ * Split each damage value's probability mass across outcome labels, returning
2443
+ * per-label maps of `damage value → probability mass attributable to that
2444
+ * label`. Summing over labels at a given value recovers that value's `p`.
2445
+ *
2446
+ * Damage-bearing bins are split by `attr` weight (the share of damage each
2447
+ * outcome contributed); the clean-miss bin at 0 is split by `count` weight
2448
+ * (there is no damage to attribute). Attribution is computed on demand via
2449
+ * {@link withAttribution} when absent, so builder-generated PMFs work too.
2450
+ *
2451
+ * This is the provenance core of the stacked damage-attribution chart — the
2452
+ * caller only maps these series into its rendering format (colors, binning,
2453
+ * axis labels).
2454
+ */
2455
+ attributionByValue() {
2456
+ const src = this.hasAttribution() ? this : this.withAttribution();
2457
+ const result = /* @__PURE__ */ new Map();
2458
+ const add = (label, damage, mass) => {
2459
+ if (!(mass > 0)) return;
2460
+ let series = result.get(label);
2461
+ if (!series) {
2462
+ series = /* @__PURE__ */ new Map();
2463
+ result.set(label, series);
2464
+ }
2465
+ series.set(damage, (series.get(damage) ?? 0) + mass);
2466
+ };
2467
+ for (const [damage, bin] of src.map) {
2468
+ const p = bin.p || 0;
2469
+ if (p <= 0) continue;
2470
+ const isMissBin = damage === 0;
2471
+ if (isMissBin) {
2472
+ let totalCount = 0;
2473
+ for (const k in bin.count) totalCount += bin.count[k] || 0;
2474
+ if (totalCount > 0) {
2475
+ const c = bin.count[MISS_NONE_OUTCOME] || 0;
2476
+ add(MISS_NONE_OUTCOME, damage, c / totalCount * p);
2477
+ }
2478
+ continue;
2479
+ }
2480
+ let totalAttr = 0;
2481
+ if (bin.attr) for (const k in bin.attr) totalAttr += bin.attr[k] || 0;
2482
+ if (bin.attr && totalAttr > 0) {
2483
+ for (const k in bin.attr) {
2484
+ if (k === MISS_NONE_OUTCOME) continue;
2485
+ add(k, damage, (bin.attr[k] || 0) / totalAttr * p);
2486
+ }
2487
+ }
2488
+ }
2489
+ return result;
2490
+ }
2234
2491
  tailProbGE(t) {
2235
2492
  let s = 0;
2236
2493
  for (const [x, bin] of this) {
@@ -3341,13 +3598,18 @@ var Mixture = class _Mixture {
3341
3598
  }
3342
3599
  };
3343
3600
 
3601
+ exports.ALL_OUTCOME_TYPES = ALL_OUTCOME_TYPES;
3344
3602
  exports.DiceParseError = DiceParseError;
3345
3603
  exports.DiceQuery = DiceQuery;
3346
3604
  exports.EPS = EPS;
3347
3605
  exports.LRUCache = LRUCache;
3606
+ exports.MISS_NONE_OUTCOME = MISS_NONE_OUTCOME;
3348
3607
  exports.Mixture = Mixture;
3608
+ exports.OUTCOME_DISPLAY_ORDER = OUTCOME_DISPLAY_ORDER;
3349
3609
  exports.PMF = PMF;
3610
+ exports.calculateBounceOdds = calculateBounceOdds;
3350
3611
  exports.clearParserCache = clearParserCache;
3612
+ exports.critProbability = critProbability;
3351
3613
  exports.getCachingEnabled = getCachingEnabled;
3352
3614
  exports.onAnyHit = onAnyHit;
3353
3615
  exports.onCritOnly = onCritOnly;
@@ -3360,5 +3622,6 @@ exports.onSaveHalfOnly = onSaveHalfOnly;
3360
3622
  exports.parse = parse;
3361
3623
  exports.pmfCache = pmfCache;
3362
3624
  exports.setCachingEnabled = setCachingEnabled;
3625
+ exports.sortOutcomes = sortOutcomes;
3363
3626
  //# sourceMappingURL=index.cjs.map
3364
3627
  //# sourceMappingURL=index.cjs.map