From b0f96fac281aa3aa2b27271472f0a5306b213cc5 Mon Sep 17 00:00:00 2001 From: sneak Date: Sun, 9 Aug 2026 15:37:46 +0000 Subject: [PATCH] Unit-test the eight trap effects against the C reference (closes #14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. 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. --- TODO.md | 67 +++ game/traps_test.go | 1023 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1090 insertions(+) create mode 100644 game/traps_test.go diff --git a/TODO.md b/TODO.md index be090ee..b9b27eb 100644 --- a/TODO.md +++ b/TODO.md @@ -70,6 +70,73 @@ of Completed Steps. 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 Trap unit-test coverage (`test/traps-coverage`, 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` (17 tests, 13 subtests) covers all eight arms of + `move.c be_trapped`, the prologue every trap runs through, and the + `rust_armor` tail `T_RUST` calls. Package coverage 56.2% -> 57.9% measured on + `main` at `bf820e3`, the branch point, before the sticks tests landed. Every + expected value is transcribed from `origin/c-master` and quoted in the file. + **No divergence from C was found.** + + The issue body's trap list was wrong and the correction is the first thing + worth recording: there is no separate "poison dart" trap — `T_DART` **is** + the poisoned dart, its death message being "a poisoned dart killed you" — + and the list omitted `T_MYST`, the mystery trap, whose arm is an eleven-way + `rnd(11)` message switch. `rogue.h` 192-200 is the authority + (`T_DOOR`/`T_ARROW`/`T_SLEEP`/`T_BEAR`/`T_TELEP`/`T_DART`/`T_RUST`/`T_MYST`, + `NTRAPS` 8) and the Go `TrapKind` iota matches it index-for-index. + + Three C details the tests are built around. (1) `BEARTIME` and `SLEEPTIME` + are `spread(3)` and `spread(5)` (`rogue.h` 108-109), and `spread` is + `nm - nm/20 + rnd(nm/10)`; for both, `nm/10` is 0 and C's `rnd` short + circuits a zero range without touching the generator, so each is an exact + constant that costs **no** random number — and the tests assert the no-draw + half as well as the value, because a stray draw desynchronises the + seed-compatible stream. (2) `T_ARROW` swings at `s_lvl - 1` and `T_DART` at + `s_lvl + 1`: opposite signs, which is exactly the kind of detail a + transliterating port drops. (3) The strength loss is gated on + `!ISWEARING(R_SUSTSTR) && !save(VS_POISON)`, and the `&&` is load-bearing — + with the ring on, C never rolls the save, so the arm must spend two random + numbers and not three. + + Two shapes worth keeping, both forced by mutation results rather than + foresight. Damage dice are checked by a **sweep**, not one shot: `rnd(n)` is + "raw value % n", so a single draw agrees between a d6 and a d5 five times in + six and leaves the generator identical either way — the first draft's + single-trial arrow test passed with `roll(1,6)` mutated to `roll(1,5)`. + Likewise the swing arguments are pinned by a 200-trial boundary sweep at a + mid-range to-hit target: a forced hit and a forced miss cannot see a wrong + `at_lvl` or a dropped `op_arm`, because both arms are reachable at any level + and swing spends one `rnd(20)` regardless. + + Mutation-proved, 31 mutations, each reverted. Two were **not** caught on the + first pass and the tests were strengthened until they were, which is the + useful part of the record. (a) Deleting `new_level()` from `T_DOOR` left the + suite green: `be_trapped`'s own prologue stamps the trap glyph into the cell + the hero fell through, so "the map changed" is true even with no new level + dug. The test now counts differing cells — exactly one can change that way — + and also requires the staircase to move and the hero to be re-placed. (b) + The `roll(1,6)` case above. One line is recorded as **not isolable**: + `T_TELEP`'s `mvaddch(tc, TRAP)`, whose C comment claims `look()` will not + redraw the vacated square. It is in fact redundant in both C and this port — + the prologue has already set `p_ch` to `TRAP`, `teleport()` opens by drawing + `floor_at()` (which returns `chat(hero)`) over the departing square, and + `leave_room` only blanks squares showing `FLOOR` — so no reachable + configuration makes deleting it observable. That is stated in the test + rather than papered over. The other 29 each failed their own test and only + their own; two also moved `TestAutoSaveOnSignalRacesTurnLoop`, which drives + real turns and is legitimately sensitive to `BEARTIME` and to armor rusting. + + Deliberately uncovered: the two death messages, "an arrow killed you" and "a + poisoned dart killed you". Each is printed immediately before `death()`, + which reaches `myExit` and `os.Exit`, so provoking either would take the + test binary with it; the hero is pinned with `fortify()` and the damage + rolls are checked by replaying C's arithmetic instead of by letting HP reach + zero. `Next Step` deliberately not rotated: #14 was an out-of-band gap found + while surveying, not part of the rings/sticks/wizard step. + - 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 diff --git a/game/traps_test.go b/game/traps_test.go new file mode 100644 index 0000000..84d4d94 --- /dev/null +++ b/game/traps_test.go @@ -0,0 +1,1023 @@ +//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 vacated square is left showing the trap. +// +// One honest caveat, found by mutating the arm and watching this test +// stay green. C's second line carries the comment "since the hero's +// leaving, look() won't put a TRAP down for us, so we have to do it +// ourself", but the mvaddch it justifies is in fact redundant in both C +// and this port: be_trapped's prologue has already set the cell's p_ch to +// TRAP, and teleport() opens by drawing floor_at() over the departing +// square — floor_at() returns chat(hero) — so the glyph is on screen +// before the line runs, and rooms.c leave_room only blanks squares that +// are showing FLOOR. The screen assertion below is therefore a true +// statement about the end state and not an isolation of that one call; +// no reachable configuration makes it one. Left in place rather than +// deleted: the property is what a player sees, and it would fail if +// either of the two writes that actually produce it went away. +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) + } +} + +// 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) + } + }) + } +} + +// 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) + } + } +}