The SIGHUP/SIGTERM handler gob-encoded the live game tree from the signal goroutine while the game goroutine was mid-turn mutating it, and AutoSave removed the save file before encoding — so the failure mode was not a stale save but a deleted one followed by a possibly torn replacement, with a window in which the player had neither. The suite has run under -race since 2026-08-09 and was green because nothing had ever driven the turn loop concurrently with a signal: evidence of untested, not of safe. The handler no longer writes anything. AutoSaveOnSignal posts a request, wakes the input read, and waits up to signalSaveTimeout for the game goroutine to take it; the encode runs on the goroutine that owns the state, at the three points where that goroutine can sit: between turns (command), on waking from a blocked readchar, and while parked in the `!` shell escape (runShellEscape, which now runs the shell on a helper goroutine so a hangup during it still rescues the game). Blocked on input is the case that matters — a dropped connection lands while the player is thinking, so a flag checked only between turns would never be looked at. Terminal.ReadChar therefore returns (byte, bool), with ok false meaning "woken by Interrupt, no key", and term.Tcell posts a tcell.EventInterrupt onto tcell's own event queue to unpark PollEvent. readchar services the request and reads again, so no caller sees it. Running the shell on a helper goroutine would also have moved term.Tcell.ShellEscape's panic on a failed Screen.Resume onto it, and a panic at the top of any goroutine terminates the process without running the deferred calls of the others — including cmd/rogue/main.go's `defer t.Fini()`. The tty would have been left raw on precisely the path where the terminal is already broken, which is issue #12's failure on a path this change created. runShellEscape therefore recovers the helper's panic and re-raises it on the game goroutine, whose stack has the restore in it, so "every path restores the terminal via Terminal.Fini before exiting" stays true. saveFile writes a temporary file in the save's own directory, fsyncs it and renames it over the target instead of truncating in place, so a save that fails — or never happens because the deadline ran out — leaves the player's previous save whole. What the handoff guarantees is stated exactly rather than flatteringly: the encode runs on the state-owning goroutine, so the snapshot is internally consistent and restorable, but it is not necessarily taken between commands. Only the check at the top of command is; the other two service points both sit inside a command call already under way. readchar is reached from mid-command prompts (--More--, askOverwrite, getStr, the direction and pack prompts) with the command's mutations already applied, and runShellEscape is reached from shell, an ordinary '!' command handler dispatched inside command, with that turn's DoDaemons(Before) and DoFuses(Before) already fired and its AFTER pass not yet. Restoring re-enters playit at the top of command, so either way the rest of that command is lost and a fresh BEFORE pass runs on top of the one in the snapshot. The SIGINT/SIGQUIT no-save decision and the single-signal-read ordering guarantee are untouched. pendingSaver reads the game out from under its mutex rather than delegating with it held, because the delegated call now blocks until the save is taken.
333 lines
8.1 KiB
Go
333 lines
8.1 KiB
Go
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
|
package game
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// io.c — the message line, the status line, and character input.
|
|
|
|
// maxMsg is io.c MAXMSG: how much message fits before --More--.
|
|
const maxMsg = NumCols - len("--More--") - 1
|
|
|
|
// MessageLine is the io.c message machinery: the static msgbuf/newpos
|
|
// pair plus the related globals (mpos, huh, and the message-behavior
|
|
// flags). It owns the top line of the screen; attach wires in the
|
|
// display and input it needs.
|
|
type MessageLine struct {
|
|
buf strings.Builder // msgbuf
|
|
newpos int
|
|
Mpos int // where cursor is on top line
|
|
Huh string // the last message printed
|
|
SaveMsg bool // remember last msg
|
|
LowerMsg bool // messages should start w/lower case
|
|
MsgEsc bool // check for ESC from msg's --More--
|
|
|
|
scr *Screen // the top line lives on scr.Std
|
|
look func(wakeup bool) // redraw before a --More-- (misc.c look)
|
|
readChar func() byte // input for --More-- prompts
|
|
}
|
|
|
|
// Msg displays a message at the top of the screen (io.c msg). It returns
|
|
// Escape if the player escaped out of a --More--, ^Escape otherwise (the
|
|
// C convention: callers compare against ESCAPE).
|
|
func (m *MessageLine) Msg(format string, a ...any) int {
|
|
// if the string is "", just clear the line
|
|
if format == "" {
|
|
m.scr.Std.Move(0, 0)
|
|
m.scr.Std.Clrtoeol()
|
|
m.Mpos = 0
|
|
|
|
return ^Escape
|
|
}
|
|
// otherwise add to the message and flush it out
|
|
m.doaddf(format, a...)
|
|
|
|
return m.End()
|
|
}
|
|
|
|
// Addf adds things to the current message (io.c addmsg).
|
|
func (m *MessageLine) Addf(format string, a ...any) {
|
|
m.doaddf(format, a...)
|
|
}
|
|
|
|
// End displays a new msg, giving the player a chance to see the previous
|
|
// one if it is up there with the --More-- (io.c endmsg).
|
|
func (m *MessageLine) End() int {
|
|
if m.SaveMsg {
|
|
m.Huh = m.buf.String()
|
|
}
|
|
|
|
if m.Mpos != 0 && m.promptMore() == Escape {
|
|
return Escape
|
|
}
|
|
// All messages should start with uppercase, except ones that start
|
|
// with a pack addressing character
|
|
out := m.buf.String()
|
|
if len(out) > 0 && isLower(out[0]) && !m.LowerMsg &&
|
|
(len(out) <= 1 || out[1] != ')') {
|
|
out = string(toUpper(out[0])) + out[1:]
|
|
}
|
|
|
|
m.scr.Std.MvAddStr(0, 0, out)
|
|
m.scr.Std.Clrtoeol()
|
|
|
|
m.Mpos = m.newpos
|
|
m.newpos = 0
|
|
m.buf.Reset()
|
|
m.scr.Refresh()
|
|
|
|
return ^Escape
|
|
}
|
|
|
|
// promptMore shows the --More-- prompt and waits for the reader to
|
|
// acknowledge; Escape means the player bailed out (the Mpos block of
|
|
// io.c endmsg).
|
|
func (m *MessageLine) promptMore() int {
|
|
m.look(false)
|
|
m.scr.Std.MvAddStr(0, m.Mpos, "--More--")
|
|
m.scr.Refresh()
|
|
|
|
if !m.MsgEsc {
|
|
m.waitForSpace()
|
|
|
|
return ^Escape
|
|
}
|
|
|
|
for {
|
|
ch := m.readChar()
|
|
if ch == ' ' {
|
|
return ^Escape
|
|
}
|
|
|
|
if ch == Escape {
|
|
m.buf.Reset()
|
|
m.Mpos = 0
|
|
m.newpos = 0
|
|
|
|
return Escape
|
|
}
|
|
}
|
|
}
|
|
|
|
// attach wires the message line to its display and input; NewGame and
|
|
// Restore call it once the screen and game exist.
|
|
func (m *MessageLine) attach(scr *Screen, look func(bool), readChar func() byte) {
|
|
m.scr = scr
|
|
m.look = look
|
|
m.readChar = readChar
|
|
}
|
|
|
|
// waitForSpace absorbs input until the player types a space: the
|
|
// --More-- acknowledgement (io.c wait_for).
|
|
func (m *MessageLine) waitForSpace() {
|
|
for {
|
|
if m.readChar() == ' ' {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// doaddf performs an add onto the message buffer (io.c doadd).
|
|
func (m *MessageLine) doaddf(format string, a ...any) {
|
|
s := fmt.Sprintf(format, a...)
|
|
if len(s)+m.newpos >= maxMsg {
|
|
m.End()
|
|
}
|
|
|
|
m.buf.WriteString(s)
|
|
m.newpos = m.buf.Len()
|
|
}
|
|
|
|
// msg, addmsgf, and endmsg are the game-side shorthands for the message
|
|
// line; the machinery lives on MessageLine.
|
|
func (g *RogueGame) msg(format string, a ...any) int {
|
|
return g.Msgs.Msg(format, a...)
|
|
}
|
|
|
|
func (g *RogueGame) addmsgf(format string, a ...any) {
|
|
g.Msgs.Addf(format, a...)
|
|
}
|
|
|
|
func (g *RogueGame) endmsg() {
|
|
g.Msgs.End()
|
|
}
|
|
|
|
// stepOk returns true if it is ok to step on ch (io.c step_ok).
|
|
func stepOk(ch byte) bool {
|
|
switch ch {
|
|
case ' ', '|', '-':
|
|
return false
|
|
default:
|
|
return !isAlpha(ch)
|
|
}
|
|
}
|
|
|
|
// readchar reads and returns a character, checking for gross input errors
|
|
// (io.c readchar).
|
|
//
|
|
// Waiting for a key is where the game spends nearly all of its wall
|
|
// clock, so it is also where a signal-triggered autosave usually finds
|
|
// it: a dropped connection lands while the player is thinking, not
|
|
// mid-turn. Terminal.Interrupt wakes the read for exactly that, and the
|
|
// save runs here, on the game goroutine, before reading again.
|
|
//
|
|
// What that buys is a snapshot taken by the goroutine that owns the
|
|
// state, so it is internally consistent and restorable. It is never a
|
|
// between-commands snapshot: readchar is reached from readCommand at the
|
|
// top of a turn that has already run its BEFORE daemons and turnUpkeep,
|
|
// and from prompts raised part-way through a command — --More--,
|
|
// askOverwrite, getStr, the direction and pack prompts — by which point
|
|
// the command has mutated state as well. See serviceAutoSaveRequest
|
|
// (save.go) for the full statement of what the handoff guarantees and
|
|
// what it costs the player.
|
|
func (g *RogueGame) readchar() byte {
|
|
for {
|
|
ch, ok := g.scr.term.ReadChar()
|
|
if !ok {
|
|
g.serviceAutoSaveRequest()
|
|
|
|
continue
|
|
}
|
|
|
|
if ch == 3 { // ^C
|
|
g.quit(0)
|
|
|
|
return 27
|
|
}
|
|
|
|
return ch
|
|
}
|
|
}
|
|
|
|
// statusCache is the set of static shadow variables in io.c status() that
|
|
// suppress redundant status-line redraws.
|
|
type statusCache struct {
|
|
hpwidth int
|
|
hungry int
|
|
lvl int
|
|
pur int
|
|
hp int
|
|
arm int
|
|
str int
|
|
exp int
|
|
init bool
|
|
}
|
|
|
|
// status displays the important stats line, keeping the cursor where it was
|
|
// (io.c status).
|
|
func (g *RogueGame) status() {
|
|
s := &g.statusCache
|
|
p := &g.Player
|
|
|
|
// If nothing has changed since the last status, don't bother.
|
|
temp := p.Stats.ArmorClass
|
|
if p.CurArmor != nil {
|
|
temp = p.CurArmor.ArmorClass
|
|
}
|
|
|
|
if g.statusUnchanged(temp) {
|
|
return
|
|
}
|
|
|
|
s.init = true
|
|
s.arm = temp
|
|
|
|
oy, ox := g.scr.Std.GetYX()
|
|
|
|
if s.hp != p.Stats.MaxHP {
|
|
s.hp = p.Stats.MaxHP
|
|
|
|
s.hpwidth = 0
|
|
for t := p.Stats.MaxHP; t != 0; t /= 10 {
|
|
s.hpwidth++
|
|
}
|
|
}
|
|
|
|
// Save current status
|
|
s.lvl = g.Depth
|
|
s.pur = p.Purse
|
|
s.hp = p.Stats.HP
|
|
s.str = p.Stats.Str
|
|
s.exp = p.Stats.Exp
|
|
s.hungry = p.HungryState
|
|
|
|
line := fmt.Sprintf(
|
|
"Level: %d Gold: %-5d Hp: %*d(%*d) Str: %2d(%d) Arm: %-2d Exp: %d/%d %s",
|
|
g.Depth, p.Purse, s.hpwidth, p.Stats.HP, s.hpwidth, p.Stats.MaxHP,
|
|
p.Stats.Str, p.MaxStats.Str, 10-s.arm, p.Stats.Lvl, p.Stats.Exp,
|
|
g.data.hungerStateName[p.HungryState])
|
|
if g.StatMsg {
|
|
g.move(0, 0)
|
|
g.msg("%s", line)
|
|
} else {
|
|
g.move(StatLine, 0)
|
|
g.addstr(line)
|
|
}
|
|
|
|
g.clrtoeol()
|
|
g.move(oy, ox)
|
|
}
|
|
|
|
// statusUnchanged reports whether the status line still shows current
|
|
// values, so it need not be redrawn (the shadow-variable check of io.c
|
|
// status). temp is the effective armor class.
|
|
func (g *RogueGame) statusUnchanged(temp int) bool {
|
|
s := &g.statusCache
|
|
p := &g.Player
|
|
|
|
return s.init && s.hp == p.Stats.HP && s.exp == p.Stats.Exp &&
|
|
s.pur == p.Purse && s.arm == temp && s.str == p.Stats.Str &&
|
|
s.lvl == g.Depth && s.hungry == p.HungryState && !g.StatMsg
|
|
}
|
|
|
|
// waitFor sits around until the guy types the right key (io.c wait_for).
|
|
func (g *RogueGame) waitFor(ch byte) {
|
|
if ch == '\n' {
|
|
for {
|
|
c := g.readchar()
|
|
if c == '\n' || c == '\r' {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
for {
|
|
if g.readchar() == ch {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// showWin displays a window and waits before returning (io.c show_win).
|
|
func (g *RogueGame) showWin(message string) {
|
|
g.scr.Hw.MvAddStr(0, 0, message)
|
|
g.scr.Hw.Move(g.Player.Pos.Y, g.Player.Pos.X)
|
|
g.scr.RefreshWin(g.scr.Hw)
|
|
g.waitFor(' ')
|
|
g.refresh()
|
|
}
|
|
|
|
// ASCII helpers standing in for <ctype.h>; the C game only ever handles
|
|
// 7-bit characters.
|
|
func isAlpha(c byte) bool { return isUpper(c) || isLower(c) }
|
|
func isUpper(c byte) bool { return c >= 'A' && c <= 'Z' }
|
|
func isLower(c byte) bool { return c >= 'a' && c <= 'z' }
|
|
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
|
|
func isPrint(c byte) bool { return c >= ' ' && c < 0x7f }
|
|
func toUpper(c byte) byte {
|
|
if isLower(c) {
|
|
return c - 'a' + 'A'
|
|
}
|
|
|
|
return c
|
|
}
|
|
func toLower(c byte) byte {
|
|
if isUpper(c) {
|
|
return c - 'A' + 'a'
|
|
}
|
|
|
|
return c
|
|
}
|