`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.
1141 lines
33 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|