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