Three behaviors from 5.4.4 that the port dropped silently. Each is a few
lines; grouped because they are all "restore something C did".
1. sticks.c 237: the "otherwise" arm closing do_zap's switch printed
"what a bizarre schtick!", and doZap had turned it into doing nothing.
The arm is under #ifdef MASTER, not under a runtime wizard test, so in
the MASTER build this port is it printed for every player and must not
be gated on g.Wizard. WS_NOP is a case of that switch in its own right
("when WS_NOP: break;"), so "no handler ran" cannot be the trigger:
the wand of nothing does nothing quietly. C's switch covers all 14 WS_
values, so its otherwise is reachable only for an o_which outside the
table, which is what Object.hasValidWhich already screens for. All
three arms fall through to obj.Charges--, as C's do.
2. command.c 288-291: CTRL('R') is "after = FALSE; clearok(curscr, TRUE);
wrefresh(curscr);" — a forced full repaint. The port called
g.refresh(), the ordinary diffing blit, which cannot fix the only
situation the command exists for: a screen corrupted by another
program's output leaves the game's record of it still correct, so the
diff sends nothing. New Terminal.Repaint (tcell Screen.Sync, which
discards tcell's record of the terminal rather than diffing against
it), Screen.Repaint and g.repaint(), implemented in term.Tcell and in
both headless test terminals. Named for the curses operation: the
interface is the game's abstraction, not tcell's. It repaints what was
last rendered — C repainted curscr, not stdscr — so it takes no
window.
3. main.c 107-113: the startup greeting existed nowhere in the tree. New
game.Greeting, printed on stdout by cmd/rogue/main.go before
term.New(), the port's initscr(). Only the wizard wording is #ifdef
MASTER; the other is unconditional. The %d is dnum, which main.c has
just assigned to seed, so it is Params.Seed. Neither wording ends in a
newline. Two placement details the tests pin: the printf sits after
parse_opts, so a ROGUEOPTS name= is what the player is greeted by; and
it sits after the -s/-d handling and after restore(), which never
returns, so a resumed game does not announce that a dungeon is being
dug (digsNewDungeon).
Greeting parses ROGUEOPTS into a throwaway game built the way New builds
the real one, tables and home directory included: ParseOpts handles every
option, not just the one the greeting reads, and inven= is matched
against inv_t_name[], which lives on the game.
All three message strings verified byte-for-byte against origin/c-master
sticks.c and main.c. No RNG call is added on any path and nothing under
game/testdata/ changed; TestSeedCompatItemTables is green against the
untouched golden.
Mutation-proved, each behavior removed in turn with only its own test
failing: dropping the message arm fails
TestZapUnhandledWandSaysBizarreSchtick; extending the message to WS_NOP
fails TestZapWandOfNothingIsSilent; putting g.refresh() back fails
TestRedrawCommandForcesFullRepaint; swapping the two wordings, and
ignoring the ROGUEOPTS name, both fail TestGreeting; greeting on the
restore path fails TestDigsNewDungeon.
ARCHITECTURE.md 5.3 gains Repaint and why a blit cannot substitute for
it; nothing here is deliberately dropped, so section 9 is unchanged.
TODO.md gets a Completed Steps entry; Next Step deliberately not rotated,
this being out-of-band issue work.
337 lines
12 KiB
Go
337 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). 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)
|
|
}
|