Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 29 additions & 6 deletions ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions ast_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -152,16 +152,16 @@ 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,
}

_, err := EvaluateProgramWithLimits(program, DefaultLimits)
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)
}
})
Expand Down
6 changes: 3 additions & 3 deletions complex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
}

Expand All @@ -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 {
Expand Down
Binary file modified docs/roll.wasm
Binary file not shown.
11 changes: 11 additions & 0 deletions docs/superpowers/plans/2026-10-06-roll20-math-and-matching-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
23 changes: 23 additions & 0 deletions onesided_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
6 changes: 3 additions & 3 deletions parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions roll20_spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
6 changes: 3 additions & 3 deletions rolling_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading