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