//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07) package game import "testing" // mkGameInput builds a headless game; tests script it via setInput. The // fixed seed keeps the scripted item/monster interactions stable. func mkGameInput(t *testing.T) *RogueGame { t.Helper() g := New(Params{Seed: 5, Term: &testTerm{}}) g.NewLevel() g.Oldpos = g.Player.Pos g.Oldrp = g.roomIn(g.Player.Pos) return g } // give puts an object straight into the pack and returns its pack letter. func give(g *RogueGame, obj *Object) byte { obj.Count = 1 g.addPack(obj, true) return obj.PackCh } // setInput replaces the scripted terminal input. func setInput(t *testing.T, g *RogueGame, input ...byte) { t.Helper() tt, ok := g.scr.term.(*testTerm) if !ok { t.Fatal("game terminal is not a testTerm") } tt.input = input tt.pos = 0 } func TestQuaffHealingPotion(t *testing.T) { t.Parallel() g := mkGameInput(t) pot := newObject() pot.Kind = KindPotion pot.Which = int(PotionHealing) ch := give(g, pot) setInput(t, g, ch) g.Player.Stats.HP = 1 g.quaff() if g.Player.Stats.HP <= 1 { t.Error("healing potion did not heal") } if !g.Items.Potions[PotionHealing].Know { t.Error("healing potion not identified after drinking") } if len(g.Player.Pack) != 5 { t.Errorf("potion not consumed: %d items", len(g.Player.Pack)) } } func TestQuaffConfusionSetsFlagAndFuse(t *testing.T) { t.Parallel() g := mkGameInput(t) pot := newObject() pot.Kind = KindPotion pot.Which = int(PotionConfusion) ch := give(g, pot) setInput(t, g, ch) g.quaff() if !g.Player.On(Confused) { t.Error("confusion potion did not confuse") } if g.findSlot(DUnconfuse) == nil { t.Error("no unconfuse fuse pending") } // Let the fuse burn down for range 30 { g.DoFuses(After) } if g.Player.On(Confused) { t.Error("confusion never wore off") } } func TestReadEnchantArmor(t *testing.T) { t.Parallel() g := mkGameInput(t) scr := newObject() scr.Kind = KindScroll scr.Which = int(ScrollEnchantArmor) ch := give(g, scr) setInput(t, g, ch) before := g.Player.CurArmor.ArmorClass g.readScroll() if g.Player.CurArmor.ArmorClass != before-1 { t.Errorf("enchant armor: AC %d -> %d, want %d", before, g.Player.CurArmor.ArmorClass, before-1) } } func TestReadHoldMonsterFreezesAdjacent(t *testing.T) { t.Parallel() // Note: this must not use a greedy monster ('O' orc, ISGREED): the C // wake_monster gold-guarding check has no ISHELD guard, so the // look(TRUE) at the end of read_scroll immediately re-wakes greedy // monsters. The port reproduces that quirk faithfully — see // TestHoldScrollGreedyMonsterQuirk. g := mkGameInput(t) tp := spawnAdjacent(g, 'Z') tp.Flags.Set(Awake) scr := newObject() scr.Kind = KindScroll scr.Which = int(ScrollHoldMonster) ch := give(g, scr) setInput(t, g, ch) g.readScroll() t.Logf("after scroll: flags=%o huh=%q", tp.Flags, g.Msgs.Huh) if tp.On(Awake) || !tp.On(Held) { t.Error("hold monster scroll did not hold the adjacent monster") } } // TestHoldScrollGreedyMonsterQuirk documents a C behavior the port keeps: // wake_monster's ISGREED branch lacks an ISHELD guard, so a greedy monster // (orc) held by a scroll is re-woken by the look(TRUE) that read_scroll // performs, ending up both held and running again. func TestHoldScrollGreedyMonsterQuirk(t *testing.T) { t.Parallel() g := mkGameInput(t) tp := spawnAdjacent(g, 'O') tp.Flags.Set(Awake) scr := newObject() scr.Kind = KindScroll scr.Which = int(ScrollHoldMonster) ch := give(g, scr) setInput(t, g, ch) g.readScroll() t.Logf("orc after scroll: flags=%o (Awake=%v Held=%v)", tp.Flags, tp.On(Awake), tp.On(Held)) if !tp.On(Held) { t.Error("orc lost Held entirely") } if !tp.On(Awake) { t.Error("quirk changed: greedy monster stayed held; if this is a " + "deliberate fix, update this test and ARCHITECTURE.md") } } func TestZapSlowMonster(t *testing.T) { t.Parallel() g := mkGameInput(t) tp := spawnAdjacent(g, 'Z') stick := newObject() stick.Kind = KindWand stick.Which = int(WandSlowMonster) g.fixStick(stick) ch := give(g, stick) setInput(t, g, ch) g.Delta = Coord{X: 1, Y: 0} // aim at the monster charges := stick.Charges g.doZap() if !tp.On(Slowed) { t.Error("slow monster wand did not slow") } if stick.Charges != charges-1 { t.Error("zap did not use a charge") } } // bizarreSchtick is C's message for a zap that matched no case at all // (sticks.c do_zap, the "otherwise" arm). Shared by the pair of tests // below so that the one asserting it appears and the one asserting it // does not can never drift apart. const bizarreSchtick = "what a bizarre schtick!" // TestZapUnhandledWandSaysBizarreSchtick pins the closing arm of C's zap // switch. Every WS_ kind has a case, so the arm is reachable only for an // o_which outside the table — here a wand one past the end, the state a // corrupt save file can still describe. C's message is not gated on the // wizard flag, only on the MASTER build this port is, so no test setup // turns it on. func TestZapUnhandledWandSaysBizarreSchtick(t *testing.T) { t.Parallel() g := mkGameInput(t) wand := malformed(KindWand) wand.Charges = 3 ch := give(g, wand) setInput(t, g, ch) g.Msgs.Huh = "" g.doZap() if g.Msgs.Huh != bizarreSchtick { t.Errorf("message = %q, want %q", g.Msgs.Huh, bizarreSchtick) } // C falls out of the switch into o_charges-- from the otherwise arm // as much as from any other. if wand.Charges != 2 { t.Errorf("charges = %d after zapping, want 2", wand.Charges) } } // TestZapWandOfNothingIsSilent is the other half, and the reason the // message cannot simply be attached to "no handler ran". WS_NOP is a case // of C's switch in its own right — "when WS_NOP: break;" — so the wand // that does nothing does it quietly, and only a kind C had no case for // is bizarre. func TestZapWandOfNothingIsSilent(t *testing.T) { t.Parallel() g := mkGameInput(t) stick := newObject() stick.Kind = KindWand stick.Which = int(WandNothing) g.fixStick(stick) ch := give(g, stick) setInput(t, g, ch) charges := stick.Charges g.Msgs.Huh = "" g.doZap() if g.Msgs.Huh == bizarreSchtick { t.Errorf("the wand of nothing said %q; WS_NOP is a case of C's "+ "switch, not an unhandled kind", bizarreSchtick) } if stick.Charges != charges-1 { t.Error("zap did not use a charge") } } func TestParseOpts(t *testing.T) { t.Parallel() g := New(Params{Seed: 1}) g.ParseOpts("terse,nojump,name=Conan,fruit=mango,inven=slow") if !g.Options.Terse { t.Error("terse not set") } if g.Options.Jump { t.Error("nojump not honored") } if g.Whoami != "Conan" { t.Errorf("name = %q", g.Whoami) } if g.Fruit != "mango" { t.Errorf("fruit = %q", g.Fruit) } if g.Options.InvType != InvSlow { t.Errorf("inven = %d", g.Options.InvType) } }