// 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). C followed the printf with fflush because // its stdout was buffered; os.Stdout is not, so the write is the // flush. if digsNewDungeon(*deathDemo, flag.Args()) { _, _ = fmt.Fprint(os.Stdout, game.Greeting(params)) // CLI output } 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) }