[![npm version](https://img.shields.io/npm/v/@yipe/dice.svg)](https://www.npmjs.com/package/@yipe/dice)
[![License: MIT](https://img.shields.io/badge/License-MIT-007ec6.svg)](LICENSE)
![TypeScript](https://img.shields.io/badge/TypeScript-Ready-3178c6?logo=typescript)
![Size](https://img.shields.io/bundlephobia/minzip/@yipe/dice?logo=npm&logoColor=fff&label=Size)
![Dice](https://dprcalc.com/api/roll.svg)

![Last Commit](https://img.shields.io/github/last-commit/yipe/dice?logo=github)
![Dependencies](https://img.shields.io/librariesio/release/npm/@yipe/dice?logo=npm)
[![GitHub issues](https://img.shields.io/github/issues/yipe/dice.svg?logo=github)](https://github.com/yipe/dice/issues)
[![Build Status](https://github.com/yipe/dice/actions/workflows/ci.yml/badge.svg)](https://github.com/yipe/dice/actions)
[![Tests](https://img.shields.io/badge/tests-passing-4c1.svg?logo=vitest&logoColor=white)](https://github.com/yipe/dice/actions/workflows/ci.yml)

# 🎲 @yipe/dice

A TypeScript library for **D&D 5e damage-per-round (DPR) calculations**, designed for players, Dungeon Masters, and developers who want to analyze combat mathematically.

This library powers [dprcalc.com](https://dprcalc.com) and provides a precise, composable way to model dice rolls, attacks, and outcomes with probability mass functions (PMFs) — not just averages. This allows for rich charting and statistics with full outcome attribution. It provides two main entry points: a fluent typescript interface or a dice expression string.

```ts
import { parse } from "@yipe/dice";

const attack = parse("(d20 + 8 AC 16) * (1d8 + 4) crit (2d8 + 4)");
console.log("DPR:", attack.mean());

// or

const attack = d20.plus(8).ac(16).onHit(d8.plus(4));
console.log("DPR:", attack.mean());
```

## ✨ Features

- **D&D 5e Focused**: Designed around 5e rules (2014 and 2024).
- **Probability Mass Functions (PMF)**: Precise modeling of dice rolls and outcomes, not just averages.
- **Complex Attack Expressions**: Supports crit ranges, advantage/disadvantage, conditional damage, rerolls, minimum damage, and more.
- **Composable API**: Build dice expressions, run queries, and analyze results in just a few lines.
- **TypeScript First**: Full type safety and developer experience.

## 🚀 Quick Start

### Installation

```bash
# Install with npm or yarn
npm install @yipe/dice
# or
yarn add @yipe/dice
```

### Basic Usage

```ts
import { parse, DiceQuery } from "@yipe/dice";

const query = d20.plus(8).ac(16).onHit(d4.plus(4)).toQuery();

console.log("Hit chance:", query.probAtLeastOne(["hit", "crit"]));
console.log("Crit chance:", query.probAtLeastOne(["crit"]));
console.log("DPR:", query.mean());
```

**Output:**

```
Hit chance: 0.65
Crit chance: 0.05
DPR: 4.35
```

## 🛠 Development Setup

### Prerequisites

- **Node.js**: >= 18.17
- **Yarn**: 4.9.4 (specified in `packageManager`)

### Initial Setup

```bash
# Clone the repository
git clone https://github.com/yipe/dice.git
cd dice

# Install dependencies
yarn install

# Build the project
yarn build

# Run tests
yarn test

# Run examples
yarn example
```

### Available Scripts

| Command | Purpose |
|---------|---------|
| `yarn build` | Compile TypeScript to JavaScript (outputs to `dist/`) |
| `yarn test` | Run test suite once |
| `yarn test:watch` | Run tests in watch mode |
| `yarn typecheck` | Type-check without emitting files |
| `yarn lint` | Run ESLint |
| `yarn example` | Run example scripts |

### Project Structure

```
src/
├── builder/          # Fluent API for building dice expressions
│   ├── factory.ts    # Factory functions (d20, d6, roll, etc.)
│   ├── roll.ts       # RollBuilder - core builder class
│   ├── ac.ts         # ACBuilder - attack roll builder
│   ├── attack.ts     # AttackBuilder - attack with damage
│   ├── save.ts       # SaveBuilder - saving throw builder
│   ├── dc.ts         # DCBuilder - difficulty check builder
│   ├── ast.ts        # AST generation and PMF conversion
│   └── nodes.ts      # AST node type definitions
├── parser/           # String-based dice expression parser
│   ├── parser.ts     # Main parser implementation
│   └── dice.ts       # Dice class (legacy parser representation)
├── turn/             # Turns: attacks + conditional damage riders
│   ├── types.ts      # Trigger, Rider, TurnSpec, TurnSpecError
│   ├── plan.ts       # Spec validation and step/group resolution
│   ├── state.ts      # Packed per-group trigger state
│   └── turn.ts       # Turn class - exact joint distribution
├── pmf/              # Probability Mass Function core
│   ├── pmf.ts        # PMF class - core data structure
│   ├── query.ts      # DiceQuery - analysis interface
│   └── mixture.ts   # Mixture operations
└── common/           # Shared utilities
    ├── types.ts      # Type definitions
    └── lru-cache.ts  # LRU cache implementation
```

## 🏗 Architecture Overview

The library provides two parallel entry points for creating dice expressions:

### Entry Point 1: String Parser

Parses text expressions like `"(d20 + 8 AC 16) * (1d8 + 4) crit (2d8 + 4)"`:

```
String Expression
    │
    ├─ parse() ──────────────┐
    │                        │
    │                        ▼
    │             parseExpression()
    │                        │
    │                        ├─ parseArgument() ──► Dice objects
    │                        │
    │                        └─ parseOperation() ──► Dice operations
    │                        │
    │                        ▼
    │             Dice.toPMF() ──► PMF
    │                        │
    └────────────────────────┘
```

### Entry Point 2: Fluent Builder API

Type-safe builder pattern:

```
RollBuilder (d20, d6, roll(), etc.)
    │
    ├─ .plus() ──► RollBuilder
    ├─ .ac() ────► ACBuilder
    │                 │
    │                 └─ .onHit() ──► AttackBuilder
    │                                    │
    │                                    ├─ .toQuery() ──► DiceQuery
    │                                    └─ .pmf ─────────► PMF
    │
    └─ .toPMF() ──► PMF
```

### Core Class Flow

```
┌─────────────────────────────────────────────────────────────┐
│                    User Input Layer                         │
├─────────────────────────────────────────────────────────────┤
│  String Parser          │  Fluent Builder                   │
│  parse("...")           │  d20.plus(8).ac(16)               │
└────────────┬────────────┴────────────┬──────────────────────┘
             │                         │
             ▼                         ▼
┌─────────────────────────────────────────────────────────────┐
│                    Builder Layer                            │
├─────────────────────────────────────────────────────────────┤
│  RollBuilder ──► ACBuilder ──► AttackBuilder                │
│       │              │              │                       │
│       │              │              │                       │
│       └──────────────┼──────────────┘                       │
│                      │                                      │
│                      ▼                                      │
│              astFromRollConfigs()                           │
│                      │                                      │
│                      ▼                                      │
│              ExpressionNode (AST)                           │
└──────────────────────┬──────────────────────────────────────┘
                       │
                       ▼
┌─────────────────────────────────────────────────────────────┐
│                    PMF Generation                           │
├─────────────────────────────────────────────────────────────┤
│  pmfFromRollBuilder()                                       │
│       │                                                     │
│       ├─ d20RollPMF() ──► PMF (for d20 rolls)               │
│       ├─ diePMF() ──────► PMF (for regular dice)            │
│       └─ combinePMFs() ─► PMF (convolve multiple PMFs)      │
└──────────────────────┬──────────────────────────────────────┘
                       │
                       ▼
┌─────────────────────────────────────────────────────────────┐
│                    Query & Analysis                         │
├─────────────────────────────────────────────────────────────┤
│  DiceQuery                                                  │
│       │                                                     │
│       ├─ .mean() ────────────► Expected damage              │
│       ├─ .variance() ────────► Damage variance              │
│       ├─ .probAtLeastOne() ──► Hit/crit probabilities       │
│       ├─ .toChartSeries() ────► Chart data                  │
│       └─ .combined ───────────► Final PMF                   │
└─────────────────────────────────────────────────────────────┘
```

### PMF Data Structure

The `PMF` class is the core mathematical representation:

```
PMF
├── map: Map<number, Bin>
│   └── Bin
│       ├── p: number          (probability)
│       ├── count: {...}       (outcome counts: hit, crit, miss)
│       └── attr: {...}        (damage attribution)
├── epsilon: number            (probability threshold)
├── normalized: boolean        (whether PMF sums to 1.0)
└── identifier: string         (cache key / debug name)
```

### Main Flow Example

Here's how a simple attack flows through the system:

```
1. User creates: d20.plus(5).ac(15).onHit(d6.plus(2))

2. Builder chain:
   RollBuilder(d20) 
     → plus(5) → RollBuilder(d20 + 5)
     → ac(15) → ACBuilder(d20 + 5 AC 15)
     → onHit(...) → AttackBuilder

3. AST generation:
   RollConfig[] → ExpressionNode
     - DieNode (d20)
     - ConstantNode (+5)
     - D20RollNode (AC check)
     - ConditionalNode (on hit)

4. PMF generation:
   AST → PMF operations
     - d20RollPMF(rollType, rerollOne) → PMF
     - Conditional application → PMF.branch()
     - Damage PMF → PMF
     - Combine → PMF (final result)

5. Query creation:
   AttackBuilder.toQuery() → DiceQuery
     - singles: [PMF]
     - combined: PMF (convolved)

6. Analysis:
   DiceQuery.mean() → 3.20 DPR
```

## 📦 Core Concepts

| Concept    | Description                                                                  |
| ---------- | ---------------------------------------------------------------------------- |
| **PMF**    | Probability Mass Function. The core mathematical representation of outcomes. |
| **Query**  | Runs calculations and scenarios over one or more PMFs.                       |
| **Parser** | Parses text-based dice expressions like `(d20 + 8 AC 16) * (1d4 + 4)`.       |
| **Builder**| Fluent TypeScript API for building dice expressions.                         |
| **AST**    | Abstract Syntax Tree representing dice operations.                           |

## 🧙 Usage Examples

### Basic Attack

```ts
import { parse, DiceQuery } from "@yipe/dice";

const query = d20.plus(8).ac(16).onHit(d4.plus(4)).toQuery();

console.log("Hit chance:", query.probAtLeastOne(["hit", "crit"]));
console.log("Crit chance:", query.probAtLeastOne(["crit"]));
console.log("DPR:", query.mean());
```

### String Parser

```ts
import { parse } from "@yipe/dice";

const pmf = parse("(d20 + 8 AC 16) * (1d8 + 4) crit (2d8 + 4)");
const query = new DiceQuery(pmf);

console.log("DPR:", query.mean());
```

### Error Handling

`parse()` throws a `DiceParseError` (a subclass of `Error`) for invalid input.
Narrow with `instanceof` and inspect the offending `expression`:

```ts
import { parse, DiceParseError } from "@yipe/dice";

try {
  parse("d6@3");
} catch (err) {
  if (err instanceof DiceParseError) {
    console.warn(`Bad dice expression: ${err.expression}`);
  }
}
```

For UI code that parses on every keystroke, `tryParse()` returns an empty PMF
instead of throwing, and accepts a bare integer — which the grammar rejects, but
a half-typed damage field is one for a keystroke or two:

```ts
import { tryParse } from "@yipe/dice";

tryParse("1d6 + 2").mean(); // 5.5
tryParse("-3").mean(); // -3   — signed integers, which the grammar rejects
tryParse("0x10").mass(); // 0  — decimal only
tryParse("1d").mass(); // 0   — empty PMF
```

The failure value has **mass 0**, not a distribution, and convolving it collapses
the whole result to mass 0 — check `mass()` or skip empties when combining
several expressions. Where a bad expression should surface rather than be
absorbed, use `parse()` and handle `DiceParseError`.

### Roll Types

`withRollType()` rewrites an expression's attack roll, leaving the damage, crit
and miss clauses alone — the usual way to chart one attack across advantage
states:

```ts
import { withRollType } from "@yipe/dice";

const attack = "(d20 + 8 AC 16) * (1d4 + 4) crit (2d4 + 4)";

withRollType(attack, "advantage"); // "(d20 > d20 + 8 AC 16) * (1d4 + 4) crit (2d4 + 4)"
withRollType(attack, "elven accuracy"); // "(d20 > d20 > d20 + 8 AC 16) * ..."
```

Every attack roll is rewritten, so an expression holding several attacks is fully
converted, nesting and all. Each `d20` is resolved against the nearest enclosing
check: `AC` is an attack roll, while `DC` is the *target's* saving throw, which
the attacker's advantage does not affect. Saves therefore come back unchanged, as
does anything with no check at all, which makes this safe to map over a mixed
list. A halfling-luck `h` prefix is preserved.

### Damage Riders (Sneak Attack, Smite, Hunter's Mark)

Most of what makes 5e damage interesting is conditional: Sneak Attack needs *a* dagger to land,
Divine Smite wants a crit, a flurry of blows only happens if you didn't smite. A **`turn()`** is
attacks plus riders that fire based on what those attacks did.

```ts
import { turn, d20, d4, d6, roll } from "@yipe/dice/builder";

const dagger = d20.plus(8).ac(16).onHit(d4.plus(4));

const rogue = turn([dagger, dagger]).onFirstHit(roll(3, d6));

rogue.mean();      // 18.6225
rogue.pmf.pAt(0);  // 0.1225 — chance the whole turn whiffs
```

That is the whole API for the common case. A `Turn` resolves the **exact joint distribution**: a
rider is correlated with the attacks that trigger it, so building one as a separate PMF and
convolving it in gets the right mean but the wrong shape — the example above would report a whiff
chance of 0.015 instead of 0.1225.

| method | fires | example |
|---|---|---|
| `onFirstHit` | once, on the first attack that lands — doubled if it crit | Sneak Attack |
| `onAnyCrit` | once, if any attack crit | Divine Smite |
| `onAnyMiss` | once, if any attack missed | Unerring Accuracy, Lucky |
| `onEveryHit` | once per attack that lands | Hunter's Mark, Hex, Rage |
| `otherwise` | when the rider before it did *not* | flurry of blows if you didn't smite |

#### Extra Attack

`attacks(count, source)` mirrors `roll(count, die)`, so the Fighter's four — or eight, with Action
Surge — stays one line:

```ts
const sword = d20.plus(9).ac(16).onHit(d6.plus(5));

turn().attacks(4, sword).onEveryHit(d6).mean(); // 35.0  — hunter's mark on each hit
turn().attacks(8, sword).onEveryHit(d6).mean(); // 70.0  — action surge
```

#### A rider can be anything that makes damage

Riders take the same builders attacks do, so "extra damage" and "an extra attack" are the same call.
A whole attack, a list of attacks, a flat bonus, or a saving throw all work:

```ts
const dagger = d20.plus(8).ac(16).onHit(d4.plus(4));
const sword = d20.plus(9).ac(16).onHit(d6.plus(5));
const greatsword = d20.plus(9).ac(16).onHit(roll(2, d6).plus(5));
const unarmed = d20.plus(8).ac(16).onHit(d6.plus(4));
const poison = d20.dc(13).onSaveFailure(roll(3, d6)).saveHalf();

turn([greatsword, greatsword]).onAnyCrit(greatsword);   // Great Weapon Master's bonus attack
turn([dagger]).onFirstHit(poison);                      // hit, then the target saves
turn([sword, sword]).onEveryHit(flat(2));               // Rage
turn([dagger, dagger]).onAnyCrit(roll(4, d8)).otherwise([unarmed, unarmed]); // smite, or flurry
```

#### The hard build

A goliath rogue/monk/paladin, every trigger at once:

```ts
const goliath = turn([dagger, dagger])
  .onFirstHit(roll(3, d6))       // sneak attack
  .onFirstHit(d10)               // fire's burn
  .onAnyCrit(roll(2, d8))        // divine smite
  .otherwise([unarmed, unarmed]) // flurry of blows, if the smite didn't happen
  .onEveryHit(d6);               // hunter's mark

goliath.mean();                                // 39.5903
goliath.toQuery().damageAttributionChartModel();
```

Two things that would be easy to get wrong are handled for you. Riders sharing a trigger resolve
**jointly** — sneak attack and fire's burn fire together or not at all, which shows up in the spread
even though it never moves the mean. And `otherwise()` binds to the rider immediately before it, so
the smite and the flurry are two branches of one decision and can never both land.

#### Asking questions

```ts
const t = turn([dagger, dagger]).onFirstHit(roll(3, d6));

t.mean();                                   // 18.6225
t.pmf.pAt(0);                               // 0.1225  — P(whiff)
t.pmf.stdev();                              // 8.8860
t.toQuery().probTotalAtLeast(20);           // 0.5062  — P(20+ damage)
t.toQuery().percentiles([0.25, 0.5, 0.75]); // [15, 20, 24]
```

#### Ids, errors, and plain data

Nothing above needs an `id`: attacks and riders get `attack 1`, `rider 2`, … in declaration order,
and `otherwise()` finds its own target. Name a rider when you want to ask about it afterwards:

```ts
const paladin = turn([dagger, dagger]).onAnyCrit(roll(2, d8), { id: "smite" });

paladin.fireProbability("smite"); // 0.0975
paladin.attackIds;                // ["attack 1", "attack 2"]
paladin.riderIds;                 // ["smite"]
```

Every construction path validates immediately and throws a `TurnSpecError` whose `code` —
`unknown-id`, `cycle`, `not-an-attack`, `duplicate-id`, `self-reference`, `unused-crit-damage`,
`too-many-groups` — maps
straight onto a UI field state. A bad `of` fails at the call that introduced it, not later at
`.mean()`.

Each `onX` method takes an optional `{ id, of, critDamage }`, where `of` picks which attacks the
rider watches and defaults to all of them. All of them are sugar over `rider()`, which takes the
trigger as plain data — and `Trigger` is JSON-safe, so a UI can persist one and hand it straight
back:

```ts
import { Turn, d20, d4, d6, roll } from "@yipe/dice/builder";

const dagger = d20.plus(8).ac(16).onHit(d4.plus(4));

const fromUI = Turn.from({
  attacks: [{ id: "dagger 1", source: dagger }, { id: "dagger 2", source: dagger }],
  riders: [{ id: "sneak", damage: roll(3, d6), on: "first-hit" }],
});
```

### Statistics and Charts

```ts
import { parse, DiceQuery } from "@yipe/dice";

const query = parse("(d20 + 8 AC 16) * (1d4 + 4) crit (2d4 + 4)").toQuery();
console.table(query.toChartSeries());
```

**Output:**

```
┌─────────┬────┬──────────┐
│ (index) │ x  │ y        │
├─────────┼────┼──────────┤
│ 0       │ 0  │ 0.35     │
│ 1       │ 5  │ 0.15     │
│ 2       │ 6  │ 0.153125 │
│ 3       │ 7  │ 0.15625  │
│ 4       │ 8  │ 0.159375 │
│ 5       │ 9  │ 0.0125   │
│ 6       │ 10 │ 0.009375 │
│ 7       │ 11 │ 0.00625  │
│ 8       │ 12 │ 0.003125 │
└─────────┴────┴──────────┘
```

## 🧪 Running Examples

This repository includes example scripts:

```bash
yarn example basic
yarn example stats
yarn example turn
yarn example misc
```

Here is the basic example output:

```
% yarn example basic

┌────────────┐
│ Summary    │
├────────────┴─────────────────────────────────────────────┐
│ Expression:     (d20 + 5 AC 15) * (1d6+2) crit (2d6 + 2) │
│ Success Chance: 0.55                                     │
│ Expected DPR:   3.20                                     │
└──────────────────────────────────────────────────────────┘

┌────────────┐
│ PMF ()     │
├────────────┴───────────────────────────────────────────────────────────────────────────┐
│   0: █████████████████████████████████████████████████████████████████████████ 45.00%  │
│   3: █████████████▌                                                             8.33%  │
│   4: █████████████▋                                                             8.47%  │
│   5: █████████████▉                                                             8.61%  │
│   6: ██████████████▏                                                            8.75%  │
│   7: ██████████████▍                                                            8.89%  │
│   8: ██████████████▋                                                            9.03%  │
│   9: █▎                                                                         0.83%  │
│  10: █▏                                                                         0.69%  │
│  11: ▉                                                                          0.56%  │
│  12: ▋                                                                          0.42%  │
│  13: ▍                                                                          0.28%  │
│  14: ▏                                                                          0.14%  │
└────────────────────────────────────────────────────────────────────────────────────────┘

┌──────────────────┐
│ CDF (): P(X ≤ x) │
├──────────────────┴─────────────────────────────────────────────────────────────────────┐
│   0: ████████████████████████████████▍                                         45.00%  │
│   1: ████████████████████████████████▍                                         45.00%  │
│   2: ████████████████████████████████▍                                         45.00%  │
│   3: ██████████████████████████████████████▍                                   53.33%  │
│   4: ████████████████████████████████████████████▌                             61.81%  │
│   5: ██████████████████████████████████████████████████▋                       70.42%  │
│   6: █████████████████████████████████████████████████████████▏                79.17%  │
│   7: ███████████████████████████████████████████████████████████████▍          88.06%  │
│   8: █████████████████████████████████████████████████████████████████████▉    97.08%  │
│   9: ██████████████████████████████████████████████████████████████████████▌   97.92%  │
│  10: ███████████████████████████████████████████████████████████████████████   98.61%  │
│  11: ███████████████████████████████████████████████████████████████████████▍  99.17%  │
│  12: ███████████████████████████████████████████████████████████████████████▋  99.58%  │
│  13: ███████████████████████████████████████████████████████████████████████▉  99.86%  │
│  14: ████████████████████████████████████████████████████████████████████████ 100.00%  │
└────────────────────────────────────────────────────────────────────────────────────────┘

┌──────────────────┐
│ Outcome Table () │
├──────────────────┴───────────────────────────┐
│ DAMAGE │ PERCENT │ Crit % │  Hit % │  Miss % │
├────────┼─────────┼────────┼────────┼─────────┤
│ 0      │ 45.000% │ 0.000% │ 0.000% │ 45.000% │
│ 3      │  8.333% │ 0.000% │ 8.333% │  0.000% │
│ 4      │  8.472% │ 0.139% │ 8.333% │  0.000% │
│ 5      │  8.611% │ 0.278% │ 8.333% │  0.000% │
│ 6      │  8.750% │ 0.417% │ 8.333% │  0.000% │
│ 7      │  8.889% │ 0.556% │ 8.333% │  0.000% │
│ 8      │  9.028% │ 0.694% │ 8.333% │  0.000% │
│ 9      │  0.833% │ 0.833% │ 0.000% │  0.000% │
│ 10     │  0.694% │ 0.694% │ 0.000% │  0.000% │
│ 11     │  0.556% │ 0.556% │ 0.000% │  0.000% │
│ 12     │  0.417% │ 0.417% │ 0.000% │  0.000% │
│ 13     │  0.278% │ 0.278% │ 0.000% │  0.000% │
│ 14     │  0.139% │ 0.139% │ 0.000% │  0.000% │
└────────┴─────────┴────────┴────────┴─────────┘
```

This enables rich statistics like "how much damage comes from crits vs hits".

## 🧱 Roadmap

- [ ] Create a **web playground** with live examples
- [x] Higher-level `Turn` API for conditional damage riders (0.9.0)
- [ ] Add more comprehensive 5e rule examples
- [ ] Performance improvements for DPR-only calculations
- [ ] Multi-round and sustained vs nova simulations
- [ ] Deeper integration with [dprcalc.com](https://dprcalc.com)
- [ ] Blog posts and documentation
- [ ] Grammar refinements and new YACC parsing

## 💬 Discuss

Join our [Discord](https://dprcalc.com/discord) to discuss this library and more!

## 🤝 Contributing

Clone the repo and install dependencies:

```bash
git clone https://github.com/yipe/dice.git
cd dice
yarn install
```

Run tests:

```bash
yarn test
```

Run examples:

```bash
yarn example
```

## 📜 License

2025 MIT © [Michael Margolis](https://github.com/yipe)

## ⚖️ Legal / Trademarks

Wizards of the Coast, Dungeons & Dragons, and their logos are trademarks of Wizards of the Coast LLC in the United States and other countries.

© 2025 Wizards. All Rights Reserved.

## ❤️ Credits

Portions of this code are inspired by [dice.clockworkmod.com](https://github.com/koush/dice.clockworkmod.com) by Koushik Dutta (2013), licensed under the [Apache License 2.0](http://www.apache.org/licenses/LICENSE-2.0).

Initial [TypeScript port](https://github.com/loginName1/dice-calculator-ts) expertly created by [loginName1](https://github.com/loginName1).