dicerollerts
v0.52.0
Published
A TypeScript library for parsing, rolling, and analyzing dice expressions, plus a small program language for tabletop-RPG-flavoured automation. Supports standard dice notation, custom dice, dice pools, exploding/rerolling/compound mechanics, structured-fa
Readme
dicerollerts
A TypeScript library for parsing, rolling, and analyzing dice expressions, plus a small program language for tabletop-RPG-flavoured automation. Supports standard dice notation, custom dice, dice pools, exploding/rerolling/compound mechanics, structured-face dice, parametric dice, and full probability analysis (exact distributions, joint distributions, and adaptive Monte Carlo).
Install
npm install dicerollertsQuick Start
import { ProgramParser, Evaluator, RR } from 'dicerollerts'
const result = ProgramParser.parse('4d6 drop 1')
if (result.success) {
const evaluator = new Evaluator(
(sides) => Math.floor(Math.random() * sides) + 1,
)
console.log(evaluator.run(result.program)) // e.g. 14
}One language
This library is a single language. Dice atoms (d6, 4d6, dF, d%,
d{1,2,3}, @name, 3 @name) are first-class expressions and compose
freely with variables, arithmetic, control flow, records, arrays, and
comprehensions:
$str = 5
$attack = d20 + $str
if $attack >= 15 then 2d6 + $str else 0There is no separate dice-expression "mode" and no backtick wrapping -
write the same notation everywhere. The single public parser entry point
is ProgramParser.parse(source).
Migrating from 0.x:
- Backticks are no longer accepted. Replace
`d20 + $mod`withd20 + $mod. Run stored programs throughmigrateSource(text)(a one-line regex that strips backticks) for a quick mechanical pass.- Arithmetic that previously lived inside a backtick block is now program-level, so
4d6 drop 1and4d6 + 2d8behave identically - the dice modifiers still bind tighter than+/-, so4 + 4d6 drop 1parses as4 + ((4d6) drop 1).- Public
DiceParseris gone; useProgramParser.parsefor everything.Roller.rollandDiceStats.{distribution,mean,...}now accept the unifiedExpressionAST emitted by the parser.- The
DiceExprwrapper node is gone. Dice atoms (Die,NDice,CustomDie,DiceReduce,StructuredDiceRoll) appear directly inExpression. Thesource: stringcosmetic field is removed.Evaluator'sonDiceExpr+onStructuredDiceRollcallbacks merged into a singleonDiceTerm: (event: DiceTermResult) => void. Theevent.kinddiscriminator is'arithmetic'|'structured'.RollResulttag renames:binary-op-result→binary-expr-result,literal-result→number-literal-result,unary-op-result→unary-expr-result. Constructors renamed to match (binaryExprResult,numberLiteralResult,unaryExprResult). Op fields use the program vocabulary (add/subtract/multiply/divide).ProgramParameters.listshape:defaultExpr+defaultSource→ singledefaultExpression: Expression.partsingruntime dependency is gone - the package now ships with zero runtime deps.
1. Dice Expression Language
Pass any of the following to ProgramParser.parse(...). Whitespace is
generally free; _ is treated as whitespace.
Multiline expressions
A newline normally ends a statement. An expression may still span several lines
when a line ends with a pending binary operator (+ - * / and or and the
comparison operators) - the trailing operator signals that the right-hand side
continues on the next line:
$dmg = 2d6 + $str +
$weapon_bonus +
$crit_dice
$hit = $attack >= $ac and
not $defender_invisibleThe operator must be the last token on the line; a newline placed before an
operator ends the statement instead. A # comment is allowed between a trailing
operator and the newline.
Basic dice and literals
| Notation | Description |
| -------------- | ---------------------------- |
| d6 | One six-sided die |
| 3d6 | Three six-sided dice, summed |
| d20 | Twenty-sided die |
| d% or d100 | Percent die |
| 42 | Literal number |
| -d6 | Negated die |
Custom dice
| Notation | Description |
| ---------------- | --------------------------- |
| d{1,1,2,2,3,4} | Die with custom face values |
| dF | Fate / Fudge die (-1, 0, 1) |
| 4dF | Four Fate dice |
Custom face values may be negative or zero.
Arithmetic
Standard precedence (multiplication / division before addition / subtraction), left-to-right within each tier. Parentheses group sub-expressions.
| Notation | Description |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 3d6 + 4 | Addition |
| 2d8 - 1 | Subtraction |
| d6 * 2 | Multiplication |
| d100 / 10 | Division (truncates to integer; throws UndefinedOutcomeError on zero divisor - the analyser turns this into partial-number / undefined FieldStats rather than crashing) |
| (2d6+1)*3 | Parenthesized |
Unicode aliases: × and ⋅ are accepted for multiplication; ÷ for division.
≤ for <=, ≥ for >=, ≠ for !=. → for the -> match-arm / fold
lambda arrow. The letter x and the colon : are not accepted as
operators - they collide with identifiers (and 0x… literals) and with
record-field / array-slice syntax respectively.
Rounding
Five postfix operators turn a numeric expression into a rounded integer. They
bind tighter than arithmetic - 2d6 / 3 round rounds the quotient, not the
operand.
| Form | Mode | 1.5 | -1.5 | 2.5 |
| ------------------- | -------------------------------- | ----- | ------ | ----- |
| … round | Half away from zero | 2 | -2 | 3 |
| … round up | Ceiling - toward +∞ | 2 | -1 | 3 |
| … round down | Floor - toward -∞ | 1 | -2 | 2 |
| … truncate | Toward zero | 1 | -1 | 2 |
| … round half even | Banker's rounding (nearest even) | 2 | -2 | 2 |
round is not JavaScript's Math.round - -1.5 round is -2, not -1.
This matches the convention used by spreadsheet ROUND, Python's
decimal.ROUND_HALF_UP, and most tabletop rulebooks.
The keywords are not lexer-level reserved - $round, $truncate, etc. remain
valid identifiers outside the postfix slot.
The runtime helpers are also exported for consumers that fold RoundExpr
nodes into a normalised number themselves (UIs that display the rounded value
alongside the source, serializers that pre-compute the result):
import {
applyRoundMode,
roundHalfAwayFromZero,
roundHalfEven,
} from 'dicerollerts'
applyRoundMode(1.5, 'round-half-even') // 2
roundHalfAwayFromZero(-1.5) // -2Expression sets and reducers
Group dice into a pool with square brackets. The bare bracket form
[d, d, …] is an array literal (list-typed); adding a reducer
suffix turns it into a scalar dice-pool expression.
| Notation | Description |
| --------------------- | --------------------------- |
| [2d6, 3d8, d10] | List of three rolled values |
| [2d6, 3d8, d10] sum | Sum of all (explicit sum) |
| [2d6, 3d8] min | Lowest result |
| [2d6, 3d8] max | Highest result |
| [2d6, 3d8] average | Arithmetic mean (rounded) |
| [2d6, 3d8] median | Median |
| [d20, d20] keep 1 | Filter + default sum |
Reducer aliases: min / minimum / take least; max / maximum / take best;
average / avg; median / med.
Migrating from older versions:
- Paren-list dice pools
(d, d, …)were removed; use[d, d, …].as poolis gone - bracket literals are already list-typed, so$rolls = [d20, d20]replaces the historic$rolls = 2d20 as pool.- Bare
(expr)parens still work for arithmetic grouping ((d6 + 1) * 2); only the multi-element comma-separated paren form is gone.
Drop and Keep
Drop or keep some of the dice in a pool. Filters can be chained - each filter operates on the surviving dice from the previous one.
| Notation | Shorthand | Description |
| ---------------------------- | --------- | --------------------------------------- |
| 4d6 drop 1 | 4d6d1 | Drop lowest 1 |
| 4d6 drop lowest 1 | | Same, explicit (low is also accepted) |
| 4d6 drop highest 1 | | Drop highest 1 (high also accepted) |
| 4d6 keep 3 | 4d6k3 | Keep highest 3 |
| 4d6 keep lowest 1 | | Keep lowest 1 |
| 5d6 keep middle 3 | | Keep the central band of 3 sorted dice |
| 5d6 drop middle 1 | | Drop the central die (keep the outer 4) |
| 5d6 drop high 1 drop low 1 | | Drop high 1 then low 1 (keep middle 3) |
For middle, when the pool size minus N is odd, the extra dropped die is taken
from the upper end of the sorted pool. So 5d6 keep middle 2 keeps sorted
ranks {1, 2} (the lower-middle pair), and 5d6 drop middle 2 keeps the
complement {0, 3, 4}.
Heterogeneous filterable pools are also allowed: [d20, d12, d10] keep 2.
Exploding
Roll again when a trigger condition is met. Extra rolls are added as separate
dice and contribute to the reducer (typically sum).
| Notation | Shorthand | Description |
| -------------------------- | --------- | ---------------------------- |
| 3d6 explode on 6 | | Explode on exact 6, no limit |
| 3d6 explode once on 6 | | Explode at most once |
| 3d6 explode twice on 6 | | At most twice |
| 3d6 explode thrice on 6 | | At most three times |
| 3d6 explode 3 times on 6 | | At most N explosions |
| 3d6 explode on 5 or more | 3d6e5 | Explode on 5+ |
| 3d6 explode on 2 or less | | Explode on 1 or 2 |
| 3d6 explode on 3..5 | | Explode on a value in [3, 5] |
| 3d6 explode on max | 3d6em | Explode on the die's maximum |
| 3d6 explode max | | Same |
| 3d6 explode always on 6 | | Same as explode on 6 |
Rerolling
Roll again when triggered, but only the last roll counts.
| Notation | Shorthand | Description |
| ------------------------- | --------- | ------------------- |
| 3d6 reroll on 1 | | Reroll 1s, no limit |
| d6 reroll once on 1 | | Reroll at most once |
| 3d6 reroll on 2 or less | 3d6r2 | Reroll 1s and 2s |
Compound exploding
Like exploding, but extra rolls are summed into the original die - one die, higher value.
| Notation | Shorthand | Description |
| --------------------------- | --------- | ------------------------- |
| d6 compound on 6 | | Compound on 6 |
| d6 compound once on 6 | | At most once |
| 3d6 compound on 6 or more | 3d6ce6 | Compound on 6+ |
| 3d6 compound on max | 3d6cem | Compound on the die's max |
| 3d6 compound max | | Same |
Chaining functors with filters
Filters can chain after a functor - the filter ranks dice by their per-original-die total (sum of an explode chain, last reroll, compound total, or the kept-of-pair for emphasis):
4d6 explode on 6 keep 3 # keep top 3 post-explode totals
4d6 reroll on 1 drop lowest 1
4d6 explode on 6 count >= 7 # successes ≥ 7 over per-die totals
5d6 explode max keep 3
4d6 reroll on 1 explode on 6 keep 3 # combined functor + filterThe filter must follow the functor, never precede it
(4d6 keep 3 explode on 6 is rejected with a migration hint that
suggests the swap). Bounded-times forms (once, twice,
N times, emphasis) get exact-tier distributions. Unbounded
always-times also goes exact via a probability-mass cutoff
(epsilon = 1e-12); only chains that exceed the joint-enumeration
cap (e.g. 4d6 explode on 6 keep 3, ~54M joint cells) fall back
to Monte Carlo. Combined functors (reroll once on 1 explode once
on 6) take the same exact path in their bounded form.
Emphasis
Roll two dice, keep the result furthest from a center point.
| Notation | Description |
| --------------------------- | ---------------------------------- |
| d20 emphasis | Furthest from average; reroll ties |
| d20 emphasis high | Tie-break to higher value |
| d20 emphasis low | Tie-break to lower value |
| d20 emphasis reroll | Reroll ties (explicit) |
| d20 furthest from 10 | Custom center point (reroll ties) |
| d20 furthest from 10 high | Custom center, high tie-break |
| d20 furthest from 10 low | Custom center, low tie-break |
Dice pools / success counting
Count how many dice meet one or more thresholds. Thresholds can be expressed
with operators, English on ... triggers, bare numbers, or the exactly
keyword. Multiple thresholds may be chained with and so each die contributes
one success per matching threshold.
| Notation | Shorthand | Description |
| -------------------------------- | --------- | ------------------------------------------------------- |
| 8d10 count >= 6 | 8d10c6 | Count successes >= 6 |
| 8d10 count on 6 or more | | Same, English trigger form |
| 3d6 count == 5 | | Count exact 5s (operator form) |
| 3d6 count exactly 5 | | Same, keyword form |
| 3d6 count on 5 | | Same, trigger form |
| 3d6 count on 3..5 | | Count dice between 3 and 5 inclusive |
| 4d6 count <= 2 | | Count values <= 2 |
| 4d6 count > 4 | | Count values > 4 (canonicalises to >= 5) |
| 4d6 count < 3 | | Count values < 3 (canonicalises to <= 2) |
| 5d10 count on 6 or more and 10 | | Multi-step: each die counts once per matching threshold |
Threshold values may be a positive literal, a $variable, or a
parenthesised expression: 8d10 count >= (3d6) rolls 3d6 once and
counts how many of the eight dice land at or above that value. This
applies generally to dice-modifier arguments: triggers, counts, and
keep/drop amounts all accept literals, variables, and parenthesised
expressions (e.g. 4d6 explode on $threshold, 6d20 keep ($n + 1),
5d10 reroll once on (d4)).
The and separator only has this meaning inside a count reducer; outside it
remains the boolean operator.
Multi-step counts
Chain thresholds with and to give each die one success per matching
threshold. For example, in 5d10 count >= 6 and == 10, a 10 matches both
>= 6 and == 10, so that die contributes 2 successes; a 7 only matches >= 6
and contributes 1; a 3 matches neither and contributes 0. Each segment must be
an explicit threshold form (operator, exactly, or on):
5d10 count >= 6 and == 10
4d12 count >= 6 and >= 10
8d10 count on 6 or more and on 10Variables in dice expressions
Inside a dice expression, $var references resolve to the variable's value
at roll time. Three positions are supported.
Additive position - modify a roll with a constant or computed value:
$mod = 5
$attack = d20 + $modCount position (parametric count) - roll a variable number of dice:
$rolls = 3
$damage = $rolls D6 # rolls 3d6The variable may itself be a dice expression:
$rolls = d4 # rolls a d4 (1-4)
$damage = $rolls D6 # rolls between 1d6 and 4d6, summedSides position (parametric sides):
$sides = 8
$roll = 1d$sides # rolls a d8Both positions:
$n = 3
$s = 6
$roll = $n D$s # rolls 3d6Parametric forms require whitespace between the variable name and the
d/D ($n d6, $n D6, $n DF, $n d{1,2}). The no-whitespace form
$nD6 is rejected - it's visually ambiguous with a long variable name. The
parser accepts either lowercase d or uppercase D after the whitespace.
The renderer emits uppercase D as the canonical form ($n D6) to
distinguish parametric heads from compact literal-count dice (3d6).
Counts of 0 or fewer return 0; the resolved count is capped at
MAX_DICE_COUNT = 10000 per expression - programs that try to roll more
(e.g., 100000d6) throw a clear runtime error. The AST holds count and
sides compactly regardless of size, so 100000d6 keep 1 parses instantly; the
runtime cap only fires when you actually roll.
Parametric counts compose with all modifiers:
$n = d6
$attack = $n D6 keep 1 # roll N d6, keep highest 1 (where N is itself a roll)
$pool = $n D10 count >= 6 # variable-size dice pool
$burst = $n D6 explode on 6 # variable-count exploding dice
$fate = $n DF # N Fate diceParse errors
import { ProgramParser, suggestKeyword } from 'dicerollerts'
const r = ProgramParser.parse('3d6 explod on 6')
if (!r.success) {
r.errors[0].message // error description
r.errors[0].position // character offset
r.errors[0].suggestion // e.g. "Did you mean 'explode'?"
r.errors[0].context // surrounding input text
}
suggestKeyword('explod') // "explode"2. Roller and roll results
import { Roller, RR, MAX_DICE_COUNT } from 'dicerollerts'
const roller = new Roller((sides) => Math.floor(Math.random() * sides) + 1)
// With explicit options:
const roller2 = new Roller(rollFn, {
maxExplodeIterations: 100, // default 100
maxRerollIterations: 100,
maxEmphasisIterations: 100,
})
const roll = roller.roll(expr) // RollResult tree
RR.getResult(roll) // numeric resultRoller operates on the unified Expression AST emitted by
ProgramParser. The roll result is a structured tree (RollResult)
describing every die rolled, kept, and discarded - useful for transcript
rendering and replay.
MAX_DICE_COUNT is 10_000. Roller enforces this for every n-dice
expression at roll time.
To roll structured-dice-roll expressions (@name references), use
Evaluator.run instead - Roller deals only with numeric results.
Expression utilities (DE)
import { DE } from 'dicerollerts'
DE.toString(expr) // canonical string representation
DE.validate(expr) // null if valid, ValidationMessage[] if not
DE.calculateBasicRolls(expr) // count of dice in expression
DE.simplify(expr) // constant folding & identity eliminationDE.simplify performs algebraic simplification on a DiceExpression:
literal+literal folding, unary negate folding, double-negation, identity
elimination (x + 0 → x, x * 1 → x, etc.).
3. Distribution analysis on dice expressions (DiceStats)
import { DiceStats } from 'dicerollerts'
// Exact distribution (for expressions whose support is enumerable):
const dist = DiceStats.distribution(expr) // Map<number, number>
DiceStats.mean(expr)
DiceStats.stddev(expr)
DiceStats.min(expr)
DiceStats.max(expr)
DiceStats.percentile(expr, 50)
// Monte Carlo (for any expression):
const mc = DiceStats.monteCarlo(expr, { trials: 50000 })
mc.mean
mc.stddev
mc.min
mc.max
mc.distribution // Map<number, number> - conditional on defined outcomes
mc.percentile(75)
mc.undefinedMass // fraction of trials that produced an undefined outcome
// (division by zero); 0 if every trial was defined
// Streaming Monte Carlo with progress:
for await (const progress of DiceStats.monteCarloAsync(expr, {
trials: 50_000,
chunkSize: 1000,
})) {
// progress.completed, progress.total, progress.result
}
// Summary: exact when possible (≤ 20 basic rolls), Monte Carlo fallback:
const s = DiceStats.summary(expr)
s.min
s.max
s.mean
s.stddev
s.distribution
s.percentiles // { 25, 50, 75 }Exact distribution is supported for: die, custom-die, literal, binary
and unary ops on those, every dice-reduce shape (including dice-list-with-filter,
chained drop/keep, sums, mins, max, average, median, count), explode / reroll /
compound (both bounded times - once, twice, thrice, N times - and the
unbounded always form via a probability-mass cutoff), combined functors
(reroll … explode …), emphasis, and literal-count structured-dice rolls
(dice @die [...]; 3 @die). Parametric-count dice ($n D6), variable-count
repeat $n { … }, and chains whose joint enumeration exceeds the
five-million-cell cap fall back to Monte Carlo (or ProgramStats, which
can handle them in context).
4. Program Language
A small scripting language for tabletop-RPG-flavoured automation. Variables, conditionals, records, arrays, comprehensions, and dice declarations.
import { ProgramParser, Evaluator, ProgramStats } from 'dicerollerts'
const source =
$str_mod = 5
$ac = 15
$attack = d20 + $str_mod
$hit = $attack >= $ac
$damage = if $hit then 2d6 + $str_mod else 0
{ attack: $attack, hit: $hit, damage: $damage }
const parsed = ProgramParser.parse(source)
if (!parsed.success) {
console.error(parsed.errors)
} else {
// Single roll:
const evaluator = new Evaluator(
(sides) => Math.floor(Math.random() * sides) + 1,
)
const result = evaluator.run(parsed.program)
// e.g. { attack: 18, hit: true, damage: 14 }
// Probability analysis (auto-detects strategy):
const analysis = ProgramStats.analyze(parsed.program)
// analysis.strategy.tier: 'constant' | 'exact' | 'monte-carlo'
// analysis.stats: per-field distributions
}Language reference
Statements
- Assignment:
$name = expr- variables are immutable; reassigning throws. Names match$[a-z_][a-z0-9_]*. - Parameter declaration:
$name is { default: ..., ... }- see Parameters. - Dice declaration:
dice @name [ ... ]- see Structured-face dice. - Expression statement: any expression. The program's final value is the value of the last statement.
Statements are separated by newlines. Lines starting with # are comments;
# to end-of-line is also a comment.
Expressions
| Form | Notes |
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
| 42, -3, true, false, "text" | Literals |
| d6, 4d6, 4d6 drop 1, @name, 3 @name | Dice atoms - see Dice Expression Language |
| $name | Variable reference |
| +, -, *, / | Arithmetic (/ is integer division) |
| … round, round up, round down, truncate, round half even | Postfix rounding - see Rounding |
| ==, !=, <, <=, >, >= | Comparisons |
| and, or, not | Booleans |
| if cond then a else b | Conditional (else required) |
| match { ... }, match VALUE { ... } | Pattern matching - see Match |
| { key: value, ... }, { $var } (shorthand) | Records |
| $rec.field | Field access |
| [1, 2, 3], [d6, d8] | Array literal (also acts as a dice pool with a trailing modifier) |
| [...$arr, d6], [...3d6] | Spread element - inline a list into an array literal |
| $arr[0], $arr[1:3], $arr[:-1], $arr[-3:] | Indexing and slicing - see Slicing |
| repeat N { body } | Returns array of N body evaluations (literal N is exact-analyzable; variable $n forces Monte Carlo) |
| for $x in arr { body } | Comprehension - see Comprehensions |
| <aggregator> for ... | Aggregator-prefixed comprehension |
| argmin, argmax | Sugar for sort + slice (returns 1-element array) |
| fold arr from init ($acc, $x) -> body | Generic left fold |
+ is overloaded: when either operand is a string, it concatenates; otherwise
numeric.
Match expressions
Replace nested if-else chains with a flat form. Two modes are supported.
Guard mode (no value after match): picks the first arm whose pattern is
truthy.
$damage = match {
$crit -> 4d6 + 1d4 + 4
$hit -> 2d6 + 4
_ -> 0
}Value mode (value after match): compares the matched value against each
arm's pattern using ==.
$roll = match $roll_mode {
"advantage" -> 2d20 keep highest 1
"disadvantage" -> 2d20 keep lowest 1
_ -> d20
}Arms can carry an optional if guard clause, evaluated only when the pattern
matches:
$damage = match $weapon {
"sword" if $crit -> 4d6
"sword" -> 2d6
"dagger" if $crit -> 2d4
"dagger" -> 1d4
_ -> 0
}_is the wildcard (matches anything; use it as a catch-all).- Arms are separated by newlines or commas; trailing separators are allowed.
- The first matching arm wins; remaining arms are not evaluated.
- A trailing
_ -> defaultis recommended - non-matching trials throw a runtime error.
Comprehensions and fold
Iterate over arrays with optional filtering, optionally collapsing via an aggregator prefix.
# Map: returns an array.
for $x in [1, 2, 3] { $x * 2 } # [2, 4, 6]
# Map + filter.
for $x in [1, 2, 3, 4] if $x > 2 { $x } # [3, 4]
# Aggregator prefix collapses to a single value.
sum for $x in [1, 2, 3] { $x } # 6
product for $x in [2, 3, 4] { $x } # 24
max for $x in [3, 1, 5, 2] { $x } # 5
min for $x in [3, 1, 5, 2] { $x } # 1
average for $x in [2, 4, 6] { $x } # 4
count for $x in [1, 2, 3, 4, 5] if $x > 2 # 3 (no body - count only)
first for $x in [1, 2, 3] if $x > 1 # 2 (short-circuits)
last for $x in [1, 2, 3] if $x > 1 # 3Aggregator body rules:
count: body is forbidden (usecount for $x in arr if cond).first/last: body is optional (defaults to the element itself).- All others (and the no-aggregator form): body is required.
sum over records does field-wise sum (matching structured-face dice
semantics):
sum for $r in [{a: 1, b: 2}, {a: 3, c: 4}] { $r }
# {a: 4, b: 2, c: 4}The sort aggregator returns the elements in sorted order (ascending by
default) rather than collapsing them. Optional by (sort key) and explicit
asc / desc:
sort for $x in [3, 1, 2] { $x } # [1, 2, 3]
sort for $x in [3, 1, 2] desc { $x } # [3, 2, 1]
sort for $r in $records by $r.priority { $r } # records sorted by priority
sort for $r in $records by $r.priority desc { $r } # descending
sort for $r in $records if $r.active by $r.priority desc { $r } # filter + by + descargmin and argmax are sugar for sort + slice [0:1]. They return a
single-element array containing the element with the smallest / largest key:
argmin for $r in $records by $r.priority { $r } # [record_with_min_priority]
argmax for $r in $records by $r.priority { $r } # [record_with_max_priority]
argmin for $x in [3, 1, 2] { $x } # [1]Both forms accept a by clause and follow the same rules as sort. They
always sort in their natural direction (argmin ascending, argmax
descending) - explicit asc / desc is rejected.
For arbitrary combining logic, use fold:
fold [1, 2, 3, 4] from 0 ($acc, $x) -> $acc + $x # 10
fold [{a: 1}, {a: 2}, {a: 3}] from {total: 0}
($acc, $x) -> {total: $acc.total + $x.a} # {total: 6}Comprehension and fold introduce lexical scopes - inner $x, $acc, etc.
shadow outer bindings of the same name within the body.
Array slicing
Arrays support Python-style half-open slice notation with optional negative indices (counted from the end):
$arr[1:3] # elements at indices 1, 2 (not 3)
$arr[:-1] # all but the last element
$arr[-3:] # the last 3 elements
$arr[:] # full copySlices clamp out-of-range bounds rather than erroring. Slicing and indexing
can be chained: $arr[:3][0] returns the first element of the first three.
Step ([a:b:c]) is not supported. $arr[] is rejected.
Spread elements
[...expr] flattens a list-valued expression into the enclosing array
literal. Three operand shapes are lifted to lists automatically:
[...$arr, 99] # $arr is already a list; concat with 99
[...3d6] # 3d6 is normally a scalar sum - spread lifts
# it to the three individual rolls
[...3d6 sum] # explicit sum can also be lifted to its pool
[...repeat 4 { d6 }, 0] # repeat produces a list; spread inlines itBare-scalar values ([...5], [...d20 + 3]) are rejected - spread is
list-only. Inside dice-pool position ([...3d6] keep 3), the spread is
equivalent to writing the elements out by hand.
Structured-face dice
Define a die whose faces are records, then roll N of them with field-wise sum. Useful for game systems where each die face yields multiple symbols (Descent, Star Wars FFG, Genesys, etc.).
dice @attack [
{ damage: 1 },
{ damage: 2, range: 1 },
{ surge: 1 },
{ damage: 1, range: 1 },
{ damage: 0 },
{ damage: 2 }
]
# Bare reference rolls one face. The result carries the die's full declared
# field set, with fields the rolled face lacks defaulting to 0.
$one = @attack # e.g. {damage: 2, range: 1, surge: 0}
# With a count, rolls N and field-wise sums (missing fields default to 0).
$total = 3 @attack # e.g. {damage: 4, range: 1, surge: 1}
# Parametric count.
$n is { default: 5 }
$variable = $n @attackNumeric-faced dice are also supported and sum numerically:
dice @loaded [ 6, 6, 6, 6, 6, 1 ]
100 @loaded # heavily biased toward 6Rules:
- Faces must be all-numeric or all-record (no mixing).
- Record-face values must be integers (no decimals, strings, booleans, or nested records).
- Every record roll (any count
>= 1, including a bare@d) carries the field union of every field that appears in any declared face; a field missing from a particular rolled face contributes 0. This makes@dandN @dfield-set-consistent, and means a mixed pool built withsum for $d in [@a, @b] { $d }can be read per symbol ($pool.x) without adefaultfor any symbol whose source die is in the pool. (Acount == 0roll has no schema to project and stays the empty record{}.) - Structured-dice rolls cannot be combined with arithmetic or other dice in the same expression - keep them in their own statement (assign to a variable, or place inside a record) and combine at the program level.
- Each
dice @namemay be declared once per program; redeclaration throws.
See docs/structured-dice.md for the trace shape
(StructuredDiceRollResult), the flatStructuredFaces walker for
rendering each die independently, and end-to-end consumer patterns.
Reading symbols from a mixed pool
Different structured dice can't be combined in one expression, so a mixed pool (e.g. an FFG / Genesys narrative-dice roll) is built by summing a comprehension over the dice, then read per symbol:
$pool = sum for $d in [@proficiency, @difficulty, @difficulty] { $d }
$pool.success - $pool.failureBecause every roll carries its die's full declared schema, any symbol whose
source die is in the pool reads directly and defaults to 0 when it wasn't
shown — $pool.triumph is 0 on a roll where no triumph face came up, not an
error. A symbol whose source die is not in the pool is still absent and
throws Record has no field 'despair'. Use the default operator there
(and anywhere you want an absent field, out-of-range index, or undefined
outcome to fall back) to read it as 0:
{
net_success: $pool.success - $pool.failure, # both source dice in the pool
triumphs: $pool.triumph, # 0 when no triumph face came up
despairs: $pool.despair default 0, # 0 — no despair die in this pool
}primary default fallback yields primary, or fallback when primary
has no value — a missing record field, an out-of-range index
($xs[99] default 0), or an undefined outcome such as division by zero
((a / b) default 0). Every other error (type errors, undefined variables)
still propagates, so strict access remains the default everywhere else. It
binds tighter than arithmetic, so $a.x default 0 - $b.y default 0 groups as
($a.x default 0) - ($b.y default 0). default is exact in ProgramStats
analysis, not only at roll time.
Parameters
Declare a variable as a parameter with is { ... } to give it a default value
plus optional metadata. Tools can introspect the program to render UI inputs;
callers can override defaults at runtime.
$str_mod is {
default: 5,
min: 0,
max: 30,
label: "STR Modifier",
description: "Your strength bonus",
}
$weapon is {
default: "longsword",
enum: ["longsword", "dagger", "greataxe"],
}
$advantage is { default: false }
$attack_die is { default: d20 }
$attack = $attack_die + $str_mod
$hit = $attack >= 15
{ attack: $attack, hit: $hit }Field rules:
default(required): a number, boolean, string, or dice expression.label,description: string literals. Either"double"or'single'quotes work; both accept the usual\n,\t,\\escapes and let the other quote appear unescaped ("can't",'he said "hi"').min,max: only valid when the default is a number, or a dice expression.enum: only valid when the default is non-number; entries must match the default's primitive type and the default must be a member of the enum.- Dice-expression defaults may not reference structured-face dice (
@name)- use a numeric default.
- A parameter may not be reassigned with
=.
Override at runtime:
evaluator.run(program, { parameters: { str_mod: 7, weapon: 'dagger' } })
ProgramStats.analyze(program, { parameters: { str_mod: 7 } })Overrides are validated (unknown name, type mismatch, out of range, not in enum) and throw clear errors. Without overrides, literal defaults act as constants and dice-expression defaults are rolled per execution (or analysed exactly).
Introspect for UI:
import { ProgramParameters } from 'dicerollerts'
const params = ProgramParameters.list(program)
// Parameter[] = [{
// name, default?, defaultExpression?,
// label?, description?, min?, max?, enum?
// }, ...]The input kind is inferred by the consumer from the data: number with min
and max → bounded slider; string with enum → dropdown; boolean → toggle;
dice expression default → free-form expression input.
For consumers that want a canonical version of that dispatch rather than
re-implementing it, classifyParameter returns a tagged ParameterKind:
import { ProgramParameters, classifyParameter } from 'dicerollerts'
ProgramParameters.list(program).map(classifyParameter)
// ParameterKind[] - { kind: "boolean" } | { kind: "string-enum"; choices }
// | { kind: "number-enum"; choices }
// | { kind: "number-range"; min, max, default }
// | { kind: "number"; default } | { kind: "string"; default }
// | { kind: "expression" }Precedence: dice-expression default wins, then boolean, then enum (string
or number), then number-range (both min and max present), then plain
number, then string. The classifier never throws; ambiguous specs collapse
to the most informative kind.
Reserved words
Cannot be used as record keys or identifiers: if, then, else, true,
false, and, or, not, repeat, is, match, dice, for, in,
fold, from, by, asc, desc, argmin, argmax. The wildcard _ is
also reserved.
5. Evaluator
import { Evaluator } from 'dicerollerts'
const evaluator = new Evaluator(
(sides) => Math.floor(Math.random() * sides) + 1,
{
maxRepeatIterations: 10000, // default 10000
onDiceTerm: (event) => {
// event.kind is 'arithmetic' | 'structured'
// event.node is the lifted dice AST node (Die / NDice / DiceReduce / ...
// or StructuredDiceRoll for the structured case)
// event.result is a RollResult tree (arithmetic) or
// StructuredDiceRollResult (structured)
},
},
)
evaluator.run(program)
evaluator.run(program, { parameters: { str_mod: 7 } })Tokenization
For syntax highlighting and editor integration, the package exports a
tokenize(source) function that classifies a source string into a flat
array of position-tagged tokens.
import { tokenize } from 'dicerollerts'
const tokens = tokenize('[d20 + $mod, d20 + $mod] max')
// → [{ kind: 'punct', text: '[', start: 0, end: 1 },
// { kind: 'dice', text: 'd20', ... },
// { kind: 'whitespace', text: ' ', ... },
// { kind: 'operator', text: '+', ... },
// { kind: 'variable', text: '$mod', ... },
// ...
// { kind: 'reducer', text: 'max', ... }]Properties:
- Total coverage. Every character of
sourceis covered;tokens.map(t => t.text).join('')always equalssource. Whitespace and comments (# ...) are first-class tokens. - Context-aware classification. The same word lands in different
kinds depending on grammatical role:maxin[a, b] max→reducer;maxinmax for $x in $xs { $x }→aggregator;maxin{ max: 5 }(plain record) →identifier;maxin$max→ part ofvariable;maxin$x is { max: 10 }→param-field.
- Error recovery.
tokenizenever throws and never returns an empty array for non-empty input - unrecognized runs are emitted aserrortokens so half-typed editor buffers keep producing useful output. - Shorthand splitting. Dice modifier shorthands split into a role
token plus a number:
4d6k3→dice("4d6") filter("k") number("3").
The full set of token kinds:
| Kind | Description |
| ---------------- | ------------------------------------------------------ |
| whitespace | runs of spaces / tabs / newlines |
| comment | # to end of line |
| string | "..." or '...' (symmetric quotes) |
| number | integer or decimal, including unary-minus signed forms |
| dice | d6, 2d20, dF, d%, d{1,2,3} |
| variable | $name (the $ is part of the token) |
| identifier | bare identifier in non-keyword position |
| structured-die | @name (the @ is part of the token) |
| keyword | if, then, match, for, is, … |
| reducer | sum, max, … in [...] reducer position |
| aggregator | sum, count, sort, … in ... for $x in position |
| functor | explode, reroll, … and shorthands e, r, ce |
| filter | keep, drop, … and shorthands k, d, kh, kl |
| param-field | default, min, max, … inside $x is { ... } |
| operator | + - * / == != < <= > >= = -> and or not × ⋅ ÷ |
| punct | ( ) [ ] { } , . : ; % |
| error | unrecognized run; tokenizer skipped to keep going |
Token and TokenKind are exported as types. Adding a new TokenKind
is a minor-version bump; renaming or removing one is breaking. Consumers
can default-case unknown kinds to plain rendering.
Hooks
onDiceTerm fires once per dice term the evaluator executes, in evaluation
order. It fires:
- once per iteration inside
repeat; - once per passing element inside a comprehension or fold;
- only for the taken branch of an
if/match; - for parameter dice defaults (rolled when no override is provided).
The callback receives a tagged DiceTermResult event:
event.kind === 'arithmetic': ordinary dice term (die,n-dice,custom-die,dice-reduce).event.resultis aRollResulttree.event.kind === 'structured':structured-dice-rollexpression.event.resultis aStructuredDiceRollResultcarryingname, per-diedraws, and the field-wise sum.
event.node is the exact AST node from the parsed program - stable across
runs.
onScope is a separate callback that fires at scope boundaries -
iteration boundaries (comprehensions, repeat, fold) and conditional
branch boundaries (if, match). Consumers that walk the AST in
lockstep with execution - visualizers, debuggers, the dicerun2
inline-math walker - use it to know which iteration or branch they're
in without re-implementing the evaluator's dispatch semantics:
new Evaluator(rng, {
onDiceTerm: (e) => {
/* dice events */
},
onScope: (e) => {
switch (e.kind) {
case 'iteration-enter':
/* { node, index, total, element?, accumulator? } */ break
case 'iteration-exit':
/* { node, index } */ break
case 'filter-skip':
/* { node, index, element } - comprehension filter rejected */ break
case 'sort-reorder':
/* { node, sourceToSorted } - emitted once per sort */ break
case 'branch-enter':
/* { node, branch, scrutinee? } - if-then / if-else / match-arm */ break
case 'branch-exit':
/* { node } - pair with the matching branch-enter */ break
}
},
})element carries the bound element (comprehensions + fold), accumulator
the running fold state. For sort comprehensions, body events fire in
source order - sort-reorder.sourceToSorted is the permutation
(sorted[i] === source[sourceToSorted[i]]), so consumers render the rows
in sorted order without re-running the sort.
For branch-enter, branch is a tagged union: { kind: 'if-then' },
{ kind: 'if-else' }, or { kind: 'match-arm', armIndex, isWildcard }
(where armIndex is the source-order position of the matched arm and
isWildcard is true for _). scrutinee carries the matched value
for value-mode match VAL { … }; it is absent (not undefined) for
if and for guard-mode match { … }. Use 'scrutinee' in event as the
value-mode discriminator - the absence pattern survives JSON round-trips.
Branch events nest with iteration events on a shared depth stack. The
canonical consumer pattern is: push on every enter, pop on every exit,
and the stack's depth at any dice event is that die's "open conditional
depth" - useful for animation staggering, debugger frame indicators,
and conditional-frequency analysis. Condition / scrutinee dice fire on
onDiceTerm before the matching branch-enter; arm body dice fire
between branch-enter and branch-exit. Both callbacks fire
synchronously from the same evaluator, so push order is evaluation
order - subscribe to both and append to one buffer for a merged stream.
If an arm body throws, branch-exit does not fire (the event log is
left mid-scope, mirroring the iteration convention). A match whose
arms all fail raises No match arm fired before any branch-enter,
so the stack stays balanced.
recordRun includes scope events in log.events only when called with
{ captureScopeEvents: true } (default false, so existing replay
consumers stay unchanged).
onAssignment is a third callback that fires once per execution of an
Assignment statement, after the RHS resolves. Drives Monaco-style
inlay hints and any other "show me the resolved value of $name = …"
tooling that can't run the program client-side because the RHS contains
random dice:
const values = new Map<Assignment, Value>()
new Evaluator(rng, {
onAssignment: (e) => values.set(e.node, e.value),
}).run(program)
// values keyed by AST node identity - last write of each Assignment winsFires once per execution, so the same assignment inside repeat N
fires N times with iteration-distinct values. Consumers that want one
value per source location should key by event.node (or
event.node.loc.offset) and use last-write-wins - keying by
event.node.name is a footgun because different scopes can declare the
same name with distinct AST nodes. Dice events in the RHS fire on
onDiceTerm before the matching onAssignment event. Does NOT fire
for parameter declarations ($x is { … }) or comprehension / fold
binders.
Replay / audit log
import {
recordRun,
replayRun,
logToJSON,
logFromJSON,
ReplayDesyncError,
type ExecutionLog,
} from 'dicerollerts'
const { value, log } = recordRun(program, rollFn, {
parameters: { x: 5 },
maxRepeatIterations: 1000,
})
// log.draws: number[] - every value returned by rollFn, in evaluation order
// log.events: ExecutionEvent[] - one entry per dice-expr / structured-dice-roll
// Replay using only the recorded draws (rollFn is not called):
const { value: replayed } = replayRun(program, log)
// throws ReplayDesyncError if log under- or over-supplies draws
// JSON round-trip (Maps are not used in the log; standard JSON.stringify works):
const json = logToJSON(log)
const restored = logFromJSON(json)recordRun and replayRun accept the same parameters and
maxRepeatIterations options as Evaluator.run. Branching, comprehensions,
and repeat are all handled - only the draws actually consumed during the
recording are stored, and replaying takes the same branches deterministically.
6. Probability analysis on programs (ProgramStats)
ProgramStats.analyze() picks one of three strategies:
constant- no randomness, single evaluation.exact- covers single dice expressions, comparisons, conditionals, arithmetic on independent distributions, shared variables (joint enumeration), boolean ops, sort / slice / index over shared rolls, comprehensions oversumandsort,if-driven discriminated outputs (per-variant conditional stats), and more.monte-carlo- adaptive batched simulation; stops when per-bin frequencies stabilise.
import { ProgramStats } from 'dicerollerts'
const result = ProgramStats.analyze(program, {
minTrials: 1000, // initial batch
maxTrials: 100000, // cap (default 100000)
batchSize: 1000, // batch increment (default 1000)
targetRelativeError: 0.01, // 1% convergence target
parameters: { x: 5 }, // optional overrides
signal, // optional AbortSignal for cancellation
})
result.strategy.tier // 'constant' | 'exact' | 'monte-carlo'
result.strategy.trials // actual trials run (Monte Carlo only)
result.strategy.converged // hit target before maxTrials
result.stats // FieldStats (see below)
result.diagnostics // { classifyTimeMs, analyzeTimeMs, fellBackToMC, jointSizeMax? }
// Manual classification:
ProgramStats.classify(program) // Tier
ProgramStats.classify(program, { parameters: { x: 5 } }) // TierPer-binding distributions
Pass perBinding: true to additionally get the marginal distribution of
every top-level $assignment and $parameter:
const result = ProgramStats.analyze(program, { perBinding: true })
for (const [name, binding] of result.perBinding!) {
// binding.kind: 'assignment' | 'parameter'
// binding.stats: FieldStats - same shape as result.stats
}Loop binders (for $x in …) and nested-scope assignments are not
included - they don't reduce to a single distribution. Cost is free in
the exact tier (the SymDists already exist), light in constant, and
linear in trials × bindings for monte-carlo (every sampled value is
retained until aggregation). In the rare case where the exact tier
can't represent a binding's joint, that binding is omitted from the
map; the same program under monte-carlo captures it via per-trial
hooks.
FieldStats
FieldStats is a discriminated union covering the shape of the program's
output:
number:mean,stddev,variance,mode,min,max,distribution: Map<number, number>,cdf: Map<number, number>,percentiles(p5/p10/p25/p50/p75/p90/p95),skewness,kurtosis, optionalstandardError(Monte Carlo only).partial-number: every field ofnumberplusundefinedMass: number(strictly in(0, 1)). Produced when some outcomes are undefined (currently only division by zero, e.g.d6 / (d6 - 1)). The numeric stats are CONDITIONAL on the outcome being defined anddistributionsums to 1; multiply by(1 - undefinedMass)to recover absolute probability mass.undefined: every outcome is undefined (e.g.1 / 0). No numeric stats - the field has justtype: 'undefined'.boolean:pTrue(probability in[0, 1]), optionalstandardError.string:frequencies: Map<string, number>, optional per-bucketstandardErrors.array:elements: FieldStats[], optionalaggregate: NumberAggregateStats(pooled stats when all elements are numeric), optionaljoint: Map<string, number>(full joint distribution over the array values), optionaljointTruncated: boolean, optionaljointStructured: JointDistribution(decoded form - see below).record:fields: Record<string, FieldStats>, optionaljoint: Map<string, number>, optionaljointTruncated: boolean, optionaljointStructured: JointDistribution.discriminated:discriminator: 'kind' | 'shape',variants: DiscriminatedVariant[].mixed: the output isn't of a single uniform kind across trials.
NumberAggregateStats (used by array.aggregate): mean, stddev, min,
max, distribution, cdf, percentiles, count.
Joint distributions
When a record or array's joint distribution can be computed, joint is a
Map<string, number> keyed by plain JSON-encoded values (no internal
sentinel - decode with JSON.parse). Per-position / per-field marginals are
always available via elements[i] / fields[k].
const result = ProgramStats.analyze(program)
if (result.stats.type === 'array' && result.stats.joint) {
for (const [key, prob] of result.stats.joint) {
const values = JSON.parse(key) // e.g. [1, 6]
console.log(values, prob)
}
}
if (result.stats.type === 'record' && result.stats.joint) {
for (const [key, prob] of result.stats.joint) {
const record = JSON.parse(key) // e.g. {x: 4, y: 3}
}
}jointStructured - decoded tuples, no key parsing
jointStructured is the same distribution as joint, decoded once so you
never JSON.parse a key and numeric ordering / binning survive. It is
populated wherever joint (or jointTruncated) is, with one
dimensions descriptor per field / position in canonical (sorted) order
and one outcomes entry per joint cell:
interface JointDistribution {
dimensions: ReadonlyArray<{ name: string }> // field name, or "0","1",… for arrays
outcomes: ReadonlyArray<{ values: Value[]; p: number }> // values aligned to dimensions
truncated?: boolean // mirrors jointTruncated: outcomes is then empty
}
const result = ProgramStats.analyze(program) // `{ atk: 1d20, dmg: 2d6 }`
const js = result.stats.type === 'record' && result.stats.jointStructured
if (js) {
const atk = js.dimensions.findIndex((d) => d.name === 'atk')
// Numbers stay numbers - pivot / sort / bin directly, no key parsing.
for (const { values, p } of js.outcomes) {
console.log(values[atk], p) // e.g. 17, 0.0023
}
}When the joint is truncated, jointStructured.truncated is true,
outcomes is empty, and dimensions still lists the complete field /
position set. The legacy joint: Map<string, number> is unchanged and
remains available alongside it.
The joint is omitted and jointTruncated: true is set when the joint
exceeds the relevant cap:
| Cap | Default | Used for |
| ----------------------- | --------- | --------------------------------------------------------------------------------- |
| MAX_JOINT_SIZE | 300,000 | Most exact-tier joint distributions |
| MAX_REPEAT_JOINT_SIZE | 300,000 | repeat-expr arrays (e.g. repeat 7 { d6 } = 6^7) |
| MAX_SORT_JOINT_SIZE | 500,000 | Sum-over-sorted-slice path |
| MAX_COMPOSITION_COUNT | 1,000,000 | Multinomial-composition enumerator for homogeneous keep/drop (9d6 keep 1, etc.) |
These caps are exported from sym-dist.ts and surfaced via the analysis
pipeline; they're not part of the public API as import symbols, but their
values match the documented behaviour above.
Discriminated outputs
When a program's output can take multiple record shapes, use a kind field
to discriminate variants. The analyser detects this and produces per-variant
statistics:
$hit = d20 + 5 >= 15
if $hit
then { kind: "hit", damage: 2d6 + 3 }
else { kind: "miss", margin: 0 }The result is a discriminated FieldStats with one entry per kind value,
each containing the probability of that variant and the marginal stats for
its fields. The kind field itself is constant within each variant and is
not included in the per-variant stats.
If trials produce records with different keys but no kind field, the
analyser falls back to grouping by key set (discriminator: 'shape'). Prefer
the kind convention for consistent UI rendering.
DiscriminatedVariant shape:
interface DiscriminatedVariant {
tag: string // kind value or shape signature
probability: number // share of trials matching this variant
standardError?: number // Monte Carlo only
keys: string[] // field names in this variant (excluding kind)
fields: Record<string, FieldStats> // marginal stats per field, conditional on variant
}Conditional field stats
When a variant's field references the same dice as the discriminating condition, the field's stats are computed conditional on that variant being chosen:
$attack = d20
if $attack >= 11
then { kind: "hit", attack: $attack }
else { kind: "miss", attack: $attack }The hit variant's attack field has distribution {11..20} (each 1/10), not
the unconditional {1..20}. Same for miss: {1..10}. This works for arbitrary
if-then-else ladders and is computed exactly when feasible. Very large joint
distributions fall back to Monte Carlo with the same shape.
Diagnosing tier choices: explainTier
When a program lands in monte-carlo and you want to know why, call
ProgramStats.explainTier:
const explanation = ProgramStats.explainTier(program)
// explanation.tier: 'constant' | 'exact' | 'monte-carlo'
// explanation.reason: string (top-level summary)
// explanation.contributors: TierExplanationContributor[] | undefined
// Each contributor:
// {
// location?: string, // e.g. 'statement 3'
// nodeType: string, // e.g. 'repeat-expr'
// cause: string, // why this node prevented exact analysis
// }For exact / constant tiers, contributors is empty. For Monte Carlo, the
output identifies the AST nodes that blocked exact analysis with short
heuristic causes (e.g. "repeat with non-constant count goes Monte Carlo",
"match has no exhaustive default arm", "dice expression contains a variable
reference"). The output is informational, not a stable parseable contract.
Optional parameter overrides apply just like in analyze:
ProgramStats.explainTier(program, { x: 4 })Async analysis with cancellation
const controller = new AbortController()
for await (const progress of ProgramStats.analyzeAsync(program, {
signal: controller.signal,
yieldEvery: 1000, // emit progress every N trials (defaults to batchSize)
yieldEveryMs: 150, // OR every N wall-clock ms (wins when both are set)
convergenceTimeout: 2000, // soft wall-clock cap; maxTrials still applies
minTrials: 1000,
maxTrials: 100000,
batchSize: 1000,
targetRelativeError: 0.01,
})) {
// progress.stats: FieldStats - running snapshot
// progress.trials: number - trials completed so far (0 for non-MC tiers)
// progress.converged: boolean - true once threshold met
// progress.fieldConvergence?: FieldConvergence[] - per-field breakdown
// progress.perBinding?: ReadonlyMap<string, BindingStats>
// - present iff `perBinding: true` was passed; rebuilt each yield
updateUI(progress)
}For constant and exact tiers, analyzeAsync yields a single final
snapshot with converged: true. For Monte Carlo, it yields snapshots in
chunks. Calling controller.abort() causes the next await to throw an
AbortError.
yieldEveryMs gates progress by wall-clock rather than trial count and is
preferred when both are set - snapshot cost grows as new distinct outcomes
are sampled, so a fixed trial gate produces non-linear pauses on long runs.
convergenceTimeout caps the loop's wall-clock budget; the final progress
carries converged: false when the timeout fires and fieldConvergence[]
shows which fields are still noisy.
fieldConvergence[] is populated on the Monte Carlo path. Each entry is:
interface FieldConvergence {
path: string // "", "damage", "Hit", "Hit.damage", "0" (array idx)
converged: boolean
samples: number // total trials for the root; fewer inside variants
relativeError: number | null // null for mixed-type / structural nodes
}For discriminated records, the variant tag is a path segment that names the
variant's frequency entry; per-variant fields nest below it (e.g. Hit is
the variant probability, Hit.damage is the damage field within Hit-tagged
trials).
Comparing two programs
ProgramStats.compare(a, b, options?) analyses both programs and, when both
produce a numeric output, returns a numeric summary combining the two
distributions:
const result = ProgramStats.compare(programA, programB)
result.a.stats // AnalyzeResult for programA
result.b.stats // AnalyzeResult for programB
result.numeric?.probabilityAGreaterThanB
result.numeric?.probabilityAEqualsB
result.numeric?.probabilityALessThanB
result.numeric?.totalVariationDistance
result.numeric?.klDivergenceAFromB // may be Infinity when supports disjoint
result.numeric?.klDivergenceBFromA
result.numeric?.meanDiff // mean(a) - mean(b)
result.numeric?.stddevDiff // stddev(a) - stddev(b)When either program's output is non-numeric (record, array, boolean, string,
mixed), the numeric field is omitted; the per-program analyses are still
available.
Stats utilities
import {
fieldStatsToJSON,
fieldStatsFromJSON, // JSON round-trip (Maps preserved)
totalVariationDistance,
klDivergence, // distribution comparison
probabilityGreaterThan,
probabilityLessThan,
probabilityEqual, // P(X op Y) for independent X, Y
boxPlotData, // quartiles / whiskers / outliers
sampleFromDistribution, // sample n values via inverse-CDF
fieldFromRecord,
elementFromArray, // accessor helpers
suggestBucketSize,
binDistribution, // chart helpers (number bucketing)
} from 'dicerollerts'
const bucketSize = suggestBucketSize(stats.min, stats.max, 100)
const binned = binDistribution(stats.distribution, bucketSize)
const bp = boxPlotData(distribution)
// bp.min, bp.q1, bp.median, bp.q3, bp.max
// bp.iqr, bp.lowerWhisker, bp.upperWhisker, bp.outliersfieldStatsToJSON / fieldStatsFromJSON preserve Maps (encoded as
{ __map: [[k, v], ...] } internally) so the full FieldStats tree survives a
JSON round-trip.
7. Language metadata (for editor integrations)
Structured, JSON-serialisable description of the dice language's surface
vocabulary - keywords, aggregators, dice reducers/functors/filters, operators,
and @param field names. Designed for Monaco/Monarch tokenizer rules,
completion providers, hover providers, and --help generators.
import {
LANGUAGE_METADATA_VERSION,
LANGUAGE_ENTRIES, // tagged-union flat list of every entry
KEYWORDS,
AGGREGATORS,
DICE_REDUCERS,
DICE_FUNCTORS,
DICE_FILTERS,
OPERATORS,
PARAM_FIELDS, // per-kind filtered views
ALL_TOKENS, // deduped, sorted list of every token the tokenizer should recognise
lookupEntry, // (token) => Entry | null (matches name, operator symbol, or shorthand)
} from 'dicerollerts'
LANGUAGE_METADATA_VERSION // '1'
const explode = lookupEntry('explode')
// { kind: 'dice-functor', id: 'fn-explode', name: 'explode',
// shorthands: ['e'], summary: '...', details: '...', examples: [...] }
lookupEntry('==') // { kind: 'operator', id: 'op-eq', ... }
lookupEntry('k') // { kind: 'dice-filter', id: 'fl-keep', ... }Each entry carries a stable id (safe for lookup tables across versions), a
summary for completion list details, details for hover popups, and
examples that are guaranteed to parse - a round-trip test in
test/language-metadata.spec.ts runs every example through ProgramParser
on every CI build, so the metadata cannot drift away from the parser without
breaking the test suite.
Signature schemas for signature help, deprecation flags, and i18n are
deliberately out of scope at version '1'.
8. Semantic diagnostics
analyzeProgram(program, options?) runs a static pass over a parsed
program and returns an array of SemanticDiagnostic entries - problems
that are syntactically valid but semantically wrong. Designed for Monaco /
VSCode markers (each diagnostic carries a SourceSpan { offset, length })
and pre-flight checks.
import { analyzeProgram, ProgramParser } from 'dicerollerts'
const { success, program } = ProgramParser.parse(source)
if (success) {
const diagnostics = analyzeProgram(program, { deep: true })
// [{ severity, code, message, span, related? }, ...]
}