// 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 } 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 } // 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 { // AutoSave writes the game to its save file, best effort (save.c // auto_save). AutoSave() } // 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 AutoSave does nothing until set hands over the // real game. The mutex is not decoration: set runs on the main goroutine // and AutoSave on the signal goroutine. type pendingSaver struct { mu sync.Mutex game saver } // AutoSave 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. func (p *pendingSaver) AutoSave() { p.mu.Lock() defer p.mu.Unlock() if p.game == nil { return } p.game.AutoSave() } // 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 runs on a goroutine while the main goroutine is mid-turn // mutating game state, and AutoSave removes the save file before // gob-encoding that live state. On HUP/TERM that risk is accepted // because the process is about to die regardless and a best-effort save // beats none. On INT/QUIT there is nothing to rescue, so the right // choice is the one with no corruption window at all. 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 // AutoSave is still writing, 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. func leaveOnSignal(sig <-chan os.Signal, g saver, t finisher, exit func(int)) { if savesOnSignal(<-sig) { g.AutoSave() } 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) }