Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 3 additions & 1 deletion src/builder/types.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand Down
122 changes: 122 additions & 0 deletions src/common/bounce.ts
Original file line number Diff line number Diff line change
@@ -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);
}
83 changes: 83 additions & 0 deletions src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends string>(
outcomes: Iterable<T>,
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"];
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./common/bounce";
export * from "./common/errors";
export * from "./common/lru-cache";
export * from "./common/types";
Expand Down
Loading
Loading