@yipe/dice 0.10.0 → 0.12.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/README.md +278 -8
- package/dist/builder/ac.d.ts +44 -1
- package/dist/builder/ac.d.ts.map +1 -1
- package/dist/builder/arguments.d.ts +6 -0
- package/dist/builder/arguments.d.ts.map +1 -0
- package/dist/builder/ast.d.ts +36 -7
- package/dist/builder/ast.d.ts.map +1 -1
- package/dist/builder/attack.d.ts +104 -2
- package/dist/builder/attack.d.ts.map +1 -1
- package/dist/builder/dc.d.ts +13 -0
- package/dist/builder/dc.d.ts.map +1 -1
- package/dist/builder/example.d.ts +11 -15
- package/dist/builder/example.d.ts.map +1 -1
- package/dist/builder/expression.d.ts +72 -0
- package/dist/builder/expression.d.ts.map +1 -0
- package/dist/builder/factory.d.ts +2 -3
- package/dist/builder/factory.d.ts.map +1 -1
- package/dist/builder/index.cjs +3844 -1273
- package/dist/builder/index.cjs.map +1 -1
- package/dist/builder/index.js +3836 -1274
- package/dist/builder/index.js.map +1 -1
- package/dist/builder/nodes.d.ts +8 -1
- package/dist/builder/nodes.d.ts.map +1 -1
- package/dist/builder/prob.d.ts +9 -0
- package/dist/builder/prob.d.ts.map +1 -1
- package/dist/builder/roll.d.ts +157 -19
- package/dist/builder/roll.d.ts.map +1 -1
- package/dist/builder/save.d.ts +6 -1
- package/dist/builder/save.d.ts.map +1 -1
- package/dist/builder/types.d.ts +19 -0
- package/dist/builder/types.d.ts.map +1 -1
- package/dist/common/bounce.d.ts +63 -5
- package/dist/common/bounce.d.ts.map +1 -1
- package/dist/common/lru-cache.d.ts +29 -1
- package/dist/common/lru-cache.d.ts.map +1 -1
- package/dist/common/types.d.ts +33 -0
- package/dist/common/types.d.ts.map +1 -1
- package/dist/index.cjs +1288 -388
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1285 -389
- package/dist/index.js.map +1 -1
- package/dist/parser/dice.d.ts +52 -16
- package/dist/parser/dice.d.ts.map +1 -1
- package/dist/parser/parser.d.ts +1 -5
- package/dist/parser/parser.d.ts.map +1 -1
- package/dist/parser/rollType.d.ts +4 -4
- package/dist/parser/scaleDice.d.ts +14 -0
- package/dist/parser/scaleDice.d.ts.map +1 -0
- package/dist/pmf/mixture.d.ts +17 -3
- package/dist/pmf/mixture.d.ts.map +1 -1
- package/dist/pmf/pmf.d.ts +130 -33
- package/dist/pmf/pmf.d.ts.map +1 -1
- package/dist/pmf/query.d.ts +25 -12
- package/dist/pmf/query.d.ts.map +1 -1
- package/dist/turn/effects.d.ts +114 -0
- package/dist/turn/effects.d.ts.map +1 -0
- package/dist/turn/index.d.ts +3 -1
- package/dist/turn/index.d.ts.map +1 -1
- package/dist/turn/plan.d.ts +115 -12
- package/dist/turn/plan.d.ts.map +1 -1
- package/dist/turn/state.d.ts +22 -8
- package/dist/turn/state.d.ts.map +1 -1
- package/dist/turn/turn.d.ts +162 -26
- package/dist/turn/turn.d.ts.map +1 -1
- package/dist/turn/types.d.ts +180 -21
- package/dist/turn/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,4 +1,25 @@
|
|
|
1
1
|
// src/common/bounce.ts
|
|
2
|
+
function faceWeights(faces, minimum = 0, reroll = 0) {
|
|
3
|
+
const f = Math.max(0, Math.floor(faces));
|
|
4
|
+
if (f <= 0) return [];
|
|
5
|
+
let weights = new Array(f).fill(1 / f);
|
|
6
|
+
const r = Math.max(0, Math.min(Math.floor(reroll), f));
|
|
7
|
+
if (r > 0) {
|
|
8
|
+
const rerollMass = r / f;
|
|
9
|
+
const uniformReroll = rerollMass / f;
|
|
10
|
+
weights = weights.map((_, i) => (i < r ? 0 : 1 / f) + uniformReroll);
|
|
11
|
+
}
|
|
12
|
+
const minV = Math.max(0, Math.floor(minimum));
|
|
13
|
+
if (minV > 1) {
|
|
14
|
+
const collapsed = new Array(f).fill(0);
|
|
15
|
+
for (let v = 1; v <= f; v++) {
|
|
16
|
+
const target = Math.min(f, Math.max(v, minV));
|
|
17
|
+
collapsed[target - 1] += weights[v - 1];
|
|
18
|
+
}
|
|
19
|
+
weights = collapsed;
|
|
20
|
+
}
|
|
21
|
+
return weights;
|
|
22
|
+
}
|
|
2
23
|
function binom(n, k) {
|
|
3
24
|
if (k < 0 || k > n) return 0;
|
|
4
25
|
let result = 1;
|
|
@@ -33,19 +54,161 @@ function calculateBounceOdds(diceCount, dieFaces, options) {
|
|
|
33
54
|
const minimumDieRoll = options?.minimumDieRoll ?? 0;
|
|
34
55
|
const rerollDamageDice = options?.rerollDamageDice ?? 0;
|
|
35
56
|
const pMatchFirst = pMatch(diceCount, dieFaces, minimumDieRoll);
|
|
36
|
-
const
|
|
37
|
-
if (
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
57
|
+
const rerollLimit = Math.min(Math.floor(rerollDamageDice), diceCount);
|
|
58
|
+
if (rerollLimit <= 0 || pMatchFirst >= 1) return pMatchFirst;
|
|
59
|
+
const collapsed = minimumDieRoll >= 2;
|
|
60
|
+
const lightCount = collapsed ? dieFaces - minimumDieRoll : dieFaces;
|
|
61
|
+
const heavyWeight = collapsed ? minimumDieRoll / dieFaces : 0;
|
|
62
|
+
const pMissAfter = (rerolled, keptLight, heavyKept) => pAllDistinct(rerolled, dieFaces, lightCount - keptLight, heavyKept ? 0 : heavyWeight);
|
|
63
|
+
let missLightOnly = 1;
|
|
64
|
+
let missWithHeavy = 1;
|
|
65
|
+
for (let rerolled = 1; rerolled <= rerollLimit; rerolled++) {
|
|
66
|
+
const allLightKept = pMissAfter(rerolled, diceCount - rerolled, false);
|
|
67
|
+
missLightOnly = Math.min(missLightOnly, allLightKept);
|
|
68
|
+
missWithHeavy = Math.min(missWithHeavy, allLightKept);
|
|
69
|
+
if (rerolled < diceCount) {
|
|
70
|
+
missWithHeavy = Math.min(missWithHeavy, pMissAfter(rerolled, diceCount - 1 - rerolled, true));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const pLightOnly = pAllDistinct(diceCount, dieFaces, lightCount, 0);
|
|
74
|
+
const pWithHeavy = diceCount * heavyWeight * pAllDistinct(diceCount - 1, dieFaces, lightCount, 0);
|
|
75
|
+
return Math.min(
|
|
45
76
|
1,
|
|
46
|
-
|
|
77
|
+
pMatchFirst + pLightOnly * (1 - missLightOnly) + pWithHeavy * (1 - missWithHeavy)
|
|
47
78
|
);
|
|
48
|
-
|
|
79
|
+
}
|
|
80
|
+
function diceSumDistribution(dice, weights) {
|
|
81
|
+
let dist = /* @__PURE__ */ new Map([[0, 1]]);
|
|
82
|
+
for (let die = 0; die < dice; die++) {
|
|
83
|
+
const next = /* @__PURE__ */ new Map();
|
|
84
|
+
for (const [sum, mass] of dist) {
|
|
85
|
+
for (let face = 1; face <= weights.length; face++) {
|
|
86
|
+
const w = weights[face - 1] ?? 0;
|
|
87
|
+
if (w <= 0) continue;
|
|
88
|
+
const s = sum + face;
|
|
89
|
+
next.set(s, (next.get(s) ?? 0) + mass * w);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
dist = next;
|
|
93
|
+
}
|
|
94
|
+
return dist;
|
|
95
|
+
}
|
|
96
|
+
function sumAllDistinctDistribution(dice, weights) {
|
|
97
|
+
const faceCount = weights.length;
|
|
98
|
+
let dp = /* @__PURE__ */ new Map([[0, /* @__PURE__ */ new Map([[0, 1]])]]);
|
|
99
|
+
for (let face = 1; face <= faceCount; face++) {
|
|
100
|
+
const w = weights[face - 1] ?? 0;
|
|
101
|
+
const next = /* @__PURE__ */ new Map();
|
|
102
|
+
for (const [count, sumMap] of dp) next.set(count, new Map(sumMap));
|
|
103
|
+
if (w > 0) {
|
|
104
|
+
for (const [count, sumMap] of dp) {
|
|
105
|
+
const nextCount = count + 1;
|
|
106
|
+
if (nextCount > dice) continue;
|
|
107
|
+
const target = next.get(nextCount) ?? /* @__PURE__ */ new Map();
|
|
108
|
+
for (const [sum, mass] of sumMap) {
|
|
109
|
+
const s = sum + face;
|
|
110
|
+
target.set(s, (target.get(s) ?? 0) + mass * w);
|
|
111
|
+
}
|
|
112
|
+
next.set(nextCount, target);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
dp = next;
|
|
116
|
+
}
|
|
117
|
+
let factorial = 1;
|
|
118
|
+
for (let i = 2; i <= dice; i++) factorial *= i;
|
|
119
|
+
const chosen = dp.get(dice) ?? /* @__PURE__ */ new Map();
|
|
120
|
+
const result = /* @__PURE__ */ new Map();
|
|
121
|
+
for (const [sum, mass] of chosen) result.set(sum, mass * factorial);
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
function jointSumAndMatch(dice, weights) {
|
|
125
|
+
if (dice <= 1) return /* @__PURE__ */ new Map();
|
|
126
|
+
const total = diceSumDistribution(dice, weights);
|
|
127
|
+
const distinct = sumAllDistinctDistribution(dice, weights);
|
|
128
|
+
const rest = diceSumDistribution(dice - 2, weights);
|
|
129
|
+
const matchable = /* @__PURE__ */ new Set();
|
|
130
|
+
weights.forEach((weight, index) => {
|
|
131
|
+
if (weight <= 0) return;
|
|
132
|
+
for (const sum of rest.keys()) matchable.add(2 * (index + 1) + sum);
|
|
133
|
+
});
|
|
134
|
+
const result = /* @__PURE__ */ new Map();
|
|
135
|
+
for (const [sum, mass] of total) {
|
|
136
|
+
if (!matchable.has(sum)) continue;
|
|
137
|
+
const matchMass = Math.max(0, mass - (distinct.get(sum) ?? 0));
|
|
138
|
+
if (matchMass > 0) result.set(sum, matchMass);
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
function explodingPoolMkDistribution(pMax, count, budget) {
|
|
143
|
+
const binomial = (n, p) => {
|
|
144
|
+
const result = new Array(n + 1).fill(0);
|
|
145
|
+
result[0] = 1;
|
|
146
|
+
for (let trial = 0; trial < n; trial++) {
|
|
147
|
+
const next = new Array(n + 1).fill(0);
|
|
148
|
+
for (let successes = 0; successes <= trial; successes++) {
|
|
149
|
+
const mass = result[successes];
|
|
150
|
+
if (mass <= 0) continue;
|
|
151
|
+
next[successes] += mass * (1 - p);
|
|
152
|
+
next[successes + 1] += mass * p;
|
|
153
|
+
}
|
|
154
|
+
for (let i = 0; i <= n; i++) result[i] = next[i];
|
|
155
|
+
}
|
|
156
|
+
return result;
|
|
157
|
+
};
|
|
158
|
+
const memo = /* @__PURE__ */ new Map();
|
|
159
|
+
const f = (pending, remainingBudget) => {
|
|
160
|
+
if (pending === 0) return /* @__PURE__ */ new Map([["0,0", 1]]);
|
|
161
|
+
if (remainingBudget === 0) {
|
|
162
|
+
const binom2 = binomial(pending, pMax);
|
|
163
|
+
const result2 = /* @__PURE__ */ new Map();
|
|
164
|
+
for (let m = 0; m <= pending; m++) {
|
|
165
|
+
const mass = binom2[m];
|
|
166
|
+
if (mass > 0) result2.set(`${m},${pending - m}`, mass);
|
|
167
|
+
}
|
|
168
|
+
return result2;
|
|
169
|
+
}
|
|
170
|
+
const key = `${pending},${remainingBudget}`;
|
|
171
|
+
const cached = memo.get(key);
|
|
172
|
+
if (cached) return cached;
|
|
173
|
+
const result = /* @__PURE__ */ new Map();
|
|
174
|
+
const accumulate = (mk, mass) => {
|
|
175
|
+
result.set(mk, (result.get(mk) ?? 0) + mass);
|
|
176
|
+
};
|
|
177
|
+
for (const [mk, mass] of f(pending, remainingBudget - 1)) {
|
|
178
|
+
const [m, k] = mk.split(",").map(Number);
|
|
179
|
+
accumulate(`${m + 1},${k}`, mass * pMax);
|
|
180
|
+
}
|
|
181
|
+
for (const [mk, mass] of f(pending - 1, remainingBudget)) {
|
|
182
|
+
const [m, k] = mk.split(",").map(Number);
|
|
183
|
+
accumulate(`${m},${k + 1}`, mass * (1 - pMax));
|
|
184
|
+
}
|
|
185
|
+
memo.set(key, result);
|
|
186
|
+
return result;
|
|
187
|
+
};
|
|
188
|
+
return f(count, budget);
|
|
189
|
+
}
|
|
190
|
+
function allDistinctProbability(count, weights) {
|
|
191
|
+
const symmetric = new Array(count + 1).fill(0);
|
|
192
|
+
symmetric[0] = 1;
|
|
193
|
+
for (const weight of weights) {
|
|
194
|
+
if (weight <= 0) continue;
|
|
195
|
+
for (let chosen = count; chosen >= 1; chosen--) symmetric[chosen] += symmetric[chosen - 1] * weight;
|
|
196
|
+
}
|
|
197
|
+
let factorial = 1;
|
|
198
|
+
for (let i = 2; i <= count; i++) factorial *= i;
|
|
199
|
+
return factorial * symmetric[count];
|
|
200
|
+
}
|
|
201
|
+
function explodingPoolMatchProbability(weights, count, budget) {
|
|
202
|
+
if (count <= 0 || weights.length === 0) return 0;
|
|
203
|
+
const pMax = weights[weights.length - 1];
|
|
204
|
+
const nonMax = pMax < 1 ? weights.slice(0, -1).map((weight) => weight / (1 - pMax)) : [];
|
|
205
|
+
let pMatchTotal = 0;
|
|
206
|
+
for (const [mk, weight] of explodingPoolMkDistribution(pMax, count, budget)) {
|
|
207
|
+
const [m, k] = mk.split(",").map(Number);
|
|
208
|
+
if (m >= 2) pMatchTotal += weight;
|
|
209
|
+
else if (k >= 2) pMatchTotal += weight * (1 - allDistinctProbability(k, nonMax));
|
|
210
|
+
}
|
|
211
|
+
return Math.min(1, Math.max(0, pMatchTotal));
|
|
49
212
|
}
|
|
50
213
|
|
|
51
214
|
// src/common/errors.ts
|
|
@@ -60,12 +223,40 @@ var DiceParseError = class _DiceParseError extends Error {
|
|
|
60
223
|
};
|
|
61
224
|
|
|
62
225
|
// src/common/lru-cache.ts
|
|
226
|
+
var cachingEnabled = true;
|
|
227
|
+
var cacheGeneration = 0;
|
|
228
|
+
function setCachingEnabled(enabled) {
|
|
229
|
+
cachingEnabled = enabled;
|
|
230
|
+
if (!enabled) cacheGeneration++;
|
|
231
|
+
}
|
|
232
|
+
function getCachingEnabled() {
|
|
233
|
+
return cachingEnabled;
|
|
234
|
+
}
|
|
63
235
|
var LRUCache = class {
|
|
64
|
-
|
|
236
|
+
/**
|
|
237
|
+
* @param maxSize Entries kept before the least recently used is evicted. A capacity of 0 or
|
|
238
|
+
* less (or NaN) makes the cache store nothing.
|
|
239
|
+
*/
|
|
240
|
+
constructor(maxSize = 1e3, options = {}) {
|
|
65
241
|
this.maxSize = maxSize;
|
|
66
242
|
this.cache = /* @__PURE__ */ new Map();
|
|
243
|
+
this.generation = cacheGeneration;
|
|
244
|
+
this.onInsert = options.onInsert;
|
|
245
|
+
this.followsCachingToggle = options.followsCachingToggle ?? false;
|
|
246
|
+
}
|
|
247
|
+
/** Drops the entries of a toggle-following cache when caching was turned off since last use. */
|
|
248
|
+
sync() {
|
|
249
|
+
if (this.followsCachingToggle && this.generation !== cacheGeneration) {
|
|
250
|
+
this.cache.clear();
|
|
251
|
+
this.generation = cacheGeneration;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
get storing() {
|
|
255
|
+
this.sync();
|
|
256
|
+
return this.maxSize > 0 && (cachingEnabled || !this.followsCachingToggle);
|
|
67
257
|
}
|
|
68
258
|
get(key) {
|
|
259
|
+
if (!this.storing) return void 0;
|
|
69
260
|
const value = this.cache.get(key);
|
|
70
261
|
if (value === void 0) return void 0;
|
|
71
262
|
this.cache.delete(key);
|
|
@@ -76,11 +267,13 @@ var LRUCache = class {
|
|
|
76
267
|
this.cache.delete(key);
|
|
77
268
|
}
|
|
78
269
|
set(key, value) {
|
|
79
|
-
if (this.
|
|
270
|
+
if (!this.storing) return this;
|
|
271
|
+
this.onInsert?.(value);
|
|
272
|
+
this.cache.delete(key);
|
|
273
|
+
if (this.cache.size >= this.maxSize) {
|
|
80
274
|
const oldestKey = this.cache.keys().next().value;
|
|
81
275
|
this.cache.delete(oldestKey);
|
|
82
276
|
}
|
|
83
|
-
this.cache.delete(key);
|
|
84
277
|
this.cache.set(key, value);
|
|
85
278
|
return this;
|
|
86
279
|
}
|
|
@@ -88,15 +281,18 @@ var LRUCache = class {
|
|
|
88
281
|
this.cache.clear();
|
|
89
282
|
}
|
|
90
283
|
get size() {
|
|
284
|
+
this.sync();
|
|
91
285
|
return this.cache.size;
|
|
92
286
|
}
|
|
93
287
|
has(key) {
|
|
94
|
-
return this.cache.has(key);
|
|
288
|
+
return this.storing && this.cache.has(key);
|
|
95
289
|
}
|
|
96
290
|
keys() {
|
|
291
|
+
this.sync();
|
|
97
292
|
return this.cache.keys();
|
|
98
293
|
}
|
|
99
294
|
values() {
|
|
295
|
+
this.sync();
|
|
100
296
|
return this.cache.values();
|
|
101
297
|
}
|
|
102
298
|
};
|
|
@@ -365,33 +561,14 @@ var _DiceQuery = class _DiceQuery {
|
|
|
365
561
|
return probabilitySum;
|
|
366
562
|
}
|
|
367
563
|
/**
|
|
368
|
-
* Returns damage values at specific percentiles
|
|
564
|
+
* Returns damage values at specific percentiles: for each p, the smallest damage x with
|
|
565
|
+
* P(total ≤ x) ≥ p, exact at CDF boundaries (see {@link PMF.quantile}).
|
|
369
566
|
*
|
|
370
567
|
* Example: `query.percentiles([0.25, 0.5, 0.75])` → [8, 12, 18]
|
|
371
568
|
* Use case: "What are my 25th, 50th, and 75th percentile damage values?"
|
|
372
569
|
*/
|
|
373
570
|
percentiles(percentileValues) {
|
|
374
|
-
|
|
375
|
-
if (sortedDamageValues.length === 0) return percentileValues.map(() => 0);
|
|
376
|
-
const cumulativeProbabilities = [];
|
|
377
|
-
let runningProbabilitySum = 0;
|
|
378
|
-
for (const damageValue of sortedDamageValues) {
|
|
379
|
-
runningProbabilitySum += this.combined.map.get(damageValue).p;
|
|
380
|
-
cumulativeProbabilities.push(runningProbabilitySum);
|
|
381
|
-
}
|
|
382
|
-
return percentileValues.map((targetPercentile) => {
|
|
383
|
-
let leftBound = 0;
|
|
384
|
-
let rightBound = cumulativeProbabilities.length - 1;
|
|
385
|
-
while (leftBound <= rightBound) {
|
|
386
|
-
const middleIndex = Math.floor((leftBound + rightBound) / 2);
|
|
387
|
-
if (cumulativeProbabilities[middleIndex] >= targetPercentile) {
|
|
388
|
-
rightBound = middleIndex - 1;
|
|
389
|
-
} else {
|
|
390
|
-
leftBound = middleIndex + 1;
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
return leftBound < sortedDamageValues.length ? sortedDamageValues[leftBound] : sortedDamageValues[sortedDamageValues.length - 1];
|
|
394
|
-
});
|
|
571
|
+
return percentileValues.map((p) => this.combined.quantile(p));
|
|
395
572
|
}
|
|
396
573
|
/**
|
|
397
574
|
* Returns the minimum possible damage.
|
|
@@ -474,16 +651,14 @@ var _DiceQuery = class _DiceQuery {
|
|
|
474
651
|
* Note:
|
|
475
652
|
*
|
|
476
653
|
* - You have to pass in an array of labels to avoid double-counting if you are
|
|
477
|
-
* using multiple labels. You cannot just add them.
|
|
654
|
+
* using multiple labels. You cannot just add them. A label listed twice counts once.
|
|
478
655
|
*/
|
|
479
656
|
probAtLeastOne(labels) {
|
|
480
|
-
|
|
481
|
-
labels = [labels];
|
|
482
|
-
}
|
|
657
|
+
const distinctLabels = typeof labels === "string" ? [labels] : [...new Set(labels)];
|
|
483
658
|
let productOfNonOccurrence = 1;
|
|
484
659
|
for (let diceIndex = 0; diceIndex < this.singles.length; diceIndex++) {
|
|
485
660
|
let combinedProbability = 0;
|
|
486
|
-
for (const label of
|
|
661
|
+
for (const label of distinctLabels) {
|
|
487
662
|
combinedProbability += this.singleProb(diceIndex, label);
|
|
488
663
|
}
|
|
489
664
|
if (combinedProbability < 0) combinedProbability = 0;
|
|
@@ -539,10 +714,11 @@ var _DiceQuery = class _DiceQuery {
|
|
|
539
714
|
* - "How likely am I to get exactly 2 successes out of 3 attacks?"
|
|
540
715
|
* - "What's the probability that exactly half my attacks succeed?"
|
|
541
716
|
*
|
|
542
|
-
* Note: For arrays, an attack counts as a "success" if it has any of the specified labels
|
|
543
|
-
*
|
|
717
|
+
* Note: For arrays, an attack counts as a "success" if it has any of the specified labels,
|
|
718
|
+
* as in probAtLeastK and probAtMostK. A negative k has probability 0.
|
|
544
719
|
*/
|
|
545
720
|
probExactlyK(labels, k) {
|
|
721
|
+
if (k < 0) return 0;
|
|
546
722
|
if (typeof labels === "string") {
|
|
547
723
|
const probabilityArray = this.computeBinomialProbabilities(labels, k);
|
|
548
724
|
return probabilityArray[k];
|
|
@@ -600,16 +776,24 @@ var _DiceQuery = class _DiceQuery {
|
|
|
600
776
|
* - "How much damage do I expect from successful attacks?"
|
|
601
777
|
* - "What's the damage contribution from critical hits specifically?"
|
|
602
778
|
* - "How much damage comes from miss effects (like save-for-half spells)?"
|
|
779
|
+
*
|
|
780
|
+
* A single whose mass is not 1 is normalized exactly as {@link mean} normalizes it, so the
|
|
781
|
+
* contributions of labels that cover every outcome add up to `mean()`. A label listed twice
|
|
782
|
+
* counts once.
|
|
603
783
|
*/
|
|
604
784
|
expectedDamageFrom(labels) {
|
|
605
|
-
const wanted = Array.isArray(labels) ? labels : [labels];
|
|
785
|
+
const wanted = Array.isArray(labels) ? [...new Set(labels)] : [labels];
|
|
606
786
|
let total = 0;
|
|
607
787
|
for (const single of this.singles) {
|
|
788
|
+
const mass = single.mass();
|
|
789
|
+
if (mass <= 0) continue;
|
|
790
|
+
let contribution = 0;
|
|
608
791
|
for (const [dmg, bin] of single) {
|
|
609
792
|
let p = 0;
|
|
610
793
|
for (const label of wanted) p += bin.count[label] ?? 0;
|
|
611
|
-
|
|
794
|
+
contribution += dmg * p;
|
|
612
795
|
}
|
|
796
|
+
total += Math.abs(mass - 1) <= this._eps ? contribution : contribution / mass;
|
|
613
797
|
}
|
|
614
798
|
return total;
|
|
615
799
|
}
|
|
@@ -776,10 +960,15 @@ var _DiceQuery = class _DiceQuery {
|
|
|
776
960
|
return this.probAtLeastOne(labels);
|
|
777
961
|
}
|
|
778
962
|
/**
|
|
779
|
-
* Returns the probability
|
|
963
|
+
* Returns the probability that at least one attack misses (either kind of miss:
|
|
964
|
+
* `missNone` or `missDamage`). For a single attack this is its miss chance.
|
|
965
|
+
*
|
|
966
|
+
* Example: `query.missChance()` → 0.45
|
|
967
|
+
* Use case: "What's the chance I miss at least once this turn?"
|
|
780
968
|
*
|
|
781
|
-
*
|
|
782
|
-
*
|
|
969
|
+
* For the chance that every attack misses, use
|
|
970
|
+
* `probExactlyK(["missNone", "missDamage"], n)` with n attacks, or
|
|
971
|
+
* `probAtMostK(["hit", "crit"], 0)`.
|
|
783
972
|
*/
|
|
784
973
|
missChance() {
|
|
785
974
|
return this.probabilityOf(["missDamage", "missNone"]);
|
|
@@ -918,16 +1107,13 @@ var _DiceQuery = class _DiceQuery {
|
|
|
918
1107
|
data: ccdfData
|
|
919
1108
|
};
|
|
920
1109
|
}
|
|
921
|
-
/*
|
|
922
|
-
Statistics snapshot of the query.
|
|
923
|
-
*/
|
|
924
1110
|
/** Probability of doing strictly more than threshold damage (default >0). */
|
|
925
1111
|
probDamageGreaterThan(threshold = 0) {
|
|
926
1112
|
let acc = 0;
|
|
927
1113
|
for (const [x, bin] of this.combined.map) if (x > threshold) acc += bin.p;
|
|
928
1114
|
return acc;
|
|
929
1115
|
}
|
|
930
|
-
/** All outcome keys
|
|
1116
|
+
/** All outcome keys present in the PMF, ordered by `order` when given. */
|
|
931
1117
|
outcomeKeys(order) {
|
|
932
1118
|
const found = /* @__PURE__ */ new Set();
|
|
933
1119
|
for (const [, bin] of this.combined.map) {
|
|
@@ -1095,17 +1281,10 @@ var _DiceQuery = class _DiceQuery {
|
|
|
1095
1281
|
const averageDPR = this.mean();
|
|
1096
1282
|
let damageChance = 0;
|
|
1097
1283
|
for (const [x, bin] of this.combined.map) if (x > 0) damageChance += bin.p;
|
|
1098
|
-
const { support, data } = this.toCDFSeries(false);
|
|
1099
|
-
const quantile = (p) => {
|
|
1100
|
-
if (support.length === 0) return 0;
|
|
1101
|
-
for (let i = 0; i < support.length; i++)
|
|
1102
|
-
if (data[i] >= p) return support[i];
|
|
1103
|
-
return support[support.length - 1];
|
|
1104
|
-
};
|
|
1105
1284
|
const percentiles = {
|
|
1106
|
-
p25: quantile(0.25),
|
|
1107
|
-
p50: quantile(0.5),
|
|
1108
|
-
p75: quantile(0.75)
|
|
1285
|
+
p25: this.combined.quantile(0.25),
|
|
1286
|
+
p50: this.combined.quantile(0.5),
|
|
1287
|
+
p75: this.combined.quantile(0.75)
|
|
1109
1288
|
};
|
|
1110
1289
|
return { averageDPR, damageChance, percentiles, outcomes: outcomeMap };
|
|
1111
1290
|
}
|
|
@@ -1185,20 +1364,23 @@ var _DiceQuery = class _DiceQuery {
|
|
|
1185
1364
|
return new _DiceQuery([this.combined.mapDamage(damageTransformFunction)]);
|
|
1186
1365
|
}
|
|
1187
1366
|
/**
|
|
1188
|
-
* Returns a new DiceQuery with damage values scaled by
|
|
1189
|
-
* Convenient wrapper around mapDamage for multiplicative scaling
|
|
1367
|
+
* Returns a new DiceQuery with damage values scaled by `factor / denominator`.
|
|
1368
|
+
* Convenient wrapper around mapDamage for multiplicative scaling; see
|
|
1369
|
+
* {@link PMF.scaleDamage} for the rounding rules.
|
|
1190
1370
|
*
|
|
1191
|
-
* @param factor Scaling factor for damage values
|
|
1371
|
+
* @param factor Scaling factor for damage values (the numerator of a ratio)
|
|
1192
1372
|
* @param rounding Rounding method: "floor" (default), "round", or "ceil"
|
|
1373
|
+
* @param denominator Divisor; integer `factor` and `denominator` round exactly
|
|
1193
1374
|
* @returns New DiceQuery with scaled damage values
|
|
1194
1375
|
*
|
|
1195
1376
|
* @example
|
|
1196
1377
|
* const baseAttack = parse("2d6 + 3");
|
|
1197
1378
|
* const doubled = baseAttack.scaleDamage(2); // Double damage
|
|
1198
1379
|
* const halfDamage = baseAttack.scaleDamage(0.5, "round"); // Half damage, rounded
|
|
1380
|
+
* const sevenTenths = baseAttack.scaleDamage(7, "floor", 10); // floor(7v/10), exactly
|
|
1199
1381
|
*/
|
|
1200
|
-
scaleDamage(factor, rounding = "floor") {
|
|
1201
|
-
return new _DiceQuery([this.combined.scaleDamage(factor, rounding)]);
|
|
1382
|
+
scaleDamage(factor, rounding = "floor", denominator = 1) {
|
|
1383
|
+
return new _DiceQuery([this.combined.scaleDamage(factor, rounding, denominator)]);
|
|
1202
1384
|
}
|
|
1203
1385
|
/**
|
|
1204
1386
|
* Returns a new DiceQuery combining this query with another via convolution.
|
|
@@ -1279,14 +1461,69 @@ _DiceQuery.DEFAULT_OUTCOMES = [
|
|
|
1279
1461
|
"missNone"
|
|
1280
1462
|
];
|
|
1281
1463
|
var DiceQuery = _DiceQuery;
|
|
1282
|
-
|
|
1464
|
+
|
|
1465
|
+
// src/pmf/pmf.ts
|
|
1466
|
+
var sharedPMFCacheOptions = {
|
|
1467
|
+
onInsert: (pmf) => pmf.freeze(),
|
|
1468
|
+
followsCachingToggle: true
|
|
1469
|
+
};
|
|
1470
|
+
var pmfCache = new LRUCache(1e3, sharedPMFCacheOptions);
|
|
1471
|
+
var QUANTILE_RELATIVE_SLACK = 1e-12;
|
|
1472
|
+
var FrozenBinMap = class extends Map {
|
|
1473
|
+
constructor(source) {
|
|
1474
|
+
super();
|
|
1475
|
+
for (const [value, bin] of source) super.set(value, bin);
|
|
1476
|
+
}
|
|
1477
|
+
set(value) {
|
|
1478
|
+
throw new TypeError(`Cannot set damage value ${value}: this PMF is frozen (shared through a cache)`);
|
|
1479
|
+
}
|
|
1480
|
+
delete(value) {
|
|
1481
|
+
throw new TypeError(`Cannot delete damage value ${value}: this PMF is frozen (shared through a cache)`);
|
|
1482
|
+
}
|
|
1483
|
+
clear() {
|
|
1484
|
+
throw new TypeError("Cannot clear the map: this PMF is frozen (shared through a cache)");
|
|
1485
|
+
}
|
|
1486
|
+
};
|
|
1283
1487
|
var _PMF = class _PMF {
|
|
1488
|
+
/**
|
|
1489
|
+
* @param map Damage value → bin. Typed read-only: a PMF is immutable once built. A PMF returned
|
|
1490
|
+
* from the library's caches is frozen: its bins, its map and the `map` property itself reject
|
|
1491
|
+
* writes (see {@link freeze}).
|
|
1492
|
+
*/
|
|
1284
1493
|
constructor(map = /* @__PURE__ */ new Map(), epsilon = EPS, normalized = false, identifier = `anon#${_PMF.__anonIdCounter++}`, _preservedProvenance = true) {
|
|
1285
1494
|
this.map = map;
|
|
1286
1495
|
this.epsilon = epsilon;
|
|
1287
1496
|
this.normalized = normalized;
|
|
1288
1497
|
this.identifier = identifier;
|
|
1289
1498
|
this._preservedProvenance = _preservedProvenance;
|
|
1499
|
+
this._frozen = false;
|
|
1500
|
+
}
|
|
1501
|
+
/**
|
|
1502
|
+
* A PMF cache that freezes every stored PMF and follows `setCachingEnabled`. The library's
|
|
1503
|
+
* own caches are built with this.
|
|
1504
|
+
*/
|
|
1505
|
+
static createCache(maxSize) {
|
|
1506
|
+
return new LRUCache(maxSize, sharedPMFCacheOptions);
|
|
1507
|
+
}
|
|
1508
|
+
/**
|
|
1509
|
+
* Freezes this PMF so it can be shared through a cache. Every bin is deep-frozen, including its
|
|
1510
|
+
* `count` and `attr` maps, so writing to one throws a `TypeError`. The map is replaced by a copy
|
|
1511
|
+
* whose `set`/`delete`/`clear` throw a `TypeError` (the map this PMF was built with stays the
|
|
1512
|
+
* caller's), and the `map` property becomes read-only, so assigning it throws too.
|
|
1513
|
+
* Returns this PMF.
|
|
1514
|
+
*/
|
|
1515
|
+
freeze() {
|
|
1516
|
+
if (this._frozen) return this;
|
|
1517
|
+
const map = new FrozenBinMap(this.map);
|
|
1518
|
+
for (const bin of map.values()) {
|
|
1519
|
+
Object.freeze(bin.count);
|
|
1520
|
+
if (bin.attr) Object.freeze(bin.attr);
|
|
1521
|
+
Object.freeze(bin);
|
|
1522
|
+
}
|
|
1523
|
+
Object.freeze(map);
|
|
1524
|
+
Object.defineProperty(this, "map", { value: map, writable: false, enumerable: true, configurable: false });
|
|
1525
|
+
this._frozen = true;
|
|
1526
|
+
return this;
|
|
1290
1527
|
}
|
|
1291
1528
|
static empty(epsilon = EPS, identifier = "empty") {
|
|
1292
1529
|
return new _PMF(/* @__PURE__ */ new Map(), epsilon, false, identifier);
|
|
@@ -1547,18 +1784,10 @@ var _PMF = class _PMF {
|
|
|
1547
1784
|
}
|
|
1548
1785
|
return acc ?? _PMF.emptyMass();
|
|
1549
1786
|
}
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
setPreservedProvenance(preserved) {
|
|
1555
|
-
if (!this._preservedProvenance && preserved) {
|
|
1556
|
-
throw new Error(
|
|
1557
|
-
"Preserved provenance is already set to false, cannot fix that"
|
|
1558
|
-
);
|
|
1559
|
-
}
|
|
1560
|
-
this._preservedProvenance = preserved;
|
|
1561
|
-
}
|
|
1787
|
+
/**
|
|
1788
|
+
* False for a PMF produced by {@link power}, which folds independent attacks into one
|
|
1789
|
+
* distribution and cannot say which attack produced which label.
|
|
1790
|
+
*/
|
|
1562
1791
|
preservedProvenance() {
|
|
1563
1792
|
return this._preservedProvenance;
|
|
1564
1793
|
}
|
|
@@ -1569,14 +1798,12 @@ var _PMF = class _PMF {
|
|
|
1569
1798
|
return `${key}@${eps}|${this.fingerprint()}`;
|
|
1570
1799
|
}
|
|
1571
1800
|
/**
|
|
1572
|
-
*
|
|
1573
|
-
*
|
|
1574
|
-
*
|
|
1575
|
-
*
|
|
1576
|
-
*
|
|
1577
|
-
*
|
|
1578
|
-
* This is ONLY SAFE if you are trying to calculate masses.
|
|
1579
|
-
* If you want to query any atLeast probabilities, you should use the DiceQuery class instead without power().
|
|
1801
|
+
* Convolves this PMF with itself `n` times, by exponentiation by squaring. `n` must be a
|
|
1802
|
+
* positive integer.
|
|
1803
|
+
*
|
|
1804
|
+
* NOTE: this folds `n` independent, identical attacks into one PMF, so it loses data
|
|
1805
|
+
* provenance. It is only safe when computing masses; for `atLeast`-style queries use a
|
|
1806
|
+
* `DiceQuery` instead of `power()`.
|
|
1580
1807
|
*/
|
|
1581
1808
|
power(n, eps = this.epsilon) {
|
|
1582
1809
|
if (!Number.isInteger(n) || n <= 0) {
|
|
@@ -1585,10 +1812,8 @@ var _PMF = class _PMF {
|
|
|
1585
1812
|
if (n === 1) return this;
|
|
1586
1813
|
const epsilon = eps ?? this.epsilon;
|
|
1587
1814
|
const key = this.getPowerCacheKey(n, epsilon);
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
if (cached) return cached;
|
|
1591
|
-
}
|
|
1815
|
+
const cached = pmfCache.get(key);
|
|
1816
|
+
if (cached) return cached;
|
|
1592
1817
|
let base = this.normalized ? this : this.normalize();
|
|
1593
1818
|
let result = base;
|
|
1594
1819
|
let exp = n - 1;
|
|
@@ -1601,11 +1826,15 @@ var _PMF = class _PMF {
|
|
|
1601
1826
|
base = base.convolve(base, epsilon);
|
|
1602
1827
|
}
|
|
1603
1828
|
}
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1829
|
+
const folded = new _PMF(
|
|
1830
|
+
result.map,
|
|
1831
|
+
result.epsilon,
|
|
1832
|
+
result.normalized,
|
|
1833
|
+
result.identifier,
|
|
1834
|
+
false
|
|
1835
|
+
);
|
|
1836
|
+
pmfCache.set(key, folded);
|
|
1837
|
+
return folded;
|
|
1609
1838
|
}
|
|
1610
1839
|
/*
|
|
1611
1840
|
* Helper for chaining multiple identical attacks
|
|
@@ -1724,8 +1953,11 @@ var _PMF = class _PMF {
|
|
|
1724
1953
|
return this._max;
|
|
1725
1954
|
}
|
|
1726
1955
|
/**
|
|
1727
|
-
* Returns the expected (mean) damage value.
|
|
1728
|
-
*
|
|
1956
|
+
* Returns the expected (mean) damage value, Σ v·p. Cached.
|
|
1957
|
+
*
|
|
1958
|
+
* On a PMF whose mass is not 1 this is the partial expectation (the slice's contribution to
|
|
1959
|
+
* the whole distribution's mean), not the conditional mean; `DiceQuery.mean()` divides by the
|
|
1960
|
+
* mass instead.
|
|
1729
1961
|
*/
|
|
1730
1962
|
mean() {
|
|
1731
1963
|
if (this._mean === void 0) {
|
|
@@ -1738,18 +1970,25 @@ var _PMF = class _PMF {
|
|
|
1738
1970
|
return this._mean;
|
|
1739
1971
|
}
|
|
1740
1972
|
/**
|
|
1741
|
-
* Returns the variance of the damage distribution.
|
|
1742
|
-
*
|
|
1973
|
+
* Returns the variance of the damage distribution. Cached.
|
|
1974
|
+
*
|
|
1975
|
+
* On a PMF whose mass is not 1 (a slice such as {@link filterOutcome}'s output) this is the
|
|
1976
|
+
* variance of the distribution conditioned on the slice, Σ (v − μ)²·p / m with μ = Σ v·p / m
|
|
1977
|
+
* and m = {@link mass}, the same quantity `DiceQuery.variance()` reports for that PMF. Note
|
|
1978
|
+
* that {@link mean} is not conditioned: it stays the partial expectation Σ v·p, so slices add
|
|
1979
|
+
* up. Within 1e-12 of unit mass (or at zero or negative mass) no conditioning is applied.
|
|
1743
1980
|
*/
|
|
1744
1981
|
variance() {
|
|
1745
1982
|
if (this._variance === void 0) {
|
|
1746
|
-
const
|
|
1983
|
+
const mass = this.mass();
|
|
1984
|
+
const conditional = mass > 0 && Math.abs(mass - 1) > EPS;
|
|
1985
|
+
const meanValue = conditional ? this.mean() / mass : this.mean();
|
|
1747
1986
|
let varianceSum = 0;
|
|
1748
1987
|
for (const [damageValue, probabilityBin] of this.map) {
|
|
1749
1988
|
const deviationFromMean = damageValue - meanValue;
|
|
1750
1989
|
varianceSum += deviationFromMean * deviationFromMean * probabilityBin.p;
|
|
1751
1990
|
}
|
|
1752
|
-
this._variance = varianceSum;
|
|
1991
|
+
this._variance = conditional ? varianceSum / mass : varianceSum;
|
|
1753
1992
|
}
|
|
1754
1993
|
return this._variance;
|
|
1755
1994
|
}
|
|
@@ -1809,11 +2048,9 @@ var _PMF = class _PMF {
|
|
|
1809
2048
|
return this.addScaled(other, 1);
|
|
1810
2049
|
}
|
|
1811
2050
|
/**
|
|
1812
|
-
* Returns a new PMF with
|
|
1813
|
-
*
|
|
1814
|
-
*
|
|
1815
|
-
* able to model "I can probably have this opportunity attack 40% of rounds"
|
|
1816
|
-
* Example: `pmf.addScaled(critBranch, 0.05)` → PMF including 5% crit outcomes
|
|
2051
|
+
* Returns a new PMF with `branch` added to this one, scaled by `probability` before merging —
|
|
2052
|
+
* the primitive for conditional effects. Example: `pmf.addScaled(critBranch, 0.05)` → a PMF
|
|
2053
|
+
* including a 5% crit slice.
|
|
1817
2054
|
*/
|
|
1818
2055
|
addScaled(branch, probability) {
|
|
1819
2056
|
if (probability === 0) return this;
|
|
@@ -1836,40 +2073,42 @@ var _PMF = class _PMF {
|
|
|
1836
2073
|
);
|
|
1837
2074
|
}
|
|
1838
2075
|
/**
|
|
1839
|
-
*
|
|
1840
|
-
*
|
|
1841
|
-
* sub-one AoE target fraction.
|
|
2076
|
+
* Bernoulli thinning: the effect this PMF describes happens with probability
|
|
2077
|
+
* `frequency` and otherwise deals nothing — a conditional attack, an on-hit
|
|
2078
|
+
* rider, or a sub-one AoE target fraction. The result is
|
|
2079
|
+
* `frequency · X + (1 − frequency) · δ0`.
|
|
1842
2080
|
*
|
|
1843
|
-
* Every
|
|
1844
|
-
* per-label `count
|
|
1845
|
-
*
|
|
1846
|
-
*
|
|
2081
|
+
* Every bin (negative damage included) keeps `frequency` of its probability
|
|
2082
|
+
* mass, per-label `count` and per-label `attr`; the zero bin keeps its labels
|
|
2083
|
+
* at that share too. The freed mass, `(1 − frequency) · mass()`, is added to
|
|
2084
|
+
* the damage-0 bin under the canonical `missNone` outcome, so the total mass
|
|
2085
|
+
* is unchanged, also for a slice whose mass is not 1.
|
|
1847
2086
|
*
|
|
1848
2087
|
* Unlike a bare {@link scaleMass} or {@link mapDamage}, this keeps damage
|
|
1849
2088
|
* attribution (`attr`) intact, so a frequency-scaled PMF still renders
|
|
1850
2089
|
* correctly in the damage-attribution charts.
|
|
1851
2090
|
*
|
|
1852
2091
|
* `frequency >= 1` (or non-finite) returns this PMF unchanged; `frequency <= 0`
|
|
1853
|
-
*
|
|
1854
|
-
* encoded at damage value 0.
|
|
2092
|
+
* leaves only the damage-0 bin, holding all of the mass as `missNone`.
|
|
1855
2093
|
*
|
|
1856
2094
|
* @param frequency Probability in [0, 1] that the effect occurs.
|
|
1857
2095
|
*/
|
|
1858
2096
|
applyHitFrequency(frequency) {
|
|
1859
2097
|
if (!Number.isFinite(frequency) || frequency >= 1) return this;
|
|
1860
2098
|
const freq = Math.max(0, frequency);
|
|
1861
|
-
const
|
|
1862
|
-
const pHit = 1 - pMiss;
|
|
1863
|
-
const newMissMass = pMiss + (1 - freq) * pHit;
|
|
2099
|
+
const freedMass = (1 - freq) * this.mass();
|
|
1864
2100
|
const newMap = /* @__PURE__ */ new Map();
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
}
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
2101
|
+
if (freq > 0) {
|
|
2102
|
+
for (const [damage, bin] of this.map) {
|
|
2103
|
+
newMap.set(damage, _PMF.scaleBin(bin, freq));
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
if (freedMass > 0) {
|
|
2107
|
+
_PMF.mergeInto(newMap, 0, {
|
|
2108
|
+
p: freedMass,
|
|
2109
|
+
count: { [MISS_NONE_OUTCOME]: freedMass },
|
|
2110
|
+
attr: {}
|
|
2111
|
+
});
|
|
1873
2112
|
}
|
|
1874
2113
|
return new _PMF(
|
|
1875
2114
|
newMap,
|
|
@@ -1878,6 +2117,71 @@ var _PMF = class _PMF {
|
|
|
1878
2117
|
`freq(${this.identifier},${freq})`
|
|
1879
2118
|
);
|
|
1880
2119
|
}
|
|
2120
|
+
/**
|
|
2121
|
+
* Splits this PMF into two complementary PMFs by an arbitrary per-damage-value factor in
|
|
2122
|
+
* `[0, 1]` — bin `d`'s mass, `count`, and `attr` split `factor(d)` / `1 - factor(d)` between the
|
|
2123
|
+
* two results (via the same proportional scaling {@link applyHitFrequency} uses, `scaleBin`), so
|
|
2124
|
+
* `a.add(b)` recovers this PMF exactly and both halves stay chart-attributable. Unlike
|
|
2125
|
+
* {@link applyHitFrequency}, mass is NOT redistributed to a miss bin at 0 — each bin stays at its
|
|
2126
|
+
* own damage value in whichever half it lands in. `factor` outside `[0, 1]` is clamped.
|
|
2127
|
+
*
|
|
2128
|
+
* Built for `dice-match` trigger slicing: splitting a hit/crit sub-PMF into "matched" and
|
|
2129
|
+
* "did not match" halves by the exact per-damage-value match probability.
|
|
2130
|
+
*/
|
|
2131
|
+
splitByFactor(factor) {
|
|
2132
|
+
const a = /* @__PURE__ */ new Map();
|
|
2133
|
+
const b = /* @__PURE__ */ new Map();
|
|
2134
|
+
for (const [damage, bin] of this.map) {
|
|
2135
|
+
const f = Math.min(1, Math.max(0, factor(damage)));
|
|
2136
|
+
if (f > 0) a.set(damage, _PMF.scaleBin(bin, f));
|
|
2137
|
+
if (f < 1) b.set(damage, _PMF.scaleBin(bin, 1 - f));
|
|
2138
|
+
}
|
|
2139
|
+
return [
|
|
2140
|
+
new _PMF(a, this.epsilon, false, `split+(${this.identifier})`),
|
|
2141
|
+
new _PMF(b, this.epsilon, false, `split-(${this.identifier})`)
|
|
2142
|
+
];
|
|
2143
|
+
}
|
|
2144
|
+
/**
|
|
2145
|
+
* Max of two i.i.d. copies of this PMF's distribution: normalize, square
|
|
2146
|
+
* the CDF, then restore the original mass. The engine's damage-reroll
|
|
2147
|
+
* substitution (`onFirstHit(keepBestDamage())`) applies this to a landing
|
|
2148
|
+
* attack's base payload slice — "roll it again, keep the better total".
|
|
2149
|
+
*
|
|
2150
|
+
* PRESERVES outcome labels and attribution. A max, unlike a sum, is
|
|
2151
|
+
* literally one of the two draws: the value that wins was drawn from this
|
|
2152
|
+
* same distribution, so its `count`/`attr` composition is unchanged in
|
|
2153
|
+
* *proportion* — only that bin's total mass is recomputed (via the
|
|
2154
|
+
* squared-CDF step) and every label is rescaled by the same factor. This
|
|
2155
|
+
* is why {@link power}'s documented provenance loss does not apply here:
|
|
2156
|
+
* for a sum, one output value arises from many `(x1, x2)` pairs with
|
|
2157
|
+
* different attribution mixes, so provenance is genuinely ambiguous; for a
|
|
2158
|
+
* max, there is exactly one realized draw per bin. A naive rebuild through
|
|
2159
|
+
* {@link fromMap} would silently discard both `count` and `attr`.
|
|
2160
|
+
*
|
|
2161
|
+
* Works on a non-unit-mass slice (e.g. {@link filterOutcome}'s output):
|
|
2162
|
+
* the CDF is squared on the NORMALIZED distribution, then the result is
|
|
2163
|
+
* rescaled back to this PMF's original total mass, not to 1.
|
|
2164
|
+
*/
|
|
2165
|
+
maxOfTwo() {
|
|
2166
|
+
const totalMass = this.mass();
|
|
2167
|
+
if (totalMass <= 0) return this;
|
|
2168
|
+
const resultMap = /* @__PURE__ */ new Map();
|
|
2169
|
+
let cdf = 0;
|
|
2170
|
+
for (const damage of this.support()) {
|
|
2171
|
+
const bin = this.map.get(damage);
|
|
2172
|
+
const normalizedP = bin.p / totalMass;
|
|
2173
|
+
if (normalizedP <= 0) continue;
|
|
2174
|
+
const prevCdf = cdf;
|
|
2175
|
+
cdf += normalizedP;
|
|
2176
|
+
resultMap.set(damage, _PMF.scaleBin(bin, cdf + prevCdf));
|
|
2177
|
+
}
|
|
2178
|
+
return new _PMF(
|
|
2179
|
+
resultMap,
|
|
2180
|
+
this.epsilon,
|
|
2181
|
+
this.normalized,
|
|
2182
|
+
`maxOfTwo(${this.identifier})`
|
|
2183
|
+
);
|
|
2184
|
+
}
|
|
1881
2185
|
scaleMass(factor) {
|
|
1882
2186
|
if (factor === 1) return this;
|
|
1883
2187
|
const scaledMap = /* @__PURE__ */ new Map();
|
|
@@ -1908,9 +2212,37 @@ var _PMF = class _PMF {
|
|
|
1908
2212
|
`map(${this.identifier})`
|
|
1909
2213
|
);
|
|
1910
2214
|
}
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
2215
|
+
/**
|
|
2216
|
+
* Multiplies every damage value by `factor / denominator` and rounds: `floor`
|
|
2217
|
+
* (toward −∞, the default), `ceil` (toward +∞) or `round` (to nearest, halves
|
|
2218
|
+
* toward +∞). Values that land on the same result merge, labels included.
|
|
2219
|
+
*
|
|
2220
|
+
* With an integer `factor` and `denominator` the rounding is exact: `v·factor`
|
|
2221
|
+
* is an exact integer, and one division of two integers below 2^53 lands on
|
|
2222
|
+
* the correct side of every integer. Pass a ratio that way, e.g.
|
|
2223
|
+
* `scaleDamage(9, "ceil", 7)`; `scaleDamage(9 / 7, "ceil")` rounds the factor
|
|
2224
|
+
* to a double first, so 21·(9/7) = 27.000000000000004 would round up to 28.
|
|
2225
|
+
*/
|
|
2226
|
+
scaleDamage(factor, rounding = "floor", denominator = 1) {
|
|
2227
|
+
if (!Number.isFinite(factor)) {
|
|
2228
|
+
throw new RangeError(`scaleDamage() factor must be finite, got ${factor}`);
|
|
2229
|
+
}
|
|
2230
|
+
if (!Number.isFinite(denominator) || denominator === 0) {
|
|
2231
|
+
throw new RangeError(`scaleDamage() denominator must be finite and non-zero, got ${denominator}`);
|
|
2232
|
+
}
|
|
2233
|
+
const exact = Number.isInteger(factor) && Number.isInteger(denominator);
|
|
2234
|
+
return this.mapDamage((damageValue) => {
|
|
2235
|
+
if (exact && Number.isInteger(damageValue)) {
|
|
2236
|
+
const numerator = damageValue * factor;
|
|
2237
|
+
if (rounding === "round") {
|
|
2238
|
+
return Math.floor((2 * numerator + denominator) / (2 * denominator));
|
|
2239
|
+
}
|
|
2240
|
+
const quotient = numerator / denominator;
|
|
2241
|
+
return rounding === "ceil" ? Math.ceil(quotient) : Math.floor(quotient);
|
|
2242
|
+
}
|
|
2243
|
+
const scaled = damageValue * factor / denominator;
|
|
2244
|
+
return rounding === "round" ? Math.round(scaled) : rounding === "ceil" ? Math.ceil(scaled) : Math.floor(scaled);
|
|
2245
|
+
});
|
|
1914
2246
|
}
|
|
1915
2247
|
getPMFCombineCacheKey(p1, p2, eps, raw) {
|
|
1916
2248
|
const [id1, id2] = [p1.identifier, p2.identifier].sort();
|
|
@@ -1926,16 +2258,16 @@ var _PMF = class _PMF {
|
|
|
1926
2258
|
* because a PMF is immutable once constructed -- this avoids re-deriving the key on every
|
|
1927
2259
|
* convolve()/power() call (including cache hits). Bin order is sorted by damage value (and
|
|
1928
2260
|
* label keys sorted within each bin) so two equal-content PMFs built via different code paths
|
|
1929
|
-
* fingerprint identically regardless of Map insertion order.
|
|
2261
|
+
* fingerprint identically regardless of Map insertion order. Label keys are JSON-encoded, so a
|
|
2262
|
+
* label containing the separators cannot make two different bins read the same.
|
|
1930
2263
|
*/
|
|
1931
2264
|
fingerprint() {
|
|
1932
2265
|
if (this._fingerprint === void 0) {
|
|
2266
|
+
const labels = (m) => m === void 0 ? "" : Object.keys(m).sort().map((k) => `${JSON.stringify(k)}:${m[k]}`).join(",");
|
|
1933
2267
|
const bins = [...this.map.entries()].sort((a, b) => a[0] - b[0]);
|
|
1934
2268
|
const parts = [];
|
|
1935
2269
|
for (const [damageValue, bin] of bins) {
|
|
1936
|
-
|
|
1937
|
-
const attrStr = bin.attr ? Object.keys(bin.attr).sort().map((k) => `${k}:${bin.attr[k]}`).join(",") : "";
|
|
1938
|
-
parts.push(`${damageValue}:${bin.p}[${countStr}]{${attrStr}}`);
|
|
2270
|
+
parts.push(`${damageValue}:${bin.p}[${labels(bin.count)}]{${labels(bin.attr)}}`);
|
|
1939
2271
|
}
|
|
1940
2272
|
this._fingerprint = `${this.normalized ? 1 : 0}|${parts.join(";")}`;
|
|
1941
2273
|
}
|
|
@@ -1948,15 +2280,21 @@ var _PMF = class _PMF {
|
|
|
1948
2280
|
const B0 = norm(other);
|
|
1949
2281
|
const [A, B] = A0.identifier <= B0.identifier ? [A0, B0] : [B0, A0];
|
|
1950
2282
|
const cacheKey = this.getPMFCombineCacheKey(A, B, epsilon, raw);
|
|
1951
|
-
const cached = pmfCache
|
|
2283
|
+
const cached = pmfCache.get(cacheKey);
|
|
1952
2284
|
if (cached) return cached;
|
|
2285
|
+
const labelEntries = (m) => m === void 0 ? void 0 : Object.entries(m);
|
|
2286
|
+
const bEntries = [...B.map].map(([bVal, bBin]) => ({
|
|
2287
|
+
bVal,
|
|
2288
|
+
bp: bBin.p,
|
|
2289
|
+
count: labelEntries(bBin.count),
|
|
2290
|
+
attr: labelEntries(bBin.attr)
|
|
2291
|
+
}));
|
|
1953
2292
|
const combinedMap = /* @__PURE__ */ new Map();
|
|
1954
2293
|
for (const [aVal, aBin] of A.map) {
|
|
1955
2294
|
const ap = aBin.p;
|
|
1956
|
-
const aCount = aBin.count;
|
|
1957
|
-
const aAttr = aBin.attr;
|
|
1958
|
-
for (const
|
|
1959
|
-
const bp = bBin.p;
|
|
2295
|
+
const aCount = labelEntries(aBin.count);
|
|
2296
|
+
const aAttr = labelEntries(aBin.attr);
|
|
2297
|
+
for (const { bVal, bp, count: bCount, attr: bAttr } of bEntries) {
|
|
1960
2298
|
const dmg = aVal + bVal;
|
|
1961
2299
|
let dest = combinedMap.get(dmg);
|
|
1962
2300
|
if (dest === void 0) {
|
|
@@ -1965,20 +2303,16 @@ var _PMF = class _PMF {
|
|
|
1965
2303
|
}
|
|
1966
2304
|
dest.p += ap * bp;
|
|
1967
2305
|
const dc = dest.count;
|
|
1968
|
-
for (const k
|
|
1969
|
-
for (const k
|
|
1970
|
-
|
|
1971
|
-
if (aAttr || bBin.attr) {
|
|
2306
|
+
for (const [k, v] of aCount) dc[k] = (dc[k] || 0) + v * bp;
|
|
2307
|
+
for (const [k, v] of bCount) dc[k] = (dc[k] || 0) + v * ap;
|
|
2308
|
+
if (aAttr || bAttr) {
|
|
1972
2309
|
let da = dest.attr;
|
|
1973
2310
|
if (da === void 0) {
|
|
1974
2311
|
da = {};
|
|
1975
2312
|
dest.attr = da;
|
|
1976
2313
|
}
|
|
1977
|
-
if (aAttr)
|
|
1978
|
-
|
|
1979
|
-
if (bBin.attr)
|
|
1980
|
-
for (const k in bBin.attr)
|
|
1981
|
-
da[k] = (da[k] || 0) + bBin.attr[k] * ap;
|
|
2314
|
+
if (aAttr) for (const [k, v] of aAttr) da[k] = (da[k] || 0) + v * bp;
|
|
2315
|
+
if (bAttr) for (const [k, v] of bAttr) da[k] = (da[k] || 0) + v * ap;
|
|
1982
2316
|
}
|
|
1983
2317
|
}
|
|
1984
2318
|
}
|
|
@@ -1995,10 +2329,10 @@ var _PMF = class _PMF {
|
|
|
1995
2329
|
}
|
|
1996
2330
|
if (!raw && mGot !== 0 && Math.abs(result.mass() - 1) > epsilon)
|
|
1997
2331
|
result = result.normalize();
|
|
1998
|
-
pmfCache
|
|
2332
|
+
pmfCache.set(cacheKey, result);
|
|
1999
2333
|
return result;
|
|
2000
2334
|
}
|
|
2001
|
-
//
|
|
2335
|
+
// Convolve without renormalizing (raw = true), for callers that combine raw counts.
|
|
2002
2336
|
combineRaw(other, eps) {
|
|
2003
2337
|
return this.convolve(other, eps, true);
|
|
2004
2338
|
}
|
|
@@ -2166,18 +2500,61 @@ var _PMF = class _PMF {
|
|
|
2166
2500
|
for (const [val, bin] of this.map) if (val <= x) acc += bin.p;
|
|
2167
2501
|
return acc;
|
|
2168
2502
|
}
|
|
2169
|
-
/**
|
|
2503
|
+
/**
|
|
2504
|
+
* Quantile / inverse CDF: the smallest support value x with P(X ≤ x) ≥ p·mass().
|
|
2505
|
+
* `p <= 0` gives the smallest support value; `p >= 1` or NaN gives the largest.
|
|
2506
|
+
*
|
|
2507
|
+
* A float running sum can land an ulp short of a CDF that is exactly p (a d20's CDF(10) sums
|
|
2508
|
+
* to 0.49999999999999994), so the comparison allows a relative slack of a few ulps per bin.
|
|
2509
|
+
* Below the median it compares the CDF summed from the low end; above it, the mass strictly
|
|
2510
|
+
* above x summed from the high end. A sum of non-negative terms is accurate relative to its
|
|
2511
|
+
* own size, so the tail that decides the answer is never swamped by the rest of the mass.
|
|
2512
|
+
*/
|
|
2170
2513
|
quantile(p) {
|
|
2171
|
-
|
|
2172
|
-
const
|
|
2173
|
-
if (
|
|
2174
|
-
const
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2514
|
+
const { values, below, above } = this.cumulative();
|
|
2515
|
+
const n = values.length;
|
|
2516
|
+
if (n === 0) return 0;
|
|
2517
|
+
const total = below[n - 1];
|
|
2518
|
+
if (!(total > 0)) return 0;
|
|
2519
|
+
if (p <= 0) return values[0];
|
|
2520
|
+
if (p >= 1) return values[n - 1];
|
|
2521
|
+
const slack = Math.max(QUANTILE_RELATIVE_SLACK, 4 * n * Number.EPSILON);
|
|
2522
|
+
const lowTarget = p * total * (1 - slack);
|
|
2523
|
+
const highTarget = (1 - p) * total * (1 + slack);
|
|
2524
|
+
const lowSide = p <= 0.5;
|
|
2525
|
+
let lo = 0;
|
|
2526
|
+
let hi = n;
|
|
2527
|
+
while (lo < hi) {
|
|
2528
|
+
const mid = lo + hi >>> 1;
|
|
2529
|
+
const reached = lowSide ? below[mid] >= lowTarget : above[mid] <= highTarget;
|
|
2530
|
+
if (reached) hi = mid;
|
|
2531
|
+
else lo = mid + 1;
|
|
2532
|
+
}
|
|
2533
|
+
return values[Math.min(lo, n - 1)];
|
|
2534
|
+
}
|
|
2535
|
+
/**
|
|
2536
|
+
* Sorted support with `below[i]` = Σ p over values ≤ values[i], summed from the low end,
|
|
2537
|
+
* and `above[i]` = Σ p over values > values[i], summed from the high end. Cached.
|
|
2538
|
+
*/
|
|
2539
|
+
cumulative() {
|
|
2540
|
+
if (this._cumulative === void 0) {
|
|
2541
|
+
const values = this.support();
|
|
2542
|
+
const n = values.length;
|
|
2543
|
+
const below = new Array(n);
|
|
2544
|
+
const above = new Array(n);
|
|
2545
|
+
let sum = 0;
|
|
2546
|
+
for (let i = 0; i < n; i++) {
|
|
2547
|
+
sum += this.map.get(values[i]).p;
|
|
2548
|
+
below[i] = sum;
|
|
2549
|
+
}
|
|
2550
|
+
sum = 0;
|
|
2551
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
2552
|
+
above[i] = sum;
|
|
2553
|
+
sum += this.map.get(values[i]).p;
|
|
2554
|
+
}
|
|
2555
|
+
this._cumulative = { values, below, above };
|
|
2179
2556
|
}
|
|
2180
|
-
return
|
|
2557
|
+
return this._cumulative;
|
|
2181
2558
|
}
|
|
2182
2559
|
/** Get outcome probability at specific damage value. */
|
|
2183
2560
|
outcomeAt(damage, outcome) {
|
|
@@ -2497,35 +2874,33 @@ var _PMF = class _PMF {
|
|
|
2497
2874
|
const pNone = 1 - pSpecificSuccess - pGeneralSuccess;
|
|
2498
2875
|
return { pSpecificSuccess, pGeneralSuccess, pNone, pAny };
|
|
2499
2876
|
}
|
|
2877
|
+
/**
|
|
2878
|
+
* Maps every damage value through `f`, then optionally rounds it (`rounding`, default
|
|
2879
|
+
* `"none"`). Values that land on the same result merge their probability and, unless
|
|
2880
|
+
* `preserveCounts` is false, their per-label `count`. Damage attribution (`attr`) is dropped,
|
|
2881
|
+
* because it is tied to the old values. Nothing is pruned and the mass is unchanged.
|
|
2882
|
+
*
|
|
2883
|
+
* @param eps Epsilon carried by the result.
|
|
2884
|
+
* @throws Error when a mapped value is not a finite integer.
|
|
2885
|
+
*/
|
|
2500
2886
|
mapValues(f, eps = EPS, opts) {
|
|
2501
2887
|
const rounding = opts?.rounding ?? "none";
|
|
2502
2888
|
const preserveCounts = opts?.preserveCounts ?? true;
|
|
2503
2889
|
const round = (x) => rounding === "floor" ? Math.floor(x) : rounding === "ceil" ? Math.ceil(x) : rounding === "round" ? Math.round(x) : x;
|
|
2504
|
-
const
|
|
2505
|
-
const counts = /* @__PURE__ */ new Map();
|
|
2890
|
+
const merged = /* @__PURE__ */ new Map();
|
|
2506
2891
|
for (const [v, bin] of this) {
|
|
2507
|
-
if (
|
|
2892
|
+
if (bin.p === 0) continue;
|
|
2508
2893
|
const u = round(f(v));
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
const src = bin.count;
|
|
2512
|
-
if (src) {
|
|
2513
|
-
const dest = counts.get(u) ?? {};
|
|
2514
|
-
for (const k in src) {
|
|
2515
|
-
dest[k] = (dest[k] ?? 0) + src[k];
|
|
2516
|
-
}
|
|
2517
|
-
counts.set(u, dest);
|
|
2518
|
-
}
|
|
2894
|
+
if (!Number.isInteger(u)) {
|
|
2895
|
+
throw new Error(`mapValues: ${v} maps to ${u}, not a finite integer`);
|
|
2519
2896
|
}
|
|
2897
|
+
_PMF.mergeInto(merged, u, {
|
|
2898
|
+
p: bin.p,
|
|
2899
|
+
count: preserveCounts ? bin.count : {}
|
|
2900
|
+
});
|
|
2520
2901
|
}
|
|
2521
|
-
const
|
|
2522
|
-
|
|
2523
|
-
internal.set(u, { p, count: counts.get(u) ?? {} });
|
|
2524
|
-
}
|
|
2525
|
-
return _PMF.fromMap(
|
|
2526
|
-
new Map(Array.from(internal, ([u, b]) => [u, b.p])),
|
|
2527
|
-
eps
|
|
2528
|
-
);
|
|
2902
|
+
const sorted = new Map([...merged.entries()].sort((a, b) => a[0] - b[0]));
|
|
2903
|
+
return new _PMF(sorted, eps, this.normalized, `mapValues(${this.identifier})`);
|
|
2529
2904
|
}
|
|
2530
2905
|
static fromMap(m, eps = EPS, { requireIntegerValues = true } = {}) {
|
|
2531
2906
|
const filtered = [];
|
|
@@ -2622,7 +2997,7 @@ var Dice = class _Dice {
|
|
|
2622
2997
|
if (totalCount === 0) return 0;
|
|
2623
2998
|
return expectedDamage / totalCount;
|
|
2624
2999
|
}
|
|
2625
|
-
//
|
|
3000
|
+
// Public (no modifier) for direct test access.
|
|
2626
3001
|
calculateHitDistribution() {
|
|
2627
3002
|
const hitValues = {};
|
|
2628
3003
|
const subtractedOutcomes = [
|
|
@@ -2643,7 +3018,7 @@ var Dice = class _Dice {
|
|
|
2643
3018
|
}
|
|
2644
3019
|
}
|
|
2645
3020
|
if (numFace === 0) {
|
|
2646
|
-
hitCount = 0;
|
|
3021
|
+
hitCount = this.outcomeData.hit?.[0] ?? 0;
|
|
2647
3022
|
}
|
|
2648
3023
|
if (hitCount < 0) {
|
|
2649
3024
|
hitCount = 0;
|
|
@@ -2685,18 +3060,6 @@ var Dice = class _Dice {
|
|
|
2685
3060
|
}
|
|
2686
3061
|
return result;
|
|
2687
3062
|
}
|
|
2688
|
-
removeFaces(facesToRemove) {
|
|
2689
|
-
const result = new _Dice();
|
|
2690
|
-
for (const [key, value] of Object.entries(this.faces)) {
|
|
2691
|
-
const numKey = Number(key);
|
|
2692
|
-
if (!facesToRemove.includes(numKey)) {
|
|
2693
|
-
result.faces[numKey] = value;
|
|
2694
|
-
}
|
|
2695
|
-
}
|
|
2696
|
-
result.privateData = { ...this.privateData };
|
|
2697
|
-
result.outcomeData = { ...this.outcomeData };
|
|
2698
|
-
return result;
|
|
2699
|
-
}
|
|
2700
3063
|
// PUBLIC FUNCTIONS
|
|
2701
3064
|
getFaceEntries() {
|
|
2702
3065
|
return Object.entries(this.faces).map(([k, v]) => [Number(k), v]);
|
|
@@ -2742,13 +3105,20 @@ var Dice = class _Dice {
|
|
|
2742
3105
|
const current = this.faces[face] || 0;
|
|
2743
3106
|
this.faces[face] = current + count;
|
|
2744
3107
|
}
|
|
3108
|
+
/** Scales every face count, and every outcome's counts with it, so each outcome keeps its share. */
|
|
2745
3109
|
normalize(scalar) {
|
|
2746
3110
|
const result = new _Dice();
|
|
2747
3111
|
for (const [face, count] of Object.entries(this.faces)) {
|
|
2748
3112
|
result.faces[Number(face)] = count * scalar;
|
|
2749
3113
|
}
|
|
2750
3114
|
result.privateData = { ...this.privateData };
|
|
2751
|
-
|
|
3115
|
+
for (const [key, distribution] of Object.entries(this.outcomeData)) {
|
|
3116
|
+
const scaled = {};
|
|
3117
|
+
for (const [face, count] of Object.entries(distribution)) {
|
|
3118
|
+
scaled[Number(face)] = count * scalar;
|
|
3119
|
+
}
|
|
3120
|
+
result.outcomeData[key] = scaled;
|
|
3121
|
+
}
|
|
2752
3122
|
return result;
|
|
2753
3123
|
}
|
|
2754
3124
|
// OPERATIONS
|
|
@@ -2786,10 +3156,18 @@ var Dice = class _Dice {
|
|
|
2786
3156
|
return this.binaryOp(other, (a, b) => a / b);
|
|
2787
3157
|
}
|
|
2788
3158
|
divideRoundUp(other) {
|
|
2789
|
-
|
|
3159
|
+
this.assertNonZeroDivisor(other);
|
|
3160
|
+
return this.binaryOp(other, (a, b) => b === 0 ? 0 : Math.ceil(a / b));
|
|
2790
3161
|
}
|
|
2791
3162
|
divideRoundDown(other) {
|
|
2792
|
-
|
|
3163
|
+
this.assertNonZeroDivisor(other);
|
|
3164
|
+
return this.binaryOp(other, (a, b) => b === 0 ? 0 : Math.floor(a / b));
|
|
3165
|
+
}
|
|
3166
|
+
/** A divisor that can be 0 has no quotient there. A 0 face with no weight is never rolled, so it passes. */
|
|
3167
|
+
assertNonZeroDivisor(other) {
|
|
3168
|
+
if (typeof other === "number" ? other === 0 : other.get(0) > 0) {
|
|
3169
|
+
throw new DiceParseError("Division by zero: the divisor can be 0");
|
|
3170
|
+
}
|
|
2793
3171
|
}
|
|
2794
3172
|
and(other) {
|
|
2795
3173
|
return this.binaryOp(other, (a, b) => a && b ? 1 : 0);
|
|
@@ -2825,18 +3203,19 @@ var Dice = class _Dice {
|
|
|
2825
3203
|
result.outcomeData = { ...this.outcomeData };
|
|
2826
3204
|
return result;
|
|
2827
3205
|
}
|
|
3206
|
+
/**
|
|
3207
|
+
* Roll once and, on a result in `toReroll`'s faces, roll again and keep the second roll. Each
|
|
3208
|
+
* result keeps its own weight: with T the total count and c_R the count on the rerolled faces,
|
|
3209
|
+
* face v's new count is c_v·(T·[v∉R] + c_R), i.e. p′(v) = p(v)·[v∉R] + P(R)·p(v).
|
|
3210
|
+
*/
|
|
2828
3211
|
reroll(toReroll) {
|
|
2829
|
-
const
|
|
2830
|
-
const
|
|
2831
|
-
|
|
2832
|
-
const
|
|
2833
|
-
|
|
2834
|
-
for (const face of this.
|
|
2835
|
-
|
|
2836
|
-
result = result.combine(removed);
|
|
2837
|
-
if (wasRerolled) {
|
|
2838
|
-
result = result.combine(this);
|
|
2839
|
-
}
|
|
3212
|
+
const rerolled = new Set(typeof toReroll === "number" ? [toReroll] : toReroll.keys());
|
|
3213
|
+
const total = this.total();
|
|
3214
|
+
let rerolledCount = 0;
|
|
3215
|
+
for (const [face, count] of this.getFaceEntries()) if (rerolled.has(face)) rerolledCount += count;
|
|
3216
|
+
const result = new _Dice();
|
|
3217
|
+
for (const [face, count] of this.getFaceEntries()) {
|
|
3218
|
+
result.increment(face, count * ((rerolled.has(face) ? 0 : total) + rerolledCount));
|
|
2840
3219
|
}
|
|
2841
3220
|
return result;
|
|
2842
3221
|
}
|
|
@@ -2899,6 +3278,7 @@ var Dice = class _Dice {
|
|
|
2899
3278
|
const critDistro = this.getOutcomeDistribution("crit") || {};
|
|
2900
3279
|
const missDistro = this.getOutcomeDistribution("missDamage") || {};
|
|
2901
3280
|
const saveDistro = this.getOutcomeDistribution("saveHalf") || {};
|
|
3281
|
+
const saveFailDistro = this.getOutcomeDistribution("saveFail") || {};
|
|
2902
3282
|
const pcDistro = this.getOutcomeDistribution("pc") || {};
|
|
2903
3283
|
const isSaveHalf = Object.keys(saveDistro).length > 0;
|
|
2904
3284
|
const isDCCheck = this.privateData.isDCCheck === true;
|
|
@@ -2942,15 +3322,15 @@ var Dice = class _Dice {
|
|
|
2942
3322
|
if (saveDistro[face]) {
|
|
2943
3323
|
const c = clampNonNeg(saveDistro[face] / total);
|
|
2944
3324
|
if (c > 0) {
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
3325
|
+
count.saveHalf = c;
|
|
3326
|
+
attr.saveHalf = clampNonNeg(face * saveDistro[face] / total);
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
if (saveFailDistro[face]) {
|
|
3330
|
+
const c = clampNonNeg(saveFailDistro[face] / total);
|
|
3331
|
+
if (c > 0) {
|
|
3332
|
+
count.saveFail = (count.saveFail ?? 0) + c;
|
|
3333
|
+
attr.saveFail = clampNonNeg((attr.saveFail ?? 0) + face * saveFailDistro[face] / total);
|
|
2954
3334
|
}
|
|
2955
3335
|
}
|
|
2956
3336
|
if (pcDistro[face]) {
|
|
@@ -2960,8 +3340,8 @@ var Dice = class _Dice {
|
|
|
2960
3340
|
attr.pc = clampNonNeg(face * pcDistro[face] / total);
|
|
2961
3341
|
}
|
|
2962
3342
|
}
|
|
2963
|
-
if (!
|
|
2964
|
-
const distroCountRaw = (hitDistro[face] || 0) + (critDistro[face] || 0) + (missDistro[face] || 0) + (saveDistro[face] || 0) + (pcDistro[face] || 0);
|
|
3343
|
+
if (!isDCCheck) {
|
|
3344
|
+
const distroCountRaw = (hitDistro[face] || 0) + (critDistro[face] || 0) + (missDistro[face] || 0) + (saveDistro[face] || 0) + (saveFailDistro[face] || 0) + (pcDistro[face] || 0);
|
|
2965
3345
|
const unaccountedCount = clampNonNeg(faceCount - distroCountRaw);
|
|
2966
3346
|
if (unaccountedCount > 0) {
|
|
2967
3347
|
const frac = clampNonNeg(unaccountedCount / total);
|
|
@@ -2981,25 +3361,238 @@ var Dice = class _Dice {
|
|
|
2981
3361
|
}
|
|
2982
3362
|
};
|
|
2983
3363
|
|
|
3364
|
+
// src/parser/scaleDice.ts
|
|
3365
|
+
var isDigitOrN = (c) => c !== void 0 && (c >= "0" && c <= "9" || c === "n");
|
|
3366
|
+
var isPerDieOp = (op) => op === "reroll" || op === ">" || op === "<" || op === "!";
|
|
3367
|
+
var UndoubleableExpressionError = class extends Error {
|
|
3368
|
+
};
|
|
3369
|
+
var AmbiguousCritDoublingError = class extends Error {
|
|
3370
|
+
};
|
|
3371
|
+
var DiceTermReader = class {
|
|
3372
|
+
constructor(s, expression) {
|
|
3373
|
+
this.s = s;
|
|
3374
|
+
this.expression = expression;
|
|
3375
|
+
this.pos = 0;
|
|
3376
|
+
}
|
|
3377
|
+
read() {
|
|
3378
|
+
const expr = this.expr();
|
|
3379
|
+
if (this.pos !== this.s.length) this.fail(`unexpected '${this.s[this.pos]}'`);
|
|
3380
|
+
return expr;
|
|
3381
|
+
}
|
|
3382
|
+
fail(reason) {
|
|
3383
|
+
throw new UndoubleableExpressionError(`Cannot double the dice of "${this.expression}": ${reason}.`);
|
|
3384
|
+
}
|
|
3385
|
+
expr() {
|
|
3386
|
+
const first = this.chain();
|
|
3387
|
+
const rest = [];
|
|
3388
|
+
for (let op = this.operation(); op !== void 0; op = this.operation()) {
|
|
3389
|
+
if (op === "ac") {
|
|
3390
|
+
this.fail("it contains an attack check (a d20 roll against an AC), so it is not a damage expression");
|
|
3391
|
+
}
|
|
3392
|
+
if (op === "dc") {
|
|
3393
|
+
this.fail("it contains a saving throw check (a d20 roll against a DC), so it is not a damage expression");
|
|
3394
|
+
}
|
|
3395
|
+
const arg = op === "!" ? void 0 : this.chain();
|
|
3396
|
+
const c = this.s[this.pos];
|
|
3397
|
+
if (c === "x" || c === "c" || c === "s" || c === "m" || this.s.startsWith("pc", this.pos)) {
|
|
3398
|
+
this.fail("it contains a check-outcome clause (crit/save/pc/miss), so it is not a damage expression");
|
|
3399
|
+
}
|
|
3400
|
+
rest.push({ op, arg, end: this.pos });
|
|
3401
|
+
}
|
|
3402
|
+
return { first, rest };
|
|
3403
|
+
}
|
|
3404
|
+
chain() {
|
|
3405
|
+
const start = this.pos;
|
|
3406
|
+
const atoms = [];
|
|
3407
|
+
for (let atom = this.atom(); atom !== void 0; atom = this.atom()) atoms.push(atom);
|
|
3408
|
+
return { start, atoms };
|
|
3409
|
+
}
|
|
3410
|
+
atom() {
|
|
3411
|
+
const start = this.pos;
|
|
3412
|
+
const c = this.s[start];
|
|
3413
|
+
if (c === "(") {
|
|
3414
|
+
this.pos++;
|
|
3415
|
+
const expr = this.expr();
|
|
3416
|
+
if (this.s[this.pos] !== ")") this.fail("unbalanced parentheses");
|
|
3417
|
+
this.pos++;
|
|
3418
|
+
return { kind: "group", start, end: this.pos, expr };
|
|
3419
|
+
}
|
|
3420
|
+
if (c === "h" && this.s[start + 1] === "d" && isDigitOrN(this.s[start + 2])) {
|
|
3421
|
+
this.pos += 2;
|
|
3422
|
+
this.number();
|
|
3423
|
+
return { kind: "die", start, end: this.pos };
|
|
3424
|
+
}
|
|
3425
|
+
if (c === "d" && isDigitOrN(this.s[start + 1])) {
|
|
3426
|
+
this.pos += 1;
|
|
3427
|
+
this.number();
|
|
3428
|
+
return { kind: "die", start, end: this.pos };
|
|
3429
|
+
}
|
|
3430
|
+
if (c === "k") {
|
|
3431
|
+
const mode = this.s[start + 1];
|
|
3432
|
+
if (mode !== "h" && mode !== "l") this.fail("'k' must be followed by 'h' or 'l'");
|
|
3433
|
+
this.pos += 2;
|
|
3434
|
+
const kept = this.number();
|
|
3435
|
+
const inner = this.atom();
|
|
3436
|
+
if (inner === void 0) this.fail("a keep needs dice after it");
|
|
3437
|
+
return { kind: "keep", start, end: this.pos, inner, mode, kept };
|
|
3438
|
+
}
|
|
3439
|
+
if (isDigitOrN(c)) {
|
|
3440
|
+
const value = this.number();
|
|
3441
|
+
return { kind: "number", start, end: this.pos, value };
|
|
3442
|
+
}
|
|
3443
|
+
return void 0;
|
|
3444
|
+
}
|
|
3445
|
+
number() {
|
|
3446
|
+
let digits = "";
|
|
3447
|
+
while (isDigitOrN(this.s[this.pos])) {
|
|
3448
|
+
const ch = this.s[this.pos++];
|
|
3449
|
+
digits += ch === "n" ? "0" : ch;
|
|
3450
|
+
}
|
|
3451
|
+
if (digits.length === 0) this.fail(`expected a number at '${this.s[this.pos]}'`);
|
|
3452
|
+
return parseInt(digits, 10);
|
|
3453
|
+
}
|
|
3454
|
+
operation() {
|
|
3455
|
+
const rest = this.s.slice(this.pos);
|
|
3456
|
+
const op = ["reroll", "**", "//", "~+", "ac", "dc", "!", ">", "<", "+", "-", "&", "*", "/", "="].find(
|
|
3457
|
+
(token) => rest.startsWith(token)
|
|
3458
|
+
);
|
|
3459
|
+
if (op !== void 0) this.pos += op.length;
|
|
3460
|
+
return op;
|
|
3461
|
+
}
|
|
3462
|
+
};
|
|
3463
|
+
function atomHasDice(atom) {
|
|
3464
|
+
switch (atom.kind) {
|
|
3465
|
+
case "die":
|
|
3466
|
+
return true;
|
|
3467
|
+
case "number":
|
|
3468
|
+
return false;
|
|
3469
|
+
case "keep":
|
|
3470
|
+
return atomHasDice(atom.inner);
|
|
3471
|
+
case "group":
|
|
3472
|
+
return chainHasDice(atom.expr.first) || atom.expr.rest.some(({ op, arg }) => op !== "reroll" && arg !== void 0 && chainHasDice(arg));
|
|
3473
|
+
}
|
|
3474
|
+
}
|
|
3475
|
+
var chainHasDice = (chain) => chain.atoms.some(atomHasDice);
|
|
3476
|
+
var isSingleDieAtom = (atom) => atom.kind === "die" || atom.kind === "group" && isSingleDieExpr(atom.expr, atom.expr.rest.length);
|
|
3477
|
+
function isSingleDieExpr(expr, opCount) {
|
|
3478
|
+
const operands = [expr.first];
|
|
3479
|
+
for (const { op, arg } of expr.rest.slice(0, opCount)) {
|
|
3480
|
+
if (!isPerDieOp(op)) return false;
|
|
3481
|
+
if (op !== "reroll" && arg !== void 0) operands.push(arg);
|
|
3482
|
+
}
|
|
3483
|
+
let dice = 0;
|
|
3484
|
+
for (const chain of operands) {
|
|
3485
|
+
if (chain.atoms.length === 1 && isSingleDieAtom(chain.atoms[0])) dice++;
|
|
3486
|
+
else if (chainHasDice(chain)) return false;
|
|
3487
|
+
}
|
|
3488
|
+
return dice === 1;
|
|
3489
|
+
}
|
|
3490
|
+
function scaleParsedDice(expression, scale) {
|
|
3491
|
+
let cleaned = "";
|
|
3492
|
+
const original = [];
|
|
3493
|
+
for (let i = 0; i < expression.length; i++) {
|
|
3494
|
+
if (expression[i] === " ") continue;
|
|
3495
|
+
cleaned += expression[i].toLowerCase();
|
|
3496
|
+
original.push(i);
|
|
3497
|
+
}
|
|
3498
|
+
const root = new DiceTermReader(cleaned, expression).read();
|
|
3499
|
+
const edits = [];
|
|
3500
|
+
const span = (start, end) => ({ from: original[start], to: original[end - 1] + 1 });
|
|
3501
|
+
const wrap = (start, end, open) => {
|
|
3502
|
+
const { from, to } = span(start, end);
|
|
3503
|
+
edits.push({ from, to: from, text: open }, { from: to, to, text: ")" });
|
|
3504
|
+
};
|
|
3505
|
+
const source = (start, end) => {
|
|
3506
|
+
const { from, to } = span(start, end);
|
|
3507
|
+
return expression.slice(from, to);
|
|
3508
|
+
};
|
|
3509
|
+
const ambiguous = [];
|
|
3510
|
+
const keepReading = 'has no single doubled meaning (only keep-highest-of-1, "roll it N times, keep the best", doubles its dice inside each trial)';
|
|
3511
|
+
const noteKeep = (keep, trials) => {
|
|
3512
|
+
if (keep.mode === "h" && keep.kept === 1 || !atomHasDice(keep.inner)) return;
|
|
3513
|
+
ambiguous.push(`the keep \`${source(trials?.kind === "number" ? trials.start : keep.start, keep.inner.start)}\` ${keepReading}`);
|
|
3514
|
+
};
|
|
3515
|
+
const scaleKept = (inner) => {
|
|
3516
|
+
if (isSingleDieAtom(inner)) wrap(inner.start, inner.end, `(${scale}`);
|
|
3517
|
+
else if (inner.kind === "group") scaleExpr(inner.expr);
|
|
3518
|
+
else if (inner.kind === "keep") {
|
|
3519
|
+
noteKeep(inner);
|
|
3520
|
+
scaleKept(inner.inner);
|
|
3521
|
+
}
|
|
3522
|
+
};
|
|
3523
|
+
const scaleChain = (chain) => {
|
|
3524
|
+
const { atoms } = chain;
|
|
3525
|
+
if (atoms.length === 0) return;
|
|
3526
|
+
const last = atoms[atoms.length - 1];
|
|
3527
|
+
const counts = atoms.slice(0, -1);
|
|
3528
|
+
if (counts.some(atomHasDice)) {
|
|
3529
|
+
throw new UndoubleableExpressionError(
|
|
3530
|
+
`Cannot double the dice of "${expression}": a dice-valued repeat count (like d4d6) has no single dice term to double.`
|
|
3531
|
+
);
|
|
3532
|
+
}
|
|
3533
|
+
if (last.kind === "keep") {
|
|
3534
|
+
noteKeep(last, counts[counts.length - 1]);
|
|
3535
|
+
scaleKept(last.inner);
|
|
3536
|
+
} else if (isSingleDieAtom(last)) {
|
|
3537
|
+
const count = counts[counts.length - 1];
|
|
3538
|
+
if (count?.kind === "number") {
|
|
3539
|
+
edits.push({ ...span(count.start, count.end), text: String(count.value * scale) });
|
|
3540
|
+
} else {
|
|
3541
|
+
const at = original[last.start];
|
|
3542
|
+
edits.push({ from: at, to: at, text: String(scale) });
|
|
3543
|
+
}
|
|
3544
|
+
} else if (last.kind === "group") {
|
|
3545
|
+
scaleExpr(last.expr);
|
|
3546
|
+
}
|
|
3547
|
+
};
|
|
3548
|
+
function scaleExpr(expr) {
|
|
3549
|
+
let unitOps = 0;
|
|
3550
|
+
for (let i = 1; i <= expr.rest.length && isPerDieOp(expr.rest[i - 1].op); i++) {
|
|
3551
|
+
if (isSingleDieExpr(expr, i)) unitOps = i;
|
|
3552
|
+
}
|
|
3553
|
+
if (unitOps > 0) wrap(expr.first.start, expr.rest[unitOps - 1].end, `${scale}(`);
|
|
3554
|
+
else scaleChain(expr.first);
|
|
3555
|
+
let leftHasDice = unitOps > 0 || chainHasDice(expr.first);
|
|
3556
|
+
for (const { op, arg, end } of expr.rest.slice(unitOps)) {
|
|
3557
|
+
if (op === "reroll" || arg === void 0) continue;
|
|
3558
|
+
const argHasDice = chainHasDice(arg);
|
|
3559
|
+
if (op === "<" && leftHasDice && argHasDice) {
|
|
3560
|
+
ambiguous.push(`the lower of two dice terms \`${source(expr.first.start, end)}\` ${keepReading}`);
|
|
3561
|
+
}
|
|
3562
|
+
if (op === "&" && (leftHasDice || argHasDice)) {
|
|
3563
|
+
ambiguous.push(
|
|
3564
|
+
`the mix \`${source(expr.first.start, end)}\` has no single doubled meaning (an \`&\` weights each side by its count of outcomes, so doubling a side's dice also changes its share of the mix)`
|
|
3565
|
+
);
|
|
3566
|
+
}
|
|
3567
|
+
leftHasDice || (leftHasDice = argHasDice);
|
|
3568
|
+
scaleChain(arg);
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
scaleExpr(root);
|
|
3572
|
+
if (ambiguous.length > 0) {
|
|
3573
|
+
throw new AmbiguousCritDoublingError(
|
|
3574
|
+
`Cannot double the dice of "${expression}" on a crit: ${ambiguous[0]}. Give the crit explicitly: onCrit(...) on an attack, critDamage on a rider, or a crit (...) clause.`
|
|
3575
|
+
);
|
|
3576
|
+
}
|
|
3577
|
+
let result = expression;
|
|
3578
|
+
for (const { from, to, text } of edits.sort((a, b) => b.from - a.from)) {
|
|
3579
|
+
result = result.slice(0, from) + text + result.slice(to);
|
|
3580
|
+
}
|
|
3581
|
+
return result;
|
|
3582
|
+
}
|
|
3583
|
+
|
|
2984
3584
|
// src/parser/parser.ts
|
|
2985
3585
|
var MAX_DIE_SIDES = 1e6;
|
|
2986
3586
|
var MAX_DICE_COUNT = 1e4;
|
|
2987
|
-
var
|
|
2988
|
-
var
|
|
2989
|
-
var
|
|
2990
|
-
function setCachingEnabled(enabled) {
|
|
2991
|
-
cachingEnabled = enabled;
|
|
2992
|
-
if (!enabled) clearParserCache();
|
|
2993
|
-
}
|
|
2994
|
-
function getCachingEnabled() {
|
|
2995
|
-
return cachingEnabled;
|
|
2996
|
-
}
|
|
3587
|
+
var MAX_KEEP_WORK = 1e8;
|
|
3588
|
+
var MAX_EXACT_COUNT = Number.MAX_SAFE_INTEGER;
|
|
3589
|
+
var parseCache = PMF.createCache(1e3);
|
|
2997
3590
|
function clearParserCache() {
|
|
2998
3591
|
parseCache.clear();
|
|
2999
3592
|
}
|
|
3000
3593
|
function parse(expression, n = 0) {
|
|
3001
3594
|
const cleaned = expression.replace(/ /g, "").toLowerCase();
|
|
3002
|
-
if (
|
|
3595
|
+
if (getCachingEnabled()) {
|
|
3003
3596
|
const cacheKey = `${cleaned}:${n}`;
|
|
3004
3597
|
const cached = parseCache.get(cacheKey);
|
|
3005
3598
|
if (cached) return cached;
|
|
@@ -3022,8 +3615,21 @@ function parse(expression, n = 0) {
|
|
|
3022
3615
|
{ expression }
|
|
3023
3616
|
);
|
|
3024
3617
|
}
|
|
3618
|
+
const total = result.total();
|
|
3619
|
+
if (total === 0) {
|
|
3620
|
+
throw new DiceParseError(
|
|
3621
|
+
`Cannot parse dice expression [${expression}]: it has no outcomes (a d0 has no faces; it is only a reroll set, as in \`reroll d0\`)`,
|
|
3622
|
+
{ expression }
|
|
3623
|
+
);
|
|
3624
|
+
}
|
|
3625
|
+
if (!Number.isFinite(total)) {
|
|
3626
|
+
throw new DiceParseError(
|
|
3627
|
+
`Cannot parse dice expression [${expression}]: its outcome counts overflow (too many dice combined to count exactly)`,
|
|
3628
|
+
{ expression }
|
|
3629
|
+
);
|
|
3630
|
+
}
|
|
3025
3631
|
const resultPMF = result.toPMF(-1);
|
|
3026
|
-
if (
|
|
3632
|
+
if (getCachingEnabled()) {
|
|
3027
3633
|
const cacheKey = `${cleaned}:${n}`;
|
|
3028
3634
|
parseCache.set(cacheKey, resultPMF);
|
|
3029
3635
|
}
|
|
@@ -3042,61 +3648,72 @@ function subtractCounts(a, b) {
|
|
|
3042
3648
|
for (const [key, value] of b.getFaceEntries()) result.increment(key, -value);
|
|
3043
3649
|
return result;
|
|
3044
3650
|
}
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3651
|
+
var HIT_ONLY_OPS = /* @__PURE__ */ new Set([
|
|
3652
|
+
Dice.prototype.addNonZero,
|
|
3653
|
+
Dice.prototype.conditionalApply,
|
|
3654
|
+
Dice.prototype.multiply,
|
|
3655
|
+
Dice.prototype.divideRoundUp,
|
|
3656
|
+
Dice.prototype.divideRoundDown
|
|
3657
|
+
]);
|
|
3658
|
+
var GATE_OPS = /* @__PURE__ */ new Set([Dice.prototype.ac, Dice.prototype.dc]);
|
|
3659
|
+
function lastGateAt(arr) {
|
|
3660
|
+
let depth = 0;
|
|
3661
|
+
let at;
|
|
3662
|
+
for (let i = 0; i < arr.length - 1; i++) {
|
|
3663
|
+
const c = arr[i];
|
|
3664
|
+
if (c === "(") depth++;
|
|
3665
|
+
else if (c === ")") {
|
|
3666
|
+
if (depth === 0) break;
|
|
3667
|
+
depth--;
|
|
3668
|
+
} else if (depth === 0 && (c === "a" || c === "d") && arr[i + 1] === "c") {
|
|
3669
|
+
at = arr.length - i;
|
|
3670
|
+
}
|
|
3671
|
+
}
|
|
3672
|
+
return at;
|
|
3673
|
+
}
|
|
3674
|
+
function parseExpression(arr, n, inCheck = false) {
|
|
3675
|
+
const gate = lastGateAt(arr);
|
|
3676
|
+
const buildsCheck = () => inCheck || gate !== void 0 && arr.length > gate;
|
|
3677
|
+
const readOperation = () => {
|
|
3678
|
+
const checkTerm = buildsCheck();
|
|
3679
|
+
const parsed = parseOperation(arr);
|
|
3680
|
+
return parsed === Dice.prototype.addNonZero && checkTerm ? Dice.prototype.add : parsed;
|
|
3681
|
+
};
|
|
3682
|
+
const first = parseArgument(arr, n, buildsCheck());
|
|
3683
|
+
let finalResult = typeof first === "number" ? Dice.scalar(first) : first;
|
|
3684
|
+
if (typeof first === "number") finalResult.privateData.noDie = true;
|
|
3685
|
+
let opText = finalResult.privateData.implicitCrit ? arr.join("") : void 0;
|
|
3686
|
+
let op = readOperation();
|
|
3054
3687
|
while (op != null) {
|
|
3055
|
-
const
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
bonusOnly = bonusOnly.subtract(arg);
|
|
3062
|
-
} else if (op === Dice.prototype.ac && typeof arg === "number") {
|
|
3063
|
-
const natMaxSlice = bonusOnly.add(baseDieMeta.sides);
|
|
3064
|
-
const restSlice = subtractCounts(finalResult, natMaxSlice);
|
|
3065
|
-
const gatedNatMaxSlice = natMaxSlice.ac(arg);
|
|
3066
|
-
finalResult = restSlice.ac(arg).combine(gatedNatMaxSlice);
|
|
3067
|
-
finalResult.privateData.checkDie = baseDieMeta;
|
|
3068
|
-
finalResult.privateData.natMaxCritSlice = gatedNatMaxSlice;
|
|
3069
|
-
acAlreadyApplied = true;
|
|
3070
|
-
baseDieMeta = void 0;
|
|
3071
|
-
} else {
|
|
3072
|
-
baseDieMeta = void 0;
|
|
3073
|
-
}
|
|
3074
|
-
}
|
|
3688
|
+
const pending = op === Dice.prototype.conditionalApply && finalResult.privateData.isACCheck ? arr.join("") : void 0;
|
|
3689
|
+
const arg = !op.unary ? parseArgument(arr, n, buildsCheck()) : finalResult;
|
|
3690
|
+
const hitText = pending?.slice(0, pending.length - arr.length);
|
|
3691
|
+
const termText = opText?.slice(0, opText.length - arr.length);
|
|
3692
|
+
const before = finalResult;
|
|
3693
|
+
if (op === Dice.prototype.combine) assertMixable(before, arg, arr);
|
|
3075
3694
|
let crit;
|
|
3076
3695
|
let critNorm = 1;
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3696
|
+
const critClause = arr[0] === "x" || arr[0] === "c";
|
|
3697
|
+
const implicitCrit = !critClause && hitText !== void 0 && !finalResult.privateData.noDie;
|
|
3698
|
+
if (critClause || implicitCrit) {
|
|
3699
|
+
let count = 1;
|
|
3700
|
+
if (critClause) {
|
|
3701
|
+
const isXcrit = arr[0] === "x";
|
|
3702
|
+
if (isXcrit) assertToken(arr, "x");
|
|
3703
|
+
assertToken(arr, "c");
|
|
3704
|
+
assertToken(arr, "r");
|
|
3705
|
+
assertToken(arr, "i");
|
|
3706
|
+
assertToken(arr, "t");
|
|
3707
|
+
if (isXcrit) count = parseNumber(arr, n);
|
|
3708
|
+
}
|
|
3709
|
+
if (finalResult.privateData.noDie) {
|
|
3710
|
+
parseBinaryArgument(arg, arr, n);
|
|
3089
3711
|
} else {
|
|
3090
|
-
crit =
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
finalResult = finalResult.deleteFace(max);
|
|
3095
|
-
}
|
|
3712
|
+
({ crit, rest: finalResult } = splitCrit(finalResult, count));
|
|
3713
|
+
critNorm = crit.total();
|
|
3714
|
+
crit = op.call(crit, critClause ? parseBinaryArgument(arg, arr, n) : critPayload(hitText, n));
|
|
3715
|
+
critNorm = crit && critNorm ? crit.total() / critNorm : 1;
|
|
3096
3716
|
}
|
|
3097
|
-
critNorm = crit.total();
|
|
3098
|
-
crit = op.call(crit, parseBinaryArgument(arg, arr, n));
|
|
3099
|
-
critNorm = crit && critNorm ? crit.total() / critNorm : 1;
|
|
3100
3717
|
}
|
|
3101
3718
|
let save;
|
|
3102
3719
|
let saveNorm = 1;
|
|
@@ -3143,109 +3760,351 @@ function parseExpression(arr, n) {
|
|
|
3143
3760
|
missNorm = miss && missNorm ? miss.total() / missNorm : 1;
|
|
3144
3761
|
}
|
|
3145
3762
|
let norm = finalResult.total();
|
|
3146
|
-
|
|
3763
|
+
const clause = crit !== void 0 || save !== void 0 || pc !== void 0 || miss !== void 0;
|
|
3764
|
+
const labelled = hasOutcomeLabels(finalResult);
|
|
3765
|
+
const operand = finalResult;
|
|
3766
|
+
if (!clause && labelled && HIT_ONLY_OPS.has(op)) {
|
|
3767
|
+
finalResult = applyByOutcome(finalResult, op, arg, termText, n);
|
|
3768
|
+
} else {
|
|
3147
3769
|
finalResult = op.call(finalResult, arg);
|
|
3148
3770
|
}
|
|
3771
|
+
if (operand.privateData.isDCCheck && HIT_ONLY_OPS.has(op)) {
|
|
3772
|
+
finalResult.setOutcomeDistribution("saveFail", op.call(operand.deleteFace(0), arg).getFaceMap());
|
|
3773
|
+
}
|
|
3774
|
+
if (!operand.privateData.isDCCheck && HIT_ONLY_OPS.has(op) && (hitText !== void 0 || operand.privateData.attackPayload)) {
|
|
3775
|
+
finalResult.privateData.attackPayload = true;
|
|
3776
|
+
const landed = landedHitsAtZero(operand, op, arg);
|
|
3777
|
+
if (landed > 0) finalResult.setOutcomeDistribution("hit", { 0: landed });
|
|
3778
|
+
} else if (op === Dice.prototype.combine && typeof arg !== "number" && arg.privateData.attackPayload) {
|
|
3779
|
+
finalResult.privateData.attackPayload = true;
|
|
3780
|
+
const landed = operand.getOutcomeCount("hit", 0) + arg.getOutcomeCount("hit", 0);
|
|
3781
|
+
if (landed > 0) finalResult.setOutcomeDistribution("hit", { 0: landed });
|
|
3782
|
+
}
|
|
3783
|
+
const gated = op === Dice.prototype.combine && typeof arg !== "number" && arg.privateData.isACCheck;
|
|
3784
|
+
if (op === Dice.prototype.ac || gated) finalResult.privateData.isACCheck = true;
|
|
3785
|
+
followNaturalRoll(before, op, arg, finalResult, clause);
|
|
3149
3786
|
norm = norm ? finalResult.total() / norm : 1;
|
|
3150
3787
|
if (crit) {
|
|
3151
|
-
const
|
|
3788
|
+
const result = combineDiceWithNormalization(
|
|
3152
3789
|
crit,
|
|
3153
3790
|
critNorm,
|
|
3154
3791
|
"crit",
|
|
3155
3792
|
norm,
|
|
3156
3793
|
finalResult
|
|
3157
3794
|
);
|
|
3158
|
-
norm =
|
|
3159
|
-
finalResult =
|
|
3795
|
+
norm = result.newNorm;
|
|
3796
|
+
finalResult = result.updatedResult;
|
|
3160
3797
|
}
|
|
3161
3798
|
if (save) {
|
|
3162
|
-
const
|
|
3799
|
+
const result = combineDiceWithNormalization(
|
|
3163
3800
|
save,
|
|
3164
3801
|
saveNorm,
|
|
3165
3802
|
"saveHalf",
|
|
3166
3803
|
norm,
|
|
3167
3804
|
finalResult
|
|
3168
3805
|
);
|
|
3169
|
-
norm =
|
|
3170
|
-
finalResult =
|
|
3806
|
+
norm = result.newNorm;
|
|
3807
|
+
finalResult = result.updatedResult;
|
|
3171
3808
|
}
|
|
3172
3809
|
if (miss) {
|
|
3173
|
-
const
|
|
3810
|
+
const result = combineDiceWithNormalization(
|
|
3174
3811
|
miss,
|
|
3175
3812
|
missNorm,
|
|
3176
3813
|
"missDamage",
|
|
3177
3814
|
norm,
|
|
3178
3815
|
finalResult
|
|
3179
3816
|
);
|
|
3180
|
-
norm =
|
|
3181
|
-
finalResult =
|
|
3817
|
+
norm = result.newNorm;
|
|
3818
|
+
finalResult = result.updatedResult;
|
|
3182
3819
|
}
|
|
3183
3820
|
if (pc) {
|
|
3184
|
-
const
|
|
3821
|
+
const result = combineDiceWithNormalization(
|
|
3185
3822
|
pc,
|
|
3186
3823
|
pcNorm,
|
|
3187
3824
|
"pc",
|
|
3188
3825
|
norm,
|
|
3189
3826
|
finalResult
|
|
3190
3827
|
);
|
|
3191
|
-
norm =
|
|
3192
|
-
finalResult =
|
|
3828
|
+
norm = result.newNorm;
|
|
3829
|
+
finalResult = result.updatedResult;
|
|
3193
3830
|
}
|
|
3194
|
-
|
|
3831
|
+
if (implicitCrit) finalResult.privateData.implicitCrit = { payload: hitText };
|
|
3832
|
+
opText = finalResult.privateData.implicitCrit ? arr.join("") : void 0;
|
|
3833
|
+
op = readOperation();
|
|
3195
3834
|
}
|
|
3196
3835
|
return finalResult;
|
|
3197
3836
|
}
|
|
3198
|
-
function
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3837
|
+
function naturalSides(value) {
|
|
3838
|
+
if (typeof value === "number") return 0;
|
|
3839
|
+
const { critTrack, untrackedSides } = value.privateData;
|
|
3840
|
+
return critTrack ? critTrack.sides : untrackedSides ?? 0;
|
|
3841
|
+
}
|
|
3842
|
+
function outranks(sides, other) {
|
|
3843
|
+
if (sides === other) return false;
|
|
3844
|
+
if (sides === 20 || other === 20) return sides === 20;
|
|
3845
|
+
return sides > other;
|
|
3846
|
+
}
|
|
3847
|
+
function bareTrack(die) {
|
|
3848
|
+
return {
|
|
3849
|
+
sides: die.maxFace(),
|
|
3850
|
+
bare: true,
|
|
3851
|
+
slice: (face) => {
|
|
3852
|
+
const slice = new Dice();
|
|
3853
|
+
const weight = die.get(face);
|
|
3854
|
+
if (weight) slice.setFace(face, weight);
|
|
3855
|
+
return slice;
|
|
3856
|
+
}
|
|
3857
|
+
};
|
|
3858
|
+
}
|
|
3859
|
+
function asValue(value) {
|
|
3860
|
+
if (typeof value !== "number") return value;
|
|
3861
|
+
const scalar = Dice.scalar(value);
|
|
3862
|
+
scalar.privateData.noDie = true;
|
|
3863
|
+
return scalar;
|
|
3864
|
+
}
|
|
3865
|
+
function branchesOf(value) {
|
|
3866
|
+
return typeof value === "number" ? [asValue(value)] : value.privateData.branches ?? [value];
|
|
3867
|
+
}
|
|
3868
|
+
var hasOutcomeLabels = (value) => typeof value !== "number" && Object.keys(value.getFullOutcomeDistribution()).some((label) => label !== "hit");
|
|
3869
|
+
var isSave = (value) => typeof value !== "number" && value.privateData.isDCCheck === true;
|
|
3870
|
+
function assertMixable(left, right, rest) {
|
|
3871
|
+
if (hasOutcomeLabels(left) || hasOutcomeLabels(right)) {
|
|
3872
|
+
throw new Error(
|
|
3873
|
+
"an `&` mix of an attack already split into crit, miss or save outcomes has no single reading: mix the checks before the payload, like `((d20 AC 10) & (d20 AC 15)) * (1d6)`"
|
|
3874
|
+
);
|
|
3875
|
+
}
|
|
3876
|
+
if (isSave(left) !== isSave(right)) {
|
|
3877
|
+
throw new Error("an `&` mix of a saving throw (DC) with anything but another saving throw has no single set of outcomes");
|
|
3878
|
+
}
|
|
3879
|
+
const [next, after] = rest;
|
|
3880
|
+
if (next === "x" || next === "c" || next === "s" || next === "m" || next === "p" && after === "c") {
|
|
3881
|
+
throw new Error("a crit, save, pc or miss clause on an `&` mix has no single reading: put it after the payload's `*`");
|
|
3882
|
+
}
|
|
3883
|
+
}
|
|
3884
|
+
function followNaturalRoll(before, op, arg, after, clause) {
|
|
3885
|
+
const data = after.privateData;
|
|
3886
|
+
delete data.critTrack;
|
|
3887
|
+
delete data.untrackedSides;
|
|
3888
|
+
delete data.noDie;
|
|
3889
|
+
delete data.branches;
|
|
3890
|
+
const gate = GATE_OPS.has(op);
|
|
3891
|
+
if (!clause && before.privateData.noDie && (gate || typeof arg === "number" || arg.privateData.noDie)) {
|
|
3892
|
+
data.noDie = true;
|
|
3893
|
+
}
|
|
3894
|
+
const mixed = before.privateData.branches !== void 0 || !gate && typeof arg !== "number" && arg.privateData.branches !== void 0;
|
|
3895
|
+
if (!clause && op === Dice.prototype.combine) {
|
|
3896
|
+
data.branches = [...branchesOf(before), ...branchesOf(arg)];
|
|
3897
|
+
} else if (!clause && mixed && op !== Dice.prototype.reroll) {
|
|
3898
|
+
const advantage = op === Dice.prototype.advantage;
|
|
3899
|
+
const pairOp = advantage ? Dice.prototype.max : op;
|
|
3900
|
+
const lefts = branchesOf(before);
|
|
3901
|
+
const rights = advantage ? lefts : gate ? [asValue(arg)] : branchesOf(arg);
|
|
3902
|
+
data.branches = lefts.flatMap(
|
|
3903
|
+
(left) => rights.map((right) => {
|
|
3904
|
+
const part = pairOp.call(left, right);
|
|
3905
|
+
followNaturalRoll(left, pairOp, right, part, false);
|
|
3906
|
+
return part;
|
|
3907
|
+
})
|
|
3908
|
+
);
|
|
3909
|
+
}
|
|
3910
|
+
if (data.branches) {
|
|
3911
|
+
mixNaturalRolls(after, data.branches);
|
|
3912
|
+
return;
|
|
3913
|
+
}
|
|
3914
|
+
const track = before.privateData.critTrack;
|
|
3915
|
+
const argTrack = typeof arg === "number" || gate ? void 0 : arg.privateData.critTrack;
|
|
3916
|
+
const sides = naturalSides(before);
|
|
3917
|
+
const argSides = gate ? 0 : naturalSides(arg);
|
|
3918
|
+
const extremum = op === Dice.prototype.max || op === Dice.prototype.min;
|
|
3919
|
+
const own = track !== void 0 && outranks(sides, argSides);
|
|
3920
|
+
const followed = own ? track : argTrack && outranks(argSides, sides) ? argTrack : void 0;
|
|
3921
|
+
if (clause) ; else if (op === Dice.prototype.advantage || op === Dice.prototype.reroll) {
|
|
3922
|
+
if (track?.bare) data.critTrack = bareTrack(after);
|
|
3923
|
+
} else if (extremum && track?.bare && argTrack?.bare && sides === argSides) {
|
|
3924
|
+
data.critTrack = bareTrack(after);
|
|
3925
|
+
} else if (followed) {
|
|
3926
|
+
const apply = own ? (value) => op.call(value, arg) : (value) => op.call(before, value);
|
|
3927
|
+
const step = extremum ? (slice) => keptPart(slice, apply) : apply;
|
|
3928
|
+
data.critTrack = { sides: followed.sides, bare: false, slice: (face) => step(followed.slice(face)) };
|
|
3929
|
+
}
|
|
3930
|
+
if (!data.critTrack) {
|
|
3931
|
+
const top = outranks(argSides, sides) ? argSides : sides;
|
|
3932
|
+
if (top > 0) data.untrackedSides = top;
|
|
3933
|
+
}
|
|
3934
|
+
}
|
|
3935
|
+
function mixNaturalRolls(after, branches) {
|
|
3936
|
+
const sides = branches.reduce((top, branch) => outranks(naturalSides(branch), top) ? naturalSides(branch) : top, 0);
|
|
3937
|
+
if (sides === 0) return;
|
|
3938
|
+
const tracks = [];
|
|
3939
|
+
for (const branch of branches) {
|
|
3940
|
+
if (naturalSides(branch) !== sides) continue;
|
|
3941
|
+
const track = branch.privateData.critTrack;
|
|
3942
|
+
if (!track) {
|
|
3943
|
+
after.privateData.untrackedSides = sides;
|
|
3944
|
+
return;
|
|
3945
|
+
}
|
|
3946
|
+
tracks.push(track);
|
|
3947
|
+
}
|
|
3948
|
+
after.privateData.critTrack = {
|
|
3949
|
+
sides,
|
|
3950
|
+
bare: tracks.length === branches.length && tracks.every((track) => track.bare),
|
|
3951
|
+
slice: (face) => {
|
|
3952
|
+
const slice = new Dice();
|
|
3953
|
+
for (const track of tracks) slice.combineInPlace(track.slice(face));
|
|
3954
|
+
return slice;
|
|
3955
|
+
}
|
|
3956
|
+
};
|
|
3957
|
+
}
|
|
3958
|
+
function keptPart(slice, apply) {
|
|
3959
|
+
const result = new Dice();
|
|
3960
|
+
for (const [value, count] of slice.getFaceEntries()) {
|
|
3961
|
+
const face = new Dice();
|
|
3962
|
+
face.setFace(value, count);
|
|
3963
|
+
const kept = apply(face).get(value);
|
|
3964
|
+
if (kept) result.increment(value, kept);
|
|
3965
|
+
}
|
|
3966
|
+
return result;
|
|
3967
|
+
}
|
|
3968
|
+
function splitCrit(check, count) {
|
|
3969
|
+
if (count === 0) {
|
|
3970
|
+
const none = new Dice();
|
|
3971
|
+
return { crit: none, rest: subtractCounts(check, none) };
|
|
3972
|
+
}
|
|
3973
|
+
const track = check.privateData.critTrack;
|
|
3974
|
+
if (!track) {
|
|
3975
|
+
throw new Error(
|
|
3976
|
+
"crit rate cannot be computed exactly for this attack check: its natural roll is not one die (a dice-valued check like 2d20 or 2kh2d20, two d20s like d20 + d20, or advantage over a total). Build the attack with the builder API instead."
|
|
3977
|
+
);
|
|
3978
|
+
}
|
|
3979
|
+
const { sides } = track;
|
|
3980
|
+
if (count > sides) {
|
|
3981
|
+
throw new Error(`xcrit${count} is wider than the d${sides} it reads its natural roll from`);
|
|
3982
|
+
}
|
|
3983
|
+
let crit = new Dice();
|
|
3984
|
+
for (let face = sides; face > sides - count; face--) crit.combineInPlace(track.slice(face));
|
|
3985
|
+
const rest = subtractCounts(check, crit);
|
|
3986
|
+
const missed = crit.get(0);
|
|
3987
|
+
if (missed) {
|
|
3988
|
+
crit = crit.deleteFace(0);
|
|
3989
|
+
rest.increment(0, missed);
|
|
3990
|
+
}
|
|
3991
|
+
return { crit, rest };
|
|
3992
|
+
}
|
|
3993
|
+
function critPayload(text, n) {
|
|
3994
|
+
let doubled = text;
|
|
3995
|
+
try {
|
|
3996
|
+
doubled = scaleParsedDice(text.replace(/n/g, String(n)), 2);
|
|
3997
|
+
} catch (error) {
|
|
3998
|
+
if (!(error instanceof UndoubleableExpressionError)) throw error;
|
|
3999
|
+
}
|
|
4000
|
+
const chars = [...doubled];
|
|
4001
|
+
const payload = parseExpression(chars, n);
|
|
4002
|
+
if (chars.length > 0) {
|
|
4003
|
+
throw new Error(`Unexpected token '${chars[0]}' in the crit payload '${doubled}'`);
|
|
4004
|
+
}
|
|
4005
|
+
return payload;
|
|
4006
|
+
}
|
|
4007
|
+
function applyByOutcome(labelled, op, arg, termText, n) {
|
|
4008
|
+
const implicit = labelled.privateData.implicitCrit;
|
|
4009
|
+
const argTotal = typeof arg === "number" ? 1 : arg.total();
|
|
4010
|
+
const result = new Dice();
|
|
4011
|
+
let rest = labelled;
|
|
4012
|
+
let payload;
|
|
4013
|
+
for (const [label, distribution] of Object.entries(labelled.getFullOutcomeDistribution())) {
|
|
4014
|
+
if (label === "hit" || distribution === void 0) continue;
|
|
4015
|
+
const part = new Dice();
|
|
4016
|
+
for (const [face, count] of Object.entries(distribution)) part.increment(Number(face), count);
|
|
4017
|
+
rest = subtractCounts(rest, part);
|
|
4018
|
+
let applied;
|
|
4019
|
+
if (label === "crit" && implicit && termText !== void 0) {
|
|
4020
|
+
payload = implicit.payload + termText;
|
|
4021
|
+
const doubled = critPayload(payload, n);
|
|
4022
|
+
applied = doubled.normalize(part.total() * argTotal / doubled.total());
|
|
4023
|
+
} else {
|
|
4024
|
+
applied = op.call(part, arg);
|
|
4025
|
+
}
|
|
4026
|
+
result.combineInPlace(applied);
|
|
4027
|
+
result.setOutcomeDistribution(label, applied.getFaceMap());
|
|
4028
|
+
}
|
|
4029
|
+
result.combineInPlace(op.call(rest, arg));
|
|
4030
|
+
if (labelled.privateData.isDCCheck) result.privateData.isDCCheck = true;
|
|
4031
|
+
if (payload !== void 0) result.privateData.implicitCrit = { payload };
|
|
4032
|
+
return result;
|
|
4033
|
+
}
|
|
4034
|
+
function landedHitsAtZero(operand, op, arg) {
|
|
4035
|
+
let landed = 0;
|
|
4036
|
+
for (const [face, count] of Object.entries(operand.calculateHitDistribution())) {
|
|
4037
|
+
if (!(count > 0)) continue;
|
|
4038
|
+
const hit = new Dice();
|
|
4039
|
+
hit.setFace(Number(face), count);
|
|
4040
|
+
landed += op.call(hit, arg).get(0);
|
|
4041
|
+
}
|
|
4042
|
+
return landed;
|
|
4043
|
+
}
|
|
4044
|
+
function parseArgument(s, n, inCheck = false) {
|
|
4045
|
+
if (s[0] === "-") {
|
|
4046
|
+
s.shift();
|
|
4047
|
+
const operand = parseArgument(s, n, inCheck);
|
|
4048
|
+
if (typeof operand === "number") return 0 - operand;
|
|
4049
|
+
const zero = asValue(0);
|
|
4050
|
+
const negated = zero.subtract(operand);
|
|
4051
|
+
followNaturalRoll(zero, Dice.prototype.subtract, operand, negated, false);
|
|
4052
|
+
return negated;
|
|
4053
|
+
}
|
|
4054
|
+
let result = parseArgumentInternal(s, n, inCheck);
|
|
4055
|
+
if (result === void 0) {
|
|
4056
|
+
const at = s.length === 0 ? "the end of the expression" : `'${s.slice(0, 20).join("")}'`;
|
|
4057
|
+
throw new Error(`Expected a number, a die, a keep or '(' at ${at}`);
|
|
4058
|
+
}
|
|
4059
|
+
for (let next = parseArgumentInternal(s, n, inCheck); next !== void 0; next = parseArgumentInternal(s, n, inCheck)) {
|
|
3203
4060
|
result = multiplyDiceByDice(result, next);
|
|
3204
4061
|
}
|
|
3205
4062
|
return result;
|
|
3206
4063
|
}
|
|
3207
4064
|
function multiplyDiceByDice(d1, d2) {
|
|
4065
|
+
const noDie = (typeof d1 === "number" || d1.privateData.noDie) && (typeof d2 === "number" || d2.privateData.noDie);
|
|
3208
4066
|
if (typeof d1 === "number") d1 = Dice.scalar(d1);
|
|
3209
4067
|
if (typeof d2 === "number") d2 = Dice.scalar(d2);
|
|
3210
4068
|
const result = new Dice();
|
|
3211
4069
|
const faces = /* @__PURE__ */ new Map();
|
|
3212
|
-
let
|
|
4070
|
+
let common = 1;
|
|
4071
|
+
const { keep } = d2.privateData;
|
|
3213
4072
|
for (const key of d1.keys()) {
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
continue;
|
|
3217
|
-
}
|
|
3218
|
-
if (d2.privateData.keep) {
|
|
3219
|
-
const faceCount = d2.keys().length;
|
|
3220
|
-
if (Math.pow(faceCount, key) > MAX_KEEP_OUTCOMES) {
|
|
3221
|
-
throw new DiceParseError(
|
|
3222
|
-
`Keep enumeration of ${faceCount}^${key} outcomes exceeds the maximum of ${MAX_KEEP_OUTCOMES}`
|
|
3223
|
-
);
|
|
3224
|
-
}
|
|
3225
|
-
const repeat = Array(key).fill(d2);
|
|
3226
|
-
face = opDice(repeat, d2.privateData.keep);
|
|
3227
|
-
} else {
|
|
3228
|
-
face = multiplyDice(key, d2);
|
|
3229
|
-
}
|
|
3230
|
-
normalizationFactor *= face.total();
|
|
4073
|
+
const face = keep ? keepDice(d2, key, keep) : multiplyDice(key, d2);
|
|
4074
|
+
common *= face.total();
|
|
3231
4075
|
faces.set(key, face);
|
|
3232
4076
|
}
|
|
4077
|
+
const exact = common <= MAX_EXACT_COUNT;
|
|
3233
4078
|
for (const [k, face] of faces) {
|
|
3234
4079
|
const count = d1.get(k);
|
|
3235
|
-
result.combineInPlace(
|
|
3236
|
-
face.normalize(count * normalizationFactor / face.total())
|
|
3237
|
-
);
|
|
4080
|
+
result.combineInPlace(face.normalize((exact ? common : 1) * count / face.total()));
|
|
3238
4081
|
}
|
|
3239
4082
|
result.privateData.except = {};
|
|
4083
|
+
const [only, ...more] = d1.keys();
|
|
4084
|
+
const { critTrack } = d2.privateData;
|
|
4085
|
+
const rolls = keep === void 0 ? only : Math.min(only, keep.kept);
|
|
4086
|
+
const sides = outranks(naturalSides(d2), naturalSides(d1)) ? naturalSides(d2) : naturalSides(d1);
|
|
4087
|
+
if (rolls === 1 && more.length === 0 && critTrack?.bare) {
|
|
4088
|
+
result.privateData.critTrack = bareTrack(result);
|
|
4089
|
+
} else if (sides > 0) {
|
|
4090
|
+
result.privateData.untrackedSides = sides;
|
|
4091
|
+
}
|
|
4092
|
+
if (noDie) result.privateData.noDie = true;
|
|
3240
4093
|
return result;
|
|
3241
4094
|
}
|
|
3242
|
-
function
|
|
4095
|
+
function assertRepeatCount(n) {
|
|
4096
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
4097
|
+
throw new DiceParseError(`A repeat count must be a whole number of 0 or more; this one can be ${n}`);
|
|
4098
|
+
}
|
|
3243
4099
|
if (n > MAX_DICE_COUNT) {
|
|
3244
4100
|
throw new DiceParseError(
|
|
3245
4101
|
`Dice count ${n} exceeds the maximum of ${MAX_DICE_COUNT}`
|
|
3246
4102
|
);
|
|
3247
4103
|
}
|
|
3248
|
-
|
|
4104
|
+
}
|
|
4105
|
+
function multiplyDice(n, d) {
|
|
4106
|
+
assertRepeatCount(n);
|
|
4107
|
+
if (n === 0) return Dice.scalar(0);
|
|
3249
4108
|
if (n === 1) return d;
|
|
3250
4109
|
const half = Math.floor(n / 2);
|
|
3251
4110
|
let result = multiplyDice(half, d);
|
|
@@ -3253,43 +4112,70 @@ function multiplyDice(n, d) {
|
|
|
3253
4112
|
if (n % 2 === 1) {
|
|
3254
4113
|
result = result.add(d);
|
|
3255
4114
|
}
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
function opDice(diceList, keepFn) {
|
|
3259
|
-
return opDiceInternal(diceList, new Dice(), 0, [], 1, keepFn);
|
|
4115
|
+
const total = result.total();
|
|
4116
|
+
return total > MAX_EXACT_COUNT ? result.normalize(1 / total) : result;
|
|
3260
4117
|
}
|
|
3261
|
-
function
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
const
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
values,
|
|
3273
|
-
weight * currentDice.get(face),
|
|
3274
|
-
combineFn
|
|
4118
|
+
function keepDice(die, count, { kept, lowest }) {
|
|
4119
|
+
assertRepeatCount(count);
|
|
4120
|
+
if (kept >= count) return multiplyDice(count, die);
|
|
4121
|
+
if (kept <= 0) return Dice.scalar(0);
|
|
4122
|
+
const faces = die.getFaceEntries().filter(([, weight]) => weight > 0).sort(([a], [b]) => lowest ? a - b : b - a);
|
|
4123
|
+
if (faces.length === 0) return new Dice();
|
|
4124
|
+
const span = Math.abs(faces[faces.length - 1][0] - faces[0][0]);
|
|
4125
|
+
const work = faces.length * kept * kept * (kept * span + 1);
|
|
4126
|
+
if (work > MAX_KEEP_WORK) {
|
|
4127
|
+
throw new DiceParseError(
|
|
4128
|
+
`Keep of ${kept} of ${count} copies of a ${faces.length}-face roll exceeds the maximum work of ${MAX_KEEP_WORK}`
|
|
3275
4129
|
);
|
|
3276
|
-
values.pop();
|
|
3277
4130
|
}
|
|
4131
|
+
const tails = new Array(faces.length);
|
|
4132
|
+
for (let i = faces.length - 1, tail = 0; i >= 0; i--) tails[i] = tail += faces[i][1];
|
|
4133
|
+
const addTo = (map, key, p) => {
|
|
4134
|
+
map.set(key, (map.get(key) ?? 0) + p);
|
|
4135
|
+
};
|
|
4136
|
+
let states = Array.from({ length: kept }, () => /* @__PURE__ */ new Map());
|
|
4137
|
+
states[0].set(0, 1);
|
|
4138
|
+
const done = /* @__PURE__ */ new Map();
|
|
4139
|
+
faces.forEach(([value, weight], i) => {
|
|
4140
|
+
const q = weight / tails[i];
|
|
4141
|
+
const next = Array.from({ length: kept }, () => /* @__PURE__ */ new Map());
|
|
4142
|
+
states.forEach((sums, placed) => {
|
|
4143
|
+
if (sums.size === 0) return;
|
|
4144
|
+
const left = count - placed;
|
|
4145
|
+
const need = kept - placed;
|
|
4146
|
+
const few = [];
|
|
4147
|
+
let logChoose = 0;
|
|
4148
|
+
for (let c = 0; c < need; c++) {
|
|
4149
|
+
if (c > 0) logChoose += Math.log((left - c + 1) / c);
|
|
4150
|
+
few.push(q >= 1 ? 0 : Math.exp(logChoose + c * Math.log(q) + (left - c) * Math.log1p(-q)));
|
|
4151
|
+
}
|
|
4152
|
+
const enough = Math.max(0, 1 - few.reduce((total, p) => total + p, 0));
|
|
4153
|
+
for (const [sum, p] of sums) {
|
|
4154
|
+
few.forEach((pc, c) => {
|
|
4155
|
+
if (pc > 0) addTo(next[placed + c], sum + c * value, p * pc);
|
|
4156
|
+
});
|
|
4157
|
+
if (enough > 0) addTo(done, sum + need * value, p * enough);
|
|
4158
|
+
}
|
|
4159
|
+
});
|
|
4160
|
+
states = next;
|
|
4161
|
+
});
|
|
4162
|
+
const result = new Dice();
|
|
4163
|
+
for (const [sum, p] of done) result.increment(sum, p);
|
|
3278
4164
|
return result;
|
|
3279
4165
|
}
|
|
3280
|
-
function parseArgumentInternal(s, n) {
|
|
4166
|
+
function parseArgumentInternal(s, n, inCheck = false) {
|
|
3281
4167
|
if (s.length === 0) return;
|
|
3282
4168
|
const c = s[0];
|
|
3283
4169
|
switch (c) {
|
|
3284
4170
|
case "(":
|
|
3285
4171
|
s.shift();
|
|
3286
|
-
return assertToken(s, ")", parseExpression(s, n));
|
|
4172
|
+
return assertToken(s, ")", parseExpression(s, n, inCheck));
|
|
3287
4173
|
case "h":
|
|
3288
4174
|
case "d":
|
|
3289
4175
|
return parseDice(s, n);
|
|
3290
4176
|
case "k":
|
|
3291
4177
|
assertToken(s, "k");
|
|
3292
|
-
return parseKeep(s, n);
|
|
4178
|
+
return parseKeep(s, n, inCheck);
|
|
3293
4179
|
case "n":
|
|
3294
4180
|
return parseNumber(s, n);
|
|
3295
4181
|
default:
|
|
@@ -3333,10 +4219,11 @@ function parseDice(s, n) {
|
|
|
3333
4219
|
);
|
|
3334
4220
|
}
|
|
3335
4221
|
let result = new Dice(sides);
|
|
4222
|
+
if (sides === 0) return result;
|
|
3336
4223
|
if (rerollOne) {
|
|
3337
4224
|
result = result.reroll(1);
|
|
3338
4225
|
}
|
|
3339
|
-
result.privateData.
|
|
4226
|
+
result.privateData.critTrack = bareTrack(result);
|
|
3340
4227
|
return result;
|
|
3341
4228
|
}
|
|
3342
4229
|
function peek(arr, expected) {
|
|
@@ -3364,31 +4251,24 @@ function parseNumber(s, n) {
|
|
|
3364
4251
|
function isDigit(c) {
|
|
3365
4252
|
return c >= "0" && c <= "9";
|
|
3366
4253
|
}
|
|
3367
|
-
function parseKeep(s, n) {
|
|
3368
|
-
let
|
|
4254
|
+
function parseKeep(s, n, inCheck) {
|
|
4255
|
+
let lowest = false;
|
|
3369
4256
|
if (peek(s, "l")) {
|
|
3370
4257
|
assertToken(s, "l");
|
|
3371
|
-
|
|
4258
|
+
lowest = true;
|
|
3372
4259
|
} else if (peek(s, "h")) {
|
|
3373
4260
|
assertToken(s, "h");
|
|
3374
|
-
keepLowest = false;
|
|
3375
4261
|
} else {
|
|
3376
4262
|
return;
|
|
3377
4263
|
}
|
|
3378
|
-
const
|
|
3379
|
-
const result = parseArgumentInternal(s, n);
|
|
4264
|
+
const kept = parseNumber(s, n);
|
|
4265
|
+
const result = parseArgumentInternal(s, n, inCheck);
|
|
3380
4266
|
if (result instanceof Dice) {
|
|
3381
|
-
result.privateData.keep =
|
|
4267
|
+
result.privateData.keep = { kept, lowest };
|
|
3382
4268
|
return result;
|
|
3383
4269
|
}
|
|
3384
4270
|
throw new Error("Expected Dice after keep modifier");
|
|
3385
4271
|
}
|
|
3386
|
-
function keepN(n, low) {
|
|
3387
|
-
return (values) => {
|
|
3388
|
-
const sorted = [...values].sort((a, b) => low ? a - b : b - a);
|
|
3389
|
-
return sorted.slice(0, n).reduce((sum, val) => sum + val, 0);
|
|
3390
|
-
};
|
|
3391
|
-
}
|
|
3392
4272
|
function parseOperation(s) {
|
|
3393
4273
|
switch (s[0]) {
|
|
3394
4274
|
case ")":
|
|
@@ -3531,7 +4411,7 @@ var Mixture = class _Mixture {
|
|
|
3531
4411
|
}
|
|
3532
4412
|
/**
|
|
3533
4413
|
* Add a labeled component with a mixture weight.
|
|
3534
|
-
* Weight can be any positive finite number
|
|
4414
|
+
* Weight can be any positive finite number; only the ratios between weights matter.
|
|
3535
4415
|
*/
|
|
3536
4416
|
add(label, pmf, weight = 1) {
|
|
3537
4417
|
if (!Number.isFinite(weight) || weight <= 0) return this;
|
|
@@ -3539,7 +4419,7 @@ var Mixture = class _Mixture {
|
|
|
3539
4419
|
const p = bin.p;
|
|
3540
4420
|
if (p <= 0) continue;
|
|
3541
4421
|
const add = weight * p;
|
|
3542
|
-
if (!Number.isFinite(add) ||
|
|
4422
|
+
if (!Number.isFinite(add) || add <= 0) continue;
|
|
3543
4423
|
this.totals.set(v, (this.totals.get(v) ?? 0) + add);
|
|
3544
4424
|
const bag = this.labelMass.get(v) ?? {};
|
|
3545
4425
|
bag[label] = (bag[label] ?? 0) + add;
|
|
@@ -3547,27 +4427,35 @@ var Mixture = class _Mixture {
|
|
|
3547
4427
|
}
|
|
3548
4428
|
return this;
|
|
3549
4429
|
}
|
|
4430
|
+
/**
|
|
4431
|
+
* The normalized mixture. Each bin's `p` and per-label `count` are its raw mass divided by
|
|
4432
|
+
* the grand total, so labels sum to `p` whatever the weights summed to. Outcomes below
|
|
4433
|
+
* `eps` of the total (the pruning `eps` given to the constructor) are dropped first.
|
|
4434
|
+
*
|
|
4435
|
+
* @param eps Epsilon carried by the built PMF.
|
|
4436
|
+
*/
|
|
3550
4437
|
buildPMF(eps = EPS) {
|
|
3551
|
-
|
|
3552
|
-
let c = 0;
|
|
3553
|
-
for (const m of this.totals.values()) {
|
|
3554
|
-
const y = m - c;
|
|
3555
|
-
const t = grand + y;
|
|
3556
|
-
c = t - grand - y;
|
|
3557
|
-
grand = t;
|
|
3558
|
-
}
|
|
4438
|
+
const grand = kahanSum(this.totals.values());
|
|
3559
4439
|
if (!(grand > 0)) throw new Error("Mixture: zero total mass");
|
|
4440
|
+
const threshold = this.eps * grand;
|
|
4441
|
+
const kept = [...this.totals].filter(([, m]) => m > 0 && m >= threshold);
|
|
4442
|
+
if (kept.length === 0) {
|
|
4443
|
+
throw new Error(`Mixture: pruning at eps ${this.eps} removed every outcome`);
|
|
4444
|
+
}
|
|
4445
|
+
const keptTotal = kept.length === this.totals.size ? grand : kahanSum(kept.map(([, m]) => m));
|
|
3560
4446
|
const internal = /* @__PURE__ */ new Map();
|
|
3561
|
-
for (const [v, m] of
|
|
3562
|
-
|
|
3563
|
-
const
|
|
3564
|
-
|
|
4447
|
+
for (const [v, m] of kept) {
|
|
4448
|
+
const count = {};
|
|
4449
|
+
const bag = this.labelMass.get(v) ?? {};
|
|
4450
|
+
for (const label in bag) count[label] = bag[label] / keptTotal;
|
|
4451
|
+
internal.set(v, { p: m / keptTotal, count });
|
|
3565
4452
|
}
|
|
3566
4453
|
return new PMF(internal, eps);
|
|
3567
4454
|
}
|
|
3568
4455
|
/**
|
|
3569
4456
|
* Produce normalized *per-label* PMFs (labels independent).
|
|
3570
|
-
* These are unlabeled PMFs built from the raw mass of that label alone
|
|
4457
|
+
* These are unlabeled PMFs built from the raw mass of that label alone; values below `eps`
|
|
4458
|
+
* of the label's own mass are pruned.
|
|
3571
4459
|
*/
|
|
3572
4460
|
byOutcome() {
|
|
3573
4461
|
const labels = /* @__PURE__ */ new Set();
|
|
@@ -3576,12 +4464,16 @@ var Mixture = class _Mixture {
|
|
|
3576
4464
|
}
|
|
3577
4465
|
const out = {};
|
|
3578
4466
|
for (const label of labels) {
|
|
4467
|
+
const labelTotal = kahanSum(
|
|
4468
|
+
[...this.labelMass.values()].map((bag) => bag[label] ?? 0)
|
|
4469
|
+
);
|
|
4470
|
+
if (!(labelTotal > 0)) continue;
|
|
3579
4471
|
const m = /* @__PURE__ */ new Map();
|
|
3580
4472
|
for (const [v, bag] of this.labelMass) {
|
|
3581
4473
|
const w = bag[label];
|
|
3582
|
-
if (w
|
|
4474
|
+
if (w) m.set(v, w / labelTotal);
|
|
3583
4475
|
}
|
|
3584
|
-
|
|
4476
|
+
out[label] = PMF.fromMap(m, this.eps);
|
|
3585
4477
|
}
|
|
3586
4478
|
return out;
|
|
3587
4479
|
}
|
|
@@ -3597,14 +4489,7 @@ var Mixture = class _Mixture {
|
|
|
3597
4489
|
res[lab] = (res[lab] ?? 0) + w;
|
|
3598
4490
|
}
|
|
3599
4491
|
}
|
|
3600
|
-
|
|
3601
|
-
let c = 0;
|
|
3602
|
-
for (const v of Object.values(res)) {
|
|
3603
|
-
const y = v - c;
|
|
3604
|
-
const t = total + y;
|
|
3605
|
-
c = t - total - y;
|
|
3606
|
-
total = t;
|
|
3607
|
-
}
|
|
4492
|
+
const total = kahanSum(Object.values(res));
|
|
3608
4493
|
if (total > 0) {
|
|
3609
4494
|
for (const k in res) res[k] = res[k] / total;
|
|
3610
4495
|
}
|
|
@@ -3620,10 +4505,21 @@ var Mixture = class _Mixture {
|
|
|
3620
4505
|
static mix(items, eps = EPS) {
|
|
3621
4506
|
const mix = new _Mixture(eps);
|
|
3622
4507
|
for (const [lab, pmf, w] of items) mix.add(lab, pmf, w);
|
|
3623
|
-
return mix.buildPMF();
|
|
4508
|
+
return mix.buildPMF(eps);
|
|
3624
4509
|
}
|
|
3625
4510
|
};
|
|
4511
|
+
function kahanSum(values) {
|
|
4512
|
+
let sum = 0;
|
|
4513
|
+
let c = 0;
|
|
4514
|
+
for (const v of values) {
|
|
4515
|
+
const y = v - c;
|
|
4516
|
+
const t = sum + y;
|
|
4517
|
+
c = t - sum - y;
|
|
4518
|
+
sum = t;
|
|
4519
|
+
}
|
|
4520
|
+
return sum;
|
|
4521
|
+
}
|
|
3626
4522
|
|
|
3627
|
-
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, tryParse, withRollType };
|
|
4523
|
+
export { ALL_OUTCOME_TYPES, DiceParseError, DiceQuery, EPS, LRUCache, MISS_NONE_OUTCOME, Mixture, OUTCOME_DISPLAY_ORDER, PMF, calculateBounceOdds, clearParserCache, critProbability, diceSumDistribution, explodingPoolMatchProbability, faceWeights, getCachingEnabled, jointSumAndMatch, onAnyHit, onCritOnly, onHitOnly, onMissDamageOnly, onMissOnly, onPotentCantripOnly, onSaveFailOnly, onSaveHalfOnly, parse, pmfCache, setCachingEnabled, sortOutcomes, tryParse, withRollType };
|
|
3628
4524
|
//# sourceMappingURL=index.js.map
|
|
3629
4525
|
//# sourceMappingURL=index.js.map
|