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.
498 lines
14 KiB
Go
498 lines
14 KiB
Go
//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")
|
|
}
|
|
}
|