/** * Per-evaluation shared environment. * * Lives in its own module so the modifier implementations under * `./modifiers/*` can type their `env` parameter without importing * `./evaluator.js`, which imports them back. * * @module evaluator/env */ import { EvaluatorError } from '../errors.js'; import type { ASTNode } from '../parser/ast.js'; import type { DieResult, ResolvedCritThreshold } from '../types.js'; /** * A resolved `cs`/`cf` pair, recorded per die so that dice minted later by * explode or reroll can be judged by the rule the user declared instead of * the built-in default. */ export type CritRule = { readonly success: readonly ResolvedCritThreshold[]; readonly fail: readonly ResolvedCritThreshold[]; }; /** * Per-evaluation shared environment (created once, shared across all branches). * * Exported for use by modifier implementations under `./modifiers/*`. Not part * of the public library API. */ export type EvalEnv = { readonly maxDice: number; readonly maxExplodeIterations: number; readonly maxRerollIterations: number; totalDiceRolled: number; /** * Set to `true` by `evalSuccessCount`. Propagates through the shared env * so `evaluate()` can include `successes`/`failures` fields even when no * die was tagged (impossible threshold). * * ! Set *after* the target is evaluated, not before. `evalSuccessCount` reads * ! the pre-set value to learn whether an inner count already tagged its pool * ! — moving the assignment back to the top makes it read its own write. */ hasSuccessCount: boolean; /** * What a multi-sub-roll group count (`{2d6, 2d6}>=10`) scored. Its units are * subtotals, which exist only as throwaway synthetic dice, so these verdicts * reach the top-level tally here rather than as tags on a die. Every path * that releases `TALLY_FLAGS` releases these too. */ subtotalSuccesses: number; subtotalFailures: number; /** * `true` while the evaluator is inside a `VersusNode`'s roll or DC * sub-evaluation. `evalVersus` rejects nesting via this flag — catches * paren-nested versus (`1d20 vs (5 vs 3)`) that slip past the parser's * left-chain check. */ insideVersus: boolean; /** * `true` once a `vs` has tagged its DC dice `'dc'`. Lets every pool * operation skip its per-die `isVersusDc` check outright — the tag exists in * a small minority of notation, and the call inside the loop cost 11-38% on * keep/drop, success, and sort notation that can never carry one. * * ! Monotonic: set by `evalVersus`, never cleared. `insideVersus` is not a * ! model for it — that flag resets in a `finally`, and it is false exactly * ! when the tagged DC dice become visible to the enclosing pool. */ hasVersusDc: boolean; /** * Crit rule per die, keyed on the `DieResult` object itself. Populated by * `applyCritThresholds`; read by explode and reroll so a die they mint * inherits the rule of the die it descended from. * * Out of band rather than a `DieResult` field because `DieResult` is public * and serialized. `undefined` until the first `cs`/`cf` node — declared here * so the shape stays stable for notation that has none. */ critRules: WeakMap | undefined; /** * Dice minted as explosion continuations by `!` / `!p`, keyed on the * `DieResult` object itself. Read by `extractNatural`, which counts them out * of the versus primaries — the `'exploded'` tag cannot say it alone, since * a compound explode stamps it on the original die it accumulated into. * * Out of band for the same reason as {@link EvalEnv.critRules}: `DieResult` * is public and serialized. * * ! Only populated while {@link EvalEnv.insideVersus} — nothing outside a * ! `vs` reads it, and the `add` per appended die is not free. Any new * ! reader must be on a versus path too. */ explosionDice: WeakSet | undefined; /** * User-supplied variable map for `@name` / `@{name}` references. Always * defined — `evaluate()` defaults to an empty object so lookups can be * branch-free on presence. */ readonly context: Readonly>; /** * Behavior when a referenced variable is missing from `context`. Always * defined — `evaluate()` defaults to `'throw'`. */ readonly onMissingVariable: 'throw' | 'zero'; }; /** * Reserves `count` dice against the global `maxDice` budget, throwing * `DICE_LIMIT_EXCEEDED` when the reservation would overshoot. * * Every path that consumes RNG draws for dice charges through here — initial * pools (`evalDice` / `evalFateDice`), explosion continuations, and reroll * replacements — so the limit and its message have one definition. */ export function chargeDice(env: EvalEnv, count: number, nodeType: ASTNode['type']): void { if (env.totalDiceRolled + count > env.maxDice) { throw new EvaluatorError( `Total dice count ${env.totalDiceRolled + count} exceeds limit of ${env.maxDice}`, 'DICE_LIMIT_EXCEEDED', nodeType, ); } env.totalDiceRolled += count; } /** Single-die shorthand for {@link chargeDice}. */ export function chargeDie(env: EvalEnv, nodeType: ASTNode['type']): void { chargeDice(env, 1, nodeType); }