-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add Fate/Fudge dice (dF) #36
Description
Add Fate/Fudge dice (dF) support. Fate dice produce results of -1, 0, or +1 per die. 4dF is the standard Fate roll (sum of 4 Fate dice, range -4 to +4).
Currently dF throws a lexer error. With the Stage 2 foundation (#34), the lexer already emits DICE_FATE for dF. This issue covers the AST node, parser, evaluator, and CLI changes.
AST Changes
New node type in src/parser/ast.ts:
type FateDiceNode = {
type: 'FateDice';
count: ASTNode;
};Add to ASTNode union. Add isFateDice type guard. Export from src/index.ts.
A separate node (rather than reusing DiceNode with sentinel sides) is warranted because the rolling mechanic is fundamentally different: rng.nextInt(-1, 1) vs rng.nextInt(1, sides).
Parser Changes
- NUD (prefix
dF): createFateDice(Literal(1)) - LED (infix
4dF): createFateDice(left) getLeftBp:DICE_FATE→BP.DICE_LEFT(40)- No
getRightBpneeded — Fate dice have no sides expression
Evaluator Changes
New evalFateDice function in src/evaluator/evaluator.ts:
- Evaluate
countfrom theFateDiceNode - Validate: count must be non-negative integer
- Check
env.totalDiceRolled + count > env.maxDice - Roll
rng.nextInt(-1, 1)for each die - Use
createFateDieResult(result)which sets:sides = 0(sentinel identifying Fate dice — unambiguous since normal dice havesides >= 1)critical = falsealways (no max-value concept on symmetric Fate faces)fumble = falsealways (rolling +1 is not a fumble)
- Mark all dice as
'kept'viamarkAllKept - Expression:
4dF, rendered:4dF[-1, 0, 1, 1]
Add 'FateDice' case to evalNode switch (required by exhaustive never check).
CLI Format Changes
Update formatRendered regex in src/cli/format.ts from /~~(\d+)~~/g to /~~(-?\d+)~~/g to handle negative values in strikethrough (e.g., dropped Fate dice ~~-1~~).
Keep/Drop Compatibility
Keep/drop modifiers work naturally with Fate dice because applyKeepHighest/applyKeepLowest sort by die.result, and -1 < 0 < +1 sorts correctly. 4dFkh2 keeps the two highest Fate results.
DieResult.sides = 0 Design Decision
sides = 0 was chosen as the Fate dice sentinel after evaluating alternatives:
| Option | Pro | Con |
|---|---|---|
sides = 0 |
Unambiguous identification | Breaks 1/sides probability math |
sides = 3 |
Correct probability (1/3) | Indistinguishable from normal d3 |
kind field |
Clean discrimination | Adds noise to all DieResult consumers |
Consumers needing probability math must special-case sides === 0. This is documented in the DieResult JSDoc.
Edge Cases
| Expression | Expected |
|---|---|
dF |
FateDice(1), one die in {-1, 0, +1} |
4dF |
FateDice(4), standard Fate roll, range [-4, +4] |
dF+5 |
One Fate die plus 5, range [4, 6] |
4dFkh2 |
Roll 4 Fate dice, keep 2 highest |
4dFdl1 |
Roll 4 Fate dice, drop lowest |
0dF |
Zero dice, total = 0, empty rolls |
(-1)dF |
EvaluatorError: invalid dice count |
dFdF |
Parses as (dF)dF — inner evaluates to {-1,0,+1}, outer uses that as count |
4dF * 2 |
Arithmetic on Fate total, works naturally |
Negative Compare Value Tests
When reroll is implemented, the following must be tested:
4dFr=-1— reroll Fate dice that roll -1 (ComparePoint value isUnaryOp(-, Literal(1)))
This works because ComparePoint.value is ASTNode, supporting unary minus.
Test Plan
- Lexer:
dF,df,DF,4dFtokenization (already covered in build: prepare Stage 2 foundation #34) - Parser: AST structure for
dF,4dF,(2+2)dF,dF+5,4dFkh2,-dF - Evaluator with MockRNG:
dFwith[-1]→ total = -1, sides = 04dFwith[-1, 0, 1, 1]→ total = 14dFkh2with[-1, 0, 1, 1]→ keeps [1, 1], total = 2- All results:
critical === false,fumble === false,sides === 0 (-1)dF→ throwsINVALID_DICE_COUNT5dFwithmaxDice: 4→ throwsDICE_LIMIT_EXCEEDED
- Integration:
roll('4dF', { rng }),roll('dF+5', { rng }), seeded reproducibility - Property:
NdF total is in [-N, +N], all results havesides === 0 - CLI format: verify negative strikethrough rendering
Drafted with AI assistance