From 6f409bda9efdab18d59e806d74401462d28a00ed Mon Sep 17 00:00:00 2001 From: sneak Date: Sun, 9 Aug 2026 15:39:58 +0000 Subject: [PATCH] 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. --- TODO.md | 67 ++- game/wizard_test.go | 961 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1021 insertions(+), 7 deletions(-) diff --git a/TODO.md b/TODO.md index be090ee..d507e94 100644 --- a/TODO.md +++ b/TODO.md @@ -29,12 +29,67 @@ Refactor ground rules: # Next Step -Broaden unit test coverage where playtesting finds thin spots — wizard commands -(#7). Rings and sticks, the first two thirds of this step, are done; see the top -of Completed Steps. +Tag a release once a full game (Amulet retrieval and score entry) completes +without defects. Promoted from Future Steps now that the coverage step above it +is finished. # Completed Steps +- 2026-08-09 Wizard commands under test (`test/wizard-coverage`, closes #7): the + last of the three thin spots, so the coverage step is now closed rather than + narrowed. `game/wizard.go`'s eight functions had no tests of their own, and + the file 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. Package coverage 60.6% -> 62.4%. + Everything expected was transcribed from `wizard.c`, `command.c` (the + `CTRL('I')` kit), `extern.c` (`a_class[]`), `weapons.c` (`init_dam[]`) and + `rogue.h`; 30 mutations were tried and all 30 were caught. + + Two findings came out of the reading. (1) **A wizard-created cursed weapon + is not cursed, in C or here.** `create_obj` sets `ISCURSED` and then calls + `init_weapon`, which _assigns_ `weap->o_flags = iwp->iw_flags` and so + overwrites the bit it just set; only the `o_hplus` penalty survives, and the + "cursed" weapon can still be dropped and unwielded. The port reproduces this + exactly. The test asserts the whole flag word comes back as the `init_dam[]` + row's value whatever blessing was answered, and deleting the `ISCURSED` line + from the port leaves every weapon test green — which is the evidence that + the line is dead for weapons. The armor arm has no such clobber and does + keep the curse. (2) **`show_map`'s standout is asymmetric in C and symmetric + here.** C tests `!(real & F_REAL)` before drawing and `!real` — the whole + flag word — after. `new_level` seeds every square with `p_flags = F_REAL`, + and exactly three sites clear that bit. `passages.c putpass` sets `F_PASS` + first, so its secret passage is left at `0x80`. `passages.c door`'s + secret-door arm clears it on a room-wall exit whose flags are still exactly + `F_REAL` (`rooms.c` writes no `p_flags` at all), leaving `p_flags == 0`; its + per-square gate is `rnd(5) == 0` against `putpass`'s `rnd(40) == 0`, and + `game/passages.go`'s `door` reproduces it. `new_level`'s trap loop then ORs + in `rnd(NTRAPS)`, which is `abs((int) RN) % 8` and so yields `0..7`, and + `T_DOOR` is `00` — an unsprung trapdoor square is also exactly zero + (`be_trapped` is what later ORs `F_SEEN` into it). So C _does_ turn standout + off again, 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. `game/wizard.go` tests `isReal` both times and + highlights the one square. That is a display-only difference in a + wizard-only command and was reported on the issue rather than changed here; + the test asserts the map characters unconditionally but the standout + attribute only up to the first secret square, so it pins nothing that C + contradicts. + + Two things the tests had to be built around. The `insist` arm of `whatis` is + a loop whose only exits are picking a matching item and `n_objs == 0`, so a + script that runs dry hangs instead of failing — every sequence that can + re-prompt ends in an abort tail, the `n_objs == 0` exit is reached the way a + player reaches it (`*` for a list with nothing appropriate in the pack) + rather than by poking the counter, and the one mutation that deletes that + exit is the only one of the 30 that fails by timeout instead of fast, + necessarily so. And `show_map` does **not** mark squares seen — it writes + into `hw` and touches no `PLACE` at all — so the issue's wording for it + could not be tested as written; the loop bounds are asserted instead by + planting a marker in the rows C's loop excludes, since those rows are blank + on a real level and copying blanks over blanks would have made the bound + unfalsifiable. + - 2026-08-09 Wands and staffs under test (`test/sticks-coverage`, closes #6): the second of the three thin spots the Next Step names. `game/sticks.go` was the largest under-tested file in the repo — 534 lines, 23 functions, one test @@ -638,13 +693,11 @@ of Completed Steps. # Future Steps -1. Tag a release once a full game (Amulet retrieval and score entry) completes - without defects. -2. Full-terminal-size support (deferred by explicit decision 2026-07-06): +1. Full-terminal-size support (deferred by explicit decision 2026-07-06): per-game dungeon dimensions instead of the 80x24 constants; open design questions are resize policy, gameplay tuning at larger sizes, and a --classic 80x24 mode. -3. Note: this repo is exempt from the standard policy scaffold. A minimal dev +2. Note: this repo is exempt from the standard policy scaffold. A minimal dev Makefile (fmt/fmt-check/lint/test/check targets) exists per sneak's 2026-07-07 request, but do not add a Dockerfile, CI config, or REPO_POLICIES.md. diff --git a/game/wizard_test.go b/game/wizard_test.go index 55645f5..3f9c1ba 100644 --- a/game/wizard_test.go +++ b/game/wizard_test.go @@ -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) +}