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.
This commit is contained in:
@@ -6,6 +6,8 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -530,3 +532,962 @@ func TestWizardToggleWithoutWizardSaysSorry(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user