Files
rgoue/game/io.go
clawbot 3a01283358 fix: take the signal-time autosave on the game goroutine (closes #24)
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.
2026-08-09 07:50:23 +00:00

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
}