3 Commits

Author SHA1 Message Date
29fbedb77d Test the wands, staffs and bolt geometry of sticks.c (closes #6)
game/sticks.go was the largest under-tested file in the repo: 534 lines,
23 functions and a single test. It now has two test files, both written
against the C reference (git show origin/c-master:sticks.c) rather than
against the current Go code, so they can catch divergence instead of
recording it.

game/sticks_test.go covers every zap handler that had none — light in a
room and in a corridor, drain-life's too-weak refusal (which returns
before o_charges--), drain's hit-point split and its kill arm,
drainReaches for all three of C's clauses, invisibility and the flytrap
release, polymorph's detach/re-attach dance with the pack, under-
character and delta-clobbering it does on the way, cancellation, both
teleport wands, magic missile, haste/slow in both directions, fix_stick's
damage and charge formulas, and charge_str.

game/bolt_test.go covers fire_bolt: dirch for all eight directions,
boltBounces including the door the hero stands on, an end-to-end flight
asserting the path and resting square, bounces off both wall
orientations, off a corner and diagonally off a wall (which pins C's rule
that a bounce negates both components rather than reflecting), a bounced
bolt striking the hero who fired it, the strike and miss arms, and the
dragon that shrugs off a flame but not a lightning bolt.

The tests read the flight path off the screen: fire_bolt paints its trail
and then paints chat() back over every square it recorded, so on an
otherwise blank screen the non-blank cells are exactly the squares the
bolt occupied, and the walls it bounced off are absent because C undoes
the record before the mvaddch. Determinism comes from a pinRng helper
that searches for a seed whose next draw is the wanted value, and from a
level the tests carve themselves with the generator's own drawRoom. The
hero is fortified wherever a bolt can reach him, since death exits the
process.

No divergence from C was found. Two notes are recorded in the test
comments: fire_bolt's "ch != 'M'" guard is a tautology, because winat is
t_disguise whenever a monster stands there, and the door-under-hero
exception can only be tested by the fact that the run terminates.
2026-08-09 15:11:02 +00:00
bf820e3ec9 Merge test/rings-coverage (ring tests verified against C) 2026-08-09 17:08:28 +02:00
c61e2827c5 Cover game/rings.go with C-verified unit tests (closes #5)
game/rings.go had no test coverage at all: not one of the suite's tests
touched wearing a ring, taking one off, choosing a hand, or the ring
contribution to the hunger clock. New game/rings_test.go covers ringOn,
pickRingHand, ringOff, gethand, ringEat and ringNum, plus the ring arm of
things.c dropcheck (dropRing), which is what actually removes a worn ring.
17 tests, 44 subtests; package coverage 53.7% -> 56.2%. No game code
changes.

Every expected value is transcribed from the C reference on origin/c-master
(rings.c, rogue.h, things.c) and quoted in the file, rather than from what
the port currently returns. No divergence from C was found.

ringEat is the reason this matters most: it feeds daemons.c's hunger clock,
so a wrong entry is a slow, silent drift in when the hero starves. All
fourteen ring kinds are pinned to C's uses[] table, both hands. The three C
subtleties are handled explicitly: a negative uses[] entry is a one-in-n
chance of a single unit and not a literal cost; R_DIGEST then flips the
sign, so slow digestion returns 0 or -1; and ring_num's switch closes with
the otherwise macro (rogue.h 53: break;default), so its four labels fall
through to one sprintf and every other kind returns "" from a default arm.

The chance rings are checked by snapshotting the generator, calling
ringEat, and replaying C's own expression from the identical state, which
pins the one-in-n denominator, the sign flip and the fact that exactly one
rnd call is spent; a frequency check over 4000 trials backs it. The
non-negative entries assert the opposite, that the generator is untouched,
because C never reaches rnd on that path and a stray call there would
desynchronise the game's RNG stream from C's.

Scripted hand answers carry an abort tail (a space for the reprompt's
--More--, then ESCAPE) so that a port which stopped accepting a key fails
on its assertion instead of looping forever on the headless terminal's
filler input. The "only one hand free" cases script the wrong hand key on
purpose: a port that prompted anyway would consume it and land the ring on
the wrong side.

Mutation-proved with 23 mutations, each reverted, each failing its own test
and only its own. All fourteen kinds are exercised; the eleven with no
wear-time effect in C are documented at the foot of the file as
deliberately not given a wear/remove test, and ring_off's unreachable "not
wearing such a ring" arm is documented as unreachable.
2026-08-09 14:53:46 +00:00
4 changed files with 2226 additions and 2 deletions

106
TODO.md
View File

@@ -29,11 +29,113 @@ Refactor ground rules:
# Next Step # Next Step
Broaden unit test coverage where playtesting finds thin spots (rings, sticks, Broaden unit test coverage where playtesting finds thin spots — wizard commands
wizard commands). (#7). Rings and sticks, the first two thirds of this step, are done; see the top
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
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
ring contribution to the hunger clock. New `game/rings_test.go` (17 tests, 44
subtests) covers `ringOn`, `pickRingHand`, `ringOff`, `gethand`, `ringEat` and
`ringNum`, plus the ring arm of `things.c dropcheck` (`dropRing`), which is
what actually takes a ring off. Package coverage 53.7% -> 56.2%. `Next Step`
narrowed rather than rotated: #6 and #7 are the other two thirds.
Every expected value is transcribed from `origin/c-master` (`rings.c`,
`rogue.h`, `things.c`), never from what the port returns, and the C is
quoted in the file. **No divergence from C was found**, which is the result
and is worth recording as a negative: `ringEat` is the one function here
whose being wrong would be invisible — it feeds `daemons.c`'s hunger clock,
so a bad entry is a slow drift in when the hero starves rather than anything
a playtest would notice — and it now has all fourteen ring kinds pinned to
C's table.
Three C details the tests were written around. (1) `ring_eat`'s `uses[]`
holds negatives, and a negative is **not** a cost: C computes
`eat = (rnd(-eat) == 0)`, a one-in-n chance of a single unit. (2) `R_DIGEST`
then flips the sign, so slow digestion returns 0 or **-1** and is the only
ring that gives food back. (3) `ring_num`'s switch closes with the
`otherwise` macro, which `rogue.h` 53 defines as `break;default` — so its
four labels fall through to one `sprintf` and every other kind returns `""`
from a default arm, not by falling off the end. The `RingKind` iota matches
C's `R_` numbering index-for-index, so a `uses[]` index and a `RingKind` are
the same number; `R_ADDHIT` is `RingDexterity` and `R_ADDDAM` is
`RingIncreaseDamage`.
The chance rings are checked two ways at once. Each call snapshots the
generator, runs `ringEat`, and replays C's own expression from the identical
state — which pins the one-in-n denominator, the sign flip, and the fact
that exactly one `rnd` call is spent — and a frequency check over 4000
trials backs it with a number a human can read. The non-negative entries
assert the reverse: the generator must be **untouched**, because C never
reaches `rnd` on that path and a stray call there would desynchronise the
whole game's RNG stream from C's and cost seed compatibility. That assertion
is what caught the one real bug in this work, which was in the test and not
the game: `g.Rng` is a pointer, so the first draft's snapshots aliased
instead of copying.
Two shapes worth keeping. Scripted hand answers carry an abort tail (a space
for the reprompt's `--More--`, then ESCAPE): without it a port that stopped
accepting a key would loop forever on the headless terminal's filler input
and the test would die of the 30s timeout instead of failing on its
assertion — which is exactly what the first draft did, and it was only
visible because the mutation run was inspected rather than trusted. And the
"only one hand free" case scripts the _wrong_ hand key deliberately: a port
that asked anyway consumes it and lands the ring on the wrong side, so the
test fails on a hand rather than on a hang.
Mutation-proved, 23 mutations, each reverted: breaking `pickRingHand`'s
ask/auto/reject arms, `ring_on`'s type guard, `is_current` guard and all
three effect arms, `ring_off`'s no-rings message, hand selection and ESCAPE
abort, `gethand`'s uppercase keys, ESCAPE and reprompt, `dropRing`'s hand
clearing and both effect arms, `dropcheck`'s cursed gate, three `ringUses`
entries, the `R_DIGEST` sign flip, the one-in-n roll, the empty-hand zero,
and `ring_num`'s `ISKNOW` guard, label set and `RING`-vs-`WEAPON`
formatting. Each failed its own test and only its own; stripping all three
`ring_on` effect arms failed 3 of 3. All fourteen ring kinds are exercised;
the eleven with no wear-time effect in C are documented at the foot of the
file as deliberately not given a wear/remove test, with the files their
powers actually live in, and `ring_off`'s unreachable "not wearing such a
ring" arm is documented as unreachable rather than left looking untested.
- 2026-08-09 Command dispatch audit (`audit/command-switch-coverage`, closes - 2026-08-09 Command dispatch audit (`audit/command-switch-coverage`, closes
#31): checked every case label in C's `command.c` against this port's #31): checked every case label in C's `command.c` against this port's
dispatch, and left the audit behind as a standing test dispatch, and left the audit behind as a standing test

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

751
game/rings_test.go Normal file
View File

@@ -0,0 +1,751 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import (
"fmt"
"math"
"testing"
)
// rings_test.go covers rings.c — ring_on, gethand, ring_off, ring_eat and
// ring_num — plus the ring arm of things.c dropcheck (dropRing), which is
// what actually takes a worn ring off.
//
// Every expected value below is transcribed from the C reference on the
// origin/c-master branch (rings.c, rogue.h, things.c), not from what this
// port happens to return. A test that asserts the current Go behaviour
// cannot catch the port drifting away from C, which is the only thing
// these tests exist to do.
//
// The C constants in play (rogue.h 122-123 and 275-289):
//
// #define LEFT 0 #define RIGHT 1
// R_PROTECT 0 R_ADDSTR 1 R_SUSTSTR 2 R_SEARCH 3
// R_SEEINVIS 4 R_NOP 5 R_AGGR 6 R_ADDHIT 7
// R_ADDDAM 8 R_REGEN 9 R_DIGEST 10 R_TELEPORT 11
// R_STEALTH 12 R_SUSTARM 13 MAXRINGS 14
//
// The Go RingKind iota (types.go 303-316) runs in that same order, so a C
// uses[] index and a Go RingKind are the same number. R_ADDHIT is the
// dexterity ring (RingDexterity) and R_ADDDAM is RingIncreaseDamage.
//
// Nothing here can kill the hero — no ring path in rings.c touches HP,
// food, or experience — so these tests need no fortify() pinning.
// C message text, verbatim, in the terse/verbose pairs C picks between.
const (
cWearingTwo = "you already have a ring on each hand"
cWearingTwoTerse = "wearing two"
cNotARing = "it would be difficult to wrap that around a finger"
cNotARingTerse = "not a ring"
cNoRings = "you aren't wearing any rings"
cNoRingsTerse = "no rings"
cInUse = "That's already in use"
cCursed = "you can't. It appears to be cursed"
)
// ringSeed is the fixed seed every test here runs on: nothing in rings.c
// depends on the layout, but the RNG stream must be reproducible for the
// ring_eat chance rolls.
const ringSeed = 5
// mkRingGame builds a headless game with a clear message line, so that a
// leftover mpos cannot turn the next msg() into a --More-- that eats the
// scripted keystrokes.
func mkRingGame(t *testing.T) *RogueGame {
t.Helper()
g := mkGame(t, ringSeed)
g.Msgs.Mpos = 0
g.Msgs.Huh = ""
return g
}
// handKeys scripts an answer to gethand and appends an abort tail. Without
// it, a port that stopped accepting the key under test would reprompt
// forever against the headless terminal's filler input, and the test would
// die of the 30s timeout instead of failing on its own assertion. The
// space acknowledges the reprompt's --More-- and the ESCAPE makes gethand
// give up, so the assertion gets to run and say what actually went wrong.
// For the same reason, a call that must not prompt at all is scripted with
// a lone ESCAPE rather than an empty script.
func handKeys(keys ...byte) []byte {
return append(keys, ' ', Escape)
}
// mkRing builds a ring of the given kind; bonus is C's o_arm.
func mkRing(kind RingKind, bonus int) *Object {
obj := newObject()
obj.Kind = KindRing
obj.Which = int(kind)
obj.Bonus = bonus
obj.Count = 1
return obj
}
// wear puts a ring straight onto a hand the way a restored save would,
// bypassing ring_on's prompting and effects.
func wear(g *RogueGame, hand int, obj *Object) *Object {
give(g, obj)
g.Player.CurRing[hand] = obj
return obj
}
func handDesc(obj *Object) string {
if obj == nil {
return "empty"
}
return fmt.Sprintf("ring kind %d", obj.RingKind())
}
// assertHands pins both hands at once, which is what "no state change"
// means for every rejection path in ring_on.
func assertHands(t *testing.T, g *RogueGame, left, right *Object) {
t.Helper()
if g.Player.CurRing[Left] != left {
t.Errorf("left hand = %s, want %s",
handDesc(g.Player.CurRing[Left]), handDesc(left))
}
if g.Player.CurRing[Right] != right {
t.Errorf("right hand = %s, want %s",
handDesc(g.Player.CurRing[Right]), handDesc(right))
}
}
// TestRingOnUsesTheHandTheHeroPicks covers the first arm of C's ring_on
// hand choice: "if (cur_ring[LEFT] == NULL && cur_ring[RIGHT] == NULL)
// { if ((ring = gethand()) < 0) return; }".
func TestRingOnUsesTheHandTheHeroPicks(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
key byte
hand int
}{
{"lower l", 'l', Left},
{"upper L", 'L', Left},
{"lower r", 'r', Right},
{"upper R", 'R', Right},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
ring := mkRing(RingAdornment, 0)
ch := give(g, ring)
setInput(t, g, handKeys(ch, tc.key)...)
g.ringOn()
if tc.hand == Left {
assertHands(t, g, ring, nil)
} else {
assertHands(t, g, nil, ring)
}
})
}
}
// TestRingOnEscapeFromGethandWearsNothing is the "< 0" half of that arm.
func TestRingOnEscapeFromGethandWearsNothing(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
ring := mkRing(RingAdornment, 0)
ch := give(g, ring)
setInput(t, g, ch, Escape)
g.ringOn()
assertHands(t, g, nil, nil)
}
// TestRingOnTakesTheOnlyFreeHandWithoutAsking covers C's second and third
// arms — "else if (cur_ring[LEFT] == NULL) ring = LEFT" and the RIGHT
// mirror — which must not prompt. The scripted hand key is deliberately
// the wrong hand: a port that asked anyway would consume it and put the
// ring on the occupied side's opposite, failing here instead of hanging.
func TestRingOnTakesTheOnlyFreeHandWithoutAsking(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
worn int
free int
badKey byte
}{
{"left already worn", Left, Right, 'l'},
{"right already worn", Right, Left, 'r'},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
old := wear(g, tc.worn, mkRing(RingStealth, 0))
ring := mkRing(RingAdornment, 0)
ch := give(g, ring)
setInput(t, g, handKeys(ch, tc.badKey)...)
g.ringOn()
if g.Player.CurRing[tc.free] != ring {
t.Errorf("free hand = %s, want the new ring",
handDesc(g.Player.CurRing[tc.free]))
}
if g.Player.CurRing[tc.worn] != old {
t.Error("ring_on disturbed the hand that was already worn")
}
})
}
}
// TestRingOnWithBothHandsFullIsRejected covers C's final else arm. The
// trailing ESCAPE is scripted so that a port which wrongly fell through
// to gethand() aborts instead of looping on the exhausted script.
func TestRingOnWithBothHandsFullIsRejected(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
terse bool
want string
}{
{"wearing two, verbose", false, cWearingTwo},
{"wearing two, terse", true, cWearingTwoTerse},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
g.Options.Terse = tc.terse
left := wear(g, Left, mkRing(RingStealth, 0))
right := wear(g, Right, mkRing(RingRegeneration, 0))
ch := give(g, mkRing(RingAdornment, 0))
setInput(t, g, ch, Escape)
g.ringOn()
if g.Msgs.Huh != tc.want {
t.Errorf("message = %q, want %q", g.Msgs.Huh, tc.want)
}
assertHands(t, g, left, right)
})
}
}
// TestRingOnRejectsANonRing covers C's "if (obj->o_type != RING)" guard.
func TestRingOnRejectsANonRing(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
terse bool
want string
}{
{"not a ring, verbose", false, cNotARing},
{"not a ring, terse", true, cNotARingTerse},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
g.Options.Terse = tc.terse
pot := newObject()
pot.Kind = KindPotion
pot.Which = int(PotionHealing)
ch := give(g, pot)
setInput(t, g, ch, Escape)
g.ringOn()
if g.Msgs.Huh != tc.want {
t.Errorf("message = %q, want %q", g.Msgs.Huh, tc.want)
}
assertHands(t, g, nil, nil)
})
}
}
// TestRingOnRejectsARingAlreadyWorn covers C's "if (is_current(obj))
// return", which sits between the type check and the hand choice.
func TestRingOnRejectsARingAlreadyWorn(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
worn := wear(g, Left, mkRing(RingStealth, 0))
setInput(t, g, worn.PackCh, Escape)
g.ringOn()
if g.Msgs.Huh != cInUse {
t.Errorf("message = %q, want %q", g.Msgs.Huh, cInUse)
}
assertHands(t, g, worn, nil)
}
// TestRingOnAddStrengthAndRingOffReverseEachOther pins the R_ADDSTR arm
// of ring_on ("case R_ADDSTR: chg_str(obj->o_arm)") against the R_ADDSTR
// arm of things.c dropcheck ("chg_str(-obj->o_arm)").
func TestRingOnAddStrengthAndRingOffReverseEachOther(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
ring := mkRing(RingAddStrength, 2)
ch := give(g, ring)
base := g.Player.Stats.Str
setInput(t, g, handKeys(ch, 'l')...)
g.ringOn()
if g.Player.Stats.Str != base+2 {
t.Errorf("strength after wearing = %d, want %d",
g.Player.Stats.Str, base+2)
}
assertHands(t, g, ring, nil)
// Only the left hand is worn, so ring_off's "else if (cur_ring[RIGHT]
// == NULL) ring = LEFT" arm picks it with no prompt.
setInput(t, g, Escape)
g.ringOff()
if g.Player.Stats.Str != base {
t.Errorf("strength after removal = %d, want %d",
g.Player.Stats.Str, base)
}
assertHands(t, g, nil, nil)
}
// TestRingOnSeeInvisibleAndRingOffUndoIt pins the R_SEEINVIS arms:
// invis_on() on the way in, unsee() plus extinguish(unsee) on the way
// out. The pending fuse stands in for a potion of see invisible still
// running, which is the only way the extinguish is observable.
func TestRingOnSeeInvisibleAndRingOffUndoIt(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
ring := mkRing(RingSeeInvisible, 0)
ch := give(g, ring)
setInput(t, g, handKeys(ch, 'r')...)
g.ringOn()
if !g.Player.On(CanSeeInvisible) {
t.Error("ring of see invisible did not set CanSeeInvisible")
}
g.Fuse(DUnsee, 0, 100, After)
setInput(t, g, Escape)
g.ringOff()
if g.Player.On(CanSeeInvisible) {
t.Error("taking the ring off left CanSeeInvisible set")
}
if g.findSlot(DUnsee) != nil {
t.Error("taking the ring off did not extinguish the unsee fuse")
}
}
// TestRingOnAggravateMonstersWakesThem pins the R_AGGR arm, which calls
// aggravate() — misc.c walks every monster through runTo, setting ISRUN.
func TestRingOnAggravateMonstersWakesThem(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
tp := spawnAdjacent(g, 'Z')
tp.Flags.Clear(Awake)
ring := mkRing(RingAggravateMonsters, 0)
ch := give(g, ring)
setInput(t, g, handKeys(ch, 'l')...)
g.ringOn()
if !tp.On(Awake) {
t.Error("ring of aggravate monsters did not wake the monster")
}
}
// TestGethand covers rings.c gethand end to end. The bad-key case needs
// the extra space: the reprompt happens with mpos still set from "please
// type L or R", so endmsg puts up a --More-- that wait_for absorbs.
func TestGethand(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
input []byte
want int
}{
{"l", []byte{'l'}, Left},
{"L", []byte{'L'}, Left},
{"r", []byte{'r'}, Right},
{"R", []byte{'R'}, Right},
{"escape aborts", []byte{Escape}, -1},
{"bad key reprompts", []byte{'x', ' ', 'r'}, Right},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
setInput(t, g, handKeys(tc.input...)...)
if got := g.gethand(); got != tc.want {
t.Errorf("gethand() = %d, want %d", got, tc.want)
}
})
}
}
// TestRingOffWithNoRingsSaysSo covers ring_off's first arm.
func TestRingOffWithNoRingsSaysSo(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
terse bool
want string
}{
{"no rings, verbose", false, cNoRings},
{"no rings, terse", true, cNoRingsTerse},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
g.Options.Terse = tc.terse
setInput(t, g, Escape)
g.ringOff()
if g.Msgs.Huh != tc.want {
t.Errorf("message = %q, want %q", g.Msgs.Huh, tc.want)
}
})
}
}
// TestRingOffWithBothHandsWornAsksWhich covers ring_off's else arm, both
// the answer and the "(ring = gethand()) < 0" abort.
func TestRingOffWithBothHandsWornAsksWhich(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
key byte
gone int
stays int
}{
{"takes off the left", 'l', Left, Right},
{"takes off the right", 'r', Right, Left},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
rings := [2]*Object{
Left: wear(g, Left, mkRing(RingStealth, 0)),
Right: wear(g, Right, mkRing(RingRegeneration, 0)),
}
setInput(t, g, handKeys(tc.key)...)
g.ringOff()
if g.Player.CurRing[tc.gone] != nil {
t.Errorf("chosen hand still holds %s",
handDesc(g.Player.CurRing[tc.gone]))
}
if g.Player.CurRing[tc.stays] != rings[tc.stays] {
t.Error("ring_off cleared the hand that was not chosen")
}
})
}
}
// TestRingOffEscapeKeepsBothRings is the abort half of that arm.
func TestRingOffEscapeKeepsBothRings(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
left := wear(g, Left, mkRing(RingStealth, 0))
right := wear(g, Right, mkRing(RingRegeneration, 0))
setInput(t, g, Escape)
g.ringOff()
assertHands(t, g, left, right)
}
// TestRingOffCursedRingStaysOn covers the dropcheck gate ring_off runs
// its removal through: things.c returns FALSE for an ISCURSED item after
// printing this message, and the hand is left alone.
func TestRingOffCursedRingStaysOn(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
ring := mkRing(RingAddStrength, -1)
ring.Flags.Set(Cursed)
wear(g, Left, ring)
setInput(t, g, Escape)
g.ringOff()
if g.Msgs.Huh != cCursed {
t.Errorf("message = %q, want %q", g.Msgs.Huh, cCursed)
}
assertHands(t, g, ring, nil)
}
// cRingUse is one entry of the rings.c ring_eat uses[] table.
type cRingUse struct {
kind RingKind
name string // the C R_ name, for failure messages
uses int
}
// cRingUses transcribes ring_eat's static uses[] verbatim:
//
// static int uses[] = {
// 1, /* R_PROTECT */ 1, /* R_ADDSTR */
// 1, /* R_SUSTSTR */ -3, /* R_SEARCH */
// -5, /* R_SEEINVIS */ 0, /* R_NOP */
// 0, /* R_AGGR */ -3, /* R_ADDHIT */
// -3, /* R_ADDDAM */ 2, /* R_REGEN */
// -2, /* R_DIGEST */ 0, /* R_TELEPORT */
// 1, /* R_STEALTH */ 1 /* R_SUSTARM */
// };
//
// A negative entry is not a cost: C computes eat = (rnd(-eat) == 0), a
// one-in-n chance of a single unit. R_DIGEST then flips the sign, so slow
// digestion returns 0 or -1 and is the only ring that gives food back.
func cRingUses() []cRingUse {
return []cRingUse{
{RingProtection, "R_PROTECT", 1},
{RingAddStrength, "R_ADDSTR", 1},
{RingSustainStrength, "R_SUSTSTR", 1},
{RingSearching, "R_SEARCH", -3},
{RingSeeInvisible, "R_SEEINVIS", -5},
{RingAdornment, "R_NOP", 0},
{RingAggravateMonsters, "R_AGGR", 0},
{RingDexterity, "R_ADDHIT", -3},
{RingIncreaseDamage, "R_ADDDAM", -3},
{RingRegeneration, "R_REGEN", 2},
{RingSlowDigestion, "R_DIGEST", -2},
{RingTeleportation, "R_TELEPORT", 0},
{RingStealth, "R_STEALTH", 1},
{RingMaintainArmor, "R_SUSTARM", 1},
}
}
// TestRingEatMatchesTheCUsesTable exercises all fourteen ring kinds, both
// hands, against the C table above. This is the highest-value assertion in
// the file: ring_eat feeds the hunger clock through daemons.c, so a wrong
// entry is a silent, slow divergence from C that no other test would see.
func TestRingEatMatchesTheCUsesTable(t *testing.T) {
t.Parallel()
for _, tc := range cRingUses() {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
for _, hand := range []int{Left, Right} {
g := mkRingGame(t)
g.Player.CurRing[hand] = mkRing(tc.kind, 0)
if tc.uses >= 0 {
assertFixedRingEat(t, g, hand, tc)
} else {
assertChanceRingEat(t, g, hand, tc)
}
}
})
}
}
// assertFixedRingEat checks a non-negative uses[] entry. C returns it
// unchanged and, just as importantly, never reaches rnd() on that path —
// so the generator must be untouched, or the whole game's RNG stream
// desynchronises from C's and seed compatibility is gone.
func assertFixedRingEat(t *testing.T, g *RogueGame, hand int, tc cRingUse) {
t.Helper()
for range 4 {
before := *g.Rng
if got := g.ringEat(hand); got != tc.uses {
t.Fatalf("ringEat(%d) for %s = %d, want C uses[] entry %d",
hand, tc.name, got, tc.uses)
}
if *g.Rng != before {
t.Fatalf("ringEat for %s called rnd(); C only does that for a "+
"negative uses[] entry", tc.name)
}
}
}
// assertChanceRingEat checks a negative uses[] entry. Each call is replayed
// against C's own expression from the identical generator state, which pins
// the one-in-n denominator, the sign flip R_DIGEST gets, and the fact that
// exactly one rnd() call is spent. The frequency check on top of that
// fails loudly on a wrong denominator even if the replay were ever
// weakened to agree with the code by construction.
func assertChanceRingEat(t *testing.T, g *RogueGame, hand int, tc cRingUse) {
t.Helper()
const trials = 4000
sign := 1
if tc.kind == RingSlowDigestion {
sign = -1 // rings.c: if (ring->o_which == R_DIGEST) eat = -eat
}
nonzero := 0
for range trials {
before := *g.Rng
got := g.ringEat(hand)
after := *g.Rng
// C: eat = (rnd(-eat) == 0), replayed from the same state.
*g.Rng = before
want := 0
if g.Rng.Rnd(-tc.uses) == 0 {
want = 1
}
want *= sign
if *g.Rng != after {
t.Fatalf("ringEat for %s did not spend exactly one rnd(%d) call",
tc.name, -tc.uses)
}
if got != want {
t.Fatalf("ringEat for %s = %d, want %d", tc.name, got, want)
}
if want != 0 {
nonzero++
}
}
assertOneInN(t, tc, nonzero, trials)
}
// assertOneInN checks the observed rate against C's 1/n. The tolerance is
// far tighter than the gap between the three denominators C uses (1/2,
// 1/3, 1/5) and far wider than the sampling noise at this trial count.
func assertOneInN(t *testing.T, tc cRingUse, nonzero, trials int) {
t.Helper()
const tolerance = 0.03
rate := float64(nonzero) / float64(trials)
want := 1 / float64(-tc.uses)
if math.Abs(rate-want) > tolerance {
t.Errorf("%s fired %.3f of the time over %d trials, want ~%.3f "+
"(C's one-in-%d)", tc.name, rate, trials, want, -tc.uses)
}
}
// TestRingEatEmptyHandIsZero is C's "if ((ring = cur_ring[hand]) == NULL)
// return 0" — the common case, since the hero usually wears nothing.
func TestRingEatEmptyHandIsZero(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
before := *g.Rng
for _, hand := range []int{Left, Right} {
if got := g.ringEat(hand); got != 0 {
t.Errorf("ringEat(%d) with an empty hand = %d, want 0", hand, got)
}
}
if *g.Rng != before {
t.Error("ringEat on an empty hand consumed RNG")
}
}
// TestRingNum covers rings.c ring_num. Its switch ends in the `otherwise`
// macro, which rogue.h 53 defines as `break;default` — so the four labels
// R_PROTECT, R_ADDSTR, R_ADDDAM and R_ADDHIT fall through to a single
// sprintf(" [%s]", num(o_arm, 0, RING)) and every other kind returns ""
// from the default arm before the buffer is ever reached. Unknown rings
// return "" earlier still, from the ISKNOW guard.
//
// The game pointer is C's implicit global state; ring_num reads none of
// it, and the port's signature only carries one to satisfy nameit's
// prfunc type, so nil is the honest argument here.
func TestRingNum(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
kind RingKind
bonus int
known bool
want string
}{
{"R_PROTECT known", RingProtection, 2, true, " [+2]"},
{"R_ADDSTR known", RingAddStrength, 1, true, " [+1]"},
{"R_ADDDAM known", RingIncreaseDamage, -1, true, " [-1]"},
{"R_ADDHIT known", RingDexterity, 3, true, " [+3]"},
{"R_PROTECT cursed", RingProtection, -1, true, " [-1]"},
{"R_ADDSTR unknown", RingAddStrength, 2, false, ""},
{"R_SEARCH known", RingSearching, 2, true, ""},
{"R_DIGEST known", RingSlowDigestion, 2, true, ""},
{"R_NOP known", RingAdornment, 0, true, ""},
{"R_SUSTARM known", RingMaintainArmor, 2, true, ""},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
obj := mkRing(tc.kind, tc.bonus)
if tc.known {
obj.Flags.Set(Known)
}
if got := ringNum(nil, obj); got != tc.want {
t.Errorf("ringNum() = %q, want %q", got, tc.want)
}
})
}
}
// Coverage of the fourteen ring kinds, for the record:
//
// All fourteen are exercised by TestRingEatMatchesTheCUsesTable and ten of
// them by TestRingNum. Beyond that, only three kinds have a ring_on effect
// at all — R_ADDSTR, R_SEEINVIS and R_AGGR — and each has its own test
// above, paired with the dropcheck arm that undoes it. The remaining
// eleven are deliberately not given a wear/remove test: in C they are
// inert at wear time, their powers being read from ISWEARING() elsewhere
// (R_SEARCH and R_TELEPORT in the command.c per-turn tail, R_PROTECT and
// R_ADDHIT/R_ADDDAM in fight.c, R_REGEN and R_DIGEST in daemons.c,
// R_SUSTSTR and R_SUSTARM in the drain paths, R_STEALTH in chase.c), so a
// wear/remove assertion for them would test nothing that rings.c does.
// Those call sites belong to their own files' tests, not to this one.
//
// One branch is intentionally unreachable rather than untested: ring_off's
// "obj == NULL -> not wearing such a ring" cannot fire, because every arm
// that reaches it has already established that the chosen hand is worn.
// The port keeps C's defensive check; there is no state from which to
// provoke it.

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