diff --git a/TODO.md b/TODO.md index 1ac4d16..be090ee 100644 --- a/TODO.md +++ b/TODO.md @@ -29,12 +29,47 @@ Refactor ground rules: # Next Step -Broaden unit test coverage where playtesting finds thin spots — sticks (#6) and -wizard commands (#7). Rings, the first third of this step, is done; see the top +Broaden unit test coverage where playtesting finds thin spots — wizard commands +(#7). Rings and sticks, the first two thirds of this step, are done; see the top of 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 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 diff --git a/game/bolt_test.go b/game/bolt_test.go new file mode 100644 index 0000000..c7d2c62 --- /dev/null +++ b/game/bolt_test.go @@ -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") + } +} diff --git a/game/sticks_test.go b/game/sticks_test.go new file mode 100644 index 0000000..edd36ac --- /dev/null +++ b/game/sticks_test.go @@ -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") + } + }) + } +}