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. 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. 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.
266 lines
7.3 KiB
Go
266 lines
7.3 KiB
Go
package game
|
|
|
|
import "fmt"
|
|
|
|
// The screen layer replaces curses. Game code draws into Window cell
|
|
// buffers (stdscr and the hw scratch window); a Terminal implementation
|
|
// blits them to a real device. Tests run with a scripted Terminal (or none
|
|
// at all), which is also how the "screen is a data structure" idiom —
|
|
// C code reading back what it drew with inch() — stays intact headlessly.
|
|
|
|
// Terminal is the physical device: a tcell screen in the real game, a
|
|
// script in tests.
|
|
type Terminal interface {
|
|
// Render blits the window to the device.
|
|
Render(w *Window)
|
|
// ReadChar blocks for the next key, translated to Rogue's input bytes
|
|
// (arrows become hjkl, control keys their C0 codes). ok is false when
|
|
// the read was woken by Interrupt instead of by a key, which is how a
|
|
// signal-triggered autosave reaches a game parked on input; the byte
|
|
// is meaningless then.
|
|
ReadChar() (ch byte, ok bool)
|
|
// Interrupt wakes a ReadChar that is blocked waiting for a key. It is
|
|
// the one Terminal method called from another goroutine, so an
|
|
// implementation must be safe to call concurrently with ReadChar.
|
|
Interrupt()
|
|
// Fini restores the device to its pre-game state (curses endwin). The
|
|
// game calls it on its way out, since one game run is one process.
|
|
Fini()
|
|
}
|
|
|
|
// cell is one screen position.
|
|
type cell struct {
|
|
ch byte
|
|
standout bool
|
|
}
|
|
|
|
// Window is an in-memory curses window: a cell grid with a cursor and a
|
|
// standout attribute.
|
|
type Window struct {
|
|
rows, cols int
|
|
cells []cell
|
|
cy, cx int
|
|
standout bool
|
|
}
|
|
|
|
// NewWindow returns a cleared window.
|
|
func NewWindow(rows, cols int) *Window {
|
|
w := &Window{rows: rows, cols: cols, cells: make([]cell, rows*cols)}
|
|
w.Clear()
|
|
|
|
return w
|
|
}
|
|
|
|
// Move positions the cursor (curses move/wmove).
|
|
func (w *Window) Move(y, x int) { w.cy, w.cx = y, x }
|
|
|
|
// GetYX reports the cursor position (curses getyx).
|
|
func (w *Window) GetYX() (int, int) { return w.cy, w.cx }
|
|
|
|
// AddCh writes a character at the cursor and advances it (curses addch).
|
|
func (w *Window) AddCh(ch byte) {
|
|
if ch == '\n' {
|
|
w.cy, w.cx = w.cy+1, 0
|
|
|
|
return
|
|
}
|
|
|
|
if w.cy < 0 || w.cy >= w.rows || w.cx < 0 || w.cx >= w.cols {
|
|
return
|
|
}
|
|
|
|
*w.at(w.cy, w.cx) = cell{ch: ch, standout: w.standout}
|
|
if w.cx++; w.cx >= w.cols {
|
|
w.cx = 0
|
|
if w.cy < w.rows-1 {
|
|
w.cy++
|
|
}
|
|
}
|
|
}
|
|
|
|
// AddStr writes a string at the cursor (curses addstr).
|
|
func (w *Window) AddStr(s string) {
|
|
for i := range len(s) {
|
|
w.AddCh(s[i])
|
|
}
|
|
}
|
|
|
|
// MvAddCh moves then writes (curses mvaddch).
|
|
func (w *Window) MvAddCh(y, x int, ch byte) {
|
|
w.Move(y, x)
|
|
w.AddCh(ch)
|
|
}
|
|
|
|
// MvAddStr moves then writes (curses mvaddstr).
|
|
func (w *Window) MvAddStr(y, x int, s string) {
|
|
w.Move(y, x)
|
|
w.AddStr(s)
|
|
}
|
|
|
|
// Printwf writes formatted text at the cursor (curses printw).
|
|
func (w *Window) Printwf(format string, a ...any) {
|
|
w.AddStr(fmt.Sprintf(format, a...))
|
|
}
|
|
|
|
// MvPrintwf moves then writes formatted text (curses mvprintw).
|
|
func (w *Window) MvPrintwf(y, x int, format string, a ...any) {
|
|
w.Move(y, x)
|
|
w.Printwf(format, a...)
|
|
}
|
|
|
|
// Inch returns the character under the cursor (curses inch, sans
|
|
// attributes — the C code always strips them with CCHAR).
|
|
func (w *Window) Inch() byte {
|
|
if w.cy < 0 || w.cy >= w.rows || w.cx < 0 || w.cx >= w.cols {
|
|
return ' '
|
|
}
|
|
|
|
return w.at(w.cy, w.cx).ch
|
|
}
|
|
|
|
// MvInch moves then reads (curses mvinch).
|
|
func (w *Window) MvInch(y, x int) byte {
|
|
w.Move(y, x)
|
|
|
|
return w.Inch()
|
|
}
|
|
|
|
// Standout sets or clears the standout attribute for subsequent writes
|
|
// (curses standout/standend).
|
|
func (w *Window) Standout(on bool) { w.standout = on }
|
|
|
|
// Clear blanks the window and homes the cursor (curses clear/wclear).
|
|
func (w *Window) Clear() {
|
|
for i := range w.cells {
|
|
w.cells[i] = cell{ch: ' '}
|
|
}
|
|
|
|
w.cy, w.cx = 0, 0
|
|
}
|
|
|
|
// Clrtoeol blanks from the cursor to the end of the line (curses clrtoeol).
|
|
func (w *Window) Clrtoeol() {
|
|
if w.cy < 0 || w.cy >= w.rows {
|
|
return
|
|
}
|
|
|
|
for x := w.cx; x < w.cols; x++ {
|
|
*w.at(w.cy, x) = cell{ch: ' '}
|
|
}
|
|
}
|
|
|
|
// CopyFrom copies another window's contents (curses overwrite).
|
|
func (w *Window) CopyFrom(src *Window) {
|
|
copy(w.cells, src.cells)
|
|
}
|
|
|
|
// Size reports the window dimensions as rows, columns.
|
|
func (w *Window) Size() (int, int) { return w.rows, w.cols }
|
|
|
|
// CellAt reports the character and standout attribute at a position; used
|
|
// by Terminal implementations to render the window.
|
|
func (w *Window) CellAt(y, x int) (byte, bool) {
|
|
c := w.at(y, x)
|
|
|
|
return c.ch, c.standout
|
|
}
|
|
|
|
// Contents dumps the window characters row-major (the save file keeps the
|
|
// visible map, as the C game saved the curses screen).
|
|
func (w *Window) Contents() []byte {
|
|
out := make([]byte, len(w.cells))
|
|
for i, c := range w.cells {
|
|
out[i] = c.ch
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// SetContents restores a Contents dump.
|
|
func (w *Window) SetContents(data []byte) {
|
|
for i := range w.cells {
|
|
if i < len(data) {
|
|
w.cells[i] = cell{ch: data[i]}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Line returns row y as a trimmed string; used by tests and the death/
|
|
// victory screens.
|
|
func (w *Window) Line(y int) string {
|
|
buf := make([]byte, w.cols)
|
|
for x := range w.cols {
|
|
buf[x] = w.at(y, x).ch
|
|
}
|
|
|
|
return string(buf)
|
|
}
|
|
|
|
// at addresses the cell at (y, x) in the backing array.
|
|
func (w *Window) at(y, x int) *cell { return &w.cells[y*w.cols+x] }
|
|
|
|
// Screen bundles the two windows the game draws on with the device that
|
|
// shows them.
|
|
type Screen struct {
|
|
term Terminal
|
|
Std *Window // stdscr: the dungeon view
|
|
Hw *Window // hw: the scratch window for overlays
|
|
}
|
|
|
|
// NewScreen builds the standard 24x80 game screen.
|
|
func NewScreen(term Terminal) *Screen {
|
|
return &Screen{
|
|
term: term,
|
|
Std: NewWindow(NumLines, NumCols),
|
|
Hw: NewWindow(NumLines, NumCols),
|
|
}
|
|
}
|
|
|
|
// Refresh pushes stdscr to the device (curses refresh).
|
|
func (s *Screen) Refresh() {
|
|
if s.term != nil {
|
|
s.term.Render(s.Std)
|
|
}
|
|
}
|
|
|
|
// Fini restores the terminal device, if there is one (curses endwin).
|
|
func (s *Screen) Fini() {
|
|
if s.term != nil {
|
|
s.term.Fini()
|
|
}
|
|
}
|
|
|
|
// Interrupt wakes a device read that is blocked waiting for a key, if
|
|
// there is a device. Called from the signal goroutine; everything else on
|
|
// Screen belongs to the game goroutine.
|
|
func (s *Screen) Interrupt() {
|
|
if s.term != nil {
|
|
s.term.Interrupt()
|
|
}
|
|
}
|
|
|
|
// RefreshWin pushes an arbitrary window to the device (curses wrefresh).
|
|
func (s *Screen) RefreshWin(w *Window) {
|
|
if s.term != nil {
|
|
s.term.Render(w)
|
|
}
|
|
}
|
|
|
|
// Thin RogueGame wrappers so ported bodies keep their curses shape.
|
|
|
|
func (g *RogueGame) move(y, x int) { g.scr.Std.Move(y, x) }
|
|
func (g *RogueGame) addch(ch byte) { g.scr.Std.AddCh(ch) }
|
|
func (g *RogueGame) addstr(s string) { g.scr.Std.AddStr(s) }
|
|
func (g *RogueGame) mvaddch(y, x int, c byte) { g.scr.Std.MvAddCh(y, x, c) }
|
|
func (g *RogueGame) mvaddstr(y, x int, s string) {
|
|
g.scr.Std.MvAddStr(y, x, s)
|
|
}
|
|
func (g *RogueGame) printw(f string, a ...any) { g.scr.Std.Printwf(f, a...) }
|
|
func (g *RogueGame) inch() byte { return g.scr.Std.Inch() }
|
|
func (g *RogueGame) mvinch(y, x int) byte { return g.scr.Std.MvInch(y, x) }
|
|
func (g *RogueGame) standout() { g.scr.Std.Standout(true) }
|
|
func (g *RogueGame) standend() { g.scr.Std.Standout(false) }
|
|
func (g *RogueGame) clear() { g.scr.Std.Clear() }
|
|
func (g *RogueGame) clrtoeol() { g.scr.Std.Clrtoeol() }
|
|
func (g *RogueGame) refresh() { g.scr.Refresh() }
|