Files
rgoue/game/wizard_test.go
sneak 6f409bda9e Cover the wizard commands with C-verified unit tests (closes #7)
game/wizard.go had no tests of its own. It is not purely a debug
surface: set_know writes the per-game discovered tables that name items
in ordinary play, and teleport is what the teleport ring calls every
fiftieth turn, so a defect in either leaks into a normal game.

Adds coverage for createObj (pack filing and the gold arm),
createWeaponArmor, createRing, showMap, whatis, whatisPick, setKnow,
teleport and command.go's wizardKit. Package coverage 60.6% -> 62.4%.
Expected values are transcribed from wizard.c, command.c, extern.c,
weapons.c and rogue.h rather than read off the port; wizard mode is
entered through Params.Wizard, the field main.go fills from
ROGUE_WIZARD, so no test pokes the flag.

Two notes from the C. A wizard-created "cursed" weapon is not cursed in
either language: init_weapon assigns o_flags over the ISCURSED bit
create_obj had just set, leaving only the o_hplus penalty, and the test
pins the whole flag word to the init_dam[] row to say so. And show_map
turns standout on for a square missing F_REAL but off only for a square
whose whole flag word is zero. Exactly three sites clear F_REAL:
putpass, which sets F_PASS first and so leaves 0x80; door's secret-door
arm, on a room-wall exit still holding exactly F_REAL, leaving zero;
and new_level's trap loop, whose rnd(NTRAPS) is 0..7, so the T_DOOR
(00) case leaves zero as well. C's wstandend therefore does fire, at
secret doors and unsprung trapdoors; what it gets wrong is leaking the
attribute forward from a secret passage or a non-trapdoor trap until it
reaches one of those - intermittent bands of reverse video, not a
permanently reversed map. That display-only difference is reported on
the issue and left alone here, and the test asserts standout only up to
the first secret square.

No game behavior is changed.
2026-08-09 16:01:07 +00:00

1494 lines
41 KiB
Go

//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import (
"encoding/gob"
"errors"
"os"
"path/filepath"
"slices"
"strconv"
"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")
}
}
// The rest of this file covers game/wizard.go proper (issue #7). Every
// expected value below is transcribed from origin/c-master — wizard.c for
// create_obj/whatis/set_know/teleport/show_map, command.c for the CTRL('I')
// kit, extern.c for a_class[], weapons.c for init_dam[], and rogue.h for
// the R_* numbering and the F_* place flags — never from what the port
// happens to return.
//
// Two shapes recur. Scripted input always ends with an abort tail (a space
// for a --More--, then ESCAPE), because testTerm.ReadChar hands out filler
// forever once the script runs dry and a re-prompting loop would spin to
// the suite timeout instead of failing. And where C issues no prompt at
// all, the test asserts on the scripted input cursor rather than on state:
// a stray readchar would eat the next answer and desynchronise everything
// after it, which no state assertion would notice.
// mkWizard builds a headless game in wizard mode the way the program does.
// cmd/rogue/main.go turns ROGUE_WIZARD into Params.Wizard and New consumes
// that field, so no test here pokes g.Wizard. depth is what decides
// whether the generator produces secret (non-F_REAL) squares at all.
func mkWizard(t *testing.T, seed int32, depth int) *RogueGame {
t.Helper()
g := New(Params{Seed: seed, Wizard: true, Term: &testTerm{}})
if !g.Wizard {
t.Fatal("Params.Wizard did not turn on wizard mode")
}
g.Depth = depth
g.NewLevel()
g.Oldpos = g.Player.Pos
g.Oldrp = g.roomIn(g.Player.Pos)
return g
}
// packSet snapshots pack membership by identity. add_pack files a new item
// in kind order, so its position is no guide to which one it is.
func packSet(g *RogueGame) map[*Object]bool {
seen := make(map[*Object]bool, len(g.Player.Pack))
for _, o := range g.Player.Pack {
seen[o] = true
}
return seen
}
// onlyNewItem returns the single object added to the pack since before.
func onlyNewItem(t *testing.T, g *RogueGame, before map[*Object]bool) *Object {
t.Helper()
var made []*Object
for _, o := range g.Player.Pack {
if !before[o] {
made = append(made, o)
}
}
if len(made) != 1 {
t.Fatalf("pack gained %d objects, want exactly 1", len(made))
}
return made[0]
}
// inputUsed reports how many scripted keys have been consumed so far.
func inputUsed(t *testing.T, g *RogueGame) int {
t.Helper()
tt, ok := g.scr.term.(*testTerm)
if !ok {
t.Fatal("game terminal is not a testTerm")
}
return tt.pos
}
// TestCreateObjFilesTheItemInThePack covers the tail every arm of
// wizard.c create_obj shares: o_group = 0, o_count = 1, then
// add_pack(obj, FALSE). A potion is the kind C's switch does nothing for,
// so nothing else is in the way.
func TestCreateObjFilesTheItemInThePack(t *testing.T) {
t.Parallel()
g := mkWizard(t, 21, 1)
before := packSet(g)
setInput(t, g, Potion, '0', ' ', Escape)
g.createObj()
made := onlyNewItem(t, g, before)
if made.Kind != KindPotion || made.Which != int(PotionConfusion) {
t.Fatalf("created %v which %d, want %v which %d",
made.Kind, made.Which, KindPotion, int(PotionConfusion))
}
if made.Count != 1 {
t.Errorf("count = %d, want the 1 C sets", made.Count)
}
if made.Group != 0 {
t.Errorf("group = %d, want the 0 C sets", made.Group)
}
if made.PackCh == 0 {
t.Error("created object has no pack letter: add_pack never filed it")
}
}
// TestCreateObjGoldAsksHowMuch covers the GOLD arm, C's
// msg("how much?") followed by get_num(&obj->o_goldval, stdscr).
func TestCreateObjGoldAsksHowMuch(t *testing.T) {
t.Parallel()
g := mkWizard(t, 22, 1)
before := packSet(g)
setInput(t, g, Gold, '0', '2', '5', '0', '\n', ' ', Escape)
g.createObj()
made := onlyNewItem(t, g, before)
if made.Kind != KindGold {
t.Fatalf("created %v, want %v", made.Kind, KindGold)
}
if made.GoldValue != 250 {
t.Errorf("gold value = %d, want the typed 250", made.GoldValue)
}
}
// TestCreateWeaponBlessing pins the weapon arm of create_obj to C:
//
// if (bless == '-') obj->o_flags |= ISCURSED;
// if (obj->o_type == WEAPON) {
// init_weapon(obj, obj->o_which);
// if (bless == '-') obj->o_hplus -= rnd(3)+1;
// if (bless == '+') obj->o_hplus += rnd(3)+1;
//
// A curse subtracts and a blessing adds — the opposite of the armor arm
// below, and rnd(3)+1 is 1..3 either way.
//
// The curse itself does not survive on a weapon, and that is C's own
// behavior, not a port bug: weapons.c init_weapon *assigns*
// weap->o_flags = iwp->iw_flags, so it overwrites the ISCURSED bit set
// three lines earlier with the init_dam[] row's flags. A wizard-created
// "cursed" weapon therefore carries only the hit penalty and can still be
// dropped and unwielded. The mace row's flags are 0, so the whole word
// must come back 0 here whatever was answered. The armor arm has no such
// clobber, which is why TestCreateArmorBlessing does expect ISCURSED.
func TestCreateWeaponBlessing(t *testing.T) {
t.Parallel()
cases := []struct {
name string
bless byte
low, hi int
}{
{"no blessing", 'n', 0, 0},
{"blessed adds to the hit bonus", '+', 1, 3},
{"cursed subtracts from it", '-', -3, -1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
g := mkWizard(t, 23, 1)
obj := newObject()
obj.Kind = KindWeapon
obj.Which = int(WeaponMace)
setInput(t, g, tc.bless, ' ', Escape)
g.createWeaponArmor(obj)
if obj.Flags != 0 {
t.Errorf("flags = %d, want the init_dam mace row's 0: "+
"init_weapon assigns o_flags over any curse",
obj.Flags)
}
if obj.HPlus < tc.low || obj.HPlus > tc.hi {
t.Errorf("hit bonus = %d, want %d..%d",
obj.HPlus, tc.low, tc.hi)
}
// init_weapon ran: the mace row of C's init_dam[].
if got := obj.Damage.String(); got != "2x4" {
t.Errorf("damage = %q, want the init_dam mace row 2x4", got)
}
if got := obj.HurlDmg.String(); got != "1x3" {
t.Errorf("hurl damage = %q, want 1x3", got)
}
})
}
}
// TestCreateArmorBlessing pins the armor arm, where C moves o_arm the
// other way because a lower armor class is better:
//
// obj->o_arm = a_class[obj->o_which];
// if (bless == '-') obj->o_arm += rnd(3)+1;
// if (bless == '+') obj->o_arm -= rnd(3)+1;
//
// extern.c's a_class[] has PLATE_MAIL at 3, so the three answers land at
// 3, 0..2 and 4..6.
func TestCreateArmorBlessing(t *testing.T) {
t.Parallel()
cases := []struct {
name string
bless byte
cursed bool
low, hi int
}{
{"no blessing leaves the table value", 'n', false, 3, 3},
{"blessed lowers the armor class", '+', false, 0, 2},
{"cursed raises it", '-', true, 4, 6},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
g := mkWizard(t, 24, 1)
obj := newObject()
obj.Kind = KindArmor
obj.Which = int(ArmorPlateMail)
setInput(t, g, tc.bless, ' ', Escape)
g.createWeaponArmor(obj)
if got := obj.Flags.Has(Cursed); got != tc.cursed {
t.Errorf("cursed = %v, want %v", got, tc.cursed)
}
if obj.ArmorClass < tc.low || obj.ArmorClass > tc.hi {
t.Errorf("armor class = %d, want %d..%d",
obj.ArmorClass, tc.low, tc.hi)
}
// The armor arm must not fall into init_weapon.
if obj.Kind != KindArmor || obj.Which != int(ArmorPlateMail) {
t.Errorf("armor became %v which %d", obj.Kind, obj.Which)
}
})
}
}
// TestCreateRingBonus covers the four bonus rings, C's
// obj->o_arm = (bless == '-' ? -1 : rnd(2) + 1), where rnd(2)+1 is 1..2.
// R_ADDHIT is RingDexterity and R_ADDDAM is RingIncreaseDamage; the
// RingKind iota matches C's R_ numbering index for index.
func TestCreateRingBonus(t *testing.T) {
t.Parallel()
rings := []RingKind{
RingProtection, RingAddStrength, RingDexterity, RingIncreaseDamage,
}
blessings := []struct {
name string
bless byte
cursed bool
low, hi int
}{
{"blessed", '+', false, 1, 2},
{"unblessed", 'n', false, 1, 2},
{"cursed", '-', true, -1, -1},
}
for _, ring := range rings {
for _, tc := range blessings {
t.Run(ringTestName(ring, tc.name), func(t *testing.T) {
t.Parallel()
g := mkWizard(t, 25, 1)
obj := newObject()
obj.Kind = KindRing
obj.Which = int(ring)
setInput(t, g, tc.bless, ' ', Escape)
g.createRing(obj)
if got := obj.Flags.Has(Cursed); got != tc.cursed {
t.Errorf("cursed = %v, want %v", got, tc.cursed)
}
if obj.Bonus < tc.low || obj.Bonus > tc.hi {
t.Errorf("bonus = %d, want %d..%d",
obj.Bonus, tc.low, tc.hi)
}
if used := inputUsed(t, g); used != 1 {
t.Errorf("read %d keys, want the 1 blessing answer",
used)
}
})
}
}
}
// ringTestName labels a subtest by ring index, the R_ number from rogue.h.
func ringTestName(ring RingKind, what string) string {
return "R_" + strconv.Itoa(int(ring)) + " " + what
}
// TestCreateRingCursedKindsSkipThePrompt covers C's second case group,
// "when R_AGGR: case R_TELEPORT: obj->o_flags |= ISCURSED": cursed with
// no blessing question and no bonus at all.
func TestCreateRingCursedKindsSkipThePrompt(t *testing.T) {
t.Parallel()
for _, ring := range []RingKind{RingAggravateMonsters, RingTeleportation} {
t.Run(ringTestName(ring, "is cursed silently"), func(t *testing.T) {
t.Parallel()
g := mkWizard(t, 26, 1)
obj := newObject()
obj.Kind = KindRing
obj.Which = int(ring)
setInput(t, g, ' ', Escape)
g.createRing(obj)
if !obj.Flags.Has(Cursed) {
t.Error("ring is not cursed")
}
if obj.Bonus != 0 {
t.Errorf("bonus = %d, want 0: C sets none here", obj.Bonus)
}
if used := inputUsed(t, g); used != 0 {
t.Errorf("read %d keys; C asks nothing for this kind", used)
}
})
}
}
// TestCreateRingOtherKindsAreLeftAlone is the default arm: every ring
// outside C's two case groups gets no prompt, no curse and no bonus.
func TestCreateRingOtherKindsAreLeftAlone(t *testing.T) {
t.Parallel()
others := []RingKind{
RingSustainStrength, RingSearching, RingSeeInvisible, RingAdornment,
RingRegeneration, RingSlowDigestion, RingStealth, RingMaintainArmor,
}
for _, ring := range others {
t.Run(ringTestName(ring, "is untouched"), func(t *testing.T) {
t.Parallel()
g := mkWizard(t, 27, 1)
obj := newObject()
obj.Kind = KindRing
obj.Which = int(ring)
setInput(t, g, ' ', Escape)
g.createRing(obj)
if obj.Flags.Has(Cursed) {
t.Error("ring was cursed; C curses only R_AGGR and R_TELEPORT")
}
if obj.Bonus != 0 {
t.Errorf("bonus = %d, want 0", obj.Bonus)
}
if used := inputUsed(t, g); used != 0 {
t.Errorf("read %d keys; C asks nothing for this kind", used)
}
})
}
}
// TestShowMapRendersTheWholeLevel covers wizard.c show_map against a
// generated level. C clears hw, walks y from 1 to NUMLINES-2 and x across
// every column writing chat(y,x), then show_win()s it, so the whole map
// including squares the hero has never seen has to land in the hw window.
//
// What show_map does *not* do is mark anything seen: it touches no PLACE
// at all, in C or here, so there is no F_SEEN assertion to make.
//
// The standout attribute is only asserted up to the first non-real
// square, deliberately. C's two tests are not the same test:
//
// real = flat(y, x);
// if (!(real & F_REAL)) wstandout(hw);
// ...
// if (!real) wstandend(hw); /* whole word, not the bit */
//
// new_level.c seeds every square with p_flags = F_REAL, and exactly three
// sites clear that bit. putpass sets F_PASS first, so a secret passage is
// left at 0x80. door's secret-door arm clears it on a room-wall exit whose
// flags are still exactly F_REAL, leaving p_flags == 0. And the trap loop
// ORs in rnd(NTRAPS), which is 0..7, so the T_DOOR (00) case is zero too
// until be_trapped ORs F_SEEN in. So C's wstandend does fire, at secret
// doors and unsprung trapdoors; what it gets wrong is leaking standout
// forward from a secret passage or a non-trapdoor trap until it reaches
// one of those — intermittent bands, not a permanently reversed map.
// game/wizard.go tests isReal both times and highlights the single square.
// That divergence is reported on issue #7 rather than settled here, so
// this test asserts only
// what both agree on: the characters everywhere, standout on every
// non-real square, and no standout on real squares before the first
// non-real one.
func TestShowMapRendersTheWholeLevel(t *testing.T) {
t.Parallel()
// Deep enough that putpass and the trap loop actually fire; both are
// gated on the depth, so a level-1 map would have nothing secret.
g := mkWizard(t, 31, 20)
setInput(t, g, ' ')
g.showMap()
hw := g.scr.Hw
seenSecret := false
for y := 1; y < NumLines-1; y++ {
for x := range NumCols {
c := hw.at(y, x)
if c.ch != g.Level.Char(y, x) {
t.Fatalf("hw(%d,%d) = %q, want the map char %q",
y, x, c.ch, g.Level.Char(y, x))
}
isReal := g.Level.FlagsAt(y, x).Has(FReal)
if !isReal && !c.standout {
t.Errorf("secret square (%d,%d) was not drawn in standout",
y, x)
}
if !seenSecret && isReal && c.standout {
t.Errorf("ordinary square (%d,%d) was drawn in standout",
y, x)
}
seenSecret = seenSecret || !isReal
}
}
if !seenSecret {
t.Fatal("generated level has no non-F_REAL squares: the standout " +
"half of show_map went untested, pick a deeper level or seed")
}
}
// TestShowMapLoopBoundsMatchC pins the loop bounds. C starts at y = 1
// and stops before NUMLINES-1, so the top line stays free for show_win's
// prompt and the status line is never overwritten.
func TestShowMapLoopBoundsMatchC(t *testing.T) {
t.Parallel()
g := mkWizard(t, 32, 10)
// Rows 0 and NUMLINES-1 are blank on a generated level, so a bound
// that ran off either end would copy blanks onto blanks and look
// identical. Planting a marker in places[] there is what makes the
// bound observable at all.
const marker = 'Z'
for x := range NumCols {
g.Level.SetChar(0, x, marker)
g.Level.SetChar(NumLines-1, x, marker)
}
setInput(t, g, ' ')
g.showMap()
hw := g.scr.Hw
for x := range NumCols {
if got := hw.at(NumLines-1, x).ch; got == marker {
t.Fatalf("hw(%d,%d) = %q: the loop ran onto the status line",
NumLines-1, x, got)
}
}
const want = "---More (level map)---"
// show_win's prompt covers the start of row 0; past it the row must
// still be untouched by the map loop.
for x := len(want); x < NumCols; x++ {
if got := hw.at(0, x).ch; got == marker {
t.Fatalf("hw(0,%d) = %q: the loop ran onto the message line",
x, got)
}
}
top := make([]byte, 0, len(want))
for x := range len(want) {
top = append(top, hw.at(0, x).ch)
}
if string(top) != want {
t.Errorf("top line = %q, want show_win's %q", string(top), want)
}
}
// TestWhatisMarksTheRightTable covers wizard.c whatis's switch: scrolls,
// potions, sticks and rings each go through set_know on their own
// per-game table, and the function ends with msg(inv_name(obj, FALSE)),
// so the reported name is the newly identified one.
func TestWhatisMarksTheRightTable(t *testing.T) {
t.Parallel()
cases := []struct {
name string
kind ObjectKind
which int
table func(g *RogueGame) []ObjInfo
}{
{"scroll", KindScroll, int(ScrollEnchantArmor),
func(g *RogueGame) []ObjInfo { return g.Items.Scrolls[:] }},
{"potion", KindPotion, int(PotionHealing),
func(g *RogueGame) []ObjInfo { return g.Items.Potions[:] }},
{"wand", KindWand, int(WandLight),
func(g *RogueGame) []ObjInfo { return g.Items.Sticks[:] }},
{"ring", KindRing, int(RingSearching),
func(g *RogueGame) []ObjInfo { return g.Items.Rings[:] }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
g := mkWizard(t, 33, 1)
obj := newObject()
obj.Kind = tc.kind
obj.Which = tc.which
ch := give(g, obj)
tbl := tc.table(g)
tbl[tc.which].Guess = "a wild guess"
before := g.inventoryName(obj, false)
setInput(t, g, ch, ' ', Escape)
g.whatis(false, KindNone)
if !tbl[tc.which].Know {
t.Error("set_know did not mark the table entry known")
}
if tbl[tc.which].Guess != "" {
t.Errorf("guess = %q, want it freed", tbl[tc.which].Guess)
}
if !obj.Flags.Has(Known) {
t.Error("the object did not get ISKNOW")
}
after := g.inventoryName(obj, false)
if after == before {
t.Errorf("name is still %q; identifying changed nothing",
after)
}
if g.Msgs.Huh != after {
t.Errorf("reported %q, want inv_name's %q", g.Msgs.Huh, after)
}
})
}
}
// TestWhatisIdentifiesOnlyTheChosenEntry is the other half of set_know's
// contract: one table entry, not a whole table and not its neighbours.
func TestWhatisIdentifiesOnlyTheChosenEntry(t *testing.T) {
t.Parallel()
g := mkWizard(t, 34, 1)
obj := newObject()
obj.Kind = KindScroll
obj.Which = int(ScrollEnchantArmor)
ch := give(g, obj)
setInput(t, g, ch, ' ', Escape)
g.whatis(false, KindNone)
for i := range g.Items.Scrolls {
if i == obj.Which {
continue
}
if g.Items.Scrolls[i].Know {
t.Errorf("scroll %d was marked known too", i)
}
}
if g.Items.Potions[obj.Which].Know {
t.Error("identifying a scroll marked the potion at the same index")
}
}
// TestWhatisWeaponAndArmorOnlySetTheFlag pins C's WEAPON/ARMOR arm, which
// is "obj->o_flags |= ISKNOW" and no set_know call: knowing this sword is
// a sword says nothing about the kind, so the per-kind table entry must
// stay untouched.
func TestWhatisWeaponAndArmorOnlySetTheFlag(t *testing.T) {
t.Parallel()
cases := []struct {
name string
kind ObjectKind
which int
table func(g *RogueGame) []ObjInfo
}{
{"a mace", KindWeapon, int(WeaponMace),
func(g *RogueGame) []ObjInfo { return g.Items.Weapons[:] }},
{"plate mail", KindArmor, int(ArmorPlateMail),
func(g *RogueGame) []ObjInfo { return g.Items.Armors[:] }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
g := mkWizard(t, 35, 1)
obj := newObject()
obj.Kind = tc.kind
obj.Which = tc.which
ch := give(g, obj)
setInput(t, g, ch, ' ', Escape)
g.whatis(false, KindNone)
if !obj.Flags.Has(Known) {
t.Error("the object did not get ISKNOW")
}
if tc.table(g)[tc.which].Know {
t.Error("the kind table was marked known; C calls no " +
"set_know for weapons or armor")
}
})
}
}
// TestWhatisEmptyPackSaysSo covers the early return C takes when
// pack == NULL, before any prompt happens.
func TestWhatisEmptyPackSaysSo(t *testing.T) {
t.Parallel()
g := mkWizard(t, 36, 1)
g.Player.Pack = nil
g.whatis(false, KindNone)
const want = "you don't have anything in your pack to identify"
if g.Msgs.Huh != want {
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
}
if used := inputUsed(t, g); used != 0 {
t.Errorf("read %d keys; C returns before get_item", used)
}
}
// TestWhatisInsistRepromptsUntilAMatch drives both re-prompting arms of
// C's insist loop in one pass: a wrong-kind pick ("you must identify a
// %s") and then a bare escape with n_objs non-zero ("you must identify
// something"), before the scroll finally satisfies it. The spaces in the
// script are the --More-- acknowledgements those two messages force, and
// without insist neither arm exists — the loop would have returned the
// potion on the first answer.
func TestWhatisInsistRepromptsUntilAMatch(t *testing.T) {
t.Parallel()
g := mkWizard(t, 37, 1)
pot := newObject()
pot.Kind = KindPotion
pot.Which = int(PotionHealing)
potCh := give(g, pot)
scr := newObject()
scr.Kind = KindScroll
scr.Which = int(ScrollEnchantArmor)
scrCh := give(g, scr)
setInput(t, g, potCh, ' ', Escape, ' ', scrCh, ' ', Escape)
g.whatis(true, KindScroll)
if !g.Items.Scrolls[scr.Which].Know {
t.Error("the scroll was never identified: the loop gave up early")
}
if g.Items.Potions[pot.Which].Know {
t.Error("the wrong-kind potion was identified anyway")
}
if used := inputUsed(t, g); used < 5 {
t.Errorf("consumed %d keys, want at least the 5 the two "+
"re-prompts need", used)
}
}
// TestWhatisInsistGivesUpWhenNothingMatches covers "if (n_objs == 0)
// return": asking for the list with nothing appropriate in the pack sets
// n_objs to 0, and that is the one way out of the insist loop short of
// picking something. Getting it wrong is not a wrong answer but a hang.
func TestWhatisInsistGivesUpWhenNothingMatches(t *testing.T) {
t.Parallel()
g := mkWizard(t, 38, 1)
pot := newObject()
pot.Kind = KindPotion
pot.Which = int(PotionHealing)
give(g, pot)
setInput(t, g, '*', ' ', Escape)
g.whatis(true, KindScroll)
if g.NObjs != 0 {
t.Fatalf("n_objs = %d; this test needs the empty-list path", g.NObjs)
}
if g.Items.Potions[pot.Which].Know {
t.Error("giving up identified something anyway")
}
}
// TestSetKnowDoesNotLeakAcrossGames is the reason set_know is not just a
// debug helper: the tables it writes are the per-game discovered lists
// that drive item naming in ordinary play. They live on RogueGame, and a
// second game must start ignorant.
func TestSetKnowDoesNotLeakAcrossGames(t *testing.T) {
t.Parallel()
g1 := mkWizard(t, 39, 1)
g2 := mkWizard(t, 40, 1)
ring := newObject()
ring.Kind = KindRing
ring.Which = int(RingSearching)
g1.Items.Rings[ring.Which].Guess = "a hunch"
setKnow(ring, g1.Items.Rings[:])
if !g1.Items.Rings[ring.Which].Know {
t.Error("the entry was not marked known")
}
if g1.Items.Rings[ring.Which].Guess != "" {
t.Error("the old guess was not freed")
}
if !ring.Flags.Has(Known) {
t.Error("the object did not get ISKNOW")
}
if g2.Items.Rings[ring.Which].Know {
t.Error("the second game already knows the ring: the discovered " +
"tables are shared between games")
}
if g2.Items.Rings[ring.Which].Guess != "" {
t.Error("the second game inherited the first game's guess")
}
}
// TestTeleportLandsTheHeroSomewhereLegal covers wizard.c teleport. C
// picks the spot with find_floor(NULL, &c, FALSE, TRUE) — any room, and
// monst TRUE, so the square must be steppable and unoccupied — then keeps
// the room bookkeeping straight (leave_room/enter_room when the room
// changed, look(TRUE) when it did not) and clears the run state.
func TestTeleportLandsTheHeroSomewhereLegal(t *testing.T) {
t.Parallel()
g := mkWizard(t, 41, 3)
p := &g.Player
from := p.Pos
vacated := g.floorAt()
g.NoMove = 3
g.Count = 5
g.Running = true
g.teleport()
if p.Pos == from {
t.Fatal("hero did not move; this seed teleported him onto himself")
}
pp := g.Level.At(p.Pos.Y, p.Pos.X)
if !stepOk(pp.Ch) || pp.Monst != nil {
t.Errorf("landed on %q with monster %v: find_floor's contract is "+
"a steppable, unoccupied square", pp.Ch, pp.Monst != nil)
}
if p.Room != g.roomIn(p.Pos) {
t.Error("player room does not match the square he is standing on")
}
if got := g.mvinch(p.Pos.Y, p.Pos.X); got != PlayerCh {
t.Errorf("new square shows %q, want the hero %q", got, PlayerCh)
}
if got := g.mvinch(from.Y, from.X); got != vacated {
t.Errorf("vacated square shows %q, want floor_at()'s %q",
got, vacated)
}
if g.NoMove != 0 || g.Count != 0 || g.Running {
t.Errorf("run state left at no_move=%d count=%d running=%v",
g.NoMove, g.Count, g.Running)
}
}
// TestTeleportReleasesTheFlytrap covers the tail C spells out: bamfing
// away while a Flytrap has hold of you clears ISHELD, resets vf_hit and
// puts the 'F' bestiary entry's damage back to "000x0" — the Flytrap
// grows its own damage string as it holds on, so leaving it grown would
// make the next Flytrap of the game start off mid-fight.
func TestTeleportReleasesTheFlytrap(t *testing.T) {
t.Parallel()
g := mkWizard(t, 42, 3)
p := &g.Player
p.Flags.Set(Held)
p.VfHit = 4
g.Monsters['F'-'A'].Stats.Dmg = dice("3x4")
g.teleport()
if p.On(Held) {
t.Error("hero is still held after teleporting away")
}
if p.VfHit != 0 {
t.Errorf("vf_hit = %d, want 0", p.VfHit)
}
// C strcpy's the literal "000x0"; the port keeps damage parsed, so
// the same thing reads back as the single 0x0 attack that string
// means rather than as those five characters.
dmg := g.Monsters['F'-'A'].Stats.Dmg
if len(dmg) != 1 || dmg[0].Count != 0 || dmg[0].Sides != 0 {
t.Errorf("flytrap damage = %q, want C's 000x0, one 0x0 attack", dmg)
}
}
// TestTeleportLeavesTheFlytrapAloneWhenFree pins the other side of C's
// "if (on(player, ISHELD))" guard: an ordinary wizard teleport must not
// reach into the bestiary and reset a Flytrap that is busy elsewhere.
func TestTeleportLeavesTheFlytrapAloneWhenFree(t *testing.T) {
t.Parallel()
g := mkWizard(t, 43, 3)
g.Player.VfHit = 2
g.Monsters['F'-'A'].Stats.Dmg = dice("3x4")
g.teleport()
if g.Player.VfHit != 2 {
t.Errorf("vf_hit = %d, want the untouched 2", g.Player.VfHit)
}
if got := g.Monsters['F'-'A'].Stats.Dmg.String(); got != "3x4" {
t.Errorf("flytrap damage = %q, want the untouched %q", got, "3x4")
}
}
// TestWizardKitEquipsTheHero covers the CTRL('I') arm of command.c's
// wizard switch: nine raise_level() calls, a (+1,+1) two-handed sword
// wielded, and plate mail at o_arm -5 already known and worn.
func TestWizardKitEquipsTheHero(t *testing.T) {
t.Parallel()
g := mkWizard(t, 44, 1)
p := &g.Player
if p.Stats.Lvl != 1 {
t.Fatalf("hero starts at level %d, not 1", p.Stats.Lvl)
}
// raise_level messages queue up --More-- prompts; spaces clear them.
setInput(t, g, ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ')
g.wizardKit()
if p.Stats.Lvl != 10 {
t.Errorf("level = %d, want 10 after nine raise_level calls",
p.Stats.Lvl)
}
checkKitWeapon(t, g)
checkKitArmor(t, g)
}
// checkKitWeapon asserts the sword half of the wizard kit.
func checkKitWeapon(t *testing.T, g *RogueGame) {
t.Helper()
weap := g.Player.CurWeapon
if weap == nil {
t.Fatal("no weapon wielded")
}
if weap.Kind != KindWeapon || weap.Which != int(WeaponTwoHandedSword) {
t.Errorf("wielding %v which %d, want the two-handed sword",
weap.Kind, weap.Which)
}
if weap.HPlus != 1 || weap.DPlus != 1 {
t.Errorf("sword is (%+d,%+d), want (+1,+1)", weap.HPlus, weap.DPlus)
}
// init_dam[]'s 2h sword row.
if got := weap.Damage.String(); got != "4x4" {
t.Errorf("damage = %q, want 4x4", got)
}
if !inPack(g, weap) {
t.Error("the sword was never added to the pack")
}
}
// checkKitArmor asserts the plate mail half of the wizard kit.
func checkKitArmor(t *testing.T, g *RogueGame) {
t.Helper()
armor := g.Player.CurArmor
if armor == nil {
t.Fatal("no armor worn")
}
if armor.Kind != KindArmor || armor.Which != int(ArmorPlateMail) {
t.Errorf("wearing %v which %d, want plate mail",
armor.Kind, armor.Which)
}
if armor.ArmorClass != -5 {
t.Errorf("armor class = %d, want -5", armor.ArmorClass)
}
if !armor.Flags.Has(Known) {
t.Error("the armor is not known")
}
if armor.Count != 1 {
t.Errorf("count = %d, want 1", armor.Count)
}
if !inPack(g, armor) {
t.Error("the armor was never added to the pack")
}
}
// inPack reports whether obj is filed in the hero's pack.
func inPack(g *RogueGame, obj *Object) bool {
return slices.Contains(g.Player.Pack, obj)
}