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