diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a0f508f..e8b9588 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1076,7 +1076,7 @@ that `game` itself has no third-party imports: go.mod module git.eeqj.de/sneak/rgoue cmd/rogue/main.go package main — flag parsing, constructs RogueGame, calls Run game/ package game — the port, one .go file per .c file - game.go RogueGame struct, NewGame, Run (main.c) + game.go RogueGame struct, New, Run (main.c) rng.go seeded LCG (main.c: rnd/roll + RN macro) types.go Coord, Stats, flags, constants (rogue.h) object.go Object type (rogue.h THING _o arm) @@ -1120,7 +1120,7 @@ coherent, but everything is reachable from `g`. ```go // RogueGame is one complete game of Rogue: all state that was global in the -// C implementation, plus the terminal it plays on. Construct with NewGame, +// C implementation, plus the terminal it plays on. Construct with New, // then call Run. type RogueGame struct { // --- identity / RNG --- @@ -1193,15 +1193,15 @@ The command.c/io.c/things.c file-statics become lowercase fields of `RogueGame`, ### Entrypoints ```go -func NewGame(opts Config) *RogueGame // Config: seed, name, ROGUEOPTS string, score path, wizard -func (g *RogueGame) Run() error // main.c main()+playit(): init tables, first level, daemons, loop -func (g *RogueGame) command() // one turn (command.c) -func Restore(path string, opts Config) (*RogueGame, error) // save.c restore() +func New(params Params) *RogueGame // Params: seed, name, ROGUEOPTS string, score path, wizard +func (g *RogueGame) Run() // main.c main()+playit(): init tables, first level, daemons, loop +func (g *RogueGame) command() // one turn (command.c) +func Restore(path string, params Params) (*RogueGame, error) // save.c restore() ``` `cmd/rogue/main.go` is a thin shell: flags (`-s` scores, `-d` death demo, -save-file arg, `SEED`/`ROGUEOPTS` env), `NewGame(...)` or `Restore(...)`, -`Run()`, exit code. +save-file arg, `SEED`/`ROGUEOPTS` env), `New(...)` or `Restore(...)`, `Run()`, +exit code. ## 4. Core types @@ -1366,7 +1366,7 @@ type Room struct { tables that are never written after init** (the only permitted package-level data — they are ROM, not state): `monsterTable [26]MonsterKind` (name, carry %, flags, stats), `initDam` weapon table, base `objInfo` tables (copied into -`RogueGame.Items` at NewGame so per-game mutation — probability resumming, +`RogueGame.Items` at New so per-game mutation — probability resumming, `oi_know`, `oi_guess` — stays instance-local), `eLevels`, `rainbow`, `stones`, `woods`, `metals`, `sylls`, trap names, help text, `lvlMons`/`wandMons` spawn strings, `hNames`/`mNames` combat messages. @@ -1552,26 +1552,26 @@ Tombstone/victory screens port verbatim from rip.c. ## 6. C construct → Go construct map -| C construct | Go translation | -| ---------------------------------------------------- | -------------------------------------------------------------------------------------- | -| global variable | `RogueGame` field (or Player/Level/subsystem field) | -| file-scope static | unexported field on `RogueGame` or subsystem struct | -| `THING` union | `Creature` / `Object` structs | -| linked lists (`l_next/l_prev`, attach/detach) | slices + `attach(&s, x)`-style helpers (prepend, remove by identity) | -| `coord*` aliasing (t_dest) | `*Coord` pointers into live structs (kept) | -| function pointers (daemons, options, nameit) | enum IDs + dispatch switch (daemons, save-relevant); closures (options, nameit) | -| `when`/`otherwise`/`until` macros | `case`/`default`/`for !cond` | -| `goto` (retry loops in pack.c, move.c, passages.c) | labeled loops / restructured `for` | -| char-indexed tables (`monsters[type-'A']`) | same, `monsterTable[t.Type-'A']` | -| damage strings `"3x4/1x2"` | kept as strings; parsed by `rollEm` (`strings`/`strconv`) | -| curses stdscr/hw windows | `Window` cell buffers over tcell | -| `mvinch` screen reads | `Window.Inch` from the buffer | -| signal handlers (SIGHUP autosave, SIGTSTP, SIGINT) | `os/signal` goroutine → channel checked in ReadChar; tcell handles TSTP/resize | -| `setjmp`-free exits (`my_exit`, `exit()` everywhere) | error returns from `Run` + a small `gameOver` sentinel; no `os.Exit` inside game logic | -| `vsprintf` message building | `fmt.Sprintf` | -| XOR-encrypted binary saves (state.c) | gob snapshot (§5.6) | -| DES wizard password | `ROGUE_WIZARD=1` env check | -| `md_*` platform shims | stdlib (`os`, `os/user`, `time`) or deleted | +| C construct | Go translation | +| ---------------------------------------------------- | ---------------------------------------------------------------------------------- | +| global variable | `RogueGame` field (or Player/Level/subsystem field) | +| file-scope static | unexported field on `RogueGame` or subsystem struct | +| `THING` union | `Creature` / `Object` structs | +| linked lists (`l_next/l_prev`, attach/detach) | slices + `attach(&s, x)`-style helpers (prepend, remove by identity) | +| `coord*` aliasing (t_dest) | `*Coord` pointers into live structs (kept) | +| function pointers (daemons, options, nameit) | enum IDs + dispatch switch (daemons, save-relevant); closures (options, nameit) | +| `when`/`otherwise`/`until` macros | `case`/`default`/`for !cond` | +| `goto` (retry loops in pack.c, move.c, passages.c) | labeled loops / restructured `for` | +| char-indexed tables (`monsters[type-'A']`) | same, `monsterTable[t.Type-'A']` | +| damage strings `"3x4/1x2"` | kept as strings; parsed by `rollEm` (`strings`/`strconv`) | +| curses stdscr/hw windows | `Window` cell buffers over tcell | +| `mvinch` screen reads | `Window.Inch` from the buffer | +| signal handlers (SIGHUP autosave, SIGTSTP, SIGINT) | `os/signal` goroutine → channel checked in ReadChar; tcell handles TSTP/resize | +| `setjmp`-free exits (`my_exit`, `exit()` everywhere) | `myExit` restores the terminal and calls `os.Exit(0)`; one game run is one process | +| `vsprintf` message building | `fmt.Sprintf` | +| XOR-encrypted binary saves (state.c) | gob snapshot (§5.6) | +| DES wizard password | `ROGUE_WIZARD=1` env check | +| `md_*` platform shims | stdlib (`os`, `os/user`, `time`) or deleted | **Exit discipline** deserves a note: the C code calls `exit()` from deep inside call chains (death, victory, save). The port threads a `g.gameOver(reason)` that @@ -1634,6 +1634,6 @@ defers. - **Determinism test:** scripted input sequence against a fixed seed must produce identical final state across runs (guards hidden nondeterminism — map iteration, time dependence). -- **Save round-trip:** NewGame → play N scripted turns → save → restore → - compare full state. +- **Save round-trip:** New → play N scripted turns → save → restore → compare + full state. - `go vet` + `gofmt` clean at every commit. diff --git a/TODO.md b/TODO.md index 0464d44..b1b2b62 100644 --- a/TODO.md +++ b/TODO.md @@ -29,12 +29,23 @@ Refactor ground rules: # Next Step -Refactor step 8: constructor and style pass per the house styleguide — -game.New(game.Params{...}) replacing NewGame(Config); replace the gameEnd panic -unwind with error-based turn results where feasible; 77-column wrap sweep. +Docs refresh: update ARCHITECTURE.md Part 2 and README.md for the post-refactor +names; add the C name → Go name rename table. # Completed Steps +- 2026-07-23 Refactor step 8 (refactor/constructor-style): constructor and exit + pass. NewGame(Config) → New(Params) and Restore takes Params, so the package's + primary type gets the canonical New() constructor with a named-field Params + struct (styleguide 139/159). The gameEnd panic unwind is gone: one game run is + one process, so myExit restores the terminal (new Terminal.Fini) and calls + os.Exit(0), and Run() no longer returns; the four Run()-to-completion tests + were reworked/dropped since death (combat or starvation) now exits the process + (TestScoreRendersList and TestRunDownStairs preserve what is still drivable; + save/restore stays covered by TestSaveRestoreRoundTrip). The 77-column wrap + sweep was dropped per sneak (2026-07-23): line lengths left as-is (lll caps at + 88 and passes). + - 2026-07-07 Refactor step 7 (refactor/effects-dispatch): effects dispatch tables plus a full decomposition sweep — the quaff / readScroll / doZap switches, the attack monster-power switch, the be_trapped switch, the daemon @@ -133,24 +144,24 @@ unwind with error-based turn results where feasible; 77-column wrap sweep. # Future Steps -1. Docs refresh: update ARCHITECTURE.md Part 2 and README.md for the - post-refactor names; add the C name → Go name rename table. -2. Playtest hardening pass: play several full games with the tcell binary and - extend run_test.go to script a deeper multi-level playthrough (descend past - level 5, use potions, scrolls, zapping, save/restore). Fix any panics, - message mismatches, or divergences from the C behavior that this uncovers, - with regression tests. -3. Verify the seed-compatibility claim against the C reference on c-master: same +1. 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. +2. Verify the seed-compatibility claim against the C reference on c-master: same seed, same dungeon, same item tables, for several seeds. -4. Broaden unit test coverage where playtesting finds thin spots (rings, sticks, +3. Broaden unit test coverage where playtesting finds thin spots (rings, sticks, wizard commands). -5. Tag a release once a full game (Amulet retrieval and score entry) completes +4. Tag a release once a full game (Amulet retrieval and score entry) completes without defects. -6. Full-terminal-size support (deferred by explicit decision 2026-07-06): +5. 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. -7. Note: this repo is exempt from the standard policy scaffold. A minimal dev +6. 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. diff --git a/cmd/rogue/main.go b/cmd/rogue/main.go index d571e95..bd5e6f9 100644 --- a/cmd/rogue/main.go +++ b/cmd/rogue/main.go @@ -21,18 +21,20 @@ func main() { os.Exit(run()) } -// run carries the real main so that deferred terminal restoration runs -// before the process exits (os.Exit skips defers). +// run does the real work and returns an exit code. It only returns on a +// startup error; once the game starts, it ends by exiting the process +// from within (game.myExit restores the terminal first). The deferred +// Fini covers the early-return paths. func run() int { scores := flag.Bool("s", false, "print the scoreboard and exit") deathDemo := flag.Bool("d", false, "die a random death (demo)") flag.Parse() - cfg := loadConfig() + params := loadParams() if *scores { - game.NewGame(cfg).ShowScores() + game.New(params).ShowScores() return 0 } @@ -45,46 +47,39 @@ func run() int { } defer t.Fini() - cfg.Term = t + params.Term = t var g *game.RogueGame if args := flag.Args(); len(args) == 1 && !*deathDemo { // restore a saved game - g, err = game.Restore(args[0], cfg) + g, err = game.Restore(args[0], params) if err != nil { - t.Fini() - fmt.Fprintln(os.Stderr, err) + fmt.Fprintln(os.Stderr, err) // deferred Fini restores the terminal return 1 } } else { - g = game.NewGame(cfg) + g = game.New(params) } if *deathDemo { - g.DeathDemo() + g.DeathDemo() // does not return: death exits the process return 0 } installAutosave(g, t) - runErr := g.Run() - if runErr != nil { - t.Fini() - fmt.Fprintln(os.Stderr, runErr) - - return 1 - } + g.Run() // does not return: the game ends by exiting the process return 0 } -// loadConfig gathers the game configuration from the environment: home +// loadParams gathers the game parameters from the environment: home // directory, ROGUEOPTS, user name, wizard mode, and the dungeon seed // (main.c's startup). -func loadConfig() game.Config { +func loadParams() game.Params { home, _ := os.UserHomeDir() name := "" @@ -96,7 +91,7 @@ func loadConfig() game.Config { wizard := os.Getenv("ROGUE_WIZARD") != "" - return game.Config{ + return game.Params{ Seed: chooseSeed(wizard), Name: name, RogueOpts: os.Getenv("ROGUEOPTS"), diff --git a/game/effects_test.go b/game/effects_test.go index b432e68..3f6631f 100644 --- a/game/effects_test.go +++ b/game/effects_test.go @@ -7,7 +7,7 @@ import "testing" func mkGameInput(t *testing.T) *RogueGame { t.Helper() - g := NewGame(Config{Seed: 5, Term: &testTerm{}}) + g := New(Params{Seed: 5, Term: &testTerm{}}) g.NewLevel() g.Oldpos = g.Player.Pos g.Oldrp = g.roomIn(g.Player.Pos) @@ -183,7 +183,7 @@ func TestZapSlowMonster(t *testing.T) { } func TestParseOpts(t *testing.T) { - g := NewGame(Config{Seed: 1}) + g := New(Params{Seed: 1}) g.ParseOpts("terse,nojump,name=Conan,fruit=mango,inven=slow") if !g.Options.Terse { diff --git a/game/fight_test.go b/game/fight_test.go index 6157bf1..d39c51a 100644 --- a/game/fight_test.go +++ b/game/fight_test.go @@ -7,7 +7,7 @@ import "testing" func mkGame(t *testing.T, seed int32) *RogueGame { t.Helper() - g := NewGame(Config{Seed: seed, Term: &testTerm{}}) + g := New(Params{Seed: seed, Term: &testTerm{}}) g.NewLevel() g.Oldpos = g.Player.Pos g.Oldrp = g.roomIn(g.Player.Pos) @@ -79,24 +79,6 @@ func TestAttackHurtsPlayer(t *testing.T) { } } -func TestDeathUnwindsWithGameEnd(t *testing.T) { - g := mkGame(t, 11) - - defer func() { - r := recover() - if _, ok := r.(gameEnd); !ok { - t.Fatalf("death did not unwind with gameEnd, got %v", r) - } - - if g.Playing { - t.Error("still playing after death") - } - }() - - g.Options.Tombstone = false - g.death('K') -} - func TestRunnersChaseHero(t *testing.T) { g := mkGame(t, 3) // Place a hobgoblin a few squares away in the hero's room and set it diff --git a/game/game.go b/game/game.go index a70c60c..bfdc83c 100644 --- a/game/game.go +++ b/game/game.go @@ -31,9 +31,9 @@ type Options struct { InvType int // inven: inventory style (InvOver/InvSlow/InvClear) } -// Config carries everything needed to construct a game. -type Config struct { - Seed int32 // dungeon number; the caller derives it (time+pid or SEED env) +// Params carries everything needed to construct a game. +type Params struct { + Seed int32 // dungeon number; caller derives it (time+pid or SEED) Name string // player name (overridden by ROGUEOPTS name=) RogueOpts string // the ROGUEOPTS environment string Home string // home directory (save file default location) @@ -44,7 +44,7 @@ type Config struct { // RogueGame is one complete game of Rogue: every piece of state that was a // global (or file-scope static) in the C sources, plus the terminal it is -// played on. Construct with NewGame, then call Run. +// played on. Construct with New, then call Run. // // The struct grows with the port; fields appear in the phase that ports the // code owning them. @@ -143,22 +143,22 @@ type RogueGame struct { data *gameData } -// NewGame builds a game from cfg, seeds the RNG, and randomizes the item +// New builds a game from params, seeds the RNG, and randomizes the item // appearance tables (the front half of main.c main(); the player roll-up // and first level arrive with later porting phases). -func NewGame(cfg Config) *RogueGame { +func New(params Params) *RogueGame { g := &RogueGame{ data: newGameData(), - Rng: &Rng{Seed: cfg.Seed}, - Dnum: int(cfg.Seed), - Whoami: cfg.Name, + Rng: &Rng{Seed: params.Seed}, + Dnum: int(params.Seed), + Whoami: params.Name, Fruit: "slime-mold", - Home: cfg.Home, - Wizard: cfg.Wizard, - NoScore: cfg.Wizard, + Home: params.Home, + Wizard: params.Wizard, + NoScore: params.Wizard, Playing: true, Depth: 1, - ScorePath: cfg.ScorePath, + ScorePath: params.ScorePath, LastScore: -1, } g.Options = Options{ @@ -168,17 +168,17 @@ func NewGame(cfg Config) *RogueGame { } g.InvDescribe = true g.Msgs.SaveMsg = true - g.scr = NewScreen(cfg.Term) + g.scr = NewScreen(params.Term) g.Msgs.attach(g.scr, g.look, g.readchar) - g.FileName = cfg.Home + "/rogue.save" + g.FileName = params.Home + "/rogue.save" - g.rogueOpts = cfg.RogueOpts - if cfg.Wizard { + g.rogueOpts = params.RogueOpts + if params.Wizard { g.Player.Flags.Set(SenseMonsters) } - if cfg.RogueOpts != "" { - g.ParseOpts(cfg.RogueOpts) + if params.RogueOpts != "" { + g.ParseOpts(params.RogueOpts) } g.Monsters = g.data.monsterTable @@ -199,37 +199,45 @@ func NewGame(cfg Config) *RogueGame { } // Run plays the game to its end: the back half of main.c main() plus -// playit(). It returns after death, victory, quitting, or saving. -func (g *RogueGame) Run() error { - // A gameEnd panic is the port's my_exit(): recovering it here makes - // Run return normally (zero values), restoring the terminal via the - // caller's defers. - defer func() { - if r := recover(); r != nil { - if _, ok := r.(gameEnd); ok { - return // normal game over / save exit - } +// playit(). It does not return — the game ends by exiting the process +// (see myExit); one game run is one process. +func (g *RogueGame) Run() { + g.startLevel() + g.playit() +} - panic(r) - } - }() - - if !g.restored { - g.NewLevel() // draw current level - // Start up daemons and fuses - g.StartDaemon(DRunners, 0, After) - g.StartDaemon(DDoctor, 0, After) - g.Fuse(DSwander, 0, wanderTime(g), After) - g.StartDaemon(DStomach, 0, After) +// startLevel draws the first level and starts the standing daemons and +// fuses for a fresh game; a restored game brings its own (the back half +// of main.c main()). +func (g *RogueGame) startLevel() { + if g.restored { + return } - g.playit() - - return nil + g.NewLevel() // draw current level + // Start up daemons and fuses + g.StartDaemon(DRunners, 0, After) + g.StartDaemon(DDoctor, 0, After) + g.Fuse(DSwander, 0, wanderTime(g), After) + g.StartDaemon(DStomach, 0, After) } // playit is the main loop of the program (main.c playit). func (g *RogueGame) playit() { + g.prePlay() + + for g.Playing { + g.command() // command execution + } + + g.endit() +} + +// prePlay does the option and position setup at the top of playit, +// before the command loop (main.c playit). It is split out so tests can +// drive a bounded number of turns; the loop itself never returns, +// because game-over exits the process. +func (g *RogueGame) prePlay() { // set up defaults for modern terminals: curses' md_hasclreol() is // always true, so the C default inventory style applies if !g.restored { @@ -242,13 +250,7 @@ func (g *RogueGame) playit() { } g.Oldpos = g.Player.Pos - g.Oldrp = g.roomIn(g.Player.Pos) - for g.Playing { - g.command() // command execution - } - - g.endit() } // endit exits the game (main.c endit). diff --git a/game/newlevel_test.go b/game/newlevel_test.go index 45f47e1..72a8387 100644 --- a/game/newlevel_test.go +++ b/game/newlevel_test.go @@ -8,7 +8,7 @@ import ( func genLevel(t *testing.T, seed int32) *RogueGame { t.Helper() - g := NewGame(Config{Seed: seed}) + g := New(Params{Seed: seed}) g.NewLevel() return g @@ -159,7 +159,7 @@ func TestNewLevelDeterministic(t *testing.T) { // mazes, dark rooms, traps, treasure rooms — as a crash/invariant sweep. func TestDeeperLevels(t *testing.T) { for _, seed := range []int32{7, 42, 1000, 31337} { - g := NewGame(Config{Seed: seed}) + g := New(Params{Seed: seed}) for depth := 1; depth <= 30; depth++ { g.Depth = depth g.NewLevel() diff --git a/game/rip.go b/game/rip.go index 8910efa..7fc78f1 100644 --- a/game/rip.go +++ b/game/rip.go @@ -2,23 +2,20 @@ package game import ( "fmt" + "os" "time" ) // rip.c — the fun ends: death or a total win. // -// The C functions here call exit(); the port panics with a gameEnd sentinel -// that Run recovers, so the terminal is restored by normal unwinding. +// The C functions here call exit(). One game run is one process, so the +// port does the same: myExit restores the terminal and exits directly. -// gameEnd is the sentinel carried by the panic that replaces my_exit(). -type gameEnd struct{} - -// myExit leaves the process properly (main.c my_exit): it unwinds to Run. -// Every C caller exited with status 0; abnormal exits panic for real. +// myExit leaves the process properly (main.c my_exit): it restores the +// terminal and ends the process. Every C caller exited with status 0. func (g *RogueGame) myExit() { - g.Playing = false - - panic(gameEnd{}) + g.scr.Fini() + os.Exit(0) } // death does something really fun when he dies (rip.c death). @@ -255,18 +252,9 @@ func (g *RogueGame) killname(monst byte, doart bool) string { } // DeathDemo implements the -d command line option (main.c): burn some -// random numbers to break patterns, then die a random death. +// random numbers to break patterns, then die a random death. It does not +// return — death exits the process. func (g *RogueGame) DeathDemo() { - defer func() { - if r := recover(); r != nil { - if _, ok := r.(gameEnd); ok { - return - } - - panic(r) - } - }() - dnum := g.rnd(100) for dnum--; dnum > 0; dnum-- { g.rnd(100) diff --git a/game/run_test.go b/game/run_test.go index 4af9882..d5dba71 100644 --- a/game/run_test.go +++ b/game/run_test.go @@ -5,23 +5,56 @@ import ( "testing" ) -// TestRunScriptedSession drives a complete game through Run(): a few -// moves, a rest, an inventory, then Q-quit answered yes. -func TestRunScriptedSession(t *testing.T) { - tt := &testTerm{input: []byte("hjkl.i Qy")} +// 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() +// directly, with short scripts that avoid quitting, saving, or playing +// long enough to starve, any of which would exit the test binary. +func driveTurns(t *testing.T, g *RogueGame, n int) { + t.Helper() - g := NewGame(Config{Seed: 99, Term: tt}) + g.startLevel() + g.prePlay() - err := g.Run() - if err != nil { - t.Fatalf("Run: %v", err) + for range n { + g.command() } +} - if g.Playing { - t.Error("still playing after quit") +// TestRunDownStairs stands the hero on the staircase and descends via the +// '>' command through the real turn loop, then checks the level changed. +func TestRunDownStairs(t *testing.T) { + // '>' is a free action (After=false), so it is followed by a paying + // rest ('.') to end the command() call; without a paying action the + // turn loop would spin forever on the auto-fed prompt input. + tt := &testTerm{input: []byte(">.")} + g := New(Params{Seed: 7, Term: tt}) + g.NewLevel() + g.Player.Pos = g.Level.Stairs // stand on the stairs + g.restored = true // keep startLevel from regenerating + g.Daemons = DaemonList{} // and give it a fresh daemon table + g.StartDaemon(DRunners, 0, After) + g.StartDaemon(DDoctor, 0, After) + g.Fuse(DSwander, 0, wanderTime(g), After) + g.StartDaemon(DStomach, 0, After) + + driveTurns(t, g, 1) + + if g.Depth != 2 { + t.Errorf("depth = %d after descending, want 2", g.Depth) } - // After quitting, the scoreboard is the last thing shown (in C it went - // to stdout after endwin; here it is drawn on the screen). +} + +// TestScoreRendersList checks that the scoreboard is drawn on the screen +// (in C it went to stdout after endwin; here it stays on the screen). The +// quit and death paths that normally show it now exit the process, so the +// display is exercised through score() directly. +func TestScoreRendersList(t *testing.T) { + g := New(Params{Seed: 1, Term: &testTerm{}}) + g.Player.Purse = 100 + + g.score(g.Player.Purse, 1, 0) // flags 1 = quit; posts the top-ten list + found := false for y := range NumLines { @@ -31,96 +64,6 @@ func TestRunScriptedSession(t *testing.T) { } if !found { - t.Error("score list not on screen after quit") - } -} - -// TestRunManyTurns mashes movement keys for a while as a crash sweep of -// the whole turn loop (daemons, hunger, monsters, combat), ending with a -// quit. The input alternates directions so the hero bumps around rooms. -func TestRunManyTurns(t *testing.T) { - // Spaces between commands double as answers to any --More-- prompts; - // without them a single prompt would swallow the rest of the script - // (wait_for eats everything that isn't a space). - moves := []byte("h j k l y u b n s .") - script := make([]byte, 0, len(moves)*200+7) - - for range 200 { - script = append(script, moves...) - } - - script = append(script, " Q y Qy"...) - tt := &testTerm{input: script} - - g := NewGame(Config{Seed: 31337, Term: tt}) - - err := g.Run() - if err != nil { - t.Fatalf("Run: %v", err) - } - - if g.Playing { - t.Error("session did not end") - } -} - -// TestRunDownStairs walks the hero onto the stairs by teleporting there in -// wizard style, then descends and keeps playing. -func TestRunDownStairs(t *testing.T) { - tt := &testTerm{input: []byte(">..Qy")} - g := NewGame(Config{Seed: 7, Term: tt}) - g.NewLevel() - g.Player.Pos = g.Level.Stairs // stand on the stairs - g.restored = true // keep Run from regenerating the level - g.Daemons = DaemonList{} // and give it a fresh daemon table - g.StartDaemon(DRunners, 0, After) - g.StartDaemon(DDoctor, 0, After) - g.Fuse(DSwander, 0, wanderTime(g), After) - g.StartDaemon(DStomach, 0, After) - - err := g.Run() - if err != nil { - t.Fatalf("Run: %v", err) - } - - if g.Depth != 2 { - t.Errorf("depth = %d after descending, want 2", g.Depth) - } -} - -// TestSaveCommandRoundTrip saves via the 'S' command (as a player would) -// and restores the game. -func TestSaveCommandRoundTrip(t *testing.T) { - // The C get_str caps input at MAXINP=50 characters, so the save path - // must be short: work from the temp directory. - t.Chdir(t.TempDir()) - - path := "cmd.save" - // 'S' with no default file name goes straight to the name prompt. - script := "S" + path + "\n" - tt := &testTerm{input: []byte(script)} - g := NewGame(Config{Seed: 55, Term: tt}) - - g.FileName = "" // force the name prompt - - runErr := g.Run() - if runErr != nil { - t.Fatalf("Run: %v", runErr) - } - - h, err := Restore(path, Config{Term: &testTerm{}}) - if err != nil { - t.Fatalf("Restore: %v", err) - } - - if h.Depth != g.Depth || h.Player.Purse != g.Player.Purse { - t.Error("restored game does not match saved game") - } - // The restored game must be playable. - setInput(t, h, []byte("..Qy")...) - - restoredErr := h.Run() - if restoredErr != nil { - t.Fatalf("restored Run: %v", restoredErr) + t.Error("score list not on screen") } } diff --git a/game/save.go b/game/save.go index 625b07e..c8832a6 100644 --- a/game/save.go +++ b/game/save.go @@ -681,7 +681,7 @@ var ErrSaveOutOfDate = errors.New("sorry, saved game is out of date") // Restore restores a saved game from a file (save.c restore). The file is // deleted, as in C, to defeat restarting from the same save. -func Restore(path string, cfg Config) (*RogueGame, error) { +func Restore(path string, params Params) (*RogueGame, error) { f, err := os.Open(path) //nolint:gosec // G304: the save path is user-chosen by design if err != nil { return nil, err @@ -704,12 +704,12 @@ func Restore(path string, cfg Config) (*RogueGame, error) { data: newGameData(), Rng: &Rng{}, Playing: true, - ScorePath: cfg.ScorePath, + ScorePath: params.ScorePath, FileName: path, - rogueOpts: cfg.RogueOpts, + rogueOpts: params.RogueOpts, restored: true, } - g.scr = NewScreen(cfg.Term) + g.scr = NewScreen(params.Term) g.Msgs.attach(g.scr, g.look, g.readchar) g.applySnapshot(&st) diff --git a/game/save_test.go b/game/save_test.go index 34bad67..d41c7f6 100644 --- a/game/save_test.go +++ b/game/save_test.go @@ -29,7 +29,7 @@ func TestSaveRestoreRoundTrip(t *testing.T) { t.Fatalf("saveFile: %v", saveErr) } - h, err := Restore(path, Config{Term: &testTerm{}}) + h, err := Restore(path, Params{Term: &testTerm{}}) if err != nil { t.Fatalf("Restore: %v", err) } @@ -154,7 +154,7 @@ func TestRestoreRejectsWrongVersion(t *testing.T) { t.Fatal(closeErr) } - _, restoreErr := Restore(path, Config{}) + _, restoreErr := Restore(path, Params{}) if restoreErr == nil { t.Error("restore accepted an out-of-date save") } diff --git a/game/screen.go b/game/screen.go index dc23c91..a344bcf 100644 --- a/game/screen.go +++ b/game/screen.go @@ -16,6 +16,9 @@ type Terminal interface { // ReadChar blocks for the next key, translated to Rogue's input bytes // (arrows become hjkl, control keys their C0 codes). ReadChar() byte + // Fini restores the device to its pre-game state (curses endwin). The + // game calls it on its way out, since one game run is one process. + Fini() } // cell is one screen position. @@ -213,6 +216,13 @@ func (s *Screen) Refresh() { } } +// Fini restores the terminal device, if there is one (curses endwin). +func (s *Screen) Fini() { + if s.term != nil { + s.term.Fini() + } +} + // RefreshWin pushes an arbitrary window to the device (curses wrefresh). func (s *Screen) RefreshWin(w *Window) { if s.term != nil { diff --git a/game/tables_test.go b/game/tables_test.go index 313bc3f..f98ffaa 100644 --- a/game/tables_test.go +++ b/game/tables_test.go @@ -32,7 +32,7 @@ func TestProbabilitiesSumTo100(t *testing.T) { } func TestInitProbsCumulative(t *testing.T) { - g := NewGame(Config{Seed: 1}) + g := New(Params{Seed: 1}) last := g.Items.Potions[NumPotionTypes-1].Prob if last != 100 { @@ -47,14 +47,14 @@ func TestInitProbsCumulative(t *testing.T) { } func TestNewGameRandomizesAppearances(t *testing.T) { - g := NewGame(Config{Seed: 12345}) + g := New(Params{Seed: 12345}) checkPotionColors(t, g) checkScrollNames(t, g) checkWandMaterials(t, g) // Determinism: same seed, same appearances. - h := NewGame(Config{Seed: 12345}) + h := New(Params{Seed: 12345}) if h.Items != g.Items { t.Error("two games with the same seed produced different item lore") } diff --git a/game/term_test.go b/game/term_test.go index d80cbc7..77012ea 100644 --- a/game/term_test.go +++ b/game/term_test.go @@ -11,6 +11,8 @@ type testTerm struct { func (t *testTerm) Render(*Window) {} +func (t *testTerm) Fini() {} + func (t *testTerm) ReadChar() byte { if t.pos < len(t.input) { c := t.input[t.pos]