diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e75a72..5d20b08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package.json b/package.json index 853841b..cbfafef 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/builder/ast.ts b/src/builder/ast.ts index 005f5dd..2c76953 100644 --- a/src/builder/ast.ts +++ b/src/builder/ast.ts @@ -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); + } } })(); @@ -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); @@ -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[] = []; diff --git a/src/builder/nodes.ts b/src/builder/nodes.ts index d947743..d70e415 100644 --- a/src/builder/nodes.ts +++ b/src/builder/nodes.ts @@ -6,7 +6,8 @@ export type ExpressionNode = | KeepNode | D20RollNode | HalfNode - | MaxOfNode; + | MaxOfNode + | ScaleNode; export type DieNode = { type: "die"; @@ -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; +}; diff --git a/src/builder/roll.ts b/src/builder/roll.ts index 170a495..b72d8b2 100644 --- a/src/builder/roll.ts +++ b/src/builder/roll.ts @@ -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); @@ -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, @@ -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); +} diff --git a/src/builder/scale.test.ts b/src/builder/scale.test.ts new file mode 100644 index 0000000..a5f44da --- /dev/null +++ b/src/builder/scale.test.ts @@ -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 => { + let m = 0; + for (const [v, b] of pmf.map.entries()) m += v * b.p; + return m; +}; + +const distEntries = (pmf: { map: Map }) => + [...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 = {}; + 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); + }); +});