From 061da11877f961244a29accfe5a5d87ddcaf7282 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 23 Jul 2026 08:58:05 +0700 Subject: [PATCH 1/2] Add deep-playthrough and turn-loop crash-sweep tests (playtest hardening) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- game/run_test.go | 135 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) 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 +} From e7e1bc3c40fc78e61295adbd346aa819e29fb6eb Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 23 Jul 2026 08:59:03 +0700 Subject: [PATCH 2/2] Rotate TODO to seed-verification step (playtest hardening done) --- TODO.md | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/TODO.md b/TODO.md index 704a64c..96115fc 100644 --- a/TODO.md +++ b/TODO.md @@ -29,16 +29,23 @@ Refactor ground rules: # Next Step -Playtest hardening pass: play several full games with the tcell binary and -extend run_test.go to drive a deeper multi-level playthrough (descend past level -5, use potions, scrolls, zapping, save/restore). Note: game-over now exits the -process (step 8), so scripted drives must stay bounded and avoid -death/quit/save-command; exercise the exit paths (death display, 'S' save) -another way if needed. Fix any panics, message mismatches, or divergences from -the C behavior that this uncovers, with regression tests. +Verify the seed-compatibility claim against the C reference on c-master: same +seed, same dungeon, same item tables, for several seeds. # Completed Steps +- 2026-07-23 Playtest hardening (playtest-hardening): added two death-safe + crash-sweep drives through the real 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 uses quaff/read/zap through command + dispatch, then descends to depth 8 with a save/restore at depth 4; + TestTurnLoopCrashSweep mashes movement/search/rest for 200 turns on four + seeds. Neither surfaced a panic. The interactive "play several games at a real + tcell terminal" portion needs a human at an 80x24 terminal and is left to the + maintainer; the binary's non-interactive paths (`-s` scores) were + smoke-tested. + - 2026-07-23 Docs refresh (docs-refresh): rewrote ARCHITECTURE.md Part 2 (the pre-implementation design sketch) to match the final code — current type/field names (ObjectKind, DiceSpec, split o_arm, step-1 flag names, TrapCount, Level @@ -157,17 +164,15 @@ the C behavior that this uncovers, with regression tests. # Future Steps -1. Verify the seed-compatibility claim against the C reference on c-master: same - seed, same dungeon, same item tables, for several seeds. -2. Broaden unit test coverage where playtesting finds thin spots (rings, sticks, +1. Broaden unit test coverage where playtesting finds thin spots (rings, sticks, wizard commands). -3. Tag a release once a full game (Amulet retrieval and score entry) completes +2. Tag a release once a full game (Amulet retrieval and score entry) completes without defects. -4. Full-terminal-size support (deferred by explicit decision 2026-07-06): +3. Full-terminal-size support (deferred by explicit decision 2026-07-06): per-game dungeon dimensions instead of the 80x24 constants; open design questions are resize policy, gameplay tuning at larger sizes, and a --classic 80x24 mode. -5. Note: this repo is exempt from the standard policy scaffold. A minimal dev +4. Note: this repo is exempt from the standard policy scaffold. A minimal dev Makefile (fmt/fmt-check/lint/test/check targets) exists per sneak's 2026-07-07 request, but do not add a Dockerfile, CI config, or REPO_POLICIES.md.