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.
533 lines
14 KiB
Go
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")
|
|
}
|
|
}
|