Test the wands, staffs and bolt geometry of sticks.c (closes #6) #35

Merged
clawbot merged 1 commits from test/sticks-coverage into main 2026-08-09 17:23:54 +02:00
3 changed files with 1408 additions and 2 deletions

39
TODO.md
View File

@@ -29,12 +29,47 @@ Refactor ground rules:
# Next Step # Next Step
Broaden unit test coverage where playtesting finds thin spots — sticks (#6) and Broaden unit test coverage where playtesting finds thin spots — wizard commands
wizard commands (#7). Rings, the first third of this step, is done; see the top (#7). Rings and sticks, the first two thirds of this step, are done; see the top
of Completed Steps. of Completed Steps.
# Completed Steps # Completed Steps
- 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
— and now has `game/sticks_test.go` (the zap handlers, `drain`, `fix_stick`,
`charge_str`) and `game/bolt_test.go` (the `fire_bolt` geometry). Every
expectation was read out of `sticks.c` rather than off the Go code; **no
divergence from C was found**, and three things worth knowing came out of the
reading. (1) **The bolt trail is the test instrument.** `fire_bolt` paints
each square with `dirch` and then paints `chat()` back over every square it
recorded, so on a screen nothing else has drawn on, the non-blank cells
afterwards are exactly the squares the bolt occupied — and the walls it
bounced off are absent, because C undoes the record with `c1--` and `break`s
before the `mvaddch`. That gives an exact assertion of the path and the
resting place without touching game code, and it is why the tests fire from a
square that is not the hero's (which is what `chase.c` does for dragon
breath): with the hero off the ray the run produces one message and the screen
stays readable. (2) **A bounce reverses both components of the direction, not
one.** A bolt entering a wall at 45 degrees goes back the way it came instead
of reflecting off the surface, so the diagonal-into-a-vertical-wall case is
the one that separates C's rule from the plausible wrong one, and it is
tested. (3) **The `ch != 'M'` guard on the miss message is a tautology.** `ch`
comes from `winat`, and `winat` _is_ `t_disguise` when a monster stands there
(`rogue.h` 57), so `ch == 'M'` implies `t_disguise == 'M'` and the arm can
never go quiet; it is vestigial from when 'M' was the mimic, and the test pins
the port to speaking, so nobody "tidies" it into a real silence. The
door-under-hero exception has no assertion of its own because it cannot have
one: without it the bolt bounces on the hero's own square forever, recording
nothing, and `fire_bolt` never returns — the test for it hangs rather than
fails, which the comment on it says. Determinism comes from a `pinRng` helper
that searches for a seed whose next draw is the wanted value (running the real
`Rng`, never predicting it) and from a level the tests carve themselves
through `drawRoom`, since bounce geometry and `drain`'s room/passage/door
reach only mean something against known walls and a known passage number. All
27 mutations tried against the new tests were caught.
- 2026-08-09 Ring unit-test coverage (`test/rings-coverage`, closes #5): the - 2026-08-09 Ring unit-test coverage (`test/rings-coverage`, closes #5): the
first third of the standing coverage step. `game/rings.go` had **zero** tests first third of the standing coverage step. `game/rings.go` had **zero** tests
— not one of the 32 in the suite touched wear, removal, hand choice, or the — not one of the 32 in the suite touched wear, removal, hand choice, or the

497
game/bolt_test.go Normal file
View File

@@ -0,0 +1,497 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import (
"slices"
"testing"
)
// The bolt geometry of sticks.c fire_bolt, tested on the hand-carved
// level built by mkCarvedGame in sticks_test.go.
//
// Most of these fire from a square that is not the hero's, which is what
// chase.c does when a dragon breathes (fire_bolt(&th->t_pos, ...)). That
// keeps the hero off the ray, so the run produces exactly one message
// and the screen stays readable as a record of where the bolt went — see
// litCells.
// The three names sticks.c fires a bolt under (do_zap's WS_ELECT,
// WS_FIRE and WS_COLD arms); fire_bolt prints them and hangs them on the
// FLAME weapon-table entry.
const (
boltName = "bolt"
flameName = "flame"
iceName = "ice"
)
// litCells reports every non-blank cell of the map area of the screen.
//
// fire_bolt paints its trail with dirch and then erases it by writing
// back chat() for each square it recorded, so on a screen nothing else
// has drawn on, the squares left non-blank are exactly the ones the bolt
// occupied. Squares it bounced off are absent by construction: C undoes
// the record with c1-- and breaks before the mvaddch, so a wall is
// neither painted nor erased.
func litCells(g *RogueGame) []Coord {
var out []Coord
// Row 0 is the message line, not the map.
for y := 1; y < NumLines; y++ {
line := g.scr.Std.Line(y)
for x := range len(line) {
if line[x] != ' ' {
out = append(out, Coord{X: x, Y: y})
}
}
}
return out
}
// assertErased checks that every square the bolt flew over is showing
// the map character underneath it again: fire_bolt's closing loop paints
// chat() back over the whole trail, so a bolt leaves no '/' or '\'
// behind.
func assertErased(t *testing.T, g *RogueGame, cells []Coord) {
t.Helper()
for _, c := range cells {
got := g.scr.Std.Line(c.Y)[c.X]
if want := g.Level.Char(c.Y, c.X); got != want {
t.Errorf("square %v shows %q, want the map's %q: the trail "+
"was not erased", c, got, want)
}
}
}
// TestBoltDirChar covers the dirch switch for all eight directions. C
// keys it on dir->y + dir->x: the two sums of zero are the '/' pair, the
// two of magnitude two are the '\' pair, and the four axis directions
// split on whether y is zero.
func TestBoltDirChar(t *testing.T) {
t.Parallel()
tests := []struct {
name string
dir Coord
want byte
}{
{name: "north", dir: Coord{X: 0, Y: -1}, want: '|'},
{name: "south", dir: Coord{X: 0, Y: 1}, want: '|'},
{name: "east", dir: Coord{X: 1, Y: 0}, want: '-'},
{name: "west", dir: Coord{X: -1, Y: 0}, want: '-'},
{name: "north east", dir: Coord{X: 1, Y: -1}, want: '/'},
{name: "south west", dir: Coord{X: -1, Y: 1}, want: '/'},
{name: "north west", dir: Coord{X: -1, Y: -1}, want: '\\'},
{name: "south east", dir: Coord{X: 1, Y: 1}, want: '\\'},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := boltDirChar(tt.dir); got != tt.want {
t.Errorf("boltDirChar(%v) = %q, want %q", tt.dir, got, tt.want)
}
})
}
}
// TestBoltBounces covers the case labels a bolt reflects off, and the
// door exception: C jumps to the default arm when the hero is standing
// on the door, "otherwise it would loop infinitely".
func TestBoltBounces(t *testing.T) {
t.Parallel()
const heroX, heroY = 5, 5
tests := []struct {
name string
ch byte
pos Coord
want bool
}{
{name: "vertical wall", ch: '|', pos: Coord{X: 6, Y: 5}, want: true},
{name: "horizontal wall", ch: '-', pos: Coord{X: 6, Y: 5}, want: true},
{name: "solid rock", ch: ' ', pos: Coord{X: 6, Y: 5}, want: true},
{name: "door", ch: Door, pos: Coord{X: 6, Y: 5}, want: true},
{
name: "the door under the hero",
ch: Door,
pos: Coord{X: heroX, Y: heroY},
want: false,
},
{name: "floor", ch: Floor, pos: Coord{X: 6, Y: 5}, want: false},
{name: "passage", ch: Passage, pos: Coord{X: 6, Y: 5}, want: false},
{name: "staircase", ch: Stairs, pos: Coord{X: 6, Y: 5}, want: false},
{name: "a monster", ch: 'Z', pos: Coord{X: 6, Y: 5}, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
hero := Coord{X: heroX, Y: heroY}
if got := boltBounces(tt.ch, hero, tt.pos); got != tt.want {
t.Errorf("boltBounces(%q) = %v, want %v", tt.ch, got, tt.want)
}
})
}
}
// TestFireBoltFliesStraight is the end-to-end run with nothing in the
// way: six squares, BOLT_LENGTH of them, and the last one is where the
// bolt stops.
func TestFireBoltFliesStraight(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 41)
dir := Coord{X: 1, Y: 0}
g.fireBolt(Coord{X: 2, Y: 2}, &dir, flameName)
want := []Coord{
{X: 3, Y: 2}, {X: 4, Y: 2}, {X: 5, Y: 2},
{X: 6, Y: 2}, {X: 7, Y: 2}, {X: 8, Y: 2},
}
got := litCells(g)
if !slices.Equal(got, want) {
t.Errorf("bolt path = %v, want %v", got, want)
}
assertErased(t, g, got)
if g.Msgs.Huh != "" {
t.Errorf("a bolt that hit nothing said %q", g.Msgs.Huh)
}
if (dir != Coord{X: 1, Y: 0}) {
t.Errorf("direction = %v, want it unchanged", dir)
}
}
// TestFireBoltBounces covers the reflection rule in both wall
// orientations, off a corner, and — the case that separates C's rule
// from a plausible wrong one — diagonally off a vertical wall. C negates
// *both* components, so a bolt that came in at 45 degrees goes back the
// way it came instead of reflecting off the surface.
// boltBounceCase is one wall-bounce run: where the bolt sets off, which
// way it goes, the wall it must reflect off, and the squares it must end
// up having occupied.
type boltBounceCase struct {
name string
start Coord
dir Coord
wall Coord
want []Coord
}
// run fires the case's bolt and checks its whole flight.
func (tt boltBounceCase) run(t *testing.T) {
t.Helper()
g := mkCarvedGame(t, 42)
dir := tt.dir
g.fireBolt(tt.start, &dir, flameName)
got := litCells(g)
if !slices.Equal(got, tt.want) {
t.Errorf("bolt path = %v, want %v", got, tt.want)
}
assertErased(t, g, got)
if slices.Contains(got, tt.wall) {
t.Errorf("the wall at %v was drawn on; C drops the bounce "+
"square from spotpos before the mvaddch", tt.wall)
}
if want := (Coord{X: -tt.dir.X, Y: -tt.dir.Y}); dir != want {
t.Errorf("direction = %v after one bounce, want %v", dir, want)
}
if g.Msgs.Huh != "the flame bounces" {
t.Errorf("message = %q, want %q", g.Msgs.Huh, "the flame bounces")
}
}
func TestFireBoltBounces(t *testing.T) {
t.Parallel()
tests := []boltBounceCase{
{
name: "off a vertical wall",
start: Coord{X: 3, Y: corridorY},
dir: Coord{X: -1, Y: 0},
wall: Coord{X: 1, Y: corridorY},
// Five squares, not six: the square in front of the wall is
// flown over twice, and C charges spotpos for both.
want: []Coord{
{X: 2, Y: 4}, {X: 3, Y: 4}, {X: 4, Y: 4},
{X: 5, Y: 4}, {X: 6, Y: 4},
},
},
{
name: "off a horizontal wall",
start: Coord{X: 5, Y: 2},
dir: Coord{X: 0, Y: -1},
wall: Coord{X: 5, Y: 1},
want: []Coord{
{X: 5, Y: 2}, {X: 5, Y: 3}, {X: 5, Y: 4},
{X: 5, Y: 5}, {X: 5, Y: 6}, {X: 5, Y: 7},
},
},
{
name: "off a corner",
start: Coord{X: 3, Y: 3},
dir: Coord{X: -1, Y: -1},
wall: Coord{X: 1, Y: 1},
want: []Coord{
{X: 2, Y: 2}, {X: 3, Y: 3}, {X: 4, Y: 4},
{X: 5, Y: 5}, {X: 6, Y: 6},
},
},
{
name: "diagonally off a vertical wall",
start: Coord{X: 3, Y: corridorY},
dir: Coord{X: -1, Y: -1},
wall: Coord{X: 1, Y: 2},
want: []Coord{
{X: 2, Y: 3}, {X: 3, Y: 4}, {X: 4, Y: 5},
{X: 5, Y: 6}, {X: 6, Y: 7},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
tt.run(t)
})
}
}
// TestFireBoltReboundsIntoHero covers the hit_hero/changed pair: a bolt
// the hero fires starts unable to hit him, and the first bounce flips
// that, so a wall one square away throws his own bolt back at him.
func TestFireBoltReboundsIntoHero(t *testing.T) {
t.Parallel()
tests := []struct {
name string
lvl int
wantMsg string
wantHurt bool
}{
{
name: "the hero saves",
lvl: saveProofLvl,
wantMsg: "the flame whizzes by you",
wantHurt: false,
},
{
name: "the hero is hit",
lvl: 1,
wantMsg: "you are hit by the flame",
wantHurt: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 43)
placeHero(g, Coord{X: roomAX + 1, Y: corridorY})
fortify(g) // a bolt to the face must not exit the test binary
g.Player.Stats.Lvl = tt.lvl
pinRng(t, g, d20, 1) // the lowest save throw there is
hp := g.Player.Stats.HP
dir := Coord{X: -1, Y: 0}
g.fireBolt(g.Player.Pos, &dir, flameName)
if g.Msgs.Huh != tt.wantMsg {
t.Errorf("message = %q, want %q", g.Msgs.Huh, tt.wantMsg)
}
lost := hp - g.Player.Stats.HP
if hurt := lost > 0; hurt != tt.wantHurt {
t.Errorf("hero lost %d hit points, want hurt = %v",
lost, tt.wantHurt)
}
// roll(6, 6) is six to thirty-six.
if tt.wantHurt && (lost < 6 || lost > 36) {
t.Errorf("hero lost %d hit points, want 6..36", lost)
}
})
}
}
// TestFireBoltFromDoorUnderHeroTerminates covers the guard C wrote the
// door case for: the hero standing on a door and firing into the wall
// that door sits in. Without the ce(hero, pos) exception the bolt
// bounces on his own square forever, never recording a spot and never
// filling spotpos, and fire_bolt does not return — this test hangs
// rather than fails if the exception is lost.
func TestFireBoltFromDoorUnderHeroTerminates(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 44)
placeHero(g, Coord{X: doorAX, Y: corridorY})
fortify(g)
pinRng(t, g, d20, 1) // no save: the strike ends the flight
hp := g.Player.Stats.HP
dir := Coord{X: 0, Y: -1} // north, into the wall the door is in
g.fireBolt(g.Player.Pos, &dir, boltName)
if g.Msgs.Huh != "you are hit by the bolt" {
t.Errorf("message = %q, want the hero to be hit", g.Msgs.Huh)
}
if lost := hp - g.Player.Stats.HP; lost < 6 || lost > 36 {
t.Errorf("hero lost %d hit points, want 6..36", lost)
}
}
// TestFireBoltStrikesMonster covers the monster arm both ways, and the
// dragon's immunity to flame that C spells out in the same breath.
func TestFireBoltStrikesMonster(t *testing.T) {
t.Parallel()
tests := []struct {
name string
typ byte
lvl int
bolt string
wantMsg string
wantHurt bool
}{
{
name: "it fails its save",
typ: 'Z',
lvl: 1,
bolt: boltName,
wantMsg: "the bolt hits the zombie",
wantHurt: true,
},
{
name: "it saves",
typ: 'Z',
lvl: saveProofLvl,
bolt: boltName,
wantMsg: "the bolt whizzes past the zombie",
wantHurt: false,
},
{
name: "a dragon shrugs off a flame",
typ: 'D',
lvl: 1,
bolt: flameName,
wantMsg: "the flame bounces off the dragon",
wantHurt: false,
},
{
name: "but not a lightning bolt",
typ: 'D',
lvl: 1,
bolt: boltName,
wantMsg: "the bolt hits the dragon",
wantHurt: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 45)
placeHero(g, Coord{X: 5, Y: corridorY})
tp := putMonster(g, tt.typ, Coord{X: 8, Y: corridorY})
tp.Stats.Lvl = tt.lvl
tp.Stats.HP = 500 // enough to survive 6x6 and stay assertable
pinRng(t, g, d20, 1) // the lowest save throw there is
dir := Coord{X: 1, Y: 0}
g.fireBolt(g.Player.Pos, &dir, tt.bolt)
if g.Msgs.Huh != tt.wantMsg {
t.Errorf("message = %q, want %q", g.Msgs.Huh, tt.wantMsg)
}
if hurt := tp.Stats.HP < 500; hurt != tt.wantHurt {
t.Errorf("monster hit points = %d, want hurt = %v",
tp.Stats.HP, tt.wantHurt)
}
})
}
}
// TestFireBoltMissedMonsterWakesUp covers the rest of the miss arm: a
// bolt the hero fired sets the monster running (runto) before it says
// what it whizzed past, and the bolt flies on for its full length.
func TestFireBoltMissedMonsterWakesUp(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 46)
placeHero(g, Coord{X: 5, Y: corridorY})
tp := putMonster(g, 'Z', Coord{X: 8, Y: corridorY})
tp.Stats.Lvl = saveProofLvl
tp.Flags.Clear(Awake)
dir := Coord{X: 1, Y: 0}
g.fireBolt(g.Player.Pos, &dir, boltName)
if !tp.On(Awake) {
t.Error("the monster the bolt missed is still asleep")
}
if tp.Dest != &g.Player.Pos {
t.Error("the woken monster is not chasing the hero")
}
if tp.OldCh != Floor {
t.Errorf("under-character = %q, want %q: fire_bolt records chat() "+
"before it resolves the save", tp.OldCh, Floor)
}
}
// TestFireBoltMissSpeaksEvenForAnM pins the "ch != 'M' ||
// tp->t_disguise == 'M'" guard on C's miss message, which reads as
// though something looking like an 'M' can be missed silently. It
// cannot: ch comes from winat, and winat *is* t_disguise whenever a
// monster stands there (rogue.h 57), so ch == 'M' implies
// t_disguise == 'M' and the condition is always true. The guard is
// vestigial — 'M' was the mimic in earlier Rogues — and a port that
// "tidied" it into a real silence would go quiet where C speaks.
func TestFireBoltMissSpeaksEvenForAnM(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 47)
placeHero(g, Coord{X: 5, Y: corridorY})
tp := putMonster(g, 'M', Coord{X: 8, Y: corridorY})
tp.Stats.Lvl = saveProofLvl
tp.Flags.Clear(Awake)
dir := Coord{X: 1, Y: 0}
g.fireBolt(g.Player.Pos, &dir, boltName)
const want = "the bolt whizzes past the medusa"
if g.Msgs.Huh != want {
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
}
if !tp.On(Awake) {
t.Error("the missed medusa was not set running")
}
}

874
game/sticks_test.go Normal file
View File

@@ -0,0 +1,874 @@
//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")
}
})
}
}