@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/.claude/worktrees/amazing-matsumoto-27220c/LICENSE +21 -0
- package/.claude/worktrees/amazing-matsumoto-27220c/README.md +518 -0
- package/.claude/worktrees/vibrant-lovelace-0cc9e7/LICENSE +21 -0
- package/.claude/worktrees/vibrant-lovelace-0cc9e7/README.md +518 -0
- package/.claude/worktrees/wizardly-mclean-e375de/LICENSE +21 -0
- package/.claude/worktrees/wizardly-mclean-e375de/README.md +518 -0
- package/CHANGELOG.md +239 -0
- package/dist/builder/index.cjs +270 -0
- package/dist/builder/index.cjs.map +1 -1
- package/dist/builder/index.d.cts +53 -4
- package/dist/builder/index.d.ts +53 -4
- package/dist/builder/index.js +269 -1
- package/dist/builder/index.js.map +1 -1
- package/dist/index.cjs +263 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +35 -3
- package/dist/index.d.ts +35 -3
- package/dist/index.js +258 -1
- package/dist/index.js.map +1 -1
- package/dist/{pmf-DqUCnYN9.d.cts → pmf-D5VRghZI.d.cts} +116 -1
- package/dist/{pmf-DqUCnYN9.d.ts → pmf-D5VRghZI.d.ts} +116 -1
- package/package.json +2 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.5.0]
|
|
9
|
+
|
|
10
|
+
Pushes damage-attribution / provenance and D&D-probability logic that the
|
|
11
|
+
consuming app (dprcalc) had hand-rolled over PMF internals down into the
|
|
12
|
+
library, so the provenance model and dice math stay owned here, and adds a
|
|
13
|
+
**composable scale node** so a scaled/rounded sub-roll can nest inside a larger
|
|
14
|
+
damage payload (per-damage-type resistance / immunity / vulnerability). All
|
|
15
|
+
additive except the Elemental-Adept bounce fix noted below.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **`RollBuilder.scaleResult(numerator, denominator = 1, rounding = 'floor')`** —
|
|
20
|
+
wraps a builder in a composable `scale` AST node that scales its resolved PMF
|
|
21
|
+
by `numerator / denominator` with the given rounding. Unlike the old
|
|
22
|
+
`.half()` wrapper, a scaled builder composes: it survives `sumRolls(...)`
|
|
23
|
+
instead of being dropped on a flat-config merge, so a per-type resisted or
|
|
24
|
+
doubled sub-roll keeps its own scaling inside a larger hit/crit payload. The
|
|
25
|
+
rendered expression reflects it — `denominator === 1 → "N * (child)"`,
|
|
26
|
+
`numerator === 1 → "(child) // D"`, general → `"(child) * N // D"`. `.half()`
|
|
27
|
+
is now `scaleResult(1, 2, 'floor')`.
|
|
28
|
+
- **`sumRolls(parts: RollBuilder[])`** — additive factory whose `toAST()` is an
|
|
29
|
+
`add` node over each part's AST, letting scaled and plain children sit side by
|
|
30
|
+
side without the flat `.plus()` merge collapsing them. `toExpression()` joins
|
|
31
|
+
the parts with ` + ` and `toPMF()` convolves them.
|
|
32
|
+
- **`PMF.applyHitFrequency(frequency)`** — provenance-preserving mass
|
|
33
|
+
redistribution for effects that only occur with some probability (conditional
|
|
34
|
+
attacks, on-hit riders, sub-one AoE fractions): scales every hit bin (damage
|
|
35
|
+
> 0) by `frequency` and moves the freed mass into a `missNone` bin. Unlike a
|
|
36
|
+
bare `scaleMass`/`mapDamage`, it scales per-label `count` **and** `attr`, so a
|
|
37
|
+
frequency-scaled PMF still renders correctly in the damage-attribution charts.
|
|
38
|
+
Replaces the app's hand-rolled `applyFrequencyToPMF`, which dropped `attr`.
|
|
39
|
+
- **`PMF.missNone(epsilon?)`** / **`MISS_NONE_OUTCOME`** — canonical "clean miss"
|
|
40
|
+
delta (point mass at 0 tagged with the `missNone` `OutcomeType`, distinct from
|
|
41
|
+
`PMF.zero`'s builder-side `miss` label), and the label as a single source of
|
|
42
|
+
truth.
|
|
43
|
+
- **`PMF.hitProbability()` / `PMF.missProbability()`** — the `1 - P(0)` idiom
|
|
44
|
+
(miss encoded at damage 0), centralized.
|
|
45
|
+
- **`PMF.rebin(maxBuckets)`** — coarsen a wide distribution into ≤ N contiguous
|
|
46
|
+
equal-width buckets, aggregating `count`/`attr` provenance. For charting wide
|
|
47
|
+
distributions, not DPR math.
|
|
48
|
+
- **`PMF.attributionByValue()` / `DiceQuery.attributionByValue()`** — split each
|
|
49
|
+
damage value's probability mass across outcome labels (by `attr` for
|
|
50
|
+
damage-bearing bins, by `count` for the clean-miss bin), returning per-label
|
|
51
|
+
`value → mass` series. The provenance core of the stacked attribution chart.
|
|
52
|
+
- **`DiceQuery.countSinglesWith(label)`** — how many independent single PMFs can
|
|
53
|
+
produce a given outcome label.
|
|
54
|
+
- **`ALL_OUTCOME_TYPES`**, **`OUTCOME_DISPLAY_ORDER`**, **`sortOutcomes()`** —
|
|
55
|
+
canonical `OutcomeType` enumeration + stack / display orderings, replacing
|
|
56
|
+
per-consumer outcome tables.
|
|
57
|
+
- **`critProbability(critRange, rollType)`** and **`RollType`** (now exported
|
|
58
|
+
from the package root as well as `@yipe/dice/builder`) — advantage-aware
|
|
59
|
+
P(crit) for a given crit window.
|
|
60
|
+
- **`calculateBounceOdds(diceCount, dieFaces, options?)`** and
|
|
61
|
+
**`BounceOddsOptions`** — the "birthday problem" for bouncing damage dice
|
|
62
|
+
(Chromatic Orb), honoring Elemental Adept and Empowered Spell. Moved out of the
|
|
63
|
+
app; the base and Elemental-Adept cases are now computed **exactly** (verified
|
|
64
|
+
against brute-force enumeration in `tests/bounce.test.ts`).
|
|
65
|
+
|
|
66
|
+
### Fixed
|
|
67
|
+
|
|
68
|
+
- **`calculateBounceOdds` Elemental Adept was approximate.** The former
|
|
69
|
+
hand-derived adjustment factor drifted from the exact value by up to ~3.5%
|
|
70
|
+
(e.g. 3×d8, min-roll 3: 0.4965 → 0.5313). The Elemental-Adept branch now uses
|
|
71
|
+
an exact elementary-symmetric-polynomial computation. Consumers relying on the
|
|
72
|
+
old numbers for bouncing spells with Elemental Adept will see small DPR shifts.
|
|
73
|
+
- **`calculateBounceOdds` Empowered Spell returned certainty when rerolling all
|
|
74
|
+
dice.** When `rerollDamageDice >= diceCount` (no dice kept), the model claimed
|
|
75
|
+
a guaranteed match (1.0) instead of treating the reroll as a second
|
|
76
|
+
independent roll. It now correctly yields `1 - (1 - pMatch)^2` in that case
|
|
77
|
+
(e.g. 3×d8 reroll-all: 1.0 → 0.5693).
|
|
78
|
+
|
|
79
|
+
## [0.3.0]
|
|
80
|
+
|
|
81
|
+
### Fixed (mathematical correctness)
|
|
82
|
+
|
|
83
|
+
Every fix is verified against an independent brute-force enumeration (see
|
|
84
|
+
`tests/math-correctness.test.ts`).
|
|
85
|
+
|
|
86
|
+
- **`DiceQuery.probabilityOf(label)` over-counted.** It summed the full `bin.p`
|
|
87
|
+
of every combined bin that merely *contained* a label, but bins hold multiple
|
|
88
|
+
mutually-exclusive outcomes — so `probabilityOf('crit')` returned 0.49 where
|
|
89
|
+
the true P(crit)=0.05. It now returns the correct Poisson-binomial marginal
|
|
90
|
+
(= `probAtLeastOne`). `missChance()` is fixed by the same change.
|
|
91
|
+
- **`DiceQuery.probExactlyK([labels], k)` array-path** delegated to the buggy
|
|
92
|
+
`probabilityOf`, disagreeing with the (correct) single-label string path; both
|
|
93
|
+
now match the true binomial.
|
|
94
|
+
- **`DiceQuery.variance()/stddev()`** used the unstable `E[X²]−E[X]²` form and
|
|
95
|
+
lost all precision under a large constant damage offset (`1d6 + 1e8` gave
|
|
96
|
+
variance 2 instead of 35/12). Now uses the centered, additive-per-single form.
|
|
97
|
+
- **`DiceQuery.mean()/variance()`** now stay consistent with an explicitly
|
|
98
|
+
supplied `combined` that diverges from `convolve(singles)`.
|
|
99
|
+
- **`PMF.convolve()` produced `NaN`** for a zero-mass operand (divide-by-zero in
|
|
100
|
+
the mass rescale), silently poisoning `DiceQuery.combined`. A zero-mass
|
|
101
|
+
convolution now correctly yields mass 0.
|
|
102
|
+
- **`probAtLeastOne` is now mass-invariant** (per-attack probability divided by
|
|
103
|
+
the single's mass) and clamped to `[0,1]` (was returning `1.0000000002`).
|
|
104
|
+
- **`PMF.firstSuccessWeights`** throws on `pSpecial > pSuccess` instead of
|
|
105
|
+
returning out-of-range probabilities.
|
|
106
|
+
- **Parser `hd6`/`hd20` (reroll-one)** used a weighted union giving
|
|
107
|
+
`P(1)=1/(2s−1)`; now uses `reroll(1)` for the correct `P(1)=1/s²`. The parser
|
|
108
|
+
`hd` distribution now matches the builder's `reroll(1)` exactly (the
|
|
109
|
+
previously loosened tests are tightened).
|
|
110
|
+
- **`DiceQuery.snapshot()` outcome probabilities** (`atLeastOneProbability`,
|
|
111
|
+
`allProbability`) were aggregated as expected counts and could exceed 1 for
|
|
112
|
+
multi-attack queries. They now use the correct Poisson-binomial marginals
|
|
113
|
+
(P(≥1) and P(all)) and are always in [0,1]. (`damageRange.avg` remains a
|
|
114
|
+
size-biased mean for N≥2 — see Known limitations.)
|
|
115
|
+
- **Parser save-for-half mislabeled outcomes** on odd/constant damage (e.g.
|
|
116
|
+
`(d20 DC 15) * (3) save half`): the brittle "2×half ∈ hit" detection
|
|
117
|
+
false-negatived, tagging the success mass as `saveFail` and the failure mass
|
|
118
|
+
as `hit`. Detection is now deterministic (the presence of a save distribution),
|
|
119
|
+
so `saveHalf`/`saveFail` are always labeled correctly.
|
|
120
|
+
- **`PMF.compact()` corrupted PMFs that shared bin objects.** It deleted
|
|
121
|
+
sub-epsilon `count`/`attr` entries *in place* and reused that same bin
|
|
122
|
+
reference in the compacted map. Because bins are shared by reference across
|
|
123
|
+
PMFs (the `branch()` / `addScaled()` / `scaleMass()` fast paths can carry
|
|
124
|
+
another PMF's bin objects), this silently mutated the source PMF — and the
|
|
125
|
+
receiver's own bins. `compact()` now clones each surviving bin before pruning;
|
|
126
|
+
the compacted result is unchanged.
|
|
127
|
+
|
|
128
|
+
### Security / hardening
|
|
129
|
+
|
|
130
|
+
- **Parser resource-exhaustion guards.** Adversarial expressions are rejected
|
|
131
|
+
with a `DiceParseError` instead of exhausting CPU/memory: a die over 1,000,000
|
|
132
|
+
faces, a dice count over 10,000, a keep whose `faces^count` enumeration would
|
|
133
|
+
exceed 1,000,000 outcomes, and a binary operation whose `faces₁ × faces₂` work
|
|
134
|
+
would exceed 100,000,000 face pairs. The last closes a gap the per-operand
|
|
135
|
+
caps missed — two individually-legal large dice (e.g. `d100000 + d100000`,
|
|
136
|
+
~10¹⁰ operations) previously hung for tens of seconds. All legitimate
|
|
137
|
+
expressions, including `d100000`, still parse.
|
|
138
|
+
|
|
139
|
+
### Known limitations (documented; recommend maintainer review)
|
|
140
|
+
|
|
141
|
+
These are real but require API/architecture decisions, so they are documented
|
|
142
|
+
and pinned by tests rather than changed blindly:
|
|
143
|
+
|
|
144
|
+
- **Parser crit probability with bonus to-hit dice is wrong.** With bonus dice in
|
|
145
|
+
the to-hit (e.g. Bless, `d20 + 5 + 1d4`), the string parser collapses crit to
|
|
146
|
+
`1/(20·∏bonusSides)` (and the DPR is off by a few %), because the natural-20
|
|
147
|
+
slice can't be separated after the bonus dice are convolved. **The builder API
|
|
148
|
+
computes it correctly** — use `d20.plus(..).plus(bonusDie).ac(..).onCrit(..)`.
|
|
149
|
+
- **Multi-attack conditional damage `avg` is size-biased.** The `avg` returned by
|
|
150
|
+
`damageStatsFrom()` (single label), `outcomeDamageRanges()` and
|
|
151
|
+
`snapshot().damageRange` aggregates the combined PMF's `count` (an *expected
|
|
152
|
+
count* for N≥2 attacks), so it is the size-biased mean E[dmg·#label]/E[#label]
|
|
153
|
+
rather than a clean conditional expectation. It is correct for a single attack.
|
|
154
|
+
(The associated *probabilities* are now correct — see Fixed.)
|
|
155
|
+
- **`PMF.mixN`/`gate`/`branch` build O(2ⁿ) identifier strings**, which can blow up
|
|
156
|
+
(multi-MB, eventual `RangeError`) for very deep (≈20+) gate chains. Prefer
|
|
157
|
+
`PMF.exclusive`/`PMF.mix` for large mixtures.
|
|
158
|
+
|
|
159
|
+
### Breaking
|
|
160
|
+
|
|
161
|
+
- **`PMF.toJSON()` now returns a plain object** (`{ bins, normalized, identifier }`)
|
|
162
|
+
instead of a JSON string, following the standard `toJSON` contract. This means
|
|
163
|
+
`JSON.stringify(pmf)` no longer double-encodes. If you relied on the old string
|
|
164
|
+
return, call the new `PMF.toJSONString()` instead.
|
|
165
|
+
- **`DiceQuery.firstSuccessSplit()` is typed as `OutcomeType | OutcomeType[]`**
|
|
166
|
+
(previously `string | string[]`). Only affects callers passing arbitrary strings;
|
|
167
|
+
valid outcome labels are unchanged.
|
|
168
|
+
|
|
169
|
+
### Added
|
|
170
|
+
|
|
171
|
+
- **`DiceParseError`** — `parse()` now throws this typed error (a subclass of
|
|
172
|
+
`Error`) instead of a plain `Error`. Existing `try/catch` and message checks keep
|
|
173
|
+
working; you can now narrow with `instanceof DiceParseError` and read
|
|
174
|
+
`error.expression` / `error.cause`.
|
|
175
|
+
- **`PMF.toJSONString()`** — returns the JSON string form (the previous
|
|
176
|
+
`toJSON()` behavior).
|
|
177
|
+
- **`DiceQuery.stdev()`** — alias of `stddev()`, matching `PMF.stdev()`.
|
|
178
|
+
- **`PMF.hasAttribution()`** — O(1) check for whether a PMF already carries
|
|
179
|
+
damage-attribution metadata.
|
|
180
|
+
|
|
181
|
+
### Performance
|
|
182
|
+
|
|
183
|
+
- **`DiceQuery.mean()` / `variance()` / `stddev()` use moment additivity**
|
|
184
|
+
(`E[ΣX]=ΣE[X]`, `Var[ΣX]=ΣVar[X]`) computed directly from the single PMFs.
|
|
185
|
+
- **`DiceQuery.combined` is now built lazily** (on first access) instead of in
|
|
186
|
+
the constructor. Combined with the above, a query used only for DPR / mean /
|
|
187
|
+
variance never performs the N-way convolution — multi-attack stats-only
|
|
188
|
+
queries are ~10000× faster (e.g. ~17 ms → ~0.001 ms for a heavy 4-attack
|
|
189
|
+
expression). The materialized `combined` distribution is unchanged; mean and
|
|
190
|
+
variance may differ from the previous convolution-based values by at most a
|
|
191
|
+
few ULP (well within the library's tolerances).
|
|
192
|
+
- **`DiceQuery.combinedWithAttribution()` reuses `combined`** when every single
|
|
193
|
+
already carries attribution (as parser-generated PMFs do), avoiding a
|
|
194
|
+
redundant convolution pass. Result is bit-for-bit identical.
|
|
195
|
+
- **`PMF.convolve()` inner loop accumulates directly into destination bins**
|
|
196
|
+
instead of allocating a temporary bin per term and merging — ~1.6× faster
|
|
197
|
+
convolution (the cost of building the combined distribution for charts). The
|
|
198
|
+
probability channel is bit-identical; per-label `count`/`attr` provenance may
|
|
199
|
+
re-associate by at most a few ULP (≤1e-14 even at 16 attacks, ~100× below the
|
|
200
|
+
eps pruning threshold).
|
|
201
|
+
- **Convolution cache-key fingerprint is memoized** on each (immutable) PMF
|
|
202
|
+
instead of re-summing every bin key on every `convolve()` call — ~36% faster
|
|
203
|
+
on warm cache hits. Bit-identical (`PMF.fingerprint()` returns the same string).
|
|
204
|
+
- **`Dice.calculateHitDistribution()` no longer clones outcome distributions per
|
|
205
|
+
face** — it reads the stored maps once instead of `O(faces × outcomes)` clones,
|
|
206
|
+
~10% faster cold parsing of wide-support expressions. Bit-identical.
|
|
207
|
+
- **`DiceQuery.toStackedChartData()` drops a dead `O(N×L)` precomputation pass**
|
|
208
|
+
whose result was discarded — ~2× faster. Bit-identical.
|
|
209
|
+
- Minor bit-identical cleanups on the parse path (`Dice.toPMF` iterates the
|
|
210
|
+
internal face map directly; `multiplyDiceByDice` uses a `Map`).
|
|
211
|
+
- **`DiceQuery` count queries (`probExactlyK` / `probAtLeastK` / `probAtMostK`,
|
|
212
|
+
array-label paths)** compute each attack's success probability and the binomial
|
|
213
|
+
DP once instead of rebuilding a query per requested count (~3× on the looped
|
|
214
|
+
variants).
|
|
215
|
+
- **`PMF.branch()` assembles its Bernoulli mixture in a single pass** rather than
|
|
216
|
+
chaining two `addScaled` calls (which copied the failure branch's bins twice).
|
|
217
|
+
- **`keepSumPMF` packs its DP state into a single integer key** instead of a
|
|
218
|
+
`"used|r"` string parsed on every transition.
|
|
219
|
+
- **`computeMaxOfPMF` walks the support once with a running CDF** for large pools,
|
|
220
|
+
reducing the max-of computation from O(N²) to O(N).
|
|
221
|
+
- **`Dice.reroll()` uses a `Set` for membership** and **`Dice.binaryOp()` hoists
|
|
222
|
+
the inner die's face list** out of its loop.
|
|
223
|
+
|
|
224
|
+
All of the above were verified bit-for-bit identical (probabilities, counts,
|
|
225
|
+
means, variance) across the full expression corpus.
|
|
226
|
+
|
|
227
|
+
### Changed
|
|
228
|
+
|
|
229
|
+
- Removed the stale `package-lock.json` (the project uses Yarn 4) and dropped the
|
|
230
|
+
unused `ts-node` / `tsconfig-paths` dev dependencies.
|
|
231
|
+
- Removed `console.error` calls from the parser so the library no longer writes to
|
|
232
|
+
a consumer's console.
|
|
233
|
+
- Internal refactors with no behavioral change: deduplicated `Bin` clone/scale
|
|
234
|
+
logic in `PMF`, removed dead code and impossible iterator branches, and tightened
|
|
235
|
+
internal `any` usage.
|
|
236
|
+
- `Dice.outcomeData` is typed `Partial<Record<OutcomeType, …>>` (dropping an
|
|
237
|
+
unsound `as Record<…>` cast); `getFullOutcomeDistribution()`'s return type
|
|
238
|
+
matches. Type-only change; runtime output is unchanged.
|
|
239
|
+
- Added a `yarn format` script (ESLint autofix).
|
package/dist/builder/index.cjs
CHANGED
|
@@ -44,6 +44,7 @@ var LRUCache = class {
|
|
|
44
44
|
|
|
45
45
|
// src/common/types.ts
|
|
46
46
|
var EPS = 1e-12;
|
|
47
|
+
var MISS_NONE_OUTCOME = "missNone";
|
|
47
48
|
|
|
48
49
|
// src/pmf/query.ts
|
|
49
50
|
var _DiceQuery = class _DiceQuery {
|
|
@@ -106,6 +107,29 @@ var _DiceQuery = class _DiceQuery {
|
|
|
106
107
|
this._combinedWithAttr = normalized;
|
|
107
108
|
return normalized;
|
|
108
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Per-label `damage value → probability mass` series for the combined,
|
|
112
|
+
* attribution-carrying distribution — the provenance core of the stacked
|
|
113
|
+
* damage-attribution chart. Convenience for
|
|
114
|
+
* `combinedWithAttribution().attributionByValue()`; see
|
|
115
|
+
* {@link PMF.attributionByValue}.
|
|
116
|
+
*/
|
|
117
|
+
attributionByValue() {
|
|
118
|
+
return this.combinedWithAttribution().attributionByValue();
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* How many of the independent single PMFs can produce the given outcome
|
|
122
|
+
* label. Useful for "all of them succeeded" style probabilities where the
|
|
123
|
+
* exponent is the number of contributing attacks (see
|
|
124
|
+
* {@link DiceQuery.probExactlyK}).
|
|
125
|
+
*/
|
|
126
|
+
countSinglesWith(label) {
|
|
127
|
+
let count = 0;
|
|
128
|
+
for (const single of this.singles) {
|
|
129
|
+
if (single.hasOutcome(label)) count++;
|
|
130
|
+
}
|
|
131
|
+
return count;
|
|
132
|
+
}
|
|
109
133
|
/**
|
|
110
134
|
* Returns the expected damage across all possible outcomes.
|
|
111
135
|
*
|
|
@@ -1389,6 +1413,20 @@ var _PMF = class _PMF {
|
|
|
1389
1413
|
static delta(value, epsilon = EPS) {
|
|
1390
1414
|
return _PMF.fromMap(/* @__PURE__ */ new Map([[value, 1]]), epsilon);
|
|
1391
1415
|
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Point mass at damage 0 tagged with the canonical `missNone` outcome.
|
|
1418
|
+
*
|
|
1419
|
+
* Differs from {@link PMF.zero}, which labels its zero bin `miss` — the
|
|
1420
|
+
* builder's attack-resolution vocabulary. This uses the `missNone`
|
|
1421
|
+
* {@link OutcomeType} that the attribution charts and outcome stats key on,
|
|
1422
|
+
* so it is the correct "clean miss / no damage" delta for provenance-aware
|
|
1423
|
+
* mixtures feeding those consumers.
|
|
1424
|
+
*/
|
|
1425
|
+
static missNone(epsilon = EPS) {
|
|
1426
|
+
const m = /* @__PURE__ */ new Map();
|
|
1427
|
+
m.set(0, { p: 1, count: { [MISS_NONE_OUTCOME]: 1 }, attr: {} });
|
|
1428
|
+
return new _PMF(m, epsilon, false, "missNone");
|
|
1429
|
+
}
|
|
1392
1430
|
// This creates a single bin at value 0, but with weight 0.
|
|
1393
1431
|
static emptyMass() {
|
|
1394
1432
|
return _PMF.zero().scaleMass(0);
|
|
@@ -1910,6 +1948,49 @@ var _PMF = class _PMF {
|
|
|
1910
1948
|
`${this.identifier}+scaled(${branch.identifier},${probability})`
|
|
1911
1949
|
);
|
|
1912
1950
|
}
|
|
1951
|
+
/**
|
|
1952
|
+
* Redistributes probability mass to model an effect that only occurs with
|
|
1953
|
+
* probability `frequency` — a conditional attack, an on-hit rider, or a
|
|
1954
|
+
* sub-one AoE target fraction.
|
|
1955
|
+
*
|
|
1956
|
+
* Every hit outcome (damage > 0) is scaled by `frequency` — probability mass,
|
|
1957
|
+
* per-label `count`, AND per-label `attr` — and the freed mass is moved into
|
|
1958
|
+
* the miss bin at damage 0, tagged with the canonical `missNone` outcome.
|
|
1959
|
+
* Total probability mass is preserved.
|
|
1960
|
+
*
|
|
1961
|
+
* Unlike a bare {@link scaleMass} or {@link mapDamage}, this keeps damage
|
|
1962
|
+
* attribution (`attr`) intact, so a frequency-scaled PMF still renders
|
|
1963
|
+
* correctly in the damage-attribution charts.
|
|
1964
|
+
*
|
|
1965
|
+
* `frequency >= 1` (or non-finite) returns this PMF unchanged; `frequency <= 0`
|
|
1966
|
+
* collapses all mass into the miss bin. The miss outcome is assumed to be
|
|
1967
|
+
* encoded at damage value 0.
|
|
1968
|
+
*
|
|
1969
|
+
* @param frequency Probability in [0, 1] that the effect occurs.
|
|
1970
|
+
*/
|
|
1971
|
+
applyHitFrequency(frequency) {
|
|
1972
|
+
if (!Number.isFinite(frequency) || frequency >= 1) return this;
|
|
1973
|
+
const freq = Math.max(0, frequency);
|
|
1974
|
+
const pMiss = this.pAt(0);
|
|
1975
|
+
const pHit = 1 - pMiss;
|
|
1976
|
+
const newMissMass = pMiss + (1 - freq) * pHit;
|
|
1977
|
+
const newMap = /* @__PURE__ */ new Map();
|
|
1978
|
+
newMap.set(0, {
|
|
1979
|
+
p: newMissMass,
|
|
1980
|
+
count: { [MISS_NONE_OUTCOME]: newMissMass },
|
|
1981
|
+
attr: {}
|
|
1982
|
+
});
|
|
1983
|
+
for (const [damage, bin] of this.map) {
|
|
1984
|
+
if (damage <= 0) continue;
|
|
1985
|
+
newMap.set(damage, _PMF.scaleBin(bin, freq));
|
|
1986
|
+
}
|
|
1987
|
+
return new _PMF(
|
|
1988
|
+
newMap,
|
|
1989
|
+
this.epsilon,
|
|
1990
|
+
false,
|
|
1991
|
+
`freq(${this.identifier},${freq})`
|
|
1992
|
+
);
|
|
1993
|
+
}
|
|
1913
1994
|
scaleMass(factor) {
|
|
1914
1995
|
if (factor === 1) return this;
|
|
1915
1996
|
const scaledMap = /* @__PURE__ */ new Map();
|
|
@@ -2137,6 +2218,39 @@ var _PMF = class _PMF {
|
|
|
2137
2218
|
pAt(x) {
|
|
2138
2219
|
return this.map.get(x)?.p ?? 0;
|
|
2139
2220
|
}
|
|
2221
|
+
/**
|
|
2222
|
+
* P(any damage) — the mass on all non-zero outcomes, i.e. `1 - P(0)`.
|
|
2223
|
+
* Assumes a miss is encoded as the damage-0 bin (the convention used across
|
|
2224
|
+
* attack/save PMFs). The dual of {@link missProbability}.
|
|
2225
|
+
*/
|
|
2226
|
+
hitProbability() {
|
|
2227
|
+
return 1 - this.pAt(0);
|
|
2228
|
+
}
|
|
2229
|
+
/** P(no damage) — the mass at damage 0. The dual of {@link hitProbability}. */
|
|
2230
|
+
missProbability() {
|
|
2231
|
+
return this.pAt(0);
|
|
2232
|
+
}
|
|
2233
|
+
/**
|
|
2234
|
+
* Coarsen the distribution into at most `maxBuckets` contiguous, equal-width
|
|
2235
|
+
* damage buckets, aggregating probability mass (and `count`/`attr`
|
|
2236
|
+
* provenance) into each bucket's start value. Returns this PMF unchanged when
|
|
2237
|
+
* its integer support already fits within `maxBuckets`.
|
|
2238
|
+
*
|
|
2239
|
+
* This is a lossy display/downsampling transform (bucket start replaces the
|
|
2240
|
+
* exact damage value) — use it for charting wide distributions, not for DPR
|
|
2241
|
+
* math.
|
|
2242
|
+
*/
|
|
2243
|
+
rebin(maxBuckets) {
|
|
2244
|
+
if (!(maxBuckets > 0)) return this;
|
|
2245
|
+
const support = this.support();
|
|
2246
|
+
if (support.length === 0) return this;
|
|
2247
|
+
const min = support[0];
|
|
2248
|
+
const max = support[support.length - 1];
|
|
2249
|
+
const range = max - min;
|
|
2250
|
+
if (range + 1 <= maxBuckets) return this;
|
|
2251
|
+
const binSize = Math.ceil((range + 1) / maxBuckets);
|
|
2252
|
+
return this.mapDamage((d2) => min + Math.floor((d2 - min) / binSize) * binSize);
|
|
2253
|
+
}
|
|
2140
2254
|
/** Dense integer support from min..max (inclusive).
|
|
2141
2255
|
* Useful for showing empty bars in charts.
|
|
2142
2256
|
*/
|
|
@@ -2212,6 +2326,56 @@ var _PMF = class _PMF {
|
|
|
2212
2326
|
}
|
|
2213
2327
|
return false;
|
|
2214
2328
|
}
|
|
2329
|
+
/**
|
|
2330
|
+
* Split each damage value's probability mass across outcome labels, returning
|
|
2331
|
+
* per-label maps of `damage value → probability mass attributable to that
|
|
2332
|
+
* label`. Summing over labels at a given value recovers that value's `p`.
|
|
2333
|
+
*
|
|
2334
|
+
* Damage-bearing bins are split by `attr` weight (the share of damage each
|
|
2335
|
+
* outcome contributed); the clean-miss bin at 0 is split by `count` weight
|
|
2336
|
+
* (there is no damage to attribute). Attribution is computed on demand via
|
|
2337
|
+
* {@link withAttribution} when absent, so builder-generated PMFs work too.
|
|
2338
|
+
*
|
|
2339
|
+
* This is the provenance core of the stacked damage-attribution chart — the
|
|
2340
|
+
* caller only maps these series into its rendering format (colors, binning,
|
|
2341
|
+
* axis labels).
|
|
2342
|
+
*/
|
|
2343
|
+
attributionByValue() {
|
|
2344
|
+
const src = this.hasAttribution() ? this : this.withAttribution();
|
|
2345
|
+
const result = /* @__PURE__ */ new Map();
|
|
2346
|
+
const add = (label, damage, mass) => {
|
|
2347
|
+
if (!(mass > 0)) return;
|
|
2348
|
+
let series = result.get(label);
|
|
2349
|
+
if (!series) {
|
|
2350
|
+
series = /* @__PURE__ */ new Map();
|
|
2351
|
+
result.set(label, series);
|
|
2352
|
+
}
|
|
2353
|
+
series.set(damage, (series.get(damage) ?? 0) + mass);
|
|
2354
|
+
};
|
|
2355
|
+
for (const [damage, bin] of src.map) {
|
|
2356
|
+
const p = bin.p || 0;
|
|
2357
|
+
if (p <= 0) continue;
|
|
2358
|
+
const isMissBin = damage === 0;
|
|
2359
|
+
if (isMissBin) {
|
|
2360
|
+
let totalCount = 0;
|
|
2361
|
+
for (const k in bin.count) totalCount += bin.count[k] || 0;
|
|
2362
|
+
if (totalCount > 0) {
|
|
2363
|
+
const c = bin.count[MISS_NONE_OUTCOME] || 0;
|
|
2364
|
+
add(MISS_NONE_OUTCOME, damage, c / totalCount * p);
|
|
2365
|
+
}
|
|
2366
|
+
continue;
|
|
2367
|
+
}
|
|
2368
|
+
let totalAttr = 0;
|
|
2369
|
+
if (bin.attr) for (const k in bin.attr) totalAttr += bin.attr[k] || 0;
|
|
2370
|
+
if (bin.attr && totalAttr > 0) {
|
|
2371
|
+
for (const k in bin.attr) {
|
|
2372
|
+
if (k === MISS_NONE_OUTCOME) continue;
|
|
2373
|
+
add(k, damage, (bin.attr[k] || 0) / totalAttr * p);
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
return result;
|
|
2378
|
+
}
|
|
2215
2379
|
tailProbGE(t) {
|
|
2216
2380
|
let s = 0;
|
|
2217
2381
|
for (const [x, bin] of this) {
|
|
@@ -3930,6 +4094,15 @@ var RollBuilder = class _RollBuilder {
|
|
|
3930
4094
|
half() {
|
|
3931
4095
|
return new HalfRollBuilder(this);
|
|
3932
4096
|
}
|
|
4097
|
+
/**
|
|
4098
|
+
* Scale this roll's result by `numerator / denominator`, rounding each outcome.
|
|
4099
|
+
* A general, composable form of {@link half} — used to model damage-type resistance
|
|
4100
|
+
* (`scaleResult(1, 2)` → `(expr) // 2`) and vulnerability (`scaleResult(2)` → `2 * (expr)`).
|
|
4101
|
+
* Compose several of these (and plain rolls) into one payload with {@link sumRolls}.
|
|
4102
|
+
*/
|
|
4103
|
+
scaleResult(numerator, denominator = 1, rounding = "floor") {
|
|
4104
|
+
return new ScaleRollBuilder(this, numerator, denominator, rounding);
|
|
4105
|
+
}
|
|
3933
4106
|
// Create a "max of N rolls" version of this roll for crit damage with keep operations
|
|
3934
4107
|
maxOf(count) {
|
|
3935
4108
|
return new MaxOfRollBuilder(this, count);
|
|
@@ -3984,6 +4157,50 @@ var HalfRollBuilder = class _HalfRollBuilder extends RollBuilder {
|
|
|
3984
4157
|
return new _HalfRollBuilder(this.innerRoll.copy());
|
|
3985
4158
|
}
|
|
3986
4159
|
};
|
|
4160
|
+
var ScaleRollBuilder = class _ScaleRollBuilder extends RollBuilder {
|
|
4161
|
+
constructor(innerRoll, numerator, denominator = 1, rounding = "floor") {
|
|
4162
|
+
super(0);
|
|
4163
|
+
this.innerRoll = innerRoll;
|
|
4164
|
+
this.numerator = numerator;
|
|
4165
|
+
this.denominator = denominator;
|
|
4166
|
+
this.rounding = rounding;
|
|
4167
|
+
}
|
|
4168
|
+
hasHiddenState() {
|
|
4169
|
+
return this.innerRoll.hasHiddenState();
|
|
4170
|
+
}
|
|
4171
|
+
get lastConfig() {
|
|
4172
|
+
return this.innerRoll.lastConfig;
|
|
4173
|
+
}
|
|
4174
|
+
getSubRollConfigs() {
|
|
4175
|
+
return this.innerRoll.getSubRollConfigs();
|
|
4176
|
+
}
|
|
4177
|
+
toExpression() {
|
|
4178
|
+
const inner = this.innerRoll.toExpression();
|
|
4179
|
+
if (this.denominator === 1) return `${this.numerator} * (${inner})`;
|
|
4180
|
+
if (this.numerator === 1) return `(${inner}) // ${this.denominator}`;
|
|
4181
|
+
return `(${inner}) * ${this.numerator} // ${this.denominator}`;
|
|
4182
|
+
}
|
|
4183
|
+
toAST() {
|
|
4184
|
+
return {
|
|
4185
|
+
type: "scale",
|
|
4186
|
+
numerator: this.numerator,
|
|
4187
|
+
denominator: this.denominator,
|
|
4188
|
+
rounding: this.rounding,
|
|
4189
|
+
child: this.innerRoll.toAST()
|
|
4190
|
+
};
|
|
4191
|
+
}
|
|
4192
|
+
toPMF(eps = 0) {
|
|
4193
|
+
return pmfFromRollBuilder(this, eps);
|
|
4194
|
+
}
|
|
4195
|
+
copy() {
|
|
4196
|
+
return new _ScaleRollBuilder(
|
|
4197
|
+
this.innerRoll.copy(),
|
|
4198
|
+
this.numerator,
|
|
4199
|
+
this.denominator,
|
|
4200
|
+
this.rounding
|
|
4201
|
+
);
|
|
4202
|
+
}
|
|
4203
|
+
};
|
|
3987
4204
|
var MaxOfRollBuilder = class _MaxOfRollBuilder extends RollBuilder {
|
|
3988
4205
|
constructor(innerRoll, count, diceCount, diceSides) {
|
|
3989
4206
|
super(0);
|
|
@@ -4282,6 +4499,49 @@ var PooledRollBuilder = class _PooledRollBuilder extends RollBuilder {
|
|
|
4282
4499
|
return new _PooledRollBuilder(sumNode, newExpr);
|
|
4283
4500
|
}
|
|
4284
4501
|
};
|
|
4502
|
+
var CompositeSumRollBuilder = class _CompositeSumRollBuilder extends RollBuilder {
|
|
4503
|
+
constructor(parts) {
|
|
4504
|
+
super(0);
|
|
4505
|
+
this.parts = parts;
|
|
4506
|
+
}
|
|
4507
|
+
hasHiddenState() {
|
|
4508
|
+
return true;
|
|
4509
|
+
}
|
|
4510
|
+
getSubRollConfigs() {
|
|
4511
|
+
return [];
|
|
4512
|
+
}
|
|
4513
|
+
toAST() {
|
|
4514
|
+
return {
|
|
4515
|
+
type: "add",
|
|
4516
|
+
children: this.parts.map((p) => ({
|
|
4517
|
+
node: p.toAST(),
|
|
4518
|
+
sign: 1
|
|
4519
|
+
}))
|
|
4520
|
+
};
|
|
4521
|
+
}
|
|
4522
|
+
toExpression() {
|
|
4523
|
+
const exprs = this.parts.map((p) => p.toExpression()).filter((e) => e && e !== "0");
|
|
4524
|
+
if (exprs.length === 0) return "0";
|
|
4525
|
+
let result = exprs[0];
|
|
4526
|
+
for (let i = 1; i < exprs.length; i++) {
|
|
4527
|
+
const e = exprs[i];
|
|
4528
|
+
result += e.startsWith("-") ? ` - ${e.substring(1)}` : ` + ${e}`;
|
|
4529
|
+
}
|
|
4530
|
+
return result.replace(/\+ -/g, "-");
|
|
4531
|
+
}
|
|
4532
|
+
toPMF(eps = 0) {
|
|
4533
|
+
return pmfFromRollBuilder(this, eps);
|
|
4534
|
+
}
|
|
4535
|
+
copy() {
|
|
4536
|
+
return new _CompositeSumRollBuilder(this.parts.map((p) => p.copy()));
|
|
4537
|
+
}
|
|
4538
|
+
};
|
|
4539
|
+
function sumRolls(parts) {
|
|
4540
|
+
const meaningful = parts.filter((p) => p !== void 0);
|
|
4541
|
+
if (meaningful.length === 0) return new RollBuilder(0);
|
|
4542
|
+
if (meaningful.length === 1) return meaningful[0];
|
|
4543
|
+
return new CompositeSumRollBuilder(meaningful);
|
|
4544
|
+
}
|
|
4285
4545
|
|
|
4286
4546
|
// src/builder/factory.ts
|
|
4287
4547
|
var rollFn = (count, sidesOrDie, modifier) => {
|
|
@@ -4512,6 +4772,11 @@ function resolve(node, eps = defaultEps) {
|
|
|
4512
4772
|
if (count === 1) return childPMF;
|
|
4513
4773
|
return computeMaxOfPMF(childPMF, count, eps);
|
|
4514
4774
|
}
|
|
4775
|
+
case "scale": {
|
|
4776
|
+
const childPMF = resolve(node.child, eps);
|
|
4777
|
+
const denom = node.denominator === 0 ? 1 : node.denominator;
|
|
4778
|
+
return childPMF.scaleDamage(node.numerator / denom, node.rounding);
|
|
4779
|
+
}
|
|
4515
4780
|
}
|
|
4516
4781
|
})();
|
|
4517
4782
|
builderPMFCache.set(cacheKey, result);
|
|
@@ -4583,6 +4848,7 @@ function findDie(node) {
|
|
|
4583
4848
|
case "d20Roll":
|
|
4584
4849
|
case "half":
|
|
4585
4850
|
case "maxOf":
|
|
4851
|
+
case "scale":
|
|
4586
4852
|
return findDie(node.child);
|
|
4587
4853
|
case "keep":
|
|
4588
4854
|
return findDie(node.child.child);
|
|
@@ -4767,6 +5033,8 @@ function getASTSignature(node) {
|
|
|
4767
5033
|
return `half{ch:${getASTSignature(node.child)}}`;
|
|
4768
5034
|
case "maxOf":
|
|
4769
5035
|
return `maxOf{c:${node.count},ch:${getASTSignature(node.child)}}`;
|
|
5036
|
+
case "scale":
|
|
5037
|
+
return `scale{n:${node.numerator},d:${node.denominator},r:${node.rounding},ch:${getASTSignature(node.child)}}`;
|
|
4770
5038
|
case "add": {
|
|
4771
5039
|
let constantValue = 0;
|
|
4772
5040
|
const otherChildrenSigs = [];
|
|
@@ -5214,6 +5482,7 @@ exports.ParsedRollBuilder = ParsedRollBuilder;
|
|
|
5214
5482
|
exports.PooledRollBuilder = PooledRollBuilder;
|
|
5215
5483
|
exports.RollBuilder = RollBuilder;
|
|
5216
5484
|
exports.SaveBuilder = SaveBuilder;
|
|
5485
|
+
exports.ScaleRollBuilder = ScaleRollBuilder;
|
|
5217
5486
|
exports.builderPMFCache = builderPMFCache;
|
|
5218
5487
|
exports.d = d;
|
|
5219
5488
|
exports.d10 = d10;
|
|
@@ -5227,5 +5496,6 @@ exports.defaultConfig = defaultConfig;
|
|
|
5227
5496
|
exports.flat = flat;
|
|
5228
5497
|
exports.hd20 = hd20;
|
|
5229
5498
|
exports.roll = roll;
|
|
5499
|
+
exports.sumRolls = sumRolls;
|
|
5230
5500
|
//# sourceMappingURL=index.cjs.map
|
|
5231
5501
|
//# sourceMappingURL=index.cjs.map
|