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:
@@ -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()
|
||||
```
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
52
game/game.go
52
game/game.go
@@ -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()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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).
|
||||
|
||||
30
game/rip.go
30
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)
|
||||
|
||||
149
game/run_test.go
149
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 := 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user