Files
rgoue/game/wizard_test.go
sneak 3f0a14c5e6 WIP: restore lost C behaviors (schtick message, forced redraw, greeting)
Incomplete checkpoint, preserved after the implementing session hit a
capacity limit partway through mutation verification. NOT reviewed and NOT
gate-clean: make fmt has not been run, so fmt-check currently fails on
ARCHITECTURE.md and TODO.md.

Work still outstanding before this is fit for review:
  - run make fmt and fold the result in
  - finish mutation-proving each of the three behaviors
  - verify every message string byte-for-byte against the C sources
  - confirm TestSeedCompatItemTables passes with its golden untouched

Refs #13.
2026-08-09 09:55:38 +00:00

533 lines
14 KiB
Go

//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")
}
}