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.
335 lines
12 KiB
Go
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)
|
|
}
|