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
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 Restore(path string, params Params) (*RogueGame, error) // save.c restore()
```
@@ -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

View File

@@ -21,8 +21,10 @@ 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)")
@@ -53,8 +55,7 @@ func run() int {
// restore a saved game
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
}
@@ -63,20 +64,14 @@ func run() int {
}
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
}

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) {
g := mkGame(t, 3)
// 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
// 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).

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 := New(Params{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 := 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)
t.Error("score list not on screen")
}
}

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

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