Files
rgoue/cmd/rogue/main.go
sneak 3f0a14c5e6 WIP: restore lost C behaviors (schtick message, forced redraw, greeting)
Incomplete checkpoint, preserved after the implementing session hit a
capacity limit partway through mutation verification. NOT reviewed and NOT
gate-clean: make fmt has not been run, so fmt-check currently fails on
ARCHITECTURE.md and TODO.md.

Work still outstanding before this is fit for review:
  - run make fmt and fold the result in
  - finish mutation-proving each of the three behaviors
  - verify every message string byte-for-byte against the C sources
  - confirm TestSeedCompatItemTables passes with its golden untouched

Refs #13.
2026-08-09 09:55:38 +00:00

335 lines
12 KiB
Go

// Command rogue is the Go port of Rogue 5.4.4: Exploring the Dungeons of
// Doom. It is a faithful function-by-function port of the classic C game;
// see ARCHITECTURE.md at the repository root.
package main
import (
"flag"
"fmt"
"os"
"os/signal"
"os/user"
"strconv"
"sync"
"syscall"
"time"
"git.eeqj.de/sneak/rgoue/game"
"git.eeqj.de/sneak/rgoue/term"
)
func main() {
os.Exit(run())
}
// 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()
params := loadParams()
if *scores {
game.New(params).ShowScores()
return 0
}
// C printed its greeting just before initscr(); here that means just
// before the tcell screen takes the terminal, and on stdout, exactly
// as C did (main.c main).
if digsNewDungeon(*deathDemo, flag.Args()) {
fmt.Print(game.Greeting(params))
}
t, err := term.New()
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
defer t.Fini()
// Armed here, the instant the tty goes raw, not after the game is
// built: everything below this line — the restore, the death demo
// (which never returns), the game itself — would otherwise run raw
// with no handler installed. There is no game to save yet, so the
// saver is filled in below once there is one.
pending := installSignalHandlers(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], params)
if err != nil {
fmt.Fprintln(os.Stderr, err) // deferred Fini restores the terminal
return 1
}
} else {
g = game.New(params)
}
if *deathDemo {
// The demo is left without a saver on purpose: a signal still
// restores the terminal, but a throwaway demo game is not worth
// writing over the player's save file.
g.DeathDemo() // does not return: death exits the process
return 0
}
pending.set(g)
g.Run() // does not return: the game ends by exiting the process
return 0
}
// digsNewDungeon reports whether this invocation is the one that digs a
// fresh dungeon, and so the only one that greets.
//
// C's printf is the last statement before initscr(), and everything that
// does something else has already left by then: -s scores and exits, -d
// runs the death demo and exits, and restore() — the argc == 2 case that
// is neither — never returns. So a saved game resumes without a greeting,
// which is right: nothing is being dug.
//
// The restore test is duplicated from run's own, deliberately. Keeping
// them as one predicate would mean deciding the startup path before the
// terminal exists and carrying it past the error returns, which is more
// rearrangement of run than a greeting is worth.
func digsNewDungeon(deathDemo bool, args []string) bool {
return !deathDemo && len(args) != 1
}
// loadParams gathers the game parameters from the environment: home
// directory, ROGUEOPTS, user name, wizard mode, and the dungeon seed
// (main.c's startup).
func loadParams() game.Params {
home, _ := os.UserHomeDir()
name := ""
u, userErr := user.Current()
if userErr == nil {
name = u.Username
}
wizard := os.Getenv("ROGUE_WIZARD") != ""
return game.Params{
Seed: chooseSeed(wizard),
Name: name,
RogueOpts: os.Getenv("ROGUEOPTS"),
Home: home,
ScorePath: home + "/.rogue.scores",
Wizard: wizard,
}
}
// saver is the autosave half of *game.RogueGame that the signal handler
// needs; an interface so the handler is testable headlessly.
type saver interface {
// AutoSaveOnSignal asks the game goroutine to write the save file and
// waits up to timeout for it, reporting whether the save ran (save.c
// auto_save). The handler never encodes anything itself; see
// signalSaveTimeout.
AutoSaveOnSignal(timeout time.Duration) bool
}
// signalSaveTimeout bounds how long the signal handler waits for the game
// goroutine to take its autosave.
//
// The handler cannot encode the game itself — that was issue #24's data
// race — so it has to hand the work to the goroutine that owns the state
// and wait. The game answers between turns, while parked waiting for a
// key, and while parked in the shell escape, which covers everywhere it
// can sit for any length of time; the deadline is the backstop for a game
// goroutine wedged somewhere with no service point, so that a signal can
// never fail to get the process out. It is generous next to the
// milliseconds a gob encode of one game takes, and invisible to a player
// whose connection has already dropped.
//
// Giving up costs nothing now that saveFile renames over the target
// (game/save.go): a save that does not happen leaves the previous save
// whole, where the old remove-then-encode could leave the player with
// neither.
const signalSaveTimeout = 3 * time.Second
// finisher is the terminal-restoring half of game.Terminal that the
// signal handler needs (curses endwin).
type finisher interface {
// Fini restores the terminal to its pre-game state.
Fini()
}
// pendingSaver is the saver the signal handler holds from the moment the
// terminal goes raw. The handler has to be armed before there is a game
// to save — restoring a save file and the death demo both run with the
// tty already raw — so AutoSaveOnSignal does nothing until set hands over
// the real game. The mutex is not decoration: set runs on the main
// goroutine and AutoSaveOnSignal on the signal goroutine.
type pendingSaver struct {
mu sync.Mutex
game saver
}
// AutoSaveOnSignal saves the game if there is one yet, and otherwise does
// nothing: a signal arriving before the game is built still restores the
// terminal, which is the part that matters.
//
// The lock is held only long enough to read the game, not across the
// delegated save. That changed with issue #24: the real
// AutoSaveOnSignal now blocks until the game goroutine takes the save or
// the deadline expires, and holding the mutex across a wait that long
// would stall a concurrent set — the case the PR #23 review flagged as
// safe only for as long as set is called exactly once. This shape does
// not depend on that.
func (p *pendingSaver) AutoSaveOnSignal(timeout time.Duration) bool {
p.mu.Lock()
g := p.game
p.mu.Unlock()
if g == nil {
return false
}
return g.AutoSaveOnSignal(timeout)
}
// set hands the signal handler the game to autosave, once one exists.
func (p *pendingSaver) set(g saver) {
p.mu.Lock()
defer p.mu.Unlock()
p.game = g
}
// handledSignals returns the signals the game leaves on. They split into
// two groups with deliberately different save behavior; see
// savesOnSignal.
func handledSignals() []os.Signal {
return []os.Signal{
syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT,
}
}
// savesOnSignal reports whether the game should autosave on its way out
// for this signal.
//
// THE DECISION (issue #12): SIGHUP and SIGTERM save; SIGINT and SIGQUIT
// restore the terminal and exit WITHOUT saving. This is deliberate, not
// an oversight, on three grounds.
//
// C: no path in the C game saves on INT or QUIT. The shipped build
// installs no handler at all during play (mach_dep.c setup calls
// md_onsignal_default), and the only INT handler it ever installs is
// rip.c/main.c's leave() in the endgame — endwin and exit, explicitly
// discarding pending output. The build that does wire INT during play
// (md_onsignal_autosave, mdport.c — defined unconditionally, but with
// its only call site, mach_dep.c setup, inside #ifdef DUMP) sends it to
// quit(), which confirms, scores, and exits, again without saving, and
// sends QUIT to endit() -> fatal() -> endwin + exit. save.c auto_save is
// reserved for HUP/TERM. Saving on HUP/TERM but not on INT/QUIT is
// therefore exactly C's split.
//
// Semantics: HUP and TERM mean involuntary teardown — the line dropped
// or the machine is going down — so rescuing the game is right. INT and
// QUIT are the player deliberately saying "stop now". Rogue scores a
// deliberate quit, and its save discipline is anti-save-scum by design
// (restoring consumes the file), so making Ctrl-C a free checkpoint
// would turn it into a one-keystroke undo for a bad turn: a gameplay
// change, not a robustness fix.
//
// Safety: this used to be the third ground, back when the handler
// gob-encoded live game state from its own goroutine after removing the
// save file — a data race with a window in which the player had no save
// at all, accepted on HUP/TERM because the process was dying anyway and
// avoided entirely on INT/QUIT. Issue #24 removed the window instead of
// living with it: the handler now hands the save to the game goroutine
// and waits (AutoSaveOnSignal), and the write goes to a temporary file
// renamed over the target. The split above stands on C and on semantics,
// which is where it always belonged; INT and QUIT do not save because
// the player asked to stop, not because saving is dangerous.
func savesOnSignal(sig os.Signal) bool {
return sig == syscall.SIGHUP || sig == syscall.SIGTERM
}
// installSignalHandlers arranges for the game to leave the terminal
// usable when it is signalled: C's leave(), "leave quickly but
// curteously" (main.c), extended with save.c auto_save on the two
// signals that warrant it.
//
// Call it the instant the terminal goes raw, which is earlier than the
// game exists; the returned pendingSaver takes the game once it does.
// Restoring the terminal is what has to be armed the moment the tty
// stops being usable, and it does not need a game.
func installSignalHandlers(t finisher) *pendingSaver {
pending := &pendingSaver{}
go leaveOnSignal(notifySignals(), pending, t, os.Exit)
return pending
}
// notifySignals subscribes to the handled signals and returns the
// channel they arrive on. Split out from installSignalHandlers so tests
// can drive leaveOnSignal with real signal delivery.
func notifySignals() chan os.Signal {
// Buffered so signal delivery never blocks, and deliberately never
// drained past the first signal: see leaveOnSignal.
sig := make(chan os.Signal, 1)
signal.Notify(sig, handledSignals()...)
return sig
}
// leaveOnSignal waits for one signal and takes the game out.
//
// Exactly one goroutine reads exactly one signal, which is what makes
// the exit safe: a second signal (a SIGINT landing while a SIGHUP's save
// is still being written, say) stays in the buffer unread and can never
// call exit out from under an in-flight save. The order within is the
// same one myExit uses (game/rip.go): save if this signal saves, then
// restore the terminal, then exit.
//
// AutoSaveOnSignal returns once the game goroutine has finished writing,
// or once signalSaveTimeout has run out, so the save is complete before
// the terminal is torn down and the process leaves — and the process
// leaves either way.
func leaveOnSignal(sig <-chan os.Signal, g saver, t finisher, exit func(int)) {
if savesOnSignal(<-sig) {
g.AutoSaveOnSignal(signalSaveTimeout)
}
t.Fini()
exit(0)
}
// chooseSeed picks the dungeon number: SEED for reproducible dungeons
// (wizard mode, as in the C game), else time+pid (main.c).
func chooseSeed(wizard bool) int32 {
if env := os.Getenv("SEED"); env != "" && wizard {
n, err := strconv.ParseInt(env, 10, 32)
if err == nil {
return int32(n)
}
}
// The C game computed `lowtime + getpid()` in int; the truncation to
// 32 bits is the same wraparound the C int arithmetic performed.
//nolint:mnd // C-faithful: the C int wraparound mask
return int32(time.Now().Unix()&0x7fffffff) +
int32(os.Getpid()&0x7fffffff)
}