//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07) package game import ( "encoding/gob" "errors" "os" "path/filepath" "testing" ) // mustNotPanic runs fn and turns a panic into an ordinary test failure. // The bug these tests cover (issue #10) panicked out of an array index, // and an unrecovered panic would take the whole test binary down instead // of reporting which dispatch regressed. func mustNotPanic(t *testing.T, what string, fn func()) { t.Helper() defer func() { if r := recover(); r != nil { t.Errorf("%s panicked: %v", what, r) } }() fn() } // TestCreateObjWandFReproducer is the exact reported crash: wizard mode, // C, '/' for a wand, 'f' for which. 'f' is nibble 15 and there are only // NumWandTypes (14) wands, so fixStick used to index two past the end of // ws_type[] and panic. func TestCreateObjWandFReproducer(t *testing.T) { t.Parallel() g := mkGameInput(t) g.Wizard = true before := len(g.Player.Pack) setInput(t, g, '/', 'f') mustNotPanic(t, "createObj with wand 'f'", g.createObj) if len(g.Player.Pack) != before { t.Errorf("out-of-range wand was added to the pack: %d items, want %d", len(g.Player.Pack), before) } } // TestCreateObjRejectsOutOfRangeWhich sweeps the rejection across every // kind whose Which is a table index, including input outside '0'-'f'. // isDigit is false for such input, so it takes the letter branch, where // ch-'a' is byte arithmetic and wraps rather than going negative: 'A' // (65) gives int(224)+10 == 234 and '!' (33) gives int(192)+10 == 202. // Those far-past-the-end values, not negative ones, are what the guard // has to catch on the keyboard path. func TestCreateObjRejectsOutOfRangeWhich(t *testing.T) { t.Parallel() cases := []struct { name string typ byte which byte }{ {"wand f is past NumWandTypes", Stick, 'f'}, {"potion f is past NumPotionTypes", Potion, 'f'}, {"ring f is past NumRingTypes", Ring, 'f'}, // NumScrollTypes is 18, past the 'f' the prompt tops out at, so a // scroll can only be driven out of range by input that wraps. {"scroll 'A' wraps to 234", Scroll, 'A'}, {"armor 9 is past NumArmorTypes", Armor, '9'}, {"weapon 9 is the flame pseudo-weapon", Weapon, '9'}, {"wand 'A' wraps to 234", Stick, 'A'}, {"wand '!' wraps to 202", Stick, '!'}, {"armor 'A' wraps to 234", Armor, 'A'}, {"weapon '!' wraps to 202", Weapon, '!'}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() g := mkGameInput(t) g.Wizard = true before := len(g.Player.Pack) setInput(t, g, tc.typ, tc.which) mustNotPanic(t, tc.name, g.createObj) if len(g.Player.Pack) != before { t.Errorf("pack grew to %d items, want %d: a rejected item was created", len(g.Player.Pack), before) } }) } } // TestCreateObjAcceptsValidWhich pins the other half of the contract: the // bounds check must not touch any in-range choice. func TestCreateObjAcceptsValidWhich(t *testing.T) { t.Parallel() cases := []struct { name string typ byte which byte kind ObjectKind want int }{ {"wand of light", Stick, '0', KindWand, int(WandLight)}, {"potion 0", Potion, '0', KindPotion, int(PotionConfusion)}, {"scroll 9", Scroll, '9', KindScroll, int(ScrollIdentifyRingOrStick)}, {"ring d, the last ring", Ring, 'd', KindRing, int(NumRingTypes) - 1}, {"armor 7, the last armor", Armor, '7', KindArmor, int(NumArmorTypes) - 1}, {"weapon 8, the last real weapon", Weapon, '8', KindWeapon, int(WeaponSpear)}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() g := mkGameInput(t) g.Wizard = true before := make(map[*Object]bool, len(g.Player.Pack)) for _, o := range g.Player.Pack { before[o] = true } // The armor and weapon arms read one more character for the // blessing prompt; 'n' means neither cursed nor blessed. setInput(t, g, tc.typ, tc.which, 'n') g.createObj() if len(g.Player.Pack) != len(before)+1 { t.Fatalf("pack has %d items, want %d: valid item not created", len(g.Player.Pack), len(before)+1) } // addPack files the new item in kind order, so find it by // identity rather than assuming it landed at the end. var made *Object for _, o := range g.Player.Pack { if !before[o] { made = o } } if made.Kind != tc.kind || made.Which != tc.want { t.Errorf("created %v which %d, want %v which %d", made.Kind, made.Which, tc.kind, tc.want) } }) } } // TestCreateObjKeepsRNGSequence proves the guard costs no RNG draws: a // rejected creation must leave the generator exactly where it was, or // every later roll in the game would shift. func TestCreateObjKeepsRNGSequence(t *testing.T) { t.Parallel() g := mkGameInput(t) g.Wizard = true before := g.Rng.Seed setInput(t, g, Stick, 'f') g.createObj() if g.Rng.Seed != before { t.Errorf("rejected creation consumed RNG: seed %d, want %d", g.Rng.Seed, before) } } // malformed builds an object of the given kind whose Which sits one past // the end of that kind's table — the state the wizard bug used to leave // behind, and the state a corrupt save file could still describe. func malformed(kind ObjectKind) *Object { obj := newObject() obj.Kind = kind obj.Which = whichLimit(kind) obj.Count = 1 return obj } // TestZapMalformedWandDoesNotPanic covers the sticks.go dispatch. C's // do_zap matched no case and still ran o_charges--, so the charge must be // spent even though the zap did nothing. What it says while doing nothing // belongs to TestZapUnhandledWandSaysBizarreSchtick. func TestZapMalformedWandDoesNotPanic(t *testing.T) { t.Parallel() g := mkGameInput(t) wand := malformed(KindWand) wand.Charges = 3 ch := give(g, wand) setInput(t, g, ch) mustNotPanic(t, "doZap on a malformed wand", g.doZap) if wand.Charges != 2 { t.Errorf("charges = %d after zapping, want 2", wand.Charges) } } // TestQuaffMalformedPotionDoesNotPanic covers the potions.go dispatch and // the callIt lookup that follows it. func TestQuaffMalformedPotionDoesNotPanic(t *testing.T) { t.Parallel() g := mkGameInput(t) before := len(g.Player.Pack) ch := give(g, malformed(KindPotion)) setInput(t, g, ch) mustNotPanic(t, "quaff of a malformed potion", g.quaff) if len(g.Player.Pack) != before { t.Errorf("pack has %d items, want %d: the potion was not consumed", len(g.Player.Pack), before) } } // TestReadMalformedScrollDoesNotPanic covers the scrolls.go dispatch and // its callIt lookup. func TestReadMalformedScrollDoesNotPanic(t *testing.T) { t.Parallel() g := mkGameInput(t) before := len(g.Player.Pack) ch := give(g, malformed(KindScroll)) setInput(t, g, ch) mustNotPanic(t, "readScroll of a malformed scroll", g.readScroll) if len(g.Player.Pack) != before { t.Errorf("pack has %d items, want %d: the scroll was not consumed", len(g.Player.Pack), before) } } // TestMalformedArmorDoesNotPanic covers the a_class[] reads: pricing at // death, the identified-armor name, and the detect-magic test. func TestMalformedArmorDoesNotPanic(t *testing.T) { t.Parallel() g := mkGameInput(t) armor := malformed(KindArmor) armor.Flags.Set(Known) mustNotPanic(t, "naming a malformed suit of armor", func() { if got := g.inventoryName(armor, false); got != armor.Kind.String() { t.Errorf("inventoryName = %q, want %q", got, armor.Kind.String()) } }) mustNotPanic(t, "isMagic on a malformed suit of armor", func() { g.isMagic(armor) }) mustNotPanic(t, "appraising a malformed suit of armor", func() { if worth := g.objectWorth(armor); worth != 0 { t.Errorf("objectWorth = %d, want 0", worth) } }) if got := g.data.armorClass(armor.Which); got != 0 { t.Errorf("armorClass(%d) = %d, want 0", armor.Which, got) } } // TestMalformedWeaponDoesNotPanic covers the init_dam[] read. WeaponFlame // is the first kind with no table row, so initWeapon must leave the // object alone rather than index past the end. func TestMalformedWeaponDoesNotPanic(t *testing.T) { t.Parallel() g := mkGameInput(t) weap := newObject() mustNotPanic(t, "initWeapon with the flame pseudo-weapon", func() { g.initWeapon(weap, WeaponFlame) }) mustNotPanic(t, "initWeapon with a negative weapon kind", func() { g.initWeapon(weap, WeaponKind(-1)) }) if weap.Kind != KindNone { t.Errorf("weapon was initialized from a missing table row: kind %v", weap.Kind) } } // TestFixStickMalformedWhichDoesNotPanic covers the ws_type[] read that // the reported reproducer actually crashed on. func TestFixStickMalformedWhichDoesNotPanic(t *testing.T) { t.Parallel() g := mkGameInput(t) wand := malformed(KindWand) mustNotPanic(t, "fixStick on a malformed wand", func() { g.fixStick(wand) }) if wand.Damage.String() != "1x1" { t.Errorf("damage = %q, want the wand damage %q", wand.Damage.String(), "1x1") } } // TestRestoreRejectsOutOfRangeWhich is the save-file half of the fix: a // malformed object must not be able to sneak past the keyboard guard by // arriving in a snapshot. // // A decoded save is also the only place a *negative* Which can come // from. On the keyboard path createObj's ch-'a' is byte arithmetic and // wraps, so 'A' and '!' land at 234 and 202; Which is a plain int in the // gob stream, so a tampered file can carry any value at all. Both shapes // are covered here, and the negative case is what exercises the // Which >= 0 arm of hasValidWhich. func TestRestoreRejectsOutOfRangeWhich(t *testing.T) { t.Parallel() cases := []struct { name string which int }{ {"one past the wand table", int(NumWandTypes)}, {"the value 'A' wraps to on the keyboard path", 234}, {"the value '!' wraps to on the keyboard path", 202}, {"negative, reachable only from a tampered file", -1}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() g := mkGame(t, 11) st := g.snapshot() if len(st.Player.Body.Pack) == 0 { t.Fatal("starting pack is empty; nothing to corrupt") } st.Player.Body.Pack[0].Kind = KindWand st.Player.Body.Pack[0].Which = tc.which path := filepath.Join(t.TempDir(), "rogue.save") writeSnapshot(t, path, st) _, restoreErr := Restore(path, Params{Term: &testTerm{}}) if !errors.Is(restoreErr, ErrSaveCorrupt) { t.Errorf("Restore error = %v, want ErrSaveCorrupt", restoreErr) } _, statErr := os.Stat(path) if statErr != nil { t.Error("a rejected save file was deleted; it should be left alone") } }) } } // writeSnapshot gob-encodes a snapshot to path the way saveFile does. func writeSnapshot(t *testing.T, path string, st *SaveState) { t.Helper() f, err := os.Create(path) //nolint:gosec // G304: test temp path if err != nil { t.Fatal(err) } encErr := gob.NewEncoder(f).Encode(st) if encErr != nil { t.Fatal(encErr) } closeErr := f.Close() if closeErr != nil { t.Fatal(closeErr) } } // TestWhichLimitCoversEveryIndexedTable pins the bounds table itself // against the per-kind arrays it has to agree with. func TestWhichLimitCoversEveryIndexedTable(t *testing.T) { t.Parallel() // len() of an array field is a compile-time constant, so the zero // value is enough to read the table sizes off. var it ItemLore cases := []struct { kind ObjectKind size int }{ {KindPotion, len(it.Potions)}, {KindScroll, len(it.Scrolls)}, {KindRing, len(it.Rings)}, {KindWand, len(it.Sticks)}, {KindArmor, len(it.Armors)}, {KindWeapon, len(it.Weapons)}, } for _, tc := range cases { if got := whichLimit(tc.kind); got != tc.size { t.Errorf("whichLimit(%v) = %d, want the table size %d", tc.kind, got, tc.size) } } // Kinds whose Which is not a table index accept anything, as in C. for _, kind := range []ObjectKind{KindFood, KindAmulet, KindGold, KindNone} { obj := &Object{Kind: kind, Which: 99} if !obj.hasValidWhich() { t.Errorf("%v should not be bounds-checked on Which", kind) } } } // TestWizardToggleOffRehidesSensedMonsters drives '+' through command // dispatch in wizard mode (command.c 317-338). Clearing the flag is the // cheap half; the substantive half is turn_see(TRUE) — leaving wizard // mode has to put the screen back, or there is no way out of wizard // sight once it is on. func TestWizardToggleOffRehidesSensedMonsters(t *testing.T) { t.Parallel() g := mkGame(t, 11) g.Wizard = true // A phantom carries ISINVIS straight from the monster table, so // seeMonst is false for it and it is on screen only because wizard // sight put it there. tp := spawnAdjacent(g, 'P') if !tp.On(Invisible) { t.Fatal("phantom is not invisible; this test needs an unseeable monster") } if g.seeMonst(tp) { t.Fatal("monster is ordinarily visible; wizard sight would reveal nothing") } if tp.OldCh == tp.Type { t.Fatalf("map char under the monster is also %q; the redraw "+ "assertion would prove nothing", tp.Type) } g.turnSee(false) if !g.Player.On(SenseMonsters) { t.Fatal("turnSee(false) did not set SenseMonsters") } if ch := g.mvinch(tp.Pos.Y, tp.Pos.X); ch != tp.Type { t.Fatalf("wizard sight did not draw the monster: cell is %q, want %q", ch, tp.Type) } if !g.scr.Std.at(tp.Pos.Y, tp.Pos.X).standout { t.Fatal("wizard-sighted monster was not drawn in standout") } g.Msgs.Mpos = 0 g.After = true g.dispatch('+') if g.Wizard { t.Error("'+' did not clear the wizard flag") } if g.Player.On(SenseMonsters) { t.Error("'+' left SenseMonsters set: turn_see(TRUE) was not performed") } if ch := g.mvinch(tp.Pos.Y, tp.Pos.X); ch != tp.OldCh { t.Errorf("monster still on screen after leaving wizard mode: cell is "+ "%q, want the map char under it, %q", ch, tp.OldCh) } if g.scr.Std.at(tp.Pos.Y, tp.Pos.X).standout { t.Error("cell left in standout after leaving wizard mode") } if g.Msgs.Huh != "not wizard any more" { t.Errorf("message = %q, want %q", g.Msgs.Huh, "not wizard any more") } if g.After { t.Error("'+' consumed a turn; C sets after = FALSE") } } // TestWizardToggleWithoutWizardSaysSorry pins the other arm. C ran // wizard = passwd() and said "sorry" when the answer was wrong; the // password machinery is dropped, so that is the only outcome left. What // it must not be any more is "illegal command '+'". func TestWizardToggleWithoutWizardSaysSorry(t *testing.T) { t.Parallel() g := mkGame(t, 12) g.Wizard = false g.Msgs.Mpos = 0 g.After = true g.dispatch('+') if g.Wizard { t.Error("'+' entered wizard mode with no password check to pass") } if g.Player.On(SenseMonsters) { t.Error("'+' turned on monster sense outside wizard mode") } if g.Msgs.Huh != "sorry" { t.Errorf("message = %q, want %q", g.Msgs.Huh, "sorry") } if g.After { t.Error("'+' consumed a turn; C sets after = FALSE") } }