/**
* Dice expressions (Phase G). Pure parsing + evaluation of the small grammar
* used in move deltas: a single dice term with an optional flat modifier, or a
* bare signed number.
*
* "-1d6" → minus one six-sided die
* "+2" → flat +2
* "1d6" → one six-sided die
* "2d6+1" → two d6 plus one
* "-1d3" → minus one three-sided die
*
* Randomness is **injected** as an `Rng` so this module never calls
* `Math.random` — the impure boundary stays in the loop handler, and tests pass
* a deterministic sequence. Garbage in → `0` / `null`, never a throw.
*/
/** A source of uniform randomness in [0, 1) — `Math.random` in production. */
export type Rng = () => number;
/** A parsed dice expression: `sign * (count d sides) + flat`. */
export interface DiceExpr {
/** Number of dice (0 for a flat-only expression). */
count: number;
/** Die size (0 for a flat-only expression). */
sides: number;
/** Sign applied to the rolled dice sum. */
sign: 1 | -1;
/** Flat modifier, already signed (the whole value for a bare number). */
flat: number;
}
const EXPR = /^([+-])?\s*(?:(\d+)\s*d\s*(\d+)|(\d+))\s*([+-]\s*\d+)?$/i;
/** Parse a trailing "+K" / "- K" modifier into a signed number (0 if absent). */
function parseTrailingFlat(raw: string | undefined): number {
if (!raw) return 0;
const compact = raw.replace(/\s+/g, "");
const n = Number(compact);
return Number.isFinite(n) ? n : 0;
}
/** Parse a dice expression, or `null` if it isn't one. */
export function parseExpr(expr: string): DiceExpr | null {
if (typeof expr !== "string") return null;
const m = expr.trim().match(EXPR);
if (!m) return null;
const sign: 1 | -1 = m[1] === "-" ? -1 : 1;
const trailing = parseTrailingFlat(m[5]);
// Bare number form (e.g. "+2"): a pure flat modifier, no dice.
if (m[4] !== undefined) {
return { count: 0, sides: 0, sign: 1, flat: sign * Number(m[4]) + trailing };
}
// Dice form "NdM" (optionally "± K").
const count = Number(m[2]);
const sides = Number(m[3]);
if (sides <= 0) return null;
return { count, sides, sign, flat: trailing };
}
/** Evaluate a dice expression with the given RNG. Unparseable → 0. */
export function rollExpr(expr: string, rng: Rng): number {
const e = parseExpr(expr);
if (!e) return 0;
let diceSum = 0;
for (let i = 0; i < e.count; i++) {
diceSum += 1 + Math.floor(rng() * e.sides);
}
return e.sign * diceSum + e.flat;
}
/** Roll a single die of `sides` faces (1..sides) with the given RNG. */
export function rollDie(sides: number, rng: Rng): number {
return 1 + Math.floor(rng() * Math.max(1, sides));
}