Add deep-playthrough and turn-loop crash-sweep tests (playtest hardening)
Two death-safe regression drives that exercise the full turn loop within the step-8 os.Exit constraint (a fortify() helper pins HP/food/exp and clears the freeze/stuck counters each turn, so no death exits the test binary; fixed seeds keep them deterministic): - TestDeepPlaythrough: quaff/read/zap through command dispatch, then descend the staircase to depth 8 with a save/restore at depth 4 — a crash sweep of deep level generation, item effects, and mid-game save/restore. It asserts the consumables identify themselves (the commands really ran) and the descent and restore land where expected. - TestTurnLoopCrashSweep: mash movement/search/rest for 200 turns on four seeds, exercising combat, monster AI, and traps. Neither surfaced a panic. Space-separated command scripts answer the --More-- prompts, as wait_for consumes input up to a space.
This commit is contained in:
135
game/run_test.go
135
game/run_test.go
@@ -1,10 +1,25 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fortify makes the hero effectively immortal for a crash-sweep drive:
|
||||
// game-over now calls myExit and os.Exit(0) (step 8), which would kill the
|
||||
// test binary, so every death vector is neutralized. Re-applied each turn
|
||||
// because combat, digestion, freezing, and level drain chip away at these.
|
||||
func fortify(g *RogueGame) {
|
||||
p := &g.Player
|
||||
p.Stats.HP = 30000 // survive combat, arrow/dart traps, bolts
|
||||
p.Stats.MaxHP = 30000 // survive vampire max-hp drain
|
||||
p.Stats.Exp = 30000 // survive wraith level drain (death when exp hits 0)
|
||||
p.FoodLeft = 30000 // never starve
|
||||
g.NoCommand = 0 // never freeze to death (ice monster / sleep trap)
|
||||
g.NoMove = 0 // never stay stuck in a bear trap
|
||||
}
|
||||
|
||||
// driveTurns runs the game's per-turn loop up to n times, doing the same
|
||||
// first-level and pre-play setup Run() does. Run() itself no longer
|
||||
// returns — game-over exits the process — so tests drive command()
|
||||
@@ -67,3 +82,123 @@ func TestScoreRendersList(t *testing.T) {
|
||||
t.Error("score list not on screen")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeepPlaythrough drives a fortified hero through the real command loop:
|
||||
// quaff/read/zap on the first level, then descend through the staircase to
|
||||
// depth 8, saving and restoring mid-way. It is a crash sweep of the turn
|
||||
// engine, deep level generation, item effects, and mid-game save/restore.
|
||||
// The hero is fortified so no death exits the process (step 8), and the fixed
|
||||
// seed keeps it deterministic.
|
||||
func TestDeepPlaythrough(t *testing.T) {
|
||||
g := New(Params{Seed: 4242, Wizard: true, Term: &testTerm{}})
|
||||
g.startLevel()
|
||||
g.prePlay()
|
||||
fortify(g)
|
||||
|
||||
// Stock and use one of each consumable through the command dispatch.
|
||||
pot := give(g, &Object{Kind: KindPotion, Which: int(PotionHealing)})
|
||||
scr := give(g, &Object{Kind: KindScroll, Which: int(ScrollMagicMapping)})
|
||||
|
||||
wand := newObject()
|
||||
wand.Kind = KindWand
|
||||
wand.Which = int(WandLight)
|
||||
wand.Charges = 5
|
||||
zap := give(g, wand)
|
||||
|
||||
setInput(t, g, 'q', pot) // quaff healing
|
||||
g.command()
|
||||
setInput(t, g, 'r', scr) // read magic mapping
|
||||
g.command()
|
||||
setInput(t, g, 'z', 'h', zap) // zap the light wand west
|
||||
g.command()
|
||||
fortify(g)
|
||||
|
||||
// Each consumable identifies itself on use, confirming the q/r/z
|
||||
// commands actually ran through dispatch (not aborted on a bad prompt).
|
||||
if !g.Items.Potions[PotionHealing].Know {
|
||||
t.Error("quaff command did not identify the healing potion")
|
||||
}
|
||||
|
||||
if !g.Items.Scrolls[ScrollMagicMapping].Know {
|
||||
t.Error("read command did not identify the magic-mapping scroll")
|
||||
}
|
||||
|
||||
if !g.Items.Sticks[WandLight].Know {
|
||||
t.Error("zap command did not identify the light wand")
|
||||
}
|
||||
|
||||
const wantDepth = 8
|
||||
|
||||
for g.Depth < wantDepth {
|
||||
g.Player.Pos = g.Level.Stairs // stand on the stairs
|
||||
setInput(t, g, '>', '.') // '>' descends (free), '.' pays the turn
|
||||
g.command()
|
||||
fortify(g)
|
||||
|
||||
if g.Depth == 4 {
|
||||
g = saveAndRestore(t, g)
|
||||
fortify(g)
|
||||
}
|
||||
}
|
||||
|
||||
if g.Depth != wantDepth {
|
||||
t.Errorf("depth = %d after descending, want %d", g.Depth, wantDepth)
|
||||
}
|
||||
|
||||
if g.Player.Stats.HP <= 0 {
|
||||
t.Error("hero died during the playthrough")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnLoopCrashSweep mashes movement, search, and rest through the real
|
||||
// turn loop for many turns on several seeds, exercising combat, monster AI,
|
||||
// and traps. The hero is fortified each turn so nothing exits the process,
|
||||
// and the fixed seeds keep it deterministic; the point is to surface panics.
|
||||
func TestTurnLoopCrashSweep(t *testing.T) {
|
||||
// A generous mix of movement, search, and rest. The spaces between
|
||||
// commands double as answers to any --More-- prompt (wait_for eats
|
||||
// everything up to a space); without them one prompt would swallow the
|
||||
// rest of the script. The script is long enough that the bounded drive
|
||||
// never exhausts it (which would spin on the auto-fed prompt input).
|
||||
script := []byte(strings.Repeat("h j k l y u b n s . ", 400))
|
||||
|
||||
for _, seed := range []int32{1, 99, 2026, 31337} {
|
||||
g := New(Params{Seed: seed, Term: &testTerm{input: script}})
|
||||
g.startLevel()
|
||||
g.prePlay()
|
||||
|
||||
for range 200 {
|
||||
fortify(g)
|
||||
g.command()
|
||||
}
|
||||
|
||||
if g.Player.Stats.HP <= 0 {
|
||||
t.Errorf("seed %d: hero died despite fortify", seed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// saveAndRestore snapshots the game to a file, restores it, checks the key
|
||||
// state survived, and returns the restored game ready to keep playing.
|
||||
func saveAndRestore(t *testing.T, g *RogueGame) *RogueGame {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "deep.save")
|
||||
|
||||
saveErr := g.saveFile(path)
|
||||
if saveErr != nil {
|
||||
t.Fatalf("saveFile: %v", saveErr)
|
||||
}
|
||||
|
||||
h, err := Restore(path, Params{Wizard: true, Term: &testTerm{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Restore: %v", err)
|
||||
}
|
||||
|
||||
if h.Depth != g.Depth || h.Player.Purse != g.Player.Purse {
|
||||
t.Errorf("restored game diverged: depth %d/%d purse %d/%d",
|
||||
h.Depth, g.Depth, h.Player.Purse, g.Player.Purse)
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user