Files
rgoue/game/traps_test.go
sneak ba444a2002 Unit-test the eight trap effects against the C reference (closes #14)
`trapHandlers` had eight entries and zero direct tests, on the one
subsystem besides combat that can kill the hero outright. New
`game/traps_test.go` covers all eight arms of `move.c be_trapped`, the
prologue every trap runs through, and the `rust_armor` tail `T_RUST`
calls. Test-only: no game code changes.

Every expected value is transcribed from `origin/c-master` (`move.c`,
`misc.c`, `fight.c`, `monsters.c`, `rogue.h`) and quoted in the file. No
divergence from C was found.

The trap set is `rogue.h` 192-200: there is no separate "poison dart"
kind — `T_DART` is the poisoned dart — and `T_MYST`, the eleven-way
`rnd(11)` message switch, is the eighth. Details the tests are built
around: `BEARTIME`/`SLEEPTIME` are `spread(3)`/`spread(5)`, both of which
reduce to `rnd(0)` and so cost no random number, which is asserted as
well as their values; `T_ARROW` swings at `s_lvl - 1` and `T_DART` at
`s_lvl + 1`; and the strength loss is gated on `!ISWEARING(R_SUSTSTR) &&
!save(VS_POISON)`, whose short circuit means the ring saves a random draw
as well as the strength.

Damage dice and swing arguments are checked by sweeps rather than single
shots: `rnd(n)` is "raw value % n", so one draw cannot separate a d6 from
a d5, and a forced hit or miss cannot see a wrong `at_lvl`. Both shapes
were forced by mutation runs that the single-shot versions survived.

`be_trapped` takes a coordinate, and which coordinate decides whether
`T_TELEP`'s `mvaddch(tc, TRAP)` does anything. Sprung under the hero
(`move.go` 105-108, `case Floor`) the line is redundant: `tc` is the
hero's square, already stamped `TRAP` by the prologue and redrawn by
`teleport()`'s opening `mvaddch(hero, floor_at())`. Walked onto
(`move.go` 94-98, `case Trap`) it is the only writer: `tc` is the square
being stepped onto while the hero still stands on the previous one,
`leave_room` writes blanks and never `TRAP`, and the arm returns before
`finishMove` so no `look()` follows. Both shapes are tested.

The two death messages are deliberately uncovered: each is printed
immediately before `death()`, which reaches `myExit` and `os.Exit`, so
provoking either would kill the test binary. The hero is pinned with
`fortify()` and the damage is checked by replaying C's arithmetic. They
are the only two: `rust_armor`'s `|| ISWEARING(R_SUSTARM)` operand and
its `if (!to_death)` message suppression are covered as well.
2026-08-09 16:01:19 +00:00

1141 lines
33 KiB
Go

//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import (
"fmt"
"strings"
"testing"
)
// traps_test.go covers move.c be_trapped — the eight-armed switch behind
// gameData.trapHandlers — plus the prologue every trap runs through and
// the rust_armor tail T_RUST calls.
//
// Every expected value below is transcribed from the C reference on the
// origin/c-master branch (move.c, misc.c, fight.c, monsters.c, rogue.h),
// not from what this port produces. 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 trap set, from rogue.h 192-200 — note there is no second "poison
// dart" kind (T_DART *is* the poisoned dart) and that T_MYST, the
// eleven-message mystery trap, is the eighth:
//
// #define T_DOOR 00 #define T_ARROW 01
// #define T_SLEEP 02 #define T_BEAR 03
// #define T_TELEP 04 #define T_DART 05
// #define T_RUST 06 #define T_MYST 07 #define NTRAPS 8
//
// The Go TrapKind iota (types.go 202-209) runs in that same order, so a
// C T_ constant and a Go TrapKind are the same number.
//
// Two of C's messages cannot be asserted in process and are deliberately
// not tested: "an arrow killed you" and "a poisoned dart killed you" are
// each printed immediately before death(), which reaches myExit and
// os.Exit, so a test that provoked either would take the test binary with
// it. Everything else in be_trapped is here. The hero is pinned with
// fortify() for the same reason, and the two damage rolls are checked by
// replaying C's own arithmetic rather than by letting HP reach zero.
// C message text, verbatim. The C source is the authority for every
// character of these, including capitalisation inside the sentence
// ("An arrow") and the exclamation marks.
const (
cFellIntoTrap = "you fell into a trap!"
cBearTrap = "you are caught in a bear trap"
cWhiteMist = "a strange white mist envelops you and you fall asleep"
cArrowHit = "oh no! An arrow shot you"
cArrowMiss = "an arrow shoots past you"
cDartMiss = "a small dart whizzes by your ear and vanishes"
cDartHit = "a small dart just hit you in the shoulder"
cGushOfWater = "a gush of water hits you on the head"
cArmorWeaker = "your armor appears to be weaker now. Oh my!"
cArmorTerse = "your armor weakens"
cRustVanishes = "the rust vanishes instantly"
)
// trapSeed is the fixed seed every game here runs on: the trap effects
// that touch the generator are checked by replay, but the level layout
// (which decides where teleport lands and where a missed arrow falls)
// must be reproducible too.
const trapSeed = 20260809
// cTrap names one rogue.h T_ constant so failures say which arm broke.
type cTrap struct {
kind TrapKind
name string
}
// cTraps is the whole rogue.h trap set, in C's numbering order.
func cTraps() []cTrap {
return []cTrap{
{TrapDoor, "T_DOOR"},
{TrapArrow, "T_ARROW"},
{TrapSleep, "T_SLEEP"},
{TrapBear, "T_BEAR"},
{TrapTeleport, "T_TELEP"},
{TrapDart, "T_DART"},
{TrapRust, "T_RUST"},
{TrapMystery, "T_MYST"},
}
}
// cSpread is misc.c spread transcribed: "return nm - nm / 20 + rnd(nm /
// 10)". rogue.h 108-109 define BEARTIME as spread(3) and SLEEPTIME as
// spread(5); for both, nm/10 is 0, and C's rnd() short-circuits a zero
// range without touching the generator ("return range == 0 ? 0 :
// abs((int) RN) % range", main.c 186-189). So each is an exact constant
// that costs no random number, and the tests below assert both halves.
func cSpread(rng *Rng, nm int) int {
return nm - nm/20 + rng.Rnd(nm/10)
}
// mkTrapGame builds a headless game on a generated level with a clear
// message line, so a leftover mpos cannot turn the next msg() into a
// --More-- that eats the scripted keystrokes, and with the hero pinned
// the way run_test.go's crash sweeps pin it: T_ARROW and T_DART both
// take HP, and a death would call myExit and kill the test binary.
//
// The scripted input is an abort tail — a space for any --More--, then
// ESCAPE. Without it, a port that started prompting where C does not
// would spin forever on the headless terminal's filler input and the
// test would die of the 30s timeout instead of failing on its assertion.
func mkTrapGame(t *testing.T) *RogueGame {
t.Helper()
g := mkGame(t, trapSeed)
g.Msgs.Mpos = 0
g.Msgs.Huh = ""
fortify(g)
setInput(t, g, ' ', Escape)
return g
}
// plantTrap hides a trap of the given kind at pos the way new_level.c
// does it: the cell keeps a floor character, F_REAL is cleared so what is
// drawn is a lie, and the kind lives in the low F_TMASK bits. F_SEEN is
// deliberately left clear, which is what makes be_trapped's discovery
// ("pp->p_ch = TRAP; ... pp->p_flags |= F_SEEN") observable.
func plantTrap(g *RogueGame, pos Coord, kind TrapKind) *Place {
pp := g.Level.At(pos.Y, pos.X)
pp.Ch = Floor
pp.Flags = PlaceFlags(kind) //nolint:gosec // G115: 0..7 fits
return pp
}
// forceSwing pins the hero's level and armor class so that fight.c swing
// — "rnd(20) + wplus >= (20 - at_lvl) - op_arm" — cannot go the other way
// whichever face the die shows. delta is what be_trapped adds to s_lvl
// for the trap under test: -1 for T_ARROW, +1 for T_DART. A guaranteed
// hit needs at_lvl 20 and op_arm 0, giving a target of 0 that rnd(20)+1
// always clears; a guaranteed miss needs at_lvl 1 and op_arm -10, giving
// a target of 29 that the highest possible 20 never reaches.
func forceSwing(g *RogueGame, delta int, hit bool) {
p := &g.Player
if hit {
p.Stats.Lvl = 20 - delta
p.Stats.ArmorClass = 0
return
}
p.Stats.Lvl = 1 - delta
p.Stats.ArmorClass = -10
}
// mkArmor builds a piece of armor with C's o_arm as its class.
func mkArmor(kind ArmorKind, class int) *Object {
obj := newObject()
obj.Kind = KindArmor
obj.Which = int(kind)
obj.ArmorClass = class
obj.Count = 1
return obj
}
// TestTrapHandlersCoverEveryTrapKind pins the dispatch table itself. C's
// be_trapped switch has an arm for all eight rogue.h kinds and no
// default, so a missing Go entry is a trap that silently does nothing —
// the failure mode this whole file exists to make impossible.
func TestTrapHandlersCoverEveryTrapKind(t *testing.T) {
t.Parallel()
g := mkTrapGame(t)
if got := len(g.data.trapHandlers); got != NumTrapTypes {
t.Fatalf("trapHandlers has %d entries, want NTRAPS = %d",
got, NumTrapTypes)
}
for _, tc := range cTraps() {
if g.data.trapHandlers[tc.kind] == nil {
t.Errorf("trapHandlers has no entry for %s (%d)", tc.name, tc.kind)
}
}
}
// TestSpringTrapRecordsTheTrapAndStopsTheHero covers everything C does
// before the switch, for every kind that leaves the level intact:
//
// running = FALSE; count = FALSE;
// pp = INDEX(tc->y, tc->x); pp->p_ch = TRAP;
// tr = pp->p_flags & F_TMASK; pp->p_flags |= F_SEEN;
// ... switch ... return tr;
//
// T_DOOR is excluded because new_level() wipes the map immediately after,
// so the record C writes is unobservable for that one kind; see
// TestTrapDoorFallsToANewLevel.
func TestSpringTrapRecordsTheTrapAndStopsTheHero(t *testing.T) {
t.Parallel()
for _, tc := range cTraps() {
if tc.kind == TrapDoor {
continue
}
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
g := mkTrapGame(t)
pos := g.Player.Pos
pp := plantTrap(g, pos, tc.kind)
g.Running = true
g.Count = 5
if got := g.springTrap(pos); got != tc.kind {
t.Errorf("springTrap returned %d, want %s (%d)",
got, tc.name, tc.kind)
}
assertTrapRevealed(t, g, pp)
})
}
}
// assertTrapRevealed is the discovery and stop-the-hero half of the
// prologue, shared by the sweep above.
func assertTrapRevealed(t *testing.T, g *RogueGame, pp *Place) {
t.Helper()
if pp.Ch != Trap {
t.Errorf("map cell shows %q, want the TRAP glyph %q", pp.Ch, Trap)
}
if !pp.Flags.Has(FSeen) {
t.Error("sprung trap did not set F_SEEN on the cell")
}
if g.Running {
t.Error("sprung trap left running set")
}
if g.Count != 0 {
t.Errorf("sprung trap left count = %d, want 0", g.Count)
}
}
// TestSpringTrapWhileLevitatingDoesNothing covers C's first line,
// "if (on(player, ISLEVIT)) return T_RUST;". The comment there — "anything
// that's not a door or teleport" — explains the odd return value: do_move
// only inspects the result to decide whether the hero stayed put, and a
// levitating hero does. Nothing else in be_trapped may run, and in
// particular the trap must not be revealed.
func TestSpringTrapWhileLevitatingDoesNothing(t *testing.T) {
t.Parallel()
g := mkTrapGame(t)
p := &g.Player
pos := p.Pos
pp := plantTrap(g, pos, TrapDart)
p.Flags.Set(Levitating)
g.Running = true
g.Count = 5
hp := p.Stats.HP
snap := *g.Rng
if got := g.springTrap(pos); got != TrapRust {
t.Errorf("springTrap while levitating = %d, want T_RUST (%d)",
got, TrapRust)
}
if pp.Ch != Floor {
t.Errorf("levitating over a trap revealed it as %q", pp.Ch)
}
if pp.Flags.Has(FSeen) {
t.Error("levitating over a trap set F_SEEN")
}
if !g.Running || g.Count != 5 {
t.Errorf("levitating over a trap stopped the hero (running=%v count=%d)",
g.Running, g.Count)
}
if p.Stats.HP != hp {
t.Errorf("levitating over a dart trap cost %d HP", hp-p.Stats.HP)
}
if *g.Rng != snap {
t.Error("levitating over a trap consumed a random number")
}
if g.Msgs.Huh != "" {
t.Errorf("levitating over a trap printed %q", g.Msgs.Huh)
}
}
// TestTrapDoorFallsToANewLevel covers "case T_DOOR: level++; new_level();
// msg("you fell into a trap!")". The map assertion is the one that
// matters, and it has to be counted rather than compared: be_trapped's
// own prologue stamps the trap glyph into the cell the hero fell through,
// so a port that dropped new_level() entirely would still leave the map
// "different". Exactly one cell can change that way, so anything above
// one is a regenerated dungeon — and the hero being re-placed and a new
// staircase dug are the same statement from the other side.
func TestTrapDoorFallsToANewLevel(t *testing.T) {
t.Parallel()
g := mkTrapGame(t)
g.Depth = 3
pos := g.Player.Pos
plantTrap(g, pos, TrapDoor)
before := g.Level.Places
stairs := g.Level.Stairs
if got := g.springTrap(pos); got != TrapDoor {
t.Errorf("springTrap returned %d, want T_DOOR (%d)", got, TrapDoor)
}
if g.Depth != 4 {
t.Errorf("depth = %d after falling through, want 4", g.Depth)
}
changed := 0
for i := range before {
if before[i] != g.Level.Places[i] {
changed++
}
}
if changed <= 1 {
t.Errorf("%d map cells changed; T_DOOR did not call new_level()",
changed)
}
if g.Level.Stairs == stairs {
t.Error("the staircase did not move: no new level was dug")
}
if g.Player.Pos == pos {
t.Error("the hero was not re-placed on a new level")
}
if g.Msgs.Huh != cFellIntoTrap {
t.Errorf("message = %q, want %q", g.Msgs.Huh, cFellIntoTrap)
}
}
// TestTrapBearHoldsTheHero covers "case T_BEAR: no_move += BEARTIME;
// msg("you are caught in a bear trap")", with BEARTIME = spread(3).
func TestTrapBearHoldsTheHero(t *testing.T) {
t.Parallel()
g := mkTrapGame(t)
pos := g.Player.Pos
plantTrap(g, pos, TrapBear)
g.NoMove = 2
snap := *g.Rng
want := g.NoMove + cSpread(&snap, 3)
g.springTrap(pos)
if g.NoMove != want {
t.Errorf("no_move = %d after the bear trap, want %d (2 + BEARTIME)",
g.NoMove, want)
}
if *g.Rng != snap {
t.Error("BEARTIME spent a random number; spread(3) reduces to " +
"rnd(0), which C short-circuits")
}
if g.Msgs.Huh != cBearTrap {
t.Errorf("message = %q, want %q", g.Msgs.Huh, cBearTrap)
}
}
// TestTrapSleepPutsTheHeroToSleep covers "case T_SLEEP: no_command +=
// SLEEPTIME; player.t_flags &= ~ISRUN; msg(...)", with SLEEPTIME =
// spread(5). ISRUN is CreatureFlags Awake, the same 0o020000 bit.
func TestTrapSleepPutsTheHeroToSleep(t *testing.T) {
t.Parallel()
g := mkTrapGame(t)
pos := g.Player.Pos
plantTrap(g, pos, TrapSleep)
g.NoCommand = 1
g.Player.Flags.Set(Awake)
snap := *g.Rng
want := g.NoCommand + cSpread(&snap, 5)
g.springTrap(pos)
if g.NoCommand != want {
t.Errorf("no_command = %d after the sleep trap, want %d (1 + SLEEPTIME)",
g.NoCommand, want)
}
if *g.Rng != snap {
t.Error("SLEEPTIME spent a random number; spread(5) reduces to " +
"rnd(0), which C short-circuits")
}
if g.Player.On(Awake) {
t.Error("the sleep trap did not clear ISRUN")
}
if g.Msgs.Huh != cWhiteMist {
t.Errorf("message = %q, want %q", g.Msgs.Huh, cWhiteMist)
}
}
// TestTrapArrowHitsTheHero covers the hit arm of case T_ARROW:
//
// if (swing(pstats.s_lvl - 1, pstats.s_arm, 1)) {
// pstats.s_hpt -= roll(1, 6); ... msg("oh no! An arrow shot you");
//
// Each trial replays C's arithmetic from the generator state the call
// started from, which pins the damage die and the fact that the arm
// spends exactly two draws — a stray draw would desynchronise the
// seed-compatible stream from C's. It has to be a sweep and not a single
// shot: rnd(n) is "raw value % n", so one draw agrees between a d6 and a
// d5 five times in six and leaves the generator in the same state either
// way. Only a run of trials separates them.
func TestTrapArrowHitsTheHero(t *testing.T) {
t.Parallel()
g := mkTrapGame(t)
p := &g.Player
forceSwing(g, -1, true)
pos := p.Pos
plantTrap(g, pos, TrapArrow)
const trials = 100
for i := range trials {
p.Stats.HP = 500
g.Msgs.Mpos = 0
snap := *g.Rng
_ = snap.Rnd(20) // fight.c swing
want := 500 - (snap.Rnd(6) + 1) // roll(1, 6)
g.springTrap(pos)
if p.Stats.HP != want {
t.Fatalf("trial %d: HP = %d after the arrow, want %d "+
"(500 - roll(1,6))", i, p.Stats.HP, want)
}
if *g.Rng != snap {
t.Fatalf("trial %d: the arrow hit did not spend exactly "+
"rnd(20) then roll(1,6)", i)
}
if g.Msgs.Huh != cArrowHit {
t.Fatalf("trial %d: message = %q, want %q",
i, g.Msgs.Huh, cArrowHit)
}
}
}
// TestTrapArrowMissesAndLandsOnTheFloor covers the else arm of T_ARROW,
// which is the more easily lost half: C builds a real ARROW object,
// counts it 1, puts it at the hero and calls fall(), so a missed arrow
// is loot. The hero must take no damage.
func TestTrapArrowMissesAndLandsOnTheFloor(t *testing.T) {
t.Parallel()
g := mkTrapGame(t)
p := &g.Player
forceSwing(g, -1, false)
p.Stats.HP = 500
pos := p.Pos
plantTrap(g, pos, TrapArrow)
before := make(map[*Object]bool, len(g.Level.Objects))
for _, obj := range g.Level.Objects {
before[obj] = true
}
g.springTrap(pos)
if p.Stats.HP != 500 {
t.Errorf("a missed arrow cost %d HP", 500-p.Stats.HP)
}
if g.Msgs.Huh != cArrowMiss {
t.Errorf("message = %q, want %q", g.Msgs.Huh, cArrowMiss)
}
assertArrowOnFloor(t, g, before, pos)
}
// assertSwingArm springs a trap repeatedly against a to-hit target that
// sits in the middle of the die's range, and checks arm by arm that the
// port went the way C's swing would have. That is the only thing that
// pins the *arguments* of the swing call: both arms are otherwise
// reachable with any level, and swing spends one rnd(20) either way, so
// a wrong at_lvl or a dropped op_arm is invisible to a forced hit or a
// forced miss. atLvl is what be_trapped passes — "pstats.s_lvl - 1" for
// T_ARROW, "pstats.s_lvl + 1" for T_DART — and fight.c swing is
// "rnd(20) + wplus >= (20 - at_lvl) - op_arm" with wplus 1, so an
// off-by-one anywhere in it moves the boundary by one face of twenty and
// changes the outcome of roughly one trial in twenty.
func assertSwingArm(
t *testing.T, g *RogueGame, pos Coord, atLvl int, hitMsg, missMsg string,
) {
t.Helper()
const trials = 200
p := &g.Player
hits, misses := 0, 0
for i := range trials {
p.Stats.HP = 500
p.Stats.Str = 16
g.Msgs.Mpos = 0
snap := *g.Rng
hit := snap.Rnd(20)+1 >= (20-atLvl)-p.Stats.ArmorClass
g.springTrap(pos)
want := missMsg
if hit {
want = hitMsg
hits++
} else {
misses++
}
if g.Msgs.Huh != want {
t.Fatalf("trial %d: rnd(20) said %v, message = %q, want %q",
i, hit, g.Msgs.Huh, want)
}
}
if hits == 0 || misses == 0 {
t.Errorf("only one arm was reached in %d trials (%d hits, %d misses)",
trials, hits, misses)
}
}
// TestTrapArrowSwingsAtLevelMinusOne pins "swing(pstats.s_lvl - 1,
// pstats.s_arm, 1)" — the minus one, and that the hero's armor is what
// is defended against.
func TestTrapArrowSwingsAtLevelMinusOne(t *testing.T) {
t.Parallel()
g := mkTrapGame(t)
p := &g.Player
p.Stats.Lvl = 10
p.Stats.ArmorClass = 3
pos := p.Pos
plantTrap(g, pos, TrapArrow)
assertSwingArm(t, g, pos, p.Stats.Lvl-1, cArrowHit, cArrowMiss)
}
// TestTrapDartSwingsAtLevelPlusOne pins "swing(pstats.s_lvl+1,
// pstats.s_arm, 1)" — note the sign is the opposite of T_ARROW's, which
// is exactly the sort of detail a transliterating port drops.
func TestTrapDartSwingsAtLevelPlusOne(t *testing.T) {
t.Parallel()
g := mkTrapGame(t)
p := &g.Player
p.Stats.Lvl = 10
p.Stats.ArmorClass = 3
pos := p.Pos
plantTrap(g, pos, TrapDart)
assertSwingArm(t, g, pos, p.Stats.Lvl+1, cDartHit, cDartMiss)
}
// assertArrowOnFloor finds the object T_ARROW's miss arm added and checks
// it is C's init_weapon(arrow, ARROW) with o_count 1, dropped by fall()
// on one of the eight squares around the hero (weapons.c fallpos).
func assertArrowOnFloor(
t *testing.T, g *RogueGame, before map[*Object]bool, pos Coord,
) {
t.Helper()
var arrow *Object
for _, obj := range g.Level.Objects {
if !before[obj] {
arrow = obj
}
}
if arrow == nil {
t.Fatal("a missed arrow left nothing on the floor")
}
if arrow.Kind != KindWeapon || arrow.WeaponKind() != WeaponArrow {
t.Errorf("the dropped object is kind %v which %d, want an ARROW",
arrow.Kind, arrow.Which)
}
if arrow.Count != 1 {
t.Errorf("the dropped arrow has o_count %d, want 1", arrow.Count)
}
dy, dx := arrow.Pos.Y-pos.Y, arrow.Pos.X-pos.X
if dy < -1 || dy > 1 || dx < -1 || dx > 1 {
t.Errorf("the arrow fell at %v, which is not next to the hero at %v",
arrow.Pos, pos)
}
}
// TestTrapTeleportMovesTheHeroAndDrawsTheTrap covers case T_TELEP: the
// hero is relocated and the square he was standing on is left showing
// the trap.
//
// be_trapped takes a coordinate, and the two call sites pass different
// ones. This test is the tc == hero shape: move.go's "case Floor" arm
// springs a trap the hero is already standing on and passes p.Pos. In
// that shape C's mvaddch(tc, TRAP) is not what puts the glyph on screen
// — the prologue has already set the cell's p_ch to TRAP and teleport()
// opens by drawing floor_at(), which returns chat(hero), over the
// departing square — so this test asserts the end state a player sees
// and does not isolate that one call. The other shape, walking onto the
// trap, is where the line is the only writer; that is
// TestTrapTeleportDrawsTheTrapOnTheSquareSteppedOnto below.
func TestTrapTeleportMovesTheHeroAndDrawsTheTrap(t *testing.T) {
t.Parallel()
g := mkTrapGame(t)
pos := g.Player.Pos
plantTrap(g, pos, TrapTeleport)
g.NoMove = 4
if got := g.springTrap(pos); got != TrapTeleport {
t.Errorf("springTrap returned %d, want T_TELEP (%d)", got, TrapTeleport)
}
if g.Player.Pos == pos {
t.Fatal("the teleport trap left the hero where he was")
}
if ch := g.Level.Char(g.Player.Pos.Y, g.Player.Pos.X); !stepOk(ch) {
t.Errorf("the hero landed on %q, which is not a walkable square", ch)
}
if ch := g.scr.Std.MvInch(pos.Y, pos.X); ch != Trap {
t.Errorf("the vacated square shows %q, want the TRAP glyph %q",
ch, Trap)
}
if g.NoMove != 0 {
t.Errorf("no_move = %d after teleporting, want 0", g.NoMove)
}
}
// steppedOnSquare picks a square next to the hero that is drawn as plain
// floor, standing in for the square do_move is about to step onto. It
// must not be the hero's own square: that is the one shape in which
// teleport() redraws the trap glyph by itself.
func steppedOnSquare(t *testing.T, g *RogueGame, hero Coord) Coord {
t.Helper()
for _, d := range []Coord{
{Y: -1, X: -1}, {Y: -1, X: 0}, {Y: -1, X: 1},
{Y: 0, X: -1}, {Y: 0, X: 1},
{Y: 1, X: -1}, {Y: 1, X: 0}, {Y: 1, X: 1},
} {
c := Coord{Y: hero.Y + d.Y, X: hero.X + d.X}
if g.Level.Char(c.Y, c.X) == Floor &&
g.scr.Std.MvInch(c.Y, c.X) == Floor {
return c
}
}
t.Fatalf("no plain floor square next to the hero at %v", hero)
return Coord{}
}
// TestTrapTeleportDrawsTheTrapOnTheSquareSteppedOnto covers the second
// line of case T_TELEP, "mvaddch(tc->y, tc->x, TRAP)", in the shape that
// makes it load-bearing — the ordinary walk onto a hidden trap.
//
// move.go's "case Trap" arm (move.c do_move) passes nh, the square being
// stepped *onto*, while the hero is still standing on the previous
// square. So teleport()'s opening mvaddch(hero, floor_at()) paints that
// previous square and not tc, and rooms.c leave_room writes blanks and
// never TRAP. The mvaddch is then the only thing that puts the glyph
// where the player has just discovered a trap, which is exactly what C's
// comment claims: "since the hero's leaving, look() won't put a TRAP
// down for us, so we have to do it ourself".
//
// Nothing later covers for it, either. The case Trap arm returns before
// finishMove when the trap was a teleporter, so this direct springTrap
// call is the whole of that path, and look() only ever redraws the nine
// squares around the hero's new position.
func TestTrapTeleportDrawsTheTrapOnTheSquareSteppedOnto(t *testing.T) {
t.Parallel()
g := mkTrapGame(t)
hero := g.Player.Pos
tc := steppedOnSquare(t, g, hero)
plantTrap(g, tc, TrapTeleport)
if got := g.springTrap(tc); got != TrapTeleport {
t.Errorf("springTrap returned %d, want T_TELEP (%d)", got, TrapTeleport)
}
if g.Player.Pos == hero || g.Player.Pos == tc {
t.Fatalf("the hero is at %v after being teleported off %v; "+
"he started on %v and must have gone somewhere else",
g.Player.Pos, tc, hero)
}
if ch := g.scr.Std.MvInch(tc.Y, tc.X); ch != Trap {
t.Errorf("the square stepped onto shows %q, want the TRAP glyph %q",
ch, Trap)
}
}
// TestTrapDartMissesTheHero covers the first arm of case T_DART,
// "if (!swing(pstats.s_lvl+1, pstats.s_arm, 1)) msg(...)". Note the
// **plus** one, the opposite of T_ARROW's minus one. Nothing else may
// happen: no damage, no strength loss, and no random number beyond the
// swing itself — the poison save is inside the other arm.
func TestTrapDartMissesTheHero(t *testing.T) {
t.Parallel()
g := mkTrapGame(t)
p := &g.Player
forceSwing(g, 1, false)
p.Stats.HP = 500
p.Stats.Str = 16
pos := p.Pos
plantTrap(g, pos, TrapDart)
snap := *g.Rng
_ = snap.Rnd(20) // fight.c swing
g.springTrap(pos)
if p.Stats.HP != 500 {
t.Errorf("a missed dart cost %d HP", 500-p.Stats.HP)
}
if p.Stats.Str != 16 {
t.Errorf("strength = %d after a missed dart, want 16", p.Stats.Str)
}
if *g.Rng != snap {
t.Error("the missed dart spent more than swing's rnd(20)")
}
if g.Msgs.Huh != cDartMiss {
t.Errorf("message = %q, want %q", g.Msgs.Huh, cDartMiss)
}
}
// TestTrapDartPoisonsTheHero covers the hit arm of T_DART:
//
// pstats.s_hpt -= roll(1, 4);
// if (pstats.s_hpt <= 0) { ... death('d'); }
// if (!ISWEARING(R_SUSTSTR) && !save(VS_POISON)) chg_str(-1);
// msg("a small dart just hit you in the shoulder");
//
// Both save outcomes are exercised, each checked against C's own
// arithmetic replayed from the generator state the call started from:
// monsters.c save_throw is "roll(1, 20) >= 14 + which - lvl / 2" and
// VS_POISON is 0 (rogue.h 135). Note that C prints the shoulder message
// on this arm whether or not the strength went.
func TestTrapDartPoisonsTheHero(t *testing.T) {
t.Parallel()
g := mkTrapGame(t)
p := &g.Player
forceSwing(g, 1, true)
pos := p.Pos
plantTrap(g, pos, TrapDart)
const trials = 60
sawLoss, sawSave := false, false
for i := range trials {
p.Stats.HP = 500
p.Stats.Str = 16
g.Msgs.Mpos = 0
snap := *g.Rng
_ = snap.Rnd(20) // fight.c swing
wantHP := 500 - (snap.Rnd(4) + 1) // roll(1, 4)
saved := snap.Roll(1, 20) >= 14+VsPoison-p.Stats.Lvl/2
g.springTrap(pos)
if *g.Rng != snap {
t.Fatalf("trial %d: the dart hit did not spend rnd(20), "+
"roll(1,4) and save's roll(1,20) and nothing else", i)
}
assertDartHit(t, g, i, wantHP, saved)
sawLoss, sawSave = sawLoss || !saved, sawSave || saved
}
if !sawLoss || !sawSave {
t.Errorf("only one save outcome came up in %d trials "+
"(strength lost: %v, saved: %v)", trials, sawLoss, sawSave)
}
}
// assertDartHit checks one trial of the dart hit arm.
func assertDartHit(t *testing.T, g *RogueGame, i, wantHP int, saved bool) {
t.Helper()
p := &g.Player
if p.Stats.HP != wantHP {
t.Fatalf("trial %d: HP = %d after the dart, want %d (500 - roll(1,4))",
i, p.Stats.HP, wantHP)
}
wantStr := 16
if !saved {
wantStr = 15 // chg_str(-1)
}
if p.Stats.Str != wantStr {
t.Fatalf("trial %d: strength = %d after the dart (saved=%v), want %d",
i, p.Stats.Str, saved, wantStr)
}
if g.Msgs.Huh != cDartHit {
t.Fatalf("trial %d: message = %q, want %q", i, g.Msgs.Huh, cDartHit)
}
}
// TestTrapDartSustainStrengthShortCircuitsTheSave pins the && in
// "!ISWEARING(R_SUSTSTR) && !save(VS_POISON)". C never reaches the save
// while the ring is worn, so the arm must spend two random numbers and
// not three; a port that evaluated the save anyway would keep the
// strength but shift every later draw, silently breaking seed
// compatibility. The damage still lands: the ring guards strength only.
func TestTrapDartSustainStrengthShortCircuitsTheSave(t *testing.T) {
t.Parallel()
g := mkTrapGame(t)
p := &g.Player
forceSwing(g, 1, true)
p.Stats.HP = 500
p.Stats.Str = 16
p.CurRing[Left] = mkRing(RingSustainStrength, 0)
pos := p.Pos
plantTrap(g, pos, TrapDart)
snap := *g.Rng
_ = snap.Rnd(20) // fight.c swing
wantHP := 500 - (snap.Rnd(4) + 1) // roll(1, 4)
g.springTrap(pos)
if p.Stats.Str != 16 {
t.Errorf("strength = %d with R_SUSTSTR worn, want 16", p.Stats.Str)
}
if p.Stats.HP != wantHP {
t.Errorf("HP = %d, want %d: R_SUSTSTR does not stop the damage",
p.Stats.HP, wantHP)
}
if *g.Rng != snap {
t.Error("R_SUSTSTR must short-circuit before save(VS_POISON), " +
"but a third random number was drawn")
}
if g.Msgs.Huh != cDartHit {
t.Errorf("message = %q, want %q", g.Msgs.Huh, cDartHit)
}
}
// TestTrapRustSoaksTheHero covers "case T_RUST: msg("a gush of water hits
// you on the head"); rust_armor(cur_armor)" together with move.c
// rust_armor, whose four outcomes are the whole content of the case. Each
// subtest reads the last message, so the gush shows up as the final
// message exactly in the cases where rust_armor returns without one.
func TestTrapRustSoaksTheHero(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
armor *Object
prot bool
terse bool
wantAC int
wantMsg string
}{
{"no armor at all", nil, false, false, 0, cGushOfWater},
{"leather is immune", mkArmor(ArmorLeather, 2), false, false, 2,
cGushOfWater},
{"already at o_arm 9", mkArmor(ArmorPlateMail, 9), false, false, 9,
cGushOfWater},
{"plate mail rusts", mkArmor(ArmorPlateMail, 3), false, false, 4,
cArmorWeaker},
{"plate mail rusts, terse", mkArmor(ArmorPlateMail, 3), false, true, 4,
cArmorTerse},
{"protected armor holds", mkArmor(ArmorPlateMail, 3), true, false, 3,
cRustVanishes},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
g := mkTrapGame(t)
g.Options.Terse = tc.terse
if tc.armor != nil && tc.prot {
tc.armor.Flags.Set(Protected)
}
// Assigned unconditionally: init_player hands the hero ring
// mail, so "no armor" has to be arranged, not assumed.
g.Player.CurArmor = tc.armor
pos := g.Player.Pos
plantTrap(g, pos, TrapRust)
g.springTrap(pos)
if tc.armor != nil && tc.armor.ArmorClass != tc.wantAC {
t.Errorf("o_arm = %d after the gush, want %d",
tc.armor.ArmorClass, tc.wantAC)
}
if g.Msgs.Huh != tc.wantMsg {
t.Errorf("message = %q, want %q", g.Msgs.Huh, tc.wantMsg)
}
})
}
}
// TestTrapRustHonoursTheRingAndTheToDeathFlag covers the two predicates
// of move.c rust_armor that the table above cannot reach, because every
// row of it takes the left branch through ISPROT and leaves to_death
// clear:
//
// if ((arm->o_flags & ISPROT) || ISWEARING(R_SUSTARM))
// {
// if (!to_death)
// msg("the rust vanishes instantly");
// }
//
// No armor here is ISPROT, so the ring is the only thing that can save
// it, and the second row then checks that fighting to the death
// swallows the message while still saving the armor. Both rows expect an
// unrusted o_arm; what separates them is which message the line is left
// showing.
func TestTrapRustHonoursTheRingAndTheToDeathFlag(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
toDeath bool
wantMsg string
}{
{"the ring alone saves the armor", false, cRustVanishes},
{"to_death swallows the message", true, cGushOfWater},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
g := mkTrapGame(t)
g.ToDeath = tc.toDeath
armor := mkArmor(ArmorPlateMail, 3)
g.Player.CurArmor = armor
g.Player.CurRing[Left] = mkRing(RingMaintainArmor, 0)
pos := g.Player.Pos
plantTrap(g, pos, TrapRust)
g.springTrap(pos)
if armor.ArmorClass != 3 {
t.Errorf("o_arm = %d with R_SUSTARM worn, want it held at 3",
armor.ArmorClass)
}
if g.Msgs.Huh != tc.wantMsg {
t.Errorf("message = %q, want %q", g.Msgs.Huh, tc.wantMsg)
}
})
}
}
// TestTrapRustAlwaysAnnouncesTheWater pins the gush in the case where a
// second message follows it and Msgs.Huh can therefore no longer see it.
// C prints the gush unconditionally, *before* rust_armor is called, so a
// port that folded it into the no-armor path would still pass every row
// of the table above.
//
// The trick is io.c's own machinery: with msg_esc set, answering the
// --More-- that rust_armor's message raises with an ESCAPE makes endmsg
// bail out before it draws, so the line the test reads is the one the
// gush left there. That the --More-- came up at all is itself the proof
// that a first message had already been posted.
func TestTrapRustAlwaysAnnouncesTheWater(t *testing.T) {
t.Parallel()
g := mkTrapGame(t)
g.Msgs.MsgEsc = true
g.Player.CurArmor = mkArmor(ArmorPlateMail, 3)
setInput(t, g, Escape)
pos := g.Player.Pos
plantTrap(g, pos, TrapRust)
g.springTrap(pos)
// endmsg upper-cases the first letter of a message that does not
// start with a pack character, so the line reads "A gush ...".
want := string(toUpper(cGushOfWater[0])) + cGushOfWater[1:] + "--More--"
if line := strings.TrimRight(g.scr.Std.Line(0), " "); line != want {
t.Errorf("message line = %q, want %q", line, want)
}
if g.Msgs.Huh != cArmorWeaker {
t.Errorf("last message = %q, want %q", g.Msgs.Huh, cArmorWeaker)
}
}
// cMysteryMsg rebuilds the message C's T_MYST arm prints for a given
// rnd(11) result, drawing the rainbow colour from rng at exactly the
// point C draws it. The strings are transcribed from move.c; the colour
// table itself is not restated here because TestSeedCompatItemTables
// already pins rainbow[] byte-for-byte against the C reference's own
// output, and what this arm can get wrong is the index, not the list.
func cMysteryMsg(rng *Rng, which int, rainbow []string) string {
switch which {
case 0:
return "you are suddenly in a parallel dimension"
case 1:
return "the light in here suddenly seems " +
rainbow[rng.Rnd(len(rainbow))]
case 2:
return "you feel a sting in the side of your neck"
case 3:
return "multi-colored lines swirl around you, then fade"
case 4:
return fmt.Sprintf("a %s light flashes in your eyes",
rainbow[rng.Rnd(len(rainbow))])
case 5:
return "a spike shoots past your ear!"
default:
return cMysteryMsgMore(rng, which, rainbow)
}
}
// cMysteryMsgMore holds the back half of C's eleven-way switch. Note
// case 10's "you pack turns", a typo in the C source that the port
// preserves deliberately along with the rest of the game's text.
func cMysteryMsgMore(rng *Rng, which int, rainbow []string) string {
switch which {
case 6:
return rainbow[rng.Rnd(len(rainbow))] +
" sparks dance across your armor"
case 7:
return "you suddenly feel very thirsty"
case 8:
return "you feel time speed up suddenly"
case 9:
return "time now seems to be going slower"
case 10:
return fmt.Sprintf("you pack turns %s!",
rainbow[rng.Rnd(len(rainbow))])
default:
return ""
}
}
// TestTrapMysteryMatchesTheCMessageSwitch covers case T_MYST, the only
// trap whose whole effect is its message. Each trial snapshots the
// generator, springs the trap, and recomputes what C would have printed
// from the identical state — which pins the rnd(11) bound, the case
// numbering, every string, and the fact that the four colour arms draw a
// second random number and the other seven do not. The sweep then insists
// every one of the eleven arms actually came up, so no arm is proved
// correct only by never being reached.
func TestTrapMysteryMatchesTheCMessageSwitch(t *testing.T) {
t.Parallel()
g := mkTrapGame(t)
pos := g.Player.Pos
plantTrap(g, pos, TrapMystery)
const (
trials = 400
arms = 11
)
seen := make(map[int]bool, arms)
for i := range trials {
g.Msgs.Mpos = 0
snap := *g.Rng
which := snap.Rnd(arms)
want := cMysteryMsg(&snap, which, g.data.rainbow)
g.springTrap(pos)
if *g.Rng != snap {
t.Fatalf("trial %d: T_MYST case %d did not spend C's "+
"random numbers", i, which)
}
if g.Msgs.Huh != want {
t.Fatalf("trial %d: T_MYST case %d printed %q, want %q",
i, which, g.Msgs.Huh, want)
}
seen[which] = true
}
for which := range arms {
if !seen[which] {
t.Errorf("T_MYST case %d never came up in %d trials",
which, trials)
}
}
}