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
21 changes: 18 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,30 @@ 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]
## [0.5.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.
library, so the provenance model and dice math stay owned here, and adds a
**composable scale node** so a scaled/rounded sub-roll can nest inside a larger
damage payload (per-damage-type resistance / immunity / vulnerability). All
additive except the Elemental-Adept bounce fix noted below.

### Added

- **`RollBuilder.scaleResult(numerator, denominator = 1, rounding = 'floor')`** —
wraps a builder in a composable `scale` AST node that scales its resolved PMF
by `numerator / denominator` with the given rounding. Unlike the old
`.half()` wrapper, a scaled builder composes: it survives `sumRolls(...)`
instead of being dropped on a flat-config merge, so a per-type resisted or
doubled sub-roll keeps its own scaling inside a larger hit/crit payload. The
rendered expression reflects it — `denominator === 1 → "N * (child)"`,
`numerator === 1 → "(child) // D"`, general → `"(child) * N // D"`. `.half()`
is now `scaleResult(1, 2, 'floor')`.
- **`sumRolls(parts: RollBuilder[])`** — additive factory whose `toAST()` is an
`add` node over each part's AST, letting scaled and plain children sit side by
side without the flat `.plus()` merge collapsing them. `toExpression()` joins
the parts with ` + ` and `toPMF()` convolves them.
- **`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
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.4.0",
"version": "0.5.0",
"description": "A high-performance dice probability engine for D&D 5e DPR calculations. Powers dprcalc.com.",
"keywords": [
"dnd",
Expand Down
11 changes: 11 additions & 0 deletions src/builder/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,12 @@ export function resolve(node: ExpressionNode, eps: number = defaultEps): PMF {
// Compute the maximum of count independent rolls of childPMF
return computeMaxOfPMF(childPMF, count, eps);
}

case "scale": {
const childPMF = resolve(node.child, eps);
const denom = node.denominator === 0 ? 1 : node.denominator;
return childPMF.scaleDamage(node.numerator / denom, node.rounding);
}
}
})();

Expand Down Expand Up @@ -318,6 +324,7 @@ function findDie(node: ExpressionNode): DieNode | undefined {
case "d20Roll":
case "half":
case "maxOf":
case "scale":
return findDie(node.child);
case "keep":
return findDie(node.child.child);
Expand Down Expand Up @@ -581,6 +588,10 @@ export function getASTSignature(node: ExpressionNode): string {
return `half{ch:${getASTSignature(node.child)}}`;
case "maxOf":
return `maxOf{c:${node.count},ch:${getASTSignature(node.child)}}`;
case "scale":
return `scale{n:${node.numerator},d:${node.denominator},r:${
node.rounding
},ch:${getASTSignature(node.child)}}`;
case "add": {
let constantValue = 0;
const otherChildrenSigs: string[] = [];
Expand Down
20 changes: 19 additions & 1 deletion src/builder/nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ export type ExpressionNode =
| KeepNode
| D20RollNode
| HalfNode
| MaxOfNode;
| MaxOfNode
| ScaleNode;

export type DieNode = {
type: "die";
Expand Down Expand Up @@ -56,3 +57,20 @@ export type MaxOfNode = {
count: number;
child: ExpressionNode;
};

/**
* Scale the child's result by `numerator / denominator`, then round.
*
* Unlike {@link HalfNode} (a fixed `// 2` with floor), this is a general, composable
* multiplier/divider — the building block for damage-type resistance (`1/2`, floor),
* vulnerability (`2/1`), and similar per-source transforms. It renders as
* `N * (child)` when the denominator is 1, `(child) // D` when the numerator is 1,
* and `(child) * N // D` otherwise.
*/
export type ScaleNode = {
type: "scale";
numerator: number;
denominator: number;
rounding: "floor" | "round" | "ceil";
child: ExpressionNode;
};
138 changes: 138 additions & 0 deletions src/builder/roll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,20 @@ export class RollBuilder {
return new HalfRollBuilder(this);
}

/**
* Scale this roll's result by `numerator / denominator`, rounding each outcome.
* A general, composable form of {@link half} — used to model damage-type resistance
* (`scaleResult(1, 2)` → `(expr) // 2`) and vulnerability (`scaleResult(2)` → `2 * (expr)`).
* Compose several of these (and plain rolls) into one payload with {@link sumRolls}.
*/
scaleResult(
numerator: number,
denominator: number = 1,
rounding: "floor" | "round" | "ceil" = "floor"
): ScaleRollBuilder {
return new ScaleRollBuilder(this, numerator, denominator, rounding);
}

// Create a "max of N rolls" version of this roll for crit damage with keep operations
maxOf(count: number): MaxOfRollBuilder {
return new MaxOfRollBuilder(this, count);
Expand Down Expand Up @@ -783,6 +797,64 @@ export class HalfRollBuilder extends RollBuilder {
}
}

/**
* A roll whose result is scaled by `numerator / denominator` and rounded — the composable
* generalization of {@link HalfRollBuilder}. Renders as `N * (inner)`, `(inner) // D`, or
* `(inner) * N // D`. Terminal (like `half`): use {@link sumRolls} to combine with other rolls.
*/
export class ScaleRollBuilder extends RollBuilder {
constructor(
private readonly innerRoll: RollBuilder,
private readonly numerator: number,
private readonly denominator: number = 1,
private readonly rounding: "floor" | "round" | "ceil" = "floor"
) {
super(0); // dummy, we override methods
}

override hasHiddenState(): boolean {
return this.innerRoll.hasHiddenState();
}

override get lastConfig(): RollConfig {
return (this.innerRoll as unknown as { lastConfig: RollConfig }).lastConfig;
}

getSubRollConfigs(): readonly RollConfig[] {
return this.innerRoll.getSubRollConfigs();
}

toExpression(): string {
const inner = this.innerRoll.toExpression();
if (this.denominator === 1) return `${this.numerator} * (${inner})`;
if (this.numerator === 1) return `(${inner}) // ${this.denominator}`;
return `(${inner}) * ${this.numerator} // ${this.denominator}`;
}

toAST(): ExpressionNode {
return {
type: "scale",
numerator: this.numerator,
denominator: this.denominator,
rounding: this.rounding,
child: this.innerRoll.toAST(),
};
}

toPMF(eps: number = 0): PMF {
return pmfFromRollBuilder(this, eps);
}

copy(): ScaleRollBuilder {
return new ScaleRollBuilder(
this.innerRoll.copy(),
this.numerator,
this.denominator,
this.rounding
);
}
}

export class MaxOfRollBuilder extends RollBuilder {
constructor(
private readonly innerRoll: RollBuilder,
Expand Down Expand Up @@ -1206,3 +1278,69 @@ export class PooledRollBuilder extends RollBuilder {
return new PooledRollBuilder(sumNode, newExpr);
}
}

/**
* An additive composite of independent rolls that preserves each part's AST — the piece
* that lets a scaled/halved sub-roll (which the flat `.plus()` merge would otherwise drop)
* sit beside plain rolls in one damage payload. Its PMF convolves the parts; its expression
* joins them with ` + `. Built via {@link sumRolls}; terminal (used as an onHit/onCrit/
* onSaveFailure payload), so it reports hidden state to reject accidental flat merges.
*/
class CompositeSumRollBuilder extends RollBuilder {
constructor(private readonly parts: readonly RollBuilder[]) {
super(0); // dummy, we override methods
}

override hasHiddenState(): boolean {
return true;
}

override getSubRollConfigs(): readonly RollConfig[] {
return [];
}

override toAST(): ExpressionNode {
return {
type: "add",
children: this.parts.map((p) => ({
node: p.toAST(),
sign: 1 as const,
})),
};
}

override toExpression(): string {
const exprs = this.parts
.map((p) => p.toExpression())
.filter((e) => e && e !== "0");
if (exprs.length === 0) return "0";
let result = exprs[0];
for (let i = 1; i < exprs.length; i++) {
const e = exprs[i];
result += e.startsWith("-") ? ` - ${e.substring(1)}` : ` + ${e}`;
}
return result.replace(/\+ -/g, "-");
}

override toPMF(eps: number = 0): PMF {
return pmfFromRollBuilder(this, eps);
}

override copy(): CompositeSumRollBuilder {
return new CompositeSumRollBuilder(this.parts.map((p) => p.copy()));
}
}

/**
* Combine several rolls into one additive payload whose PMF is their convolution and whose
* expression is them joined with ` + `. Unlike `a.plus(b)`, this preserves parts that carry
* hidden state (e.g. `roll.scaleResult(1, 2)` / `roll.half()`), so per-damage-type resistance
* and vulnerability survive into both the distribution and the rendered expression.
* Empty parts collapse to `0`; a single part is returned unwrapped.
*/
export function sumRolls(parts: readonly RollBuilder[]): RollBuilder {
const meaningful = parts.filter((p): p is RollBuilder => p !== undefined);
if (meaningful.length === 0) return new RollBuilder(0);
if (meaningful.length === 1) return meaningful[0];
return new CompositeSumRollBuilder(meaningful);
}
118 changes: 118 additions & 0 deletions src/builder/scale.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";
import { d, d20 } from "../builder";
import { RollBuilder, ScaleRollBuilder, sumRolls } from "./roll";

const mean = (pmf: { map: Map<number, { p: number }> }): number => {
let m = 0;
for (const [v, b] of pmf.map.entries()) m += v * b.p;
return m;
};

const distEntries = (pmf: { map: Map<number, { p: number }> }) =>
[...pmf.map.entries()].sort((a, b) => a[0] - b[0]).map(([v, b]) => [v, b.p]);

const d6 = () => new RollBuilder().plus(1, d(6));

describe("scaleResult / ScaleRollBuilder", () => {
it("halves (floor) a single die: expression and distribution", () => {
const half = d6().scaleResult(1, 2);
expect(half).toBeInstanceOf(ScaleRollBuilder);
expect(half.toExpression()).toBe("(1d6) // 2");
// floor(1..6 / 2) => 0,1,1,2,2,3
expect(distEntries(half.pmf)).toEqual([
[0, 1 / 6],
[1, 2 / 6],
[2, 2 / 6],
[3, 1 / 6],
]);
expect(mean(half.pmf)).toBeCloseTo(1.5, 10);
});

it("matches the existing half() node for the // 2 case", () => {
expect(d6().scaleResult(1, 2).toExpression()).toBe(d6().half().toExpression());
expect(mean(d6().scaleResult(1, 2).pmf)).toBeCloseTo(mean(d6().half().pmf), 10);
});

it("doubles the total for vulnerability (2 * (expr))", () => {
const vuln = d6().scaleResult(2);
expect(vuln.toExpression()).toBe("2 * (1d6)");
// 2 * (1..6) => even values 2..12, each 1/6 (NOT triangular like 2d6)
expect(distEntries(vuln.pmf)).toEqual([
[2, 1 / 6],
[4, 1 / 6],
[6, 1 / 6],
[8, 1 / 6],
[10, 1 / 6],
[12, 1 / 6],
]);
expect(mean(vuln.pmf)).toBeCloseTo(7, 10);
});

it("renders a general numerator/denominator form", () => {
expect(d6().scaleResult(3, 4).toExpression()).toBe("(1d6) * 3 // 4");
});

it("supports round and ceil rounding modes", () => {
// ceil(1..6 / 2) => 1,1,2,2,3,3 => mean 2
expect(mean(d6().scaleResult(1, 2, "ceil").pmf)).toBeCloseTo(2, 10);
});
});

describe("sumRolls", () => {
it("returns a single part unwrapped and empty as 0", () => {
const base = new RollBuilder().plus(3).plus(1, d(8));
expect(sumRolls([base])).toBe(base);
expect(sumRolls([]).toExpression()).toBe("0");
});

it("preserves a scaled part beside a plain part (which plus() would drop)", () => {
const base = new RollBuilder().plus(3).plus(1, d(8)); // 1d8 + 3, mean 7.5
const resistedFire = d6().scaleResult(1, 2); // (1d6)//2, mean 1.5
const payload = sumRolls([base, resistedFire]);

expect(payload.toExpression()).toBe("1d8 + 3 + (1d6) // 2");
expect(mean(payload.pmf)).toBeCloseTo(9.0, 10);

// Guard: the flat merge silently drops the scaled part.
expect(base.plus(resistedFire).toExpression()).toBe("1d8 + 3");
});

it("only scales the resisted damage type in a mixed attack (floor is per-type)", () => {
// 1d8 slashing (unresisted) + 1d6 fire (resisted). Halving the WHOLE total would be wrong.
const slashing = new RollBuilder().plus(1, d(8)); // mean 4.5
const fireResisted = d6().scaleResult(1, 2); // mean 1.5
const perType = sumRolls([slashing, fireResisted]);
expect(mean(perType.pmf)).toBeCloseTo(6.0, 10);

// Halving the merged total instead would give floor((1d8+1d6)/2) => mean ~3.79, clearly different.
const wholeHalved = new RollBuilder().plus(1, d(8)).plus(1, d(6)).scaleResult(1, 2);
expect(mean(wholeHalved.pmf)).not.toBeCloseTo(6.0, 1);
});

it("carries resistance through a full attack's hit and crit payloads + keeps attribution", () => {
const hit = sumRolls([
new RollBuilder().plus(3).plus(1, d(8)),
d6().scaleResult(1, 2),
]);
const crit = sumRolls([
new RollBuilder().plus(3).plus(2, d(8)),
new RollBuilder().plus(2, d(6)).scaleResult(1, 2),
]);
const attack = d20.ac(15).onHit(hit).onCrit(crit);

expect(attack.toExpression()).toBe(
"(d20 AC 15) * (1d8 + 3 + (1d6) // 2) crit (2d8 + 3 + (2d6) // 2)"
);

const totals: Record<string, number> = {};
for (const [, b] of attack.pmf.map.entries()) {
for (const [k, p] of Object.entries(b.count ?? {})) {
totals[k] = (totals[k] ?? 0) + (p as number);
}
}
// 0.7 miss / 0.25 hit / 0.05 crit at AC 15 with a flat d20.
expect(totals.missNone).toBeCloseTo(0.7, 6);
expect(totals.hit).toBeCloseTo(0.25, 6);
expect(totals.crit).toBeCloseTo(0.05, 6);
});
});
Loading