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,37 +199,45 @@ 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 { // startLevel draws the first level and starts the standing daemons and
return // normal game over / save exit // 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
} }
panic(r)
}
}()
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)
g.StartDaemon(DDoctor, 0, After) g.StartDaemon(DDoctor, 0, After)
g.Fuse(DSwander, 0, wanderTime(g), After) g.Fuse(DSwander, 0, wanderTime(g), After)
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]