//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07) package game import ( "slices" "strings" "testing" ) // The zap and bolt tests need a map they can reason about: real levels // put their rooms wherever rooms.c felt like, and sticks.c's geometry — // which square a bolt bounces off, which room a drain reaches — only // means anything against known walls, a known door, and a known passage // number. mkCarvedGame lays out two rooms joined by one corridor, using // the generator's own drawRoom so the wall characters (including the // '-' corners horiz() paints over vert()'s '|') are what a real level // would have: // // x: 1 20 40 59 // y=1 -------------------- ------------------ // |..................| |................| // y=4 |..................+########+................| // |..................| |................| // y=8 -------------------- ------------------ const ( carvedWidth = 20 // room width, both walls included carvedHeight = 8 // room height, both walls included roomAX = 1 // left wall of the west room roomBX = 40 // left wall of the east room carvedTopY = 1 // top wall of both rooms corridorY = 4 // row the corridor and doors run on doorAX = roomAX + carvedWidth - 1 // east wall of the west room carvedPass = 2 // passage number of the corridor ) // saveProofLvl makes save_throw(VS_MAGIC) succeed on every roll, so a // test can select the "it saved" arm without touching the RNG: C's // threshold is 14 + VS_MAGIC - lvl/2, which at level 40 is -3, and // roll(1,20) always clears that. const saveProofLvl = 40 // mkCarvedGame builds a game on the hand-carved level drawn above, with // the hero standing in the south-east corner of the west room — off // every row and column the bolt tests fire along. func mkCarvedGame(t *testing.T, seed int32) *RogueGame { t.Helper() g := New(Params{Seed: seed, Term: &testTerm{}}) for i := range g.Level.Places { g.Level.Places[i] = Place{Ch: ' ', Flags: FReal} } for i, x := range [...]int{roomAX, roomBX} { rp := &g.Level.Rooms[i] *rp = Room{ Pos: Coord{X: x, Y: carvedTopY}, Max: Coord{X: carvedWidth, Y: carvedHeight}, } g.drawRoom(rp) } for i := 2; i < MaxRooms; i++ { g.Level.Rooms[i].Flags = Gone // rooms that are not there } for x := doorAX + 1; x < roomBX; x++ { pp := g.Level.At(corridorY, x) pp.Ch = Passage pp.Flags = FReal | FPassage | carvedPass } // Doors carry the passage number in their low bits but not F_PASS, // exactly as passages.c numpass leaves them; roomin therefore reports // the room a door belongs to, and drain's corp lookup finds the // passage behind it. for _, x := range [...]int{doorAX, roomBX} { pp := g.Level.At(corridorY, x) pp.Ch = Door pp.Flags = FReal | carvedPass } placeHero(g, Coord{X: roomAX + 17, Y: carvedTopY + 6}) return g } // placeHero moves the hero and keeps proom, oldpos and oldrp in step, // the way move.c and misc.c look do; a --More-- prompt redraws through // look, which reads all three. func placeHero(g *RogueGame, pos Coord) { g.Player.Pos = pos g.Player.Room = g.roomIn(pos) g.Oldpos = pos g.Oldrp = g.Player.Room } // putMonster drops a monster of the given letter on a carved-level spot. func putMonster(g *RogueGame, typ byte, pos Coord) *Monster { tp := &Monster{} g.newMonster(tp, typ, pos) return tp } // pinRng rewinds the generator to a state whose next draw is exactly // want, so tests can choose a save-throw outcome or a polymorph letter // without assuming anything about the generator itself: the wanted // value is found by running the real Rng, not by predicting it. func pinRng(t *testing.T, g *RogueGame, draw func(*Rng) int, want int) { t.Helper() for s := int32(1); s < 100000; s++ { probe := Rng{Seed: s} if draw(&probe) == want { g.Rng.Seed = s return } } t.Fatalf("no seed found whose next draw is %d", want) } // d20 is the save_throw draw (monsters.c save_throw: roll(1, 20)). func d20(r *Rng) int { return r.Roll(1, 20) } // zapWand builds a wand of the given kind with charges to spare. func zapWand(kind WandKind) *Object { obj := newObject() obj.Kind = KindWand obj.Which = int(kind) obj.Charges = 5 return obj } // TestZapLightLightsTheRoom covers the WS_LIGHT arm: the room loses // ISDARK, the wand identifies itself, and the message is C's two-part // one (sticks.c 71-89). func TestZapLightLightsTheRoom(t *testing.T) { t.Parallel() g := mkCarvedGame(t, 11) g.Player.Room.Flags.Set(Dark) g.zapLight(zapWand(WandLight)) if g.Player.Room.Flags.Has(Dark) { t.Error("the room is still dark after a wand of light") } if !g.Items.Sticks[WandLight].Know { t.Error("the wand of light did not identify itself") } const want = "the room is lit by a shimmering blue light" if g.Msgs.Huh != want { t.Errorf("message = %q, want %q", g.Msgs.Huh, want) } } // TestZapLightInPassageFades covers the ISGONE arm: a corridor is not a // room, so nothing is lit and the wand still becomes known. func TestZapLightInPassageFades(t *testing.T) { t.Parallel() g := mkCarvedGame(t, 12) placeHero(g, Coord{X: doorAX + 3, Y: corridorY}) g.Level.Rooms[0].Flags.Set(Dark) g.zapLight(zapWand(WandLight)) if !g.Player.Room.Flags.Has(Gone) { t.Fatal("the hero is not in a passage; the test set-up is wrong") } const want = "the corridor glows and then fades" if g.Msgs.Huh != want { t.Errorf("message = %q, want %q", g.Msgs.Huh, want) } if !g.Items.Sticks[WandLight].Know { t.Error("the wand of light did not identify itself in a corridor") } if !g.Level.Rooms[0].Flags.Has(Dark) { t.Error("zapping in a corridor lit a room anyway") } } // TestZapDrainLifeTooWeakKeepsCharge covers C's early return: under two // hit points the zap is refused, and because C returns before the // switch falls out, o_charges-- never runs. func TestZapDrainLifeTooWeakKeepsCharge(t *testing.T) { t.Parallel() g := mkCarvedGame(t, 13) g.Player.Stats.HP = 1 wand := zapWand(WandDrainLife) ch := give(g, wand) setInput(t, g, ch) g.doZap() const want = "you are too weak to use it" if g.Msgs.Huh != want { t.Errorf("message = %q, want %q", g.Msgs.Huh, want) } if wand.Charges != 5 { t.Errorf("charges = %d, want 5: the refused zap must not cost one", wand.Charges) } if g.Player.Stats.HP != 1 { t.Errorf("hit points = %d, want 1", g.Player.Stats.HP) } } // TestDrainSplitsHitPoints covers sticks.c drain: the hero loses half // his hit points and the drainees each lose that half divided by their // number — monsters out of reach lose nothing. func TestDrainSplitsHitPoints(t *testing.T) { t.Parallel() g := mkCarvedGame(t, 14) placeHero(g, Coord{X: 5, Y: 3}) g.Player.Stats.HP = 20 near := [2]*Monster{ putMonster(g, 'Z', Coord{X: 7, Y: 3}), putMonster(g, 'Z', Coord{X: 9, Y: 5}), } far := putMonster(g, 'Z', Coord{X: roomBX + 5, Y: 3}) for _, tp := range []*Monster{near[0], near[1], far} { tp.Stats.HP = 100 } g.drain() if g.Player.Stats.HP != 10 { t.Errorf("hero hit points = %d, want 10", g.Player.Stats.HP) } // 10 hit points spread over two drainees is 5 apiece. for i, tp := range near { if tp.Stats.HP != 95 { t.Errorf("drainee %d hit points = %d, want 95", i, tp.Stats.HP) } } if far.Stats.HP != 100 { t.Errorf("the monster in the other room lost %d hit points", 100-far.Stats.HP) } } // TestDrainWithNoTargetsCostsNothing covers the cnt == 0 arm, which // returns before pstats.s_hpt is halved. func TestDrainWithNoTargetsCostsNothing(t *testing.T) { t.Parallel() g := mkCarvedGame(t, 15) placeHero(g, Coord{X: 5, Y: 3}) g.Player.Stats.HP = 20 g.drain() const want = "you have a tingling feeling" if g.Msgs.Huh != want { t.Errorf("message = %q, want %q", g.Msgs.Huh, want) } if g.Player.Stats.HP != 20 { t.Errorf("hero hit points = %d, want 20: a drain that found nobody "+ "returns before halving them", g.Player.Stats.HP) } } // TestDrainKillsWeakMonster covers the other arm of drain's zot loop: a // drainee whose share of the hit points finishes it is killed outright. func TestDrainKillsWeakMonster(t *testing.T) { t.Parallel() g := mkCarvedGame(t, 29) placeHero(g, Coord{X: 5, Y: 3}) g.Player.Stats.HP = 20 tp := putMonster(g, 'Z', Coord{X: 7, Y: 3}) tp.Stats.HP = 3 // less than the ten points it is about to take g.drain() if len(g.Level.Monsters) != 0 { t.Error("the drained monster is still on the level") } if g.Level.MonsterAt(7, 3) != nil { t.Error("the drained monster is still on the map") } const want = "you have defeated the zombie" if g.Msgs.Huh != want { t.Errorf("message = %q, want %q", g.Msgs.Huh, want) } } // TestZapSpeedTogglesHasteAndSlow covers both WS_HASTE_M and WS_SLOW_M // in both directions: C cancels the opposite condition when it is // already on, and only otherwise applies its own. func TestZapSpeedTogglesHasteAndSlow(t *testing.T) { t.Parallel() tests := []struct { name string kind WandKind start CreatureFlags wantHasted bool wantSlowed bool wantTurn bool }{ {name: "haste a monster", kind: WandHasteMonster, wantHasted: true}, { name: "haste cancels a slow", kind: WandHasteMonster, start: Slowed, }, { name: "slow a monster", kind: WandSlowMonster, wantSlowed: true, wantTurn: true, }, { name: "slow cancels a haste", kind: WandSlowMonster, start: Hasted, wantTurn: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() g := mkCarvedGame(t, 30) placeHero(g, Coord{X: 5, Y: corridorY}) g.Delta = Coord{X: 1, Y: 0} tp := putMonster(g, 'Z', Coord{X: 8, Y: corridorY}) tp.Flags.Clear(Hasted | Slowed) tp.Flags.Set(tt.start) tp.Turn = false // only the slow arm sets t_turn g.zapSpeed(zapWand(tt.kind)) if tp.On(Hasted) != tt.wantHasted { t.Errorf("hasted = %v, want %v", tp.On(Hasted), tt.wantHasted) } if tp.On(Slowed) != tt.wantSlowed { t.Errorf("slowed = %v, want %v", tp.On(Slowed), tt.wantSlowed) } if tp.Turn != tt.wantTurn { t.Errorf("turn = %v, want %v", tp.Turn, tt.wantTurn) } if !tp.On(Awake) { t.Error("the zapped monster was not set running") } }) } } // TestDrainReaches pins the three-clause drainee test of sticks.c drain // one clause at a time: the hero's own room, the passage behind the door // he stands on (corp), and — only when he is in a passage — a door of // that same passage. func TestDrainReaches(t *testing.T) { t.Parallel() tests := []struct { name string heroPos Coord monstPos Coord want bool }{ { name: "same room", heroPos: Coord{X: 5, Y: 3}, monstPos: Coord{X: 9, Y: 6}, want: true, }, { name: "different room", heroPos: Coord{X: 5, Y: 3}, monstPos: Coord{X: roomBX + 5, Y: 3}, want: false, }, { name: "hero on a door reaches into that passage", heroPos: Coord{X: doorAX, Y: corridorY}, monstPos: Coord{X: doorAX + 4, Y: corridorY}, want: true, }, { name: "hero in the passage reaches its doors", heroPos: Coord{X: doorAX + 4, Y: corridorY}, monstPos: Coord{X: roomBX, Y: corridorY}, want: true, }, { name: "hero in the passage does not reach into a room", heroPos: Coord{X: doorAX + 4, Y: corridorY}, monstPos: Coord{X: roomBX + 5, Y: 3}, want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() g := mkCarvedGame(t, 16) placeHero(g, tt.heroPos) tp := putMonster(g, 'Z', tt.monstPos) var corp *Room if g.Level.Char(tt.heroPos.Y, tt.heroPos.X) == Door { corp = &g.Level.Passages[*g.Level.FlagsAt( tt.heroPos.Y, tt.heroPos.X)&FPassNum] } inpass := g.Player.Room.Flags.Has(Gone) if got := g.drainReaches(tp, corp, inpass); got != tt.want { t.Errorf("drainReaches = %v, want %v (inpass=%v corp=%v)", got, tt.want, inpass, corp != nil) } }) } } // TestZapInvisibilityHidesMonster covers the WS_INVIS arm. func TestZapInvisibilityHidesMonster(t *testing.T) { t.Parallel() g := mkCarvedGame(t, 17) placeHero(g, Coord{X: 5, Y: corridorY}) g.Delta = Coord{X: 1, Y: 0} tp := putMonster(g, 'Z', Coord{X: 8, Y: corridorY}) g.zapInvisibility(zapWand(WandInvisibility)) if !tp.On(Invisible) { t.Error("the zapped monster is still visible") } } // TestZapVictimReleasesFlytrap covers the shared preamble of C's // invisibility family: the flytrap holding the hero lets go the moment // the ray reaches it, whichever of those wands was zapped. func TestZapVictimReleasesFlytrap(t *testing.T) { t.Parallel() g := mkCarvedGame(t, 18) placeHero(g, Coord{X: 5, Y: corridorY}) g.Delta = Coord{X: 1, Y: 0} g.Player.Flags.Set(Held) tp := putMonster(g, 'F', Coord{X: 6, Y: corridorY}) g.zapInvisibility(zapWand(WandInvisibility)) if g.Player.On(Held) { t.Error("the flytrap still holds the hero after the zap") } if !tp.On(Invisible) { t.Error("the flytrap was not made invisible") } } // TestZapPolymorphReplacesMonster covers the WS_POLYMORPH arm and its // detach/re-attach dance: the creature keeps its identity (the same // THING, its pack, and the character it is standing on) but becomes a // different monster, listed once and standing where it stood. func TestZapPolymorphReplacesMonster(t *testing.T) { t.Parallel() g := mkCarvedGame(t, 19) placeHero(g, Coord{X: 5, Y: corridorY}) g.Delta = Coord{X: 1, Y: 0} pos := Coord{X: 8, Y: corridorY} tp := putMonster(g, 'K', pos) loot := newObject() loot.Kind = KindPotion tp.Pack = []*Object{loot} tp.OldCh = Stairs // it is standing on the staircase const want = 'T' pinRng(t, g, func(r *Rng) int { return r.Rnd(26) }, int(want-'A')) g.zapPolymorph(zapWand(WandPolymorph)) if tp.Type != want || tp.Disguise != want { t.Errorf("monster is %q/%q after polymorph, want %q", tp.Type, tp.Disguise, want) } if tp.Stats.Lvl != g.Monsters[want-'A'].Stats.Lvl { t.Errorf("level = %d, want the troll's %d: new_monster did not "+ "re-roll the stats", tp.Stats.Lvl, g.Monsters[want-'A'].Stats.Lvl) } if len(tp.Pack) != 1 || tp.Pack[0] != loot { t.Error("polymorph lost the monster's pack") } if tp.OldCh != Stairs { t.Errorf("under-character = %q, want %q", tp.OldCh, Stairs) } if g.Level.MonsterAt(pos.Y, pos.X) != tp || tp.Pos != pos { t.Error("the polymorphed monster is not where it stood") } if n := len(g.Level.Monsters); n != 1 { t.Errorf("monster list holds %d entries, want 1: detach and "+ "new_monster's attach must balance", n) } if !g.Items.Sticks[WandPolymorph].Know { t.Error("a polymorph the hero watched did not identify the wand") } } // TestZapPolymorphClobbersDelta pins a C quirk the port keeps: do_zap // reuses the global delta as scratch for new_monster's coordinate, so // the zap direction is gone by the time the arm returns. func TestZapPolymorphClobbersDelta(t *testing.T) { t.Parallel() g := mkCarvedGame(t, 20) placeHero(g, Coord{X: 5, Y: corridorY}) g.Delta = Coord{X: 1, Y: 0} pos := Coord{X: 8, Y: corridorY} putMonster(g, 'K', pos) g.zapPolymorph(zapWand(WandPolymorph)) if g.Delta != pos { t.Errorf("delta = %v after polymorph, want the victim's %v", g.Delta, pos) } } // TestZapCancellationClearsSpecials covers the WS_CANCEL arm. CANHUH is // set on the player and never on a monster in C (only scrolls.c sets // it), so the test puts it on by hand: the clear is written to take both // bits and the port must keep doing so. func TestZapCancellationClearsSpecials(t *testing.T) { t.Parallel() g := mkCarvedGame(t, 21) placeHero(g, Coord{X: 5, Y: corridorY}) g.Delta = Coord{X: 1, Y: 0} tp := putMonster(g, 'M', Coord{X: 8, Y: corridorY}) tp.Flags.Set(Invisible | CanConfuse) g.zapCancellation(zapWand(WandCancellation)) if !tp.On(Cancelled) { t.Error("the monster was not cancelled") } if tp.On(Invisible) { t.Error("cancellation left the monster invisible") } if tp.On(CanConfuse) { t.Error("cancellation left the monster able to confuse") } // t_disguise = t_type is an identity for every monster a zap ray can // actually stop on: the one disguised kind, the xeroc, looks like an // item, and step_ok is true for item characters, so the ray walks // straight past it. Pinned anyway, because C assigns it. if tp.Disguise != tp.Type { t.Errorf("disguise = %q, want %q", tp.Disguise, tp.Type) } } // TestZapTeleportToPullsMonsterIn covers WS_TELTO: the victim lands on // hero + delta, which is the square next to the hero along the ray. func TestZapTeleportToPullsMonsterIn(t *testing.T) { t.Parallel() g := mkCarvedGame(t, 22) hero := Coord{X: 5, Y: corridorY} placeHero(g, hero) g.Delta = Coord{X: 1, Y: 0} from := Coord{X: 8, Y: corridorY} tp := putMonster(g, 'Z', from) g.zapTeleport(zapWand(WandTeleportTo)) want := Coord{X: hero.X + 1, Y: hero.Y} if tp.Pos != want { t.Errorf("monster at %v after teleport-to, want %v", tp.Pos, want) } if g.Level.MonsterAt(want.Y, want.X) != tp { t.Error("the map does not have the monster at its new spot") } if g.Level.MonsterAt(from.Y, from.X) != nil { t.Error("the monster is still on the map where it came from") } if tp.Dest != &g.Player.Pos { t.Error("the teleported monster is not chasing the hero") } if !tp.On(Awake) { t.Error("the teleported monster was not woken") } } // TestZapTeleportAwayMovesMonsterOff covers WS_TELAWAY, whose C loop // re-draws until the spot is not the hero's own. func TestZapTeleportAwayMovesMonsterOff(t *testing.T) { t.Parallel() g := mkCarvedGame(t, 23) hero := Coord{X: 5, Y: corridorY} placeHero(g, hero) g.Delta = Coord{X: 1, Y: 0} from := Coord{X: 8, Y: corridorY} tp := putMonster(g, 'Z', from) g.zapTeleport(zapWand(WandTeleportAway)) if tp.Pos == from { t.Error("teleport away did not move the monster") } if tp.Pos == hero { t.Error("teleport away dropped the monster onto the hero") } if g.Level.Char(tp.Pos.Y, tp.Pos.X) != Floor { t.Errorf("monster landed on %q, want floor", g.Level.Char(tp.Pos.Y, tp.Pos.X)) } if g.Level.MonsterAt(from.Y, from.X) != nil { t.Error("the monster is still on the map where it came from") } } // vanishMsg is what C says when the missile finds nobody to hit, with // the original spelling of "missile" intact (sticks.c 191). // //nolint:misspell // C's spelling, kept faithfully const vanishMsg = "the missle vanishes with a puff of smoke" // TestZapMagicMissile covers WS_MISSILE both ways: a victim that saves // gets C's puff-of-smoke message and no damage, one that does not is // hit by a bolt whose o_hplus of 100 cannot miss. func TestZapMagicMissile(t *testing.T) { t.Parallel() tests := []struct { name string lvl int wantMsg bool }{ {name: "victim saves", lvl: saveProofLvl, wantMsg: true}, {name: "victim is hit", lvl: 1, wantMsg: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() g := mkCarvedGame(t, 24) placeHero(g, Coord{X: 5, Y: corridorY}) g.Delta = Coord{X: 1, Y: 0} tp := putMonster(g, 'Z', Coord{X: 8, Y: corridorY}) tp.Stats.Lvl = tt.lvl tp.Stats.HP = 500 pinRng(t, g, d20, 1) // the lowest save throw there is g.zapMagicMissile(zapWand(WandMagicMissile)) if got := g.Msgs.Huh == vanishMsg; got != tt.wantMsg { t.Errorf("message = %q, want vanish = %v", g.Msgs.Huh, tt.wantMsg) } if hurt := tp.Stats.HP < 500; hurt == tt.wantMsg { t.Errorf("hit points = %d, want damage = %v", tp.Stats.HP, !tt.wantMsg) } if !g.Items.Sticks[WandMagicMissile].Know { t.Error("the magic missile wand did not identify itself") } }) } } // TestFixStickDamage covers the strcmp against ws_type: a staff swings // for 2x3, everything else for 1x1, and both hurl for 1x1. func TestFixStickDamage(t *testing.T) { t.Parallel() tests := []struct { material string want string }{ {material: staffName, want: "2x3"}, {material: wandName, want: "1x1"}, } for _, tt := range tests { t.Run(tt.material, func(t *testing.T) { t.Parallel() g := mkCarvedGame(t, 25) g.Items.WandType[WandCold] = tt.material cur := newObject() cur.Kind = KindWand cur.Which = int(WandCold) g.fixStick(cur) if !slices.Equal(cur.Damage, dice(tt.want)) { t.Errorf("damage = %v, want %v", cur.Damage, tt.want) } if !slices.Equal(cur.HurlDmg, dice("1x1")) { t.Errorf("hurl damage = %v, want 1x1", cur.HurlDmg) } }) } } // TestFixStickCharges covers the charge switch. C is rnd(10)+10 for the // wand of light and rnd(5)+3 for everything else, so both ends of both // ranges must show up over enough draws and nothing outside them ever. func TestFixStickCharges(t *testing.T) { t.Parallel() tests := []struct { name string kind WandKind lo, hi int }{ {name: "light", kind: WandLight, lo: 10, hi: 19}, {name: "other", kind: WandCold, lo: 3, hi: 7}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() g := mkCarvedGame(t, 26) lo, hi := 1<<30, -1 for range 500 { cur := newObject() cur.Kind = KindWand cur.Which = int(tt.kind) g.fixStick(cur) lo = min(lo, cur.Charges) hi = max(hi, cur.Charges) } if lo != tt.lo || hi != tt.hi { t.Errorf("charges ranged over %d..%d, want %d..%d", lo, hi, tt.lo, tt.hi) } }) } } // TestChargeStr covers sticks.c charge_str: nothing at all until the // stick is known, then the terse or verbose bracket. func TestChargeStr(t *testing.T) { t.Parallel() tests := []struct { name string known bool terse bool want string }{ {name: "unknown", known: false, terse: false, want: ""}, {name: "unknown and terse", known: false, terse: true, want: ""}, {name: "known", known: true, terse: false, want: " [7 charges]"}, {name: "known and terse", known: true, terse: true, want: " [7]"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() g := mkCarvedGame(t, 27) g.Options.Terse = tt.terse obj := zapWand(WandCold) obj.Charges = 7 if tt.known { obj.Flags.Set(Known) } if got := chargeStr(g, obj); got != tt.want { t.Errorf("chargeStr = %q, want %q", got, tt.want) } }) } } // TestZapBoltNames covers the name each of the three bolt wands fires // under (sticks.c 225-231), read back out of the weapon table entry // fire_bolt overwrites and out of the bounce message. func TestZapBoltNames(t *testing.T) { t.Parallel() tests := []struct { kind WandKind want string }{ {kind: WandLightning, want: boltName}, {kind: WandFire, want: flameName}, {kind: WandCold, want: iceName}, } for _, tt := range tests { t.Run(tt.want, func(t *testing.T) { t.Parallel() g := mkCarvedGame(t, 28) placeHero(g, Coord{X: roomAX + 1, Y: corridorY}) g.Player.Stats.Lvl = saveProofLvl // never hurt by the rebound g.Delta = Coord{X: -1, Y: 0} // straight at the west wall g.zapBolt(zapWand(tt.kind)) if got := g.Items.Weapons[WeaponFlame].Name; got != tt.want { t.Errorf("weapon name = %q, want %q", got, tt.want) } if !strings.Contains(g.Msgs.Huh, tt.want) { t.Errorf("message = %q, want it to name the %q", g.Msgs.Huh, tt.want) } if !g.Items.Sticks[tt.kind].Know { t.Error("the bolt wand did not identify itself") } }) } }