diff --git a/CHANGELOG.md b/CHANGELOG.md index 531af67..3e75a72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,62 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.0] + +Pushes damage-attribution / provenance and D&D-probability logic that the +consuming app (dprcalc) had hand-rolled over PMF internals down into the +library, so the provenance model and dice math stay owned here. All additive +except the Elemental-Adept bounce fix noted below. + +### Added + +- **`PMF.applyHitFrequency(frequency)`** — provenance-preserving mass + redistribution for effects that only occur with some probability (conditional + attacks, on-hit riders, sub-one AoE fractions): scales every hit bin (damage + > 0) by `frequency` and moves the freed mass into a `missNone` bin. Unlike a + bare `scaleMass`/`mapDamage`, it scales per-label `count` **and** `attr`, so a + frequency-scaled PMF still renders correctly in the damage-attribution charts. + Replaces the app's hand-rolled `applyFrequencyToPMF`, which dropped `attr`. +- **`PMF.missNone(epsilon?)`** / **`MISS_NONE_OUTCOME`** — canonical "clean miss" + delta (point mass at 0 tagged with the `missNone` `OutcomeType`, distinct from + `PMF.zero`'s builder-side `miss` label), and the label as a single source of + truth. +- **`PMF.hitProbability()` / `PMF.missProbability()`** — the `1 - P(0)` idiom + (miss encoded at damage 0), centralized. +- **`PMF.rebin(maxBuckets)`** — coarsen a wide distribution into ≤ N contiguous + equal-width buckets, aggregating `count`/`attr` provenance. For charting wide + distributions, not DPR math. +- **`PMF.attributionByValue()` / `DiceQuery.attributionByValue()`** — split each + damage value's probability mass across outcome labels (by `attr` for + damage-bearing bins, by `count` for the clean-miss bin), returning per-label + `value → mass` series. The provenance core of the stacked attribution chart. +- **`DiceQuery.countSinglesWith(label)`** — how many independent single PMFs can + produce a given outcome label. +- **`ALL_OUTCOME_TYPES`**, **`OUTCOME_DISPLAY_ORDER`**, **`sortOutcomes()`** — + canonical `OutcomeType` enumeration + stack / display orderings, replacing + per-consumer outcome tables. +- **`critProbability(critRange, rollType)`** and **`RollType`** (now exported + from the package root as well as `@yipe/dice/builder`) — advantage-aware + P(crit) for a given crit window. +- **`calculateBounceOdds(diceCount, dieFaces, options?)`** and + **`BounceOddsOptions`** — the "birthday problem" for bouncing damage dice + (Chromatic Orb), honoring Elemental Adept and Empowered Spell. Moved out of the + app; the base and Elemental-Adept cases are now computed **exactly** (verified + against brute-force enumeration in `tests/bounce.test.ts`). + +### Fixed + +- **`calculateBounceOdds` Elemental Adept was approximate.** The former + hand-derived adjustment factor drifted from the exact value by up to ~3.5% + (e.g. 3×d8, min-roll 3: 0.4965 → 0.5313). The Elemental-Adept branch now uses + an exact elementary-symmetric-polynomial computation. Consumers relying on the + old numbers for bouncing spells with Elemental Adept will see small DPR shifts. +- **`calculateBounceOdds` Empowered Spell returned certainty when rerolling all + dice.** When `rerollDamageDice >= diceCount` (no dice kept), the model claimed + a guaranteed match (1.0) instead of treating the reroll as a second + independent roll. It now correctly yields `1 - (1 - pMatch)^2` in that case + (e.g. 3×d8 reroll-all: 1.0 → 0.5693). + ## [0.3.0] ### Fixed (mathematical correctness) diff --git a/package.json b/package.json index bf3a3f0..853841b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@yipe/dice", - "version": "0.3.0", + "version": "0.4.0", "description": "A high-performance dice probability engine for D&D 5e DPR calculations. Powers dprcalc.com.", "keywords": [ "dnd", diff --git a/src/builder/types.ts b/src/builder/types.ts index 5c07439..a2918d0 100644 --- a/src/builder/types.ts +++ b/src/builder/types.ts @@ -1,6 +1,9 @@ import type { PMF } from "../pmf/pmf"; +import type { RollType } from "../common/types"; import type { RollBuilder } from "./roll"; +export type { RollType }; + export type RollFactory = { (count: number, sides?: number, modifier?: number): RollBuilder; (count: number, die: RollBuilder, modifier?: number): RollBuilder; @@ -16,7 +19,6 @@ export type RollFactory = { flat(n: number): RollBuilder; }; -export type RollType = "flat" | "advantage" | "disadvantage" | "elven accuracy"; export type KeepMode = "highest" | "lowest"; // Intermediate Representation, gets converetd into an AST as needed diff --git a/src/common/bounce.ts b/src/common/bounce.ts new file mode 100644 index 0000000..c02ed5e --- /dev/null +++ b/src/common/bounce.ts @@ -0,0 +1,122 @@ +/** + * Bounce odds — the "birthday problem" for bouncing damage dice (e.g. Chromatic + * Orb): the probability that at least two of K dice with S faces show the same + * value, which is what lets the spell jump to another target. + * + * Accounts for two modifiers: + * - **Elemental Adept** (`minimumDieRoll >= 2`): rolls below the minimum are + * bumped up to it, collapsing the low faces onto a single heavier value. + * - **Empowered Spell** (`rerollDamageDice > 0`): a number of dice may be + * rerolled once, giving a second chance at a match. + * + * The base and Elemental-Adept cases are computed exactly (see + * {@link pAllDistinct}); the Empowered-Spell reroll is an explicit model layered + * on the exact base match probability. + */ + +/** Options that modify bounce odds via metamagic / feats. */ +export interface BounceOddsOptions { + /** Minimum die roll — e.g. 2 for Elemental Adept, 3 for Great Weapon Fighting 2024. */ + minimumDieRoll?: number; + /** Number of dice that may be rerolled once — e.g. CHA modifier for Empowered Spell. */ + rerollDamageDice?: number; +} + +/** Binomial coefficient C(n, k), 0 for out-of-range k. */ +function binom(n: number, k: number): number { + if (k < 0 || k > n) return 0; + let result = 1; + for (let i = 0; i < k; i++) result = (result * (n - i)) / (i + 1); + return result; +} + +/** + * Exact P(all K dice show distinct values) for a die with `uniformCount` + * ordinary faces (each probability `1/faces`) plus one optional heavy face whose + * probability is `heavyWeight` (used for the Elemental-Adept collapse; pass 0 + * for a plain die). Uses the elementary symmetric polynomial e_K over the face + * probabilities: P(all distinct) = K! · e_K. + */ +function pAllDistinct( + dice: number, + faces: number, + uniformCount: number, + heavyWeight: number +): number { + const light = 1 / faces; + // e_K = (choose K distinct light faces) + (heavy face + K-1 light faces). + const eK = + binom(uniformCount, dice) * Math.pow(light, dice) + + heavyWeight * binom(uniformCount, dice - 1) * Math.pow(light, dice - 1); + let kFactorial = 1; + for (let i = 2; i <= dice; i++) kFactorial *= i; + return kFactorial * eK; +} + +/** Exact P(at least one duplicate) among `dice` dice, honoring Elemental Adept. */ +function pMatch(dice: number, faces: number, minimumDieRoll: number): number { + if (dice <= 1) return 0; + if (dice > faces) return 1; + + if (minimumDieRoll >= 2) { + // Rolls 1..minimumDieRoll collapse onto the value `minimumDieRoll`, giving it + // weight minimumDieRoll/faces; the faces above it stay uniform at 1/faces. + const uniformCount = faces - minimumDieRoll; // values minimumDieRoll+1 .. faces + const effectiveValues = uniformCount + 1; // + the collapsed value + if (dice > effectiveValues) return 1; + const heavyWeight = minimumDieRoll / faces; + const distinct = pAllDistinct(dice, faces, uniformCount, heavyWeight); + return Math.min(1, Math.max(0, 1 - distinct)); + } + + // Plain die: P(all distinct) = falling_factorial(faces, dice) / faces^dice. + let pDistinct = 1; + for (let i = 0; i < dice; i++) pDistinct *= (faces - i) / faces; + return 1 - pDistinct; +} + +/** + * P(at least two of `diceCount` dice with `dieFaces` faces match), honoring + * Elemental Adept and Empowered Spell. Returns a probability in [0, 1]. + * + * @param diceCount Number of dice rolled. + * @param dieFaces Faces per die (e.g. 8 for d8). + * @param options Optional metamagic / feat modifiers. + */ +export function calculateBounceOdds( + diceCount: number, + dieFaces: number, + options?: BounceOddsOptions +): number { + if (diceCount <= 1) return 0; + if (diceCount > dieFaces) return 1; // pigeonhole + + const minimumDieRoll = options?.minimumDieRoll ?? 0; + const rerollDamageDice = options?.rerollDamageDice ?? 0; + + const pMatchFirst = pMatch(diceCount, dieFaces, minimumDieRoll); + + // Without Empowered Spell we're done. + const rerollCount = Math.min(rerollDamageDice, diceCount); + if (rerollCount <= 0) return pMatchFirst; + + // Empowered Spell: reroll `rerollCount` non-matching dice once. Model the + // second chance as (a rerolled die matching one of the kept dice) OR (the + // rerolled dice matching among themselves). + const pNoMatchFirst = 1 - pMatchFirst; + const keptDice = diceCount - rerollCount; + const effectiveFaces = minimumDieRoll >= 2 ? dieFaces - (minimumDieRoll - 1) : dieFaces; + + // With no kept dice, a rerolled die vacuously "misses" all of them (prob 1), so + // the only way to match is among the rerolled dice themselves (pRerolledMatch below). + const pRerollDieMissesAll = + keptDice > 0 ? Math.pow((effectiveFaces - keptDice) / effectiveFaces, rerollCount) : 1; + const pAtLeastOneRerollMatches = 1 - pRerollDieMissesAll; + const pRerolledMatch = rerollCount >= 2 ? pMatch(rerollCount, dieFaces, minimumDieRoll) : 0; + const pMatchAfterReroll = Math.min( + 1, + pAtLeastOneRerollMatches + pRerolledMatch * (1 - pAtLeastOneRerollMatches) + ); + + return Math.min(1, pMatchFirst + pNoMatchFirst * pMatchAfterReroll); +} diff --git a/src/common/types.ts b/src/common/types.ts index d77bdad..7584f6f 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -32,6 +32,89 @@ export type OutcomeType = export type Rounding = "none" | "floor" | "round" | "ceil"; +/** How a d20 attack roll resolves: single die, keep-highest of 2/3, or keep-lowest of 2. */ +export type RollType = "flat" | "advantage" | "disadvantage" | "elven accuracy"; + +/** + * P(critical hit) for the given crit window and d20 {@link RollType}. + * + * `critRange` is the number of top faces that crit (1 for a natural 20, 2 for + * 19–20, …), so a single die crits with probability `critRange / 20`. Advantage + * rolls two d20s / elven accuracy three, keeping the best; disadvantage keeps + * the worst of two. + */ +export function critProbability(critRange: number, rollType: RollType = "flat"): number { + const base = critRange / 20; + switch (rollType) { + case "advantage": + return 1 - (1 - base) ** 2; + case "elven accuracy": + return 1 - (1 - base) ** 3; + case "disadvantage": + return base ** 2; + case "flat": + default: + return base; + } +} + +/** + * The canonical "clean miss" outcome — a point of zero damage with no rider. + * This is the {@link OutcomeType} that attribution charts and outcome stats key + * on, and is distinct from the builder's attack-resolution `miss` weight label. + */ +export const MISS_NONE_OUTCOME: OutcomeType = "missNone"; + +/** + * All outcome types in canonical severity order — clean miss → crit. This is + * also the natural stacking order for attribution charts (least- to + * most-impactful, bottom → top). Enumerates every {@link OutcomeType} exactly + * once; use it instead of hand-maintained per-consumer outcome tables. + */ +export const ALL_OUTCOME_TYPES: OutcomeType[] = [ + "missNone", + "missDamage", + "saveFail", + "saveHalf", + "pc", + "hit", + "crit", +]; + +/** + * Outcome types in display order for stats / breakdown rows — most prominent + * first (crit, hit, …) down to the clean miss. + */ +export const OUTCOME_DISPLAY_ORDER: OutcomeType[] = [ + "crit", + "hit", + "missDamage", + "saveHalf", + "saveFail", + "pc", + "missNone", +]; + +/** + * Sort outcome labels by a canonical order (defaults to {@link ALL_OUTCOME_TYPES}). + * Labels not present in `order` sort after known ones, alphabetically — so + * ad-hoc/test labels outside the {@link OutcomeType} union stay stable. + */ +export function sortOutcomes( + outcomes: Iterable, + order: readonly string[] = ALL_OUTCOME_TYPES +): T[] { + const rank = new Map(order.map((o, i) => [o, i])); + return [...outcomes].sort((a, b) => { + const ra = rank.get(a); + const rb = rank.get(b); + if (ra !== undefined && rb !== undefined) return ra - rb; + if (ra !== undefined) return -1; + if (rb !== undefined) return 1; + return a.localeCompare(b); + }); +} + export const onAnyHit: OutcomeType[] = ["hit", "crit"]; export const onCritOnly: OutcomeType[] = ["crit"]; export const onHitOnly: OutcomeType[] = ["hit"]; diff --git a/src/index.ts b/src/index.ts index f9bfdd1..32800c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export * from "./common/bounce"; export * from "./common/errors"; export * from "./common/lru-cache"; export * from "./common/types"; diff --git a/src/pmf/pmf.ts b/src/pmf/pmf.ts index 005bb5a..04c752e 100644 --- a/src/pmf/pmf.ts +++ b/src/pmf/pmf.ts @@ -1,6 +1,6 @@ import { LRUCache } from "../common/lru-cache"; import type { Bin, OutcomeLabelMap, Rounding } from "../common/types"; -import { EPS } from "../common/types"; +import { EPS, MISS_NONE_OUTCOME } from "../common/types"; import { DiceQuery } from "./query"; const cacheEnabled = true; @@ -47,6 +47,21 @@ export class PMF { return PMF.fromMap(new Map([[value, 1]]), epsilon); } + /** + * Point mass at damage 0 tagged with the canonical `missNone` outcome. + * + * Differs from {@link PMF.zero}, which labels its zero bin `miss` — the + * builder's attack-resolution vocabulary. This uses the `missNone` + * {@link OutcomeType} that the attribution charts and outcome stats key on, + * so it is the correct "clean miss / no damage" delta for provenance-aware + * mixtures feeding those consumers. + */ + static missNone(epsilon = EPS): PMF { + const m = new Map(); + m.set(0, { p: 1, count: { [MISS_NONE_OUTCOME]: 1 }, attr: {} }); + return new PMF(m, epsilon, false, "missNone"); + } + // This creates a single bin at value 0, but with weight 0. static emptyMass(): PMF { return PMF.zero().scaleMass(0); @@ -700,6 +715,54 @@ export class PMF { ); } + /** + * Redistributes probability mass to model an effect that only occurs with + * probability `frequency` — a conditional attack, an on-hit rider, or a + * sub-one AoE target fraction. + * + * Every hit outcome (damage > 0) is scaled by `frequency` — probability mass, + * per-label `count`, AND per-label `attr` — and the freed mass is moved into + * the miss bin at damage 0, tagged with the canonical `missNone` outcome. + * Total probability mass is preserved. + * + * Unlike a bare {@link scaleMass} or {@link mapDamage}, this keeps damage + * attribution (`attr`) intact, so a frequency-scaled PMF still renders + * correctly in the damage-attribution charts. + * + * `frequency >= 1` (or non-finite) returns this PMF unchanged; `frequency <= 0` + * collapses all mass into the miss bin. The miss outcome is assumed to be + * encoded at damage value 0. + * + * @param frequency Probability in [0, 1] that the effect occurs. + */ + applyHitFrequency(frequency: number): PMF { + if (!Number.isFinite(frequency) || frequency >= 1) return this; + const freq = Math.max(0, frequency); + + const pMiss = this.pAt(0); + const pHit = 1 - pMiss; + const newMissMass = pMiss + (1 - freq) * pHit; + + const newMap = new Map(); + newMap.set(0, { + p: newMissMass, + count: { [MISS_NONE_OUTCOME]: newMissMass }, + attr: {}, + }); + + for (const [damage, bin] of this.map) { + if (damage <= 0) continue; + newMap.set(damage, PMF.scaleBin(bin, freq)); + } + + return new PMF( + newMap, + this.epsilon, + false, + `freq(${this.identifier},${freq})` + ); + } + scaleMass(factor: number): PMF { if (factor === 1) return this; @@ -1002,6 +1065,42 @@ export class PMF { return this.map.get(x)?.p ?? 0; } + /** + * P(any damage) — the mass on all non-zero outcomes, i.e. `1 - P(0)`. + * Assumes a miss is encoded as the damage-0 bin (the convention used across + * attack/save PMFs). The dual of {@link missProbability}. + */ + hitProbability(): number { + return 1 - this.pAt(0); + } + + /** P(no damage) — the mass at damage 0. The dual of {@link hitProbability}. */ + missProbability(): number { + return this.pAt(0); + } + + /** + * Coarsen the distribution into at most `maxBuckets` contiguous, equal-width + * damage buckets, aggregating probability mass (and `count`/`attr` + * provenance) into each bucket's start value. Returns this PMF unchanged when + * its integer support already fits within `maxBuckets`. + * + * This is a lossy display/downsampling transform (bucket start replaces the + * exact damage value) — use it for charting wide distributions, not for DPR + * math. + */ + rebin(maxBuckets: number): PMF { + if (!(maxBuckets > 0)) return this; + const support = this.support(); + if (support.length === 0) return this; + const min = support[0]; + const max = support[support.length - 1]; + const range = max - min; + if (range + 1 <= maxBuckets) return this; + const binSize = Math.ceil((range + 1) / maxBuckets); + return this.mapDamage((d) => min + Math.floor((d - min) / binSize) * binSize); + } + /** Dense integer support from min..max (inclusive). * Useful for showing empty bars in charts. */ @@ -1092,6 +1191,64 @@ export class PMF { return false; } + /** + * Split each damage value's probability mass across outcome labels, returning + * per-label maps of `damage value → probability mass attributable to that + * label`. Summing over labels at a given value recovers that value's `p`. + * + * Damage-bearing bins are split by `attr` weight (the share of damage each + * outcome contributed); the clean-miss bin at 0 is split by `count` weight + * (there is no damage to attribute). Attribution is computed on demand via + * {@link withAttribution} when absent, so builder-generated PMFs work too. + * + * This is the provenance core of the stacked damage-attribution chart — the + * caller only maps these series into its rendering format (colors, binning, + * axis labels). + */ + attributionByValue(): Map> { + const src = this.hasAttribution() ? this : this.withAttribution(); + const result = new Map>(); + + const add = (label: string, damage: number, mass: number): void => { + if (!(mass > 0)) return; + let series = result.get(label); + if (!series) { + series = new Map(); + result.set(label, series); + } + series.set(damage, (series.get(damage) ?? 0) + mass); + }; + + for (const [damage, bin] of src.map) { + const p = bin.p || 0; + if (p <= 0) continue; + const isMissBin = damage === 0; + + // Damage-0 (clean miss): split by count, crediting the missNone label. + if (isMissBin) { + let totalCount = 0; + for (const k in bin.count) totalCount += (bin.count[k] as number) || 0; + if (totalCount > 0) { + const c = (bin.count[MISS_NONE_OUTCOME] as number) || 0; + add(MISS_NONE_OUTCOME, damage, (c / totalCount) * p); + } + continue; + } + + // Damage-bearing bin: split by attribution weight. + let totalAttr = 0; + if (bin.attr) for (const k in bin.attr) totalAttr += (bin.attr[k] as number) || 0; + if (bin.attr && totalAttr > 0) { + for (const k in bin.attr) { + if (k === MISS_NONE_OUTCOME) continue; + add(k, damage, (((bin.attr[k] as number) || 0) / totalAttr) * p); + } + } + } + + return result; + } + tailProbGE(t: number): number { let s = 0; for (const [x, bin] of this) { diff --git a/src/pmf/query.ts b/src/pmf/query.ts index a5f0a5b..08e5c8e 100644 --- a/src/pmf/query.ts +++ b/src/pmf/query.ts @@ -107,6 +107,31 @@ export class DiceQuery { return normalized; } + /** + * Per-label `damage value → probability mass` series for the combined, + * attribution-carrying distribution — the provenance core of the stacked + * damage-attribution chart. Convenience for + * `combinedWithAttribution().attributionByValue()`; see + * {@link PMF.attributionByValue}. + */ + attributionByValue(): Map> { + return this.combinedWithAttribution().attributionByValue(); + } + + /** + * How many of the independent single PMFs can produce the given outcome + * label. Useful for "all of them succeeded" style probabilities where the + * exponent is the number of contributing attacks (see + * {@link DiceQuery.probExactlyK}). + */ + countSinglesWith(label: string): number { + let count = 0; + for (const single of this.singles) { + if (single.hasOutcome(label)) count++; + } + return count; + } + /** * Returns the expected damage across all possible outcomes. * diff --git a/tests/apply-hit-frequency.test.ts b/tests/apply-hit-frequency.test.ts new file mode 100644 index 0000000..4c3f384 --- /dev/null +++ b/tests/apply-hit-frequency.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import type { Bin } from "../src/index"; +import { PMF } from "../src/index"; + +/** A two-outcome hit distribution with count provenance but no attr yet. */ +function hitPMF(): PMF { + const m = new Map(); + m.set(5, { p: 0.6, count: { hit: 0.6 } }); + m.set(10, { p: 0.4, count: { crit: 0.4 } }); + return new PMF(m, 1e-15, true); +} + +/** A distribution that already carries a miss bin at 0. */ +function hitWithMissPMF(): PMF { + const m = new Map(); + m.set(0, { p: 0.25, count: { missNone: 0.25 }, attr: {} }); + m.set(8, { p: 0.75, count: { hit: 0.75 } }); + return new PMF(m, 1e-15, true); +} + +describe("PMF.missNone", () => { + it("is a point mass at 0 tagged with the missNone outcome", () => { + const miss = PMF.missNone(); + expect(miss.mass()).toBeCloseTo(1, 12); + expect(miss.pAt(0)).toBeCloseTo(1, 12); + expect(miss.max()).toBe(0); + expect(miss.outcomeAt(0, "missNone")).toBeCloseTo(1, 12); + }); + + it("uses the missNone label, distinct from PMF.zero's 'miss' label", () => { + expect(PMF.missNone().outcomeAt(0, "missNone")).toBeCloseTo(1, 12); + expect(PMF.missNone().outcomeAt(0, "miss")).toBe(0); + // PMF.zero keeps the builder's 'miss' vocabulary — the two must not collide. + expect(PMF.zero().outcomeAt(0, "miss")).toBeCloseTo(1, 12); + expect(PMF.zero().outcomeAt(0, "missNone")).toBe(0); + }); +}); + +describe("PMF.applyHitFrequency", () => { + it("preserves total probability mass", () => { + expect(hitPMF().applyHitFrequency(0.5).mass()).toBeCloseTo(1, 12); + expect(hitWithMissPMF().applyHitFrequency(0.3).mass()).toBeCloseTo(1, 12); + }); + + it("scales hit bins and moves the freed mass into a missNone bin", () => { + const out = hitPMF().applyHitFrequency(0.5); + expect(out.pAt(5)).toBeCloseTo(0.3, 12); + expect(out.pAt(10)).toBeCloseTo(0.2, 12); + // pHit was 1, so freed mass is (1 - 0.5) * 1 = 0.5. + expect(out.pAt(0)).toBeCloseTo(0.5, 12); + expect(out.outcomeAt(0, "missNone")).toBeCloseTo(0.5, 12); + // Per-label count on hit bins scales too. + expect(out.outcomeAt(5, "hit")).toBeCloseTo(0.3, 12); + expect(out.outcomeAt(10, "crit")).toBeCloseTo(0.2, 12); + }); + + it("adds the freed mass on top of a pre-existing miss bin", () => { + // pMiss = 0.25, pHit = 0.75, freq 0.4 → newMiss = 0.25 + 0.6 * 0.75 = 0.70 + const out = hitWithMissPMF().applyHitFrequency(0.4); + expect(out.pAt(0)).toBeCloseTo(0.7, 12); + expect(out.pAt(8)).toBeCloseTo(0.75 * 0.4, 12); + expect(out.mass()).toBeCloseTo(1, 12); + }); + + it("scales the mean of a pure-hit PMF by the frequency", () => { + const base = hitPMF(); + const baseMean = base.mean(); // 0.6*5 + 0.4*10 = 7 + expect(base.applyHitFrequency(0.5).mean()).toBeCloseTo(baseMean * 0.5, 12); + expect(base.applyHitFrequency(0.25).mean()).toBeCloseTo(baseMean * 0.25, 12); + }); + + it("preserves and scales damage attribution (attr) on hit bins", () => { + // Regression guard: the app's original applyFrequencyToPMF dropped attr, + // silently breaking attribution charts for frequency-scaled actions. + const attributed = hitPMF().withAttribution(); + expect(attributed.outcomeAttributionAt(5, "hit")).toBeCloseTo(5 * 0.6, 12); + + const out = attributed.applyHitFrequency(0.5); + expect(out.hasAttribution()).toBe(true); + expect(out.outcomeAttributionAt(5, "hit")).toBeCloseTo(5 * 0.6 * 0.5, 12); + expect(out.outcomeAttributionAt(10, "crit")).toBeCloseTo(10 * 0.4 * 0.5, 12); + }); + + it("returns the PMF unchanged when frequency >= 1 or non-finite", () => { + const base = hitPMF(); + expect(base.applyHitFrequency(1)).toBe(base); + expect(base.applyHitFrequency(1.5)).toBe(base); + expect(base.applyHitFrequency(Number.NaN)).toBe(base); + }); + + it("collapses all mass into the miss bin when frequency <= 0", () => { + const out = hitPMF().applyHitFrequency(0); + expect(out.pAt(0)).toBeCloseTo(1, 12); + expect(out.mean()).toBeCloseTo(0, 12); + expect(out.outcomeAt(0, "missNone")).toBeCloseTo(1, 12); + }); + + it("does not mutate the source PMF (copy-on-write)", () => { + const base = hitPMF().withAttribution(); + base.applyHitFrequency(0.5); + // Original bins — probability, count, and attr — must be untouched. + expect(base.pAt(5)).toBeCloseTo(0.6, 12); + expect(base.pAt(10)).toBeCloseTo(0.4, 12); + expect(base.pAt(0)).toBe(0); + expect(base.outcomeAt(5, "hit")).toBeCloseTo(0.6, 12); + expect(base.outcomeAttributionAt(5, "hit")).toBeCloseTo(5 * 0.6, 12); + expect(base.mean()).toBeCloseTo(7, 12); + }); +}); diff --git a/tests/bounce.test.ts b/tests/bounce.test.ts new file mode 100644 index 0000000..d0ebd14 --- /dev/null +++ b/tests/bounce.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vitest"; +import { calculateBounceOdds } from "../src/index"; + +/** + * Exact P(>=1 duplicate) by full enumeration over faces^dice outcomes, applying + * the Elemental Adept collapse (rolls below `min` become `min`). This is the + * ground truth the closed form must match. + */ +function bruteMatch(dice: number, faces: number, min = 0): number { + let total = 0; + let dupes = 0; + const roll = new Array(dice).fill(1); + const rec = (pos: number): void => { + if (pos === dice) { + total++; + const vals = roll.map((v) => (min >= 2 && v < min ? min : v)); + if (new Set(vals).size < dice) dupes++; + return; + } + for (let v = 1; v <= faces; v++) { + roll[pos] = v; + rec(pos + 1); + } + }; + rec(0); + return dupes / total; +} + +describe("calculateBounceOdds — base case (exact vs enumeration)", () => { + const cases: Array<[number, number]> = [ + [2, 6], + [3, 8], + [4, 8], + [3, 6], + [4, 10], + [5, 10], + [2, 20], + [6, 8], + ]; + it.each(cases)("K=%i, d%i matches brute force", (dice, faces) => { + expect(calculateBounceOdds(dice, faces)).toBeCloseTo(bruteMatch(dice, faces), 12); + }); +}); + +describe("calculateBounceOdds — Elemental Adept (exact vs enumeration)", () => { + const cases: Array<[number, number, number]> = [ + [3, 8, 2], + [3, 8, 3], + [4, 8, 2], + [2, 6, 2], + [3, 6, 2], + [4, 10, 3], + [5, 10, 2], + [3, 12, 4], + ]; + it.each(cases)("K=%i, d%i, min=%i matches brute force", (dice, faces, min) => { + expect(calculateBounceOdds(dice, faces, { minimumDieRoll: min })).toBeCloseTo( + bruteMatch(dice, faces, min), + 12 + ); + }); +}); + +describe("calculateBounceOdds — edges and monotonicity", () => { + it("returns 0 for one die and 1 when dice exceed faces (pigeonhole)", () => { + expect(calculateBounceOdds(1, 8)).toBe(0); + expect(calculateBounceOdds(0, 8)).toBe(0); + expect(calculateBounceOdds(9, 8)).toBe(1); + }); + + it("stays finite and within [0, 1] across the full grid, incl. dice near/above the collapsed face count", () => { + // min 3–4 collapse a d8 to 6/5 distinct values, so k up to 8 pushes past the + // pigeonhole bound while Empowered Spell rerolls — the branch where keptDice + // can exceed effectiveFaces. Guard against NaN / out-of-range there. + for (const faces of [6, 8]) { + for (const min of [0, 2, 3, 4]) { + for (const rr of [0, 1, 2, 3]) { + for (let k = 2; k <= faces; k++) { + const p = calculateBounceOdds(k, faces, { minimumDieRoll: min, rerollDamageDice: rr }); + expect(Number.isFinite(p)).toBe(true); + expect(p).toBeGreaterThanOrEqual(0); + expect(p).toBeLessThanOrEqual(1); + } + } + } + } + }); + + it("Empowered Spell (reroll) never lowers the odds vs no reroll", () => { + for (let k = 2; k <= 6; k++) { + const base = calculateBounceOdds(k, 8); + const withReroll = calculateBounceOdds(k, 8, { rerollDamageDice: 2 }); + expect(withReroll).toBeGreaterThanOrEqual(base - 1e-12); + } + }); + + it("Elemental Adept raises match odds vs a plain die (fewer effective faces)", () => { + for (let k = 2; k <= 4; k++) { + const plain = calculateBounceOdds(k, 8); + const adept = calculateBounceOdds(k, 8, { minimumDieRoll: 2 }); + expect(adept).toBeGreaterThan(plain); + } + }); +}); + +describe("calculateBounceOdds — Empowered Spell (reroll)", () => { + it("rerolling ALL dice equals two independent identical rolls: 1 - (1 - pMatch)^2", () => { + // keptDice === 0: the only way to match is among the rerolled dice, which is a + // second independent roll of the same pool. (Regression guard: a former bug + // returned certainty of a match here.) + const cases: Array<[number, number, number]> = [ + [3, 8, 0], + [4, 8, 0], + [2, 6, 0], + [4, 10, 0], + [3, 8, 2], + [3, 10, 3], + ]; + for (const [k, faces, min] of cases) { + const opts = min > 0 ? { minimumDieRoll: min } : {}; + const base = calculateBounceOdds(k, faces, opts); + const rerollAll = calculateBounceOdds(k, faces, { ...opts, rerollDamageDice: k }); + expect(rerollAll).toBeCloseTo(1 - (1 - base) ** 2, 12); + } + }); + + it("is monotonically non-decreasing in the number of rerolled dice", () => { + for (const [k, faces] of [ + [3, 8], + [4, 10], + [2, 6], + [4, 8], + ] as Array<[number, number]>) { + let prev = -1; + for (let rr = 0; rr <= k + 2; rr++) { + const p = calculateBounceOdds(k, faces, { rerollDamageDice: rr }); + expect(p).toBeGreaterThanOrEqual(prev - 1e-12); + prev = p; + } + } + }); + + it("clamps rerolled dice to the pool size (reroll >= diceCount all agree)", () => { + const rerollAll = calculateBounceOdds(3, 8, { rerollDamageDice: 3 }); + for (const rr of [4, 5, 9, 100]) { + expect(calculateBounceOdds(3, 8, { rerollDamageDice: rr })).toBeCloseTo(rerollAll, 12); + } + }); + + it("combines with Elemental Adept without dropping below EA-only odds", () => { + for (const [k, faces, min] of [ + [3, 8, 2], + [3, 8, 3], + [4, 10, 2], + ] as Array<[number, number, number]>) { + const eaOnly = calculateBounceOdds(k, faces, { minimumDieRoll: min }); + const eaEmpowered = calculateBounceOdds(k, faces, { minimumDieRoll: min, rerollDamageDice: 2 }); + expect(eaEmpowered).toBeGreaterThanOrEqual(eaOnly - 1e-12); + expect(eaEmpowered).toBeLessThanOrEqual(1); + } + }); + + it("matches known values for the reroll model (regression guard)", () => { + // The reroll model is an approximation with no closed-form oracle; pin + // representative outputs so refactors don't silently shift it. + expect(calculateBounceOdds(3, 8, { rerollDamageDice: 1 })).toBeCloseTo(0.507813, 6); + expect(calculateBounceOdds(3, 8, { rerollDamageDice: 2 })).toBeCloseTo(0.560364, 6); + expect(calculateBounceOdds(4, 10, { rerollDamageDice: 2 })).toBeCloseTo(0.709696, 6); + expect(calculateBounceOdds(3, 8, { minimumDieRoll: 2, rerollDamageDice: 2 })).toBeCloseTo(0.636779, 6); + }); +}); diff --git a/tests/pushdown-additions.test.ts b/tests/pushdown-additions.test.ts new file mode 100644 index 0000000..85494e4 --- /dev/null +++ b/tests/pushdown-additions.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import type { Bin } from "../src/index"; +import { + ALL_OUTCOME_TYPES, + DiceQuery, + OUTCOME_DISPLAY_ORDER, + PMF, + critProbability, + sortOutcomes, +} from "../src/index"; + +describe("critProbability", () => { + it("scales the crit window by roll type", () => { + expect(critProbability(1, "flat")).toBeCloseTo(0.05, 12); + expect(critProbability(1, "advantage")).toBeCloseTo(1 - 0.95 ** 2, 12); + expect(critProbability(1, "elven accuracy")).toBeCloseTo(1 - 0.95 ** 3, 12); + expect(critProbability(1, "disadvantage")).toBeCloseTo(0.05 ** 2, 12); + expect(critProbability(2, "flat")).toBeCloseTo(0.1, 12); + expect(critProbability(1)).toBeCloseTo(0.05, 12); // defaults to flat + }); +}); + +describe("canonical outcome constants", () => { + it("enumerate every outcome type once", () => { + expect([...ALL_OUTCOME_TYPES].sort()).toEqual( + ["crit", "hit", "missDamage", "missNone", "pc", "saveFail", "saveHalf"].sort() + ); + expect([...OUTCOME_DISPLAY_ORDER].sort()).toEqual([...ALL_OUTCOME_TYPES].sort()); + }); + + it("sortOutcomes ranks known outcomes by order, unknown ones last alphabetically", () => { + expect(sortOutcomes(["crit", "missNone", "hit"])).toEqual(["missNone", "hit", "crit"]); + expect(sortOutcomes(["zeta", "hit", "alpha"])).toEqual(["hit", "alpha", "zeta"]); + expect(sortOutcomes(["crit", "hit"], OUTCOME_DISPLAY_ORDER)).toEqual(["crit", "hit"]); + }); +}); + +describe("PMF.hitProbability / missProbability", () => { + it("split around the damage-0 miss bin", () => { + const pmf = new PMF( + new Map([ + [0, { p: 0.3, count: { missNone: 0.3 } }], + [5, { p: 0.7, count: { hit: 0.7 } }], + ]), + 1e-15, + true + ); + expect(pmf.missProbability()).toBeCloseTo(0.3, 12); + expect(pmf.hitProbability()).toBeCloseTo(0.7, 12); + }); +}); + +describe("PMF.rebin", () => { + it("returns the same PMF when support already fits", () => { + const pmf = PMF.delta(5); + expect(pmf.rebin(500)).toBe(pmf); + }); + + it("coarsens a wide distribution, preserving total mass", () => { + const m = new Map(); + for (let d = 0; d <= 1000; d++) m.set(d, { p: 1 / 1001, count: { hit: 1 / 1001 } }); + const wide = new PMF(m, 1e-15, true); + const rebinned = wide.rebin(100); + expect(rebinned.map.size).toBeLessThanOrEqual(100); + expect(rebinned.mass()).toBeCloseTo(1, 10); + // count provenance is aggregated, not dropped. + let totalHit = 0; + for (const [, bin] of rebinned) totalHit += (bin.count.hit as number) ?? 0; + expect(totalHit).toBeCloseTo(1, 10); + }); +}); + +describe("PMF.attributionByValue", () => { + it("splits each value's mass by attribution; sums recover p", () => { + const pmf = new PMF( + new Map([ + [0, { p: 0.3, count: { missNone: 0.3 } }], + [5, { p: 0.5, count: { hit: 0.5 } }], + [8, { p: 0.2, count: { hit: 0.15, crit: 0.05 } }], + ]), + 1e-15, + true + ).withAttribution(); + + const series = pmf.attributionByValue(); + expect(series.get("missNone")?.get(0)).toBeCloseTo(0.3, 12); + expect(series.get("hit")?.get(5)).toBeCloseTo(0.5, 12); + // At 8: attr hit = 8*0.15 = 1.2, crit = 8*0.05 = 0.4, total 1.6. + expect(series.get("hit")?.get(8)).toBeCloseTo((1.2 / 1.6) * 0.2, 12); + expect(series.get("crit")?.get(8)).toBeCloseTo((0.4 / 1.6) * 0.2, 12); + + // Per-value sums across labels recover p. + for (const value of [0, 5, 8]) { + let sum = 0; + for (const [, byValue] of series) sum += byValue.get(value) ?? 0; + expect(sum).toBeCloseTo(pmf.pAt(value), 12); + } + }); + + it("works on builder PMFs (count only) via on-demand attribution", () => { + const pmf = new PMF( + new Map([ + [0, { p: 0.4, count: { missNone: 0.4 } }], + [6, { p: 0.6, count: { hit: 0.6 } }], + ]), + 1e-15, + true + ); + const series = pmf.attributionByValue(); + expect(series.get("missNone")?.get(0)).toBeCloseTo(0.4, 12); + expect(series.get("hit")?.get(6)).toBeCloseTo(0.6, 12); + }); +}); + +describe("DiceQuery.countSinglesWith / attributionByValue", () => { + const withOutcome = (value: number, label: string): PMF => + new PMF( + new Map([ + [0, { p: 0.5, count: { missNone: 0.5 } }], + [value, { p: 0.5, count: { [label]: 0.5 } }], + ]), + 1e-15, + true + ); + + it("counts how many singles can produce a label", () => { + const q = new DiceQuery([withOutcome(5, "hit"), withOutcome(10, "crit"), withOutcome(6, "hit")]); + expect(q.countSinglesWith("hit")).toBe(2); + expect(q.countSinglesWith("crit")).toBe(1); + expect(q.countSinglesWith("saveFail")).toBe(0); + }); + + it("attributionByValue delegates to the combined attributed PMF", () => { + const q = new DiceQuery([withOutcome(5, "hit")]); + const series = q.attributionByValue(); + expect(series.has("hit")).toBe(true); + expect(series.has("missNone")).toBe(true); + }); +});