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