Merge refactor/constructor-style (refactor step 8: New/Params + os.Exit)

This commit is contained in:
2026-07-23 08:02:40 +07:00
14 changed files with 204 additions and 271 deletions

View File

@@ -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 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, opts Config) (*RogueGame, error) // save.c restore()
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.
@@ -1553,7 +1553,7 @@ 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 |
@@ -1567,7 +1567,7 @@ Tombstone/victory screens port verbatim from rip.c.
| 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 |
| `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 |
@@ -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.

41
TODO.md
View File

@@ -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.

View File

@@ -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"),

View File

@@ -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 {

View File

@@ -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

View File

@@ -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,22 +199,21 @@ 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)
// 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
}
}()
if !g.restored {
g.NewLevel() // draw current level
// Start up daemons and fuses
g.StartDaemon(DRunners, 0, After)
@@ -223,13 +222,22 @@ func (g *RogueGame) Run() error {
g.StartDaemon(DStomach, 0, After)
}
g.playit()
return nil
}
// 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).

View File

@@ -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()

View File

@@ -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)

View File

@@ -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")
}
}

View File

@@ -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)

View File

@@ -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")
}

View File

@@ -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 {

View File

@@ -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")
}

View File

@@ -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]