diff --git a/game/run_test.go b/game/run_test.go index d5dba71..85cbfb3 100644 --- a/game/run_test.go +++ b/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 +}