Skip to content

feat: add Fate/Fudge dice (dF) #36

@edloidas

Description

@edloidas

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): create FateDice(Literal(1))
  • LED (infix 4dF): create FateDice(left)
  • getLeftBp: DICE_FATEBP.DICE_LEFT (40)
  • No getRightBp needed — Fate dice have no sides expression

Evaluator Changes

New evalFateDice function in src/evaluator/evaluator.ts:

  • Evaluate count from the FateDiceNode
  • 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 have sides >= 1)
    • critical = false always (no max-value concept on symmetric Fate faces)
    • fumble = false always (rolling +1 is not a fumble)
  • Mark all dice as 'kept' via markAllKept
  • 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 is UnaryOp(-, Literal(1)))

This works because ComparePoint.value is ASTNode, supporting unary minus.

Test Plan

  • Lexer: dF, df, DF, 4dF tokenization (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:
    • dF with [-1] → total = -1, sides = 0
    • 4dF with [-1, 0, 1, 1] → total = 1
    • 4dFkh2 with [-1, 0, 1, 1] → keeps [1, 1], total = 2
    • All results: critical === false, fumble === false, sides === 0
    • (-1)dF → throws INVALID_DICE_COUNT
    • 5dF with maxDice: 4 → throws DICE_LIMIT_EXCEEDED
  • Integration: roll('4dF', { rng }), roll('dF+5', { rng }), seeded reproducibility
  • Property: NdF total is in [-N, +N], all results have sides === 0
  • CLI format: verify negative strikethrough rendering

Drafted with AI assistance

Metadata

Metadata

Assignees

Labels

featureNew functionality

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions