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:
2026-07-23 08:58:05 +07:00
parent 0e6ed41351
commit 061da11877

View File

@@ -1,10 +1,25 @@
package game package game
import ( import (
"path/filepath"
"strings" "strings"
"testing" "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 // 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 // first-level and pre-play setup Run() does. Run() itself no longer
// returns — game-over exits the process — so tests drive command() // 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") 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
}