diff --git a/ast.go b/ast.go index 6694b28..e72ac19 100644 --- a/ast.go +++ b/ast.go @@ -654,6 +654,15 @@ func resolveDie(ctx *rollContext, term DiceTerm) (Die, error) { return NormalDie(int(sides)), nil } +// fixedDieValue returns the deterministic face of a die that always rolls the +// same value (a one-sided die), and whether the die is fixed. +func fixedDieValue(die Die) (int, bool) { + if d, ok := die.(NormalDie); ok && d == 1 { + return 1, true + } + return 0, false +} + func evalDiceTerm(ctx *rollContext, term DiceTerm) (result Result, err error) { die, err := resolveDie(ctx, term) if err != nil { @@ -678,20 +687,29 @@ func evalDiceTerm(ctx *rollContext, term DiceTerm) (result Result, err error) { return Result{}, ErrLimitExceeded(fmt.Sprintf("die term exceeded maximum roll count of %d", ctx.limits.MaxRollsPerDie)) } + fixedValue, fixed := fixedDieValue(die) dieRolls := 0 for i := 0; i < rollCount; i++ { if err = ctx.recordRoll(&dieRolls); err != nil { return Result{}, err } + if fixed { + result.Results = append(result.Results, DieRoll{Result: fixedValue, Symbol: strconv.Itoa(fixedValue)}) + continue + } result.Results = append(result.Results, die.Roll()) } - if err = applyRerolls(ctx, term, die, &result, &dieRolls); err != nil { - return Result{}, err - } + // A deterministic die cannot change, so rerolls and explosions are skipped + // to avoid unbounded loops. + if !fixed { + if err = applyRerolls(ctx, term, die, &result, &dieRolls); err != nil { + return Result{}, err + } - if err = applyExplosions(ctx, term, die, &result, &dieRolls); err != nil { - return Result{}, err + if err = applyExplosions(ctx, term, die, &result, &dieRolls); err != nil { + return Result{}, err + } } applyLimit(term.Limit, &result) @@ -752,7 +770,7 @@ func validateDieLimits(die Die, limits Limits) error { return fmt.Errorf("unsupported die type %T for limit validation", die) } - if size < 2 { + if size < 1 { return ErrUnsafeDie(die.String()) } if size > limits.MaxDieSize { @@ -819,6 +837,11 @@ func applyExplosions(ctx *rollContext, term DiceTerm, die Die, result *Result, d return err } roll = die.Roll() + if roll.Result <= 1 { + // A penetrating die reduced to zero cannot be rolled, so it + // is dropped from the results entirely. + break + } newRoll := roll newRoll.Result-- newRoll.Symbol = strconv.Itoa(newRoll.Result) diff --git a/ast_test.go b/ast_test.go index 6d3ba99..82844b3 100644 --- a/ast_test.go +++ b/ast_test.go @@ -40,7 +40,7 @@ func TestEvaluateProgram_DiceTerms(t *testing.T) { {name: "fate modifier", seed: 0, input: "4dF+2", res: []int{-1, -1, 0, 0}, totl: 0}, {name: "exploding", seed: 2, input: "2d6!5", res: []int{5, 1, 1}, totl: 7}, {name: "compounded", seed: 2, input: "2d6!!5", res: []int{5, 1, 5}, totl: 11}, - {name: "penetrating", seed: 2, input: "2d6!p5", res: []int{5, 1, 0}, totl: 6}, + {name: "penetrating", seed: 2, input: "2d6!p5", res: []int{5, 1}, totl: 6}, {name: "keep highest", seed: 2, input: "4d6kh3", res: []int{5, 3, 1}, totl: 9}, {name: "reroll once", seed: 2, input: "4d6ro<4", res: []int{5, 3, 3, 5}, totl: 16}, {name: "descending sort", seed: 1, input: "3d3sd", res: []int{3, 3, 1}, totl: 7}, @@ -152,8 +152,8 @@ func TestEvaluateProgramWithLimits(t *testing.T) { t.Run("rejects unsafe die built in bytecode", func(t *testing.T) { program := &Program{ Code: []Instruction{{Op: OpRollDice, Arg: 0}}, - DiceTerms: []DiceTerm{{Multiplier: 1, Die: NormalDie(1)}}, - Rendered: "d1", + DiceTerms: []DiceTerm{{Multiplier: 1, Die: NormalDie(0)}}, + Rendered: "d0", MaxDepth: 1, } @@ -161,7 +161,7 @@ func TestEvaluateProgramWithLimits(t *testing.T) { if err == nil { t.Fatal("expected unsafe die error") } - if err.Error() != `unsafe die type "d1"` { + if err.Error() != `unsafe die type "d0"` { t.Fatalf("unexpected error: %v", err) } }) diff --git a/complex_test.go b/complex_test.go index ca05d72..223ebcd 100644 --- a/complex_test.go +++ b/complex_test.go @@ -36,7 +36,8 @@ func TestRoll20Spec_InvalidDieSize(t *testing.T) { input string err string }{ - {name: "unsafe constant die size", input: "9d(floor(3/2))", err: `unsafe die type "d1"`}, + {name: "zero constant die size", input: "9d(1-1)", err: `unsafe die type "d0"`}, + {name: "negative constant die size", input: "9d(0-5)", err: `unsafe die type "d-5"`}, {name: "fractional constant die size", input: "9d(3/2)", err: "die size 1.5 is not a whole number"}, } @@ -60,9 +61,8 @@ func TestRoll20Spec_RuntimeDieSizeErrors(t *testing.T) { input string err string }{ - {name: "unsafe resolved die size", seed: 0, input: "9d(1d6)", err: `unsafe die type "d1"`}, + {name: "zero resolved die size", seed: 0, input: "9d(1d6-1)", err: `unsafe die type "d0"`}, {name: "fractional resolved die size", seed: 0, input: "9d(1d6/2)", err: "die size 0.5 is not a whole number"}, - {name: "nested unsafe resolved die size", seed: 3, input: "3d(4d(d8)kh3+2)", err: `unsafe die type "d1"`}, } for _, tt := range tests { diff --git a/docs/roll.wasm b/docs/roll.wasm index 9becddb..94e537f 100755 Binary files a/docs/roll.wasm and b/docs/roll.wasm differ diff --git a/docs/superpowers/plans/2026-10-06-roll20-math-and-matching-plan.md b/docs/superpowers/plans/2026-10-06-roll20-math-and-matching-plan.md index 770ca1c..e75d26a 100644 --- a/docs/superpowers/plans/2026-10-06-roll20-math-and-matching-plan.md +++ b/docs/superpowers/plans/2026-10-06-roll20-math-and-matching-plan.md @@ -72,3 +72,14 @@ the tree green (`go test ./...`, `go vet ./...`, `golangci-lint run`). - [x] Build the repl binary as part of `mise run build` (CI check). - [x] Document math operators, functions, matching and dynamic dice on the website. - [x] Add exhaustive tests for odd constructions (`4d%%2`, `2d%kh1`, ...). + +## Task 12 - One-sided dice (#12) + +- [x] Allow a one-sided die and fold it to its fixed value without rolling; skip + rerolls/explosions so they cannot loop. +- [x] Tests: `d1`, `4d1`, `2d1+5`, `4d1>0`, `4d1kh3`, `4d1!`, `4d1r1`, computed `9d1`. + +## Task 13 - Penetrating zero (#9) + +- [x] Drop a penetrating die whose reduced face reaches zero from the results. +- [x] Tests: `2d6!p5` and `2d6!p6` (kept reduced face). diff --git a/onesided_test.go b/onesided_test.go new file mode 100644 index 0000000..5fba1e6 --- /dev/null +++ b/onesided_test.go @@ -0,0 +1,23 @@ +package roll + +import "testing" + +// TestRoll20Spec_OneSidedDie covers deterministic one-sided dice, which are folded +// to their fixed value instead of being rolled so that rerolls and explosions +// cannot loop forever. +func TestRoll20Spec_OneSidedDie(t *testing.T) { + ones := []int{1, 1, 1, 1} + nines := []int{1, 1, 1, 1, 1, 1, 1, 1, 1} + tests := []roll20TestCase{ + {name: "single one-sided die", seed: 0, input: "d1", wantString: "d1", wantRolls: []int{1}, wantTotal: 1}, + {name: "multiple one-sided dice", seed: 0, input: "4d1", wantString: "4d1", wantRolls: ones, wantTotal: 4}, + {name: "one-sided die with modifier", seed: 0, input: "2d1+5", wantString: "2d1+5", wantRolls: []int{1, 1}, wantTotal: 7}, + {name: "one-sided die with success", seed: 0, input: "4d1>0", wantString: "4d1>0", wantRolls: ones, wantScnt: 4, wantTotal: 4}, + {name: "one-sided die with keep", seed: 0, input: "4d1kh3", wantString: "4d1kh3", wantRolls: []int{1, 1, 1}, wantTotal: 3}, + {name: "exploding one-sided die does not loop", seed: 0, input: "4d1!", wantString: "4d1!", wantRolls: ones, wantTotal: 4}, + {name: "rerolling one-sided die does not loop", seed: 0, input: "4d1r1", wantString: "4d1r1", wantRolls: ones, wantTotal: 4}, + {name: "computed one-sided die", seed: 0, input: "9d(floor(3/2))", wantString: "9d1", wantRolls: nines, wantTotal: 9}, + {name: "runtime one-sided die", seed: 0, input: "9d(1d6)", wantString: "9d(d6)", wantRolls: nines, wantTotal: 9}, + } + runRoll20TestCases(t, tests) +} diff --git a/parser_test.go b/parser_test.go index 108ddfa..3a8a868 100644 --- a/parser_test.go +++ b/parser_test.go @@ -109,10 +109,10 @@ func TestParser_ParseRejectsUnsafeDie(t *testing.T) { err string }{ { - name: "reject d1", - input: "d1", + name: "reject d0", + input: "d0", limits: DefaultLimits, - err: `unsafe die type "d1"`, + err: `unsafe die type "d0"`, }, { name: "reject oversized die", diff --git a/roll20_spec_test.go b/roll20_spec_test.go index 709d04e..964527b 100644 --- a/roll20_spec_test.go +++ b/roll20_spec_test.go @@ -91,8 +91,9 @@ func TestRoll20Spec_ExplodingDice(t *testing.T) { {name: "exploding greater than 2d6!>4", seed: 2, input: "2d6!>4", wantString: "2d6!>4", wantRolls: []int{5, 1, 1}, wantTotal: 7}, {name: "compounded exploding 2d6!!5", seed: 2, input: "2d6!!5", wantString: "2d6!!5", wantRolls: []int{5, 1, 5}, wantTotal: 11}, {name: "compounded greater than 2d6!!>4", seed: 2, input: "2d6!!>4", wantString: "2d6!!>4", wantRolls: []int{5, 1, 5}, wantTotal: 11}, - {name: "penetrating exploding 2d6!p5", seed: 2, input: "2d6!p5", wantString: "2d6!p5", wantRolls: []int{5, 1, 0}, wantTotal: 6}, - {name: "penetrating greater than 2d6!p>4", seed: 2, input: "2d6!p>4", wantString: "2d6!p>4", wantRolls: []int{5, 1, 0}, wantTotal: 6}, + {name: "penetrating exploding 2d6!p5", seed: 2, input: "2d6!p5", wantString: "2d6!p5", wantRolls: []int{5, 1}, wantTotal: 6}, + {name: "penetrating greater than 2d6!p>4", seed: 2, input: "2d6!p>4", wantString: "2d6!p>4", wantRolls: []int{5, 1}, wantTotal: 6}, + {name: "penetrating keeps reduced explosion 2d6!p6", seed: 9, input: "2d6!p6", wantString: "2d6!p6", wantRolls: []int{6, 5, 4}, wantTotal: 15}, } runRoll20TestCases(t, tests) } diff --git a/rolling_test.go b/rolling_test.go index bf3cfbc..d33bf7a 100644 --- a/rolling_test.go +++ b/rolling_test.go @@ -158,10 +158,10 @@ func TestParseStringWithLimits(t *testing.T) { err string }{ { - name: "reject d1", - input: "d1", + name: "reject d0", + input: "d0", limits: DefaultLimits, - err: `unsafe die type "d1"`, + err: `unsafe die type "d0"`, }, { name: "per die roll limit",