Files
rgoue/game/effects_test.go
sneak 3f0a14c5e6 WIP: restore lost C behaviors (schtick message, forced redraw, greeting)
Incomplete checkpoint, preserved after the implementing session hit a
capacity limit partway through mutation verification. NOT reviewed and NOT
gate-clean: make fmt has not been run, so fmt-check currently fails on
ARCHITECTURE.md and TODO.md.

Work still outstanding before this is fit for review:
  - run make fmt and fold the result in
  - finish mutation-proving each of the three behaviors
  - verify every message string byte-for-byte against the C sources
  - confirm TestSeedCompatItemTables passes with its golden untouched

Refs #13.
2026-08-09 09:55:38 +00:00

291 lines
6.7 KiB
Go

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