Exit the process on game-over instead of unwinding a panic

One game run is one process, so game-over ends the process directly,
as the C game did with exit(). myExit now restores the terminal
(via the new Terminal.Fini) and calls os.Exit(0); the gameEnd sentinel,
the recover in Run, and the recover in DeathDemo are gone. Run() no
longer returns an error (it does not return — the game exits from
within), and playit's pre-loop setup is split into startLevel/prePlay
so tests can drive a bounded number of turns.

Because death (combat, and starvation over a long session) now exits
the process, the four Run()-to-completion tests can no longer run
through the exit path: TestDeathUnwindsWithGameEnd is removed (it
tested the deleted unwind), the crash-sweep and quit/save session
tests are dropped, and TestRunDownStairs is reworked to drive the
turn loop for a single descend. Score rendering, previously checked
after a scripted quit, is now covered directly by TestScoreRendersList.
Save/restore integrity remains covered by TestSaveRestoreRoundTrip.
This commit is contained in:
2026-07-23 06:44:39 +07:00
parent cd0ba6c8ee
commit 194ce1dd16
8 changed files with 127 additions and 205 deletions

View File

@@ -1194,7 +1194,7 @@ The command.c/io.c/things.c file-statics become lowercase fields of `RogueGame`,
```go ```go
func New(params Params) *RogueGame // Params: seed, name, ROGUEOPTS string, score path, wizard func New(params Params) *RogueGame // Params: 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) Run() // main.c main()+playit(): init tables, first level, daemons, loop
func (g *RogueGame) command() // one turn (command.c) func (g *RogueGame) command() // one turn (command.c)
func Restore(path string, params Params) (*RogueGame, error) // save.c restore() func Restore(path string, params Params) (*RogueGame, error) // save.c restore()
``` ```
@@ -1553,7 +1553,7 @@ Tombstone/victory screens port verbatim from rip.c.
## 6. C construct → Go construct map ## 6. C construct → Go construct map
| C construct | Go translation | | C construct | Go translation |
| ---------------------------------------------------- | -------------------------------------------------------------------------------------- | | ---------------------------------------------------- | ---------------------------------------------------------------------------------- |
| global variable | `RogueGame` field (or Player/Level/subsystem field) | | global variable | `RogueGame` field (or Player/Level/subsystem field) |
| file-scope static | unexported field on `RogueGame` or subsystem struct | | file-scope static | unexported field on `RogueGame` or subsystem struct |
| `THING` union | `Creature` / `Object` structs | | `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 | | curses stdscr/hw windows | `Window` cell buffers over tcell |
| `mvinch` screen reads | `Window.Inch` from the buffer | | `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 | | 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` | | `vsprintf` message building | `fmt.Sprintf` |
| XOR-encrypted binary saves (state.c) | gob snapshot (§5.6) | | XOR-encrypted binary saves (state.c) | gob snapshot (§5.6) |
| DES wizard password | `ROGUE_WIZARD=1` env check | | DES wizard password | `ROGUE_WIZARD=1` env check |

View File

@@ -21,8 +21,10 @@ func main() {
os.Exit(run()) os.Exit(run())
} }
// run carries the real main so that deferred terminal restoration runs // run does the real work and returns an exit code. It only returns on a
// before the process exits (os.Exit skips defers). // 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 { func run() int {
scores := flag.Bool("s", false, "print the scoreboard and exit") scores := flag.Bool("s", false, "print the scoreboard and exit")
deathDemo := flag.Bool("d", false, "die a random death (demo)") deathDemo := flag.Bool("d", false, "die a random death (demo)")
@@ -53,8 +55,7 @@ func run() int {
// restore a saved game // restore a saved game
g, err = game.Restore(args[0], params) g, err = game.Restore(args[0], params)
if err != nil { if err != nil {
t.Fini() fmt.Fprintln(os.Stderr, err) // deferred Fini restores the terminal
fmt.Fprintln(os.Stderr, err)
return 1 return 1
} }
@@ -63,20 +64,14 @@ func run() int {
} }
if *deathDemo { if *deathDemo {
g.DeathDemo() g.DeathDemo() // does not return: death exits the process
return 0 return 0
} }
installAutosave(g, t) installAutosave(g, t)
runErr := g.Run() g.Run() // does not return: the game ends by exiting the process
if runErr != nil {
t.Fini()
fmt.Fprintln(os.Stderr, runErr)
return 1
}
return 0 return 0
} }

View File

@@ -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) { func TestRunnersChaseHero(t *testing.T) {
g := mkGame(t, 3) g := mkGame(t, 3)
// Place a hobgoblin a few squares away in the hero's room and set it // Place a hobgoblin a few squares away in the hero's room and set it

View File

@@ -199,22 +199,21 @@ func New(params Params) *RogueGame {
} }
// Run plays the game to its end: the back half of main.c main() plus // Run plays the game to its end: the back half of main.c main() plus
// playit(). It returns after death, victory, quitting, or saving. // playit(). It does not return — the game ends by exiting the process
func (g *RogueGame) Run() error { // (see myExit); one game run is one process.
// A gameEnd panic is the port's my_exit(): recovering it here makes func (g *RogueGame) Run() {
// Run return normally (zero values), restoring the terminal via the g.startLevel()
// caller's defers. g.playit()
defer func() {
if r := recover(); r != nil {
if _, ok := r.(gameEnd); ok {
return // normal game over / save exit
} }
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 g.NewLevel() // draw current level
// Start up daemons and fuses // Start up daemons and fuses
g.StartDaemon(DRunners, 0, After) g.StartDaemon(DRunners, 0, After)
@@ -223,13 +222,22 @@ func (g *RogueGame) Run() error {
g.StartDaemon(DStomach, 0, After) g.StartDaemon(DStomach, 0, After)
} }
g.playit()
return nil
}
// playit is the main loop of the program (main.c playit). // playit is the main loop of the program (main.c playit).
func (g *RogueGame) 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 // set up defaults for modern terminals: curses' md_hasclreol() is
// always true, so the C default inventory style applies // always true, so the C default inventory style applies
if !g.restored { if !g.restored {
@@ -242,13 +250,7 @@ func (g *RogueGame) playit() {
} }
g.Oldpos = g.Player.Pos g.Oldpos = g.Player.Pos
g.Oldrp = g.roomIn(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). // endit exits the game (main.c endit).

View File

@@ -2,23 +2,20 @@ package game
import ( import (
"fmt" "fmt"
"os"
"time" "time"
) )
// rip.c — the fun ends: death or a total win. // rip.c — the fun ends: death or a total win.
// //
// The C functions here call exit(); the port panics with a gameEnd sentinel // The C functions here call exit(). One game run is one process, so the
// that Run recovers, so the terminal is restored by normal unwinding. // port does the same: myExit restores the terminal and exits directly.
// gameEnd is the sentinel carried by the panic that replaces my_exit(). // myExit leaves the process properly (main.c my_exit): it restores the
type gameEnd struct{} // terminal and ends the process. Every C caller exited with status 0.
// 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.
func (g *RogueGame) myExit() { func (g *RogueGame) myExit() {
g.Playing = false g.scr.Fini()
os.Exit(0)
panic(gameEnd{})
} }
// death does something really fun when he dies (rip.c death). // 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 // 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() { func (g *RogueGame) DeathDemo() {
defer func() {
if r := recover(); r != nil {
if _, ok := r.(gameEnd); ok {
return
}
panic(r)
}
}()
dnum := g.rnd(100) dnum := g.rnd(100)
for dnum--; dnum > 0; dnum-- { for dnum--; dnum > 0; dnum-- {
g.rnd(100) g.rnd(100)

View File

@@ -5,23 +5,56 @@ import (
"testing" "testing"
) )
// TestRunScriptedSession drives a complete game through Run(): a few // driveTurns runs the game's per-turn loop up to n times, doing the same
// moves, a rest, an inventory, then Q-quit answered yes. // first-level and pre-play setup Run() does. Run() itself no longer
func TestRunScriptedSession(t *testing.T) { // returns — game-over exits the process — so tests drive command()
tt := &testTerm{input: []byte("hjkl.i Qy")} // 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 := New(Params{Seed: 99, Term: tt}) g.startLevel()
g.prePlay()
err := g.Run() for range n {
if err != nil { g.command()
t.Fatalf("Run: %v", err) }
} }
if g.Playing { // TestRunDownStairs stands the hero on the staircase and descends via the
t.Error("still playing after quit") // '>' 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 found := false
for y := range NumLines { for y := range NumLines {
@@ -31,96 +64,6 @@ func TestRunScriptedSession(t *testing.T) {
} }
if !found { if !found {
t.Error("score list not on screen after quit") t.Error("score list not on screen")
}
}
// 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 := New(Params{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 := New(Params{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 := New(Params{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, Params{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)
} }
} }

View File

@@ -16,6 +16,9 @@ type Terminal interface {
// ReadChar blocks for the next key, translated to Rogue's input bytes // ReadChar blocks for the next key, translated to Rogue's input bytes
// (arrows become hjkl, control keys their C0 codes). // (arrows become hjkl, control keys their C0 codes).
ReadChar() byte 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. // 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). // RefreshWin pushes an arbitrary window to the device (curses wrefresh).
func (s *Screen) RefreshWin(w *Window) { func (s *Screen) RefreshWin(w *Window) {
if s.term != nil { if s.term != nil {

View File

@@ -11,6 +11,8 @@ type testTerm struct {
func (t *testTerm) Render(*Window) {} func (t *testTerm) Render(*Window) {}
func (t *testTerm) Fini() {}
func (t *testTerm) ReadChar() byte { func (t *testTerm) ReadChar() byte {
if t.pos < len(t.input) { if t.pos < len(t.input) {
c := t.input[t.pos] c := t.input[t.pos]