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.
923 lines
24 KiB
Go
923 lines
24 KiB
Go
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
|
package game
|
|
|
|
import (
|
|
"encoding/gob"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
)
|
|
|
|
// save.c + state.c — game persistence. The hand-written, XOR-encrypted C
|
|
// serializer becomes a gob snapshot; what remains hand-written is exactly
|
|
// what state.c's rs_fix_thing did: pointers that alias other structures
|
|
// (equipment, chase targets, room membership) are encoded as indices.
|
|
|
|
// saveFormatVersion identifies the snapshot layout; it changes whenever a
|
|
// field rename or retype would make gob silently mis-decode an older
|
|
// save ("go2": Object.Type became Kind ObjectKind; "go3": Object.Arm split
|
|
// into ArmorClass/Charges/GoldValue/Bonus, damage strings became
|
|
// DiceSpec).
|
|
const saveFormatVersion = Release + "-go3"
|
|
|
|
// destRef encodes a monster's chase target (state.c rs_write_thing's
|
|
// (kind, index) pairs).
|
|
type destRef struct {
|
|
Kind int // 0 none, 1 hero, 2 monster index, 3 level-object index, 4 room gold
|
|
Idx int
|
|
}
|
|
|
|
// savedCreature is the pointer-free image of a Creature.
|
|
type savedCreature struct {
|
|
Pos Coord
|
|
Turn bool
|
|
Type byte
|
|
Disguise byte
|
|
OldCh byte
|
|
Flags CreatureFlags
|
|
Stats Stats
|
|
RoomIdx int // 0..MaxRooms-1; 100+i for passages; -1 none
|
|
Pack []Object
|
|
}
|
|
|
|
// savedPlayer adds the player-only state. The creature half is a named
|
|
// field, not an embedded one: gob drops embedded fields of unexported
|
|
// types silently.
|
|
type savedPlayer struct {
|
|
Body savedCreature
|
|
CurArmor int // pack indices; -1 = none
|
|
CurWeapon int
|
|
CurRing [2]int
|
|
PackUsed [26]bool
|
|
Inpack int
|
|
Purse int
|
|
FoodLeft int
|
|
HungryState int
|
|
NoFood int
|
|
MaxStats Stats
|
|
VfHit int
|
|
}
|
|
|
|
// savedPlace is one map cell without its monster pointer.
|
|
type savedPlace struct {
|
|
Ch byte
|
|
Flags PlaceFlags
|
|
}
|
|
|
|
// SaveState is the complete serialized game (the field list mirrors
|
|
// state.c rs_save_file).
|
|
type SaveState struct {
|
|
Version string
|
|
|
|
Seed int32
|
|
Dnum int
|
|
Whoami string
|
|
Fruit string
|
|
Home string
|
|
Wizard bool
|
|
|
|
Player savedPlayer
|
|
|
|
Depth int
|
|
MaxDepth int
|
|
HasAmulet bool
|
|
SeenStairs bool
|
|
|
|
Places []savedPlace
|
|
Rooms [MaxRooms]Room
|
|
Passages [MaxPass]Room
|
|
Objects []Object
|
|
Monsters []savedCreature
|
|
Dests []destRef // parallel to Monsters
|
|
Stairs Coord
|
|
TrapCount int
|
|
|
|
After bool
|
|
Again bool
|
|
NoScoreF bool
|
|
Count int
|
|
NoCommand int
|
|
NoMove int
|
|
Quiet int
|
|
Running bool
|
|
RunCh byte
|
|
DoorStop bool
|
|
Firstmove bool
|
|
MoveOn bool
|
|
ToDeath bool
|
|
Kamikaze bool
|
|
MaxHit int
|
|
Take byte
|
|
Delta Coord
|
|
DirCh byte
|
|
LastComm byte
|
|
LastDir byte
|
|
LastPick int // pack index, -1
|
|
Oldpos Coord
|
|
OldrpIdx int
|
|
|
|
Daemons DaemonList
|
|
Options Options
|
|
Items ItemLore
|
|
Bestiary [26]MonsterKind
|
|
Huh string
|
|
|
|
LastScore int
|
|
AllScore bool
|
|
|
|
Screen []byte // the visible map (Std window contents)
|
|
}
|
|
|
|
// roomIdx encodes a room pointer as an index (state.c find_room_coord).
|
|
func (g *RogueGame) roomIdx(rp *Room) int {
|
|
if rp == nil {
|
|
return -1
|
|
}
|
|
|
|
for i := range g.Level.Rooms {
|
|
if rp == &g.Level.Rooms[i] {
|
|
return i
|
|
}
|
|
}
|
|
|
|
for i := range g.Level.Passages {
|
|
if rp == &g.Level.Passages[i] {
|
|
return 100 + i
|
|
}
|
|
}
|
|
|
|
return -1
|
|
}
|
|
|
|
// roomAt decodes a room index.
|
|
func (g *RogueGame) roomAt(i int) *Room {
|
|
switch {
|
|
case i >= 100:
|
|
return &g.Level.Passages[i-100]
|
|
case i >= 0:
|
|
return &g.Level.Rooms[i]
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// packIdx finds an object in the player's pack (state.c find_list_ptr).
|
|
func (g *RogueGame) packIdx(obj *Object) int {
|
|
if obj == nil {
|
|
return -1
|
|
}
|
|
|
|
for i, o := range g.Player.Pack {
|
|
if o == obj {
|
|
return i
|
|
}
|
|
}
|
|
|
|
return -1
|
|
}
|
|
|
|
// snapshot captures the complete game state.
|
|
func (g *RogueGame) snapshot() *SaveState {
|
|
st := g.snapshotHeader()
|
|
|
|
// the map, sans monster pointers (rebuilt on load)
|
|
st.Places = make([]savedPlace, len(g.Level.Places))
|
|
for i := range g.Level.Places {
|
|
st.Places[i] = savedPlace{
|
|
Ch: g.Level.Places[i].Ch,
|
|
Flags: g.Level.Places[i].Flags,
|
|
}
|
|
}
|
|
|
|
// level objects by value; remember their pointers for dest encoding
|
|
objAt := make(map[*Object]int, len(g.Level.Objects))
|
|
for i, o := range g.Level.Objects {
|
|
st.Objects = append(st.Objects, *o)
|
|
objAt[o] = i
|
|
}
|
|
|
|
st.Player = g.snapshotPlayer()
|
|
|
|
// monsters, with chase targets as (kind, index) references
|
|
for _, m := range g.Level.Monsters {
|
|
sc := savedCreature{
|
|
Pos: m.Pos, Turn: m.Turn, Type: m.Type, Disguise: m.Disguise,
|
|
OldCh: m.OldCh, Flags: m.Flags, Stats: m.Stats,
|
|
RoomIdx: g.roomIdx(m.Room),
|
|
}
|
|
for _, o := range m.Pack {
|
|
sc.Pack = append(sc.Pack, *o)
|
|
}
|
|
|
|
st.Monsters = append(st.Monsters, sc)
|
|
st.Dests = append(st.Dests, g.destRefFor(m, objAt))
|
|
}
|
|
|
|
return st
|
|
}
|
|
|
|
// snapshotHeader captures the scalar game state (the field list of
|
|
// state.c rs_save_file).
|
|
func (g *RogueGame) snapshotHeader() *SaveState {
|
|
return &SaveState{
|
|
Version: saveFormatVersion,
|
|
Seed: g.Rng.Seed,
|
|
Dnum: g.Dnum,
|
|
Whoami: g.Whoami,
|
|
Fruit: g.Fruit,
|
|
Home: g.Home,
|
|
Wizard: g.Wizard,
|
|
Depth: g.Depth,
|
|
MaxDepth: g.MaxDepth,
|
|
HasAmulet: g.HasAmulet,
|
|
SeenStairs: g.SeenStairs,
|
|
Rooms: g.Level.Rooms,
|
|
Passages: g.Level.Passages,
|
|
Stairs: g.Level.Stairs,
|
|
TrapCount: g.Level.TrapCount,
|
|
After: g.After,
|
|
Again: g.Again,
|
|
NoScoreF: g.NoScore,
|
|
Count: g.Count,
|
|
NoCommand: g.NoCommand,
|
|
NoMove: g.NoMove,
|
|
Quiet: g.Quiet,
|
|
Running: g.Running,
|
|
RunCh: g.RunCh,
|
|
DoorStop: g.DoorStop,
|
|
Firstmove: g.Firstmove,
|
|
MoveOn: g.MoveOn,
|
|
ToDeath: g.ToDeath,
|
|
Kamikaze: g.Kamikaze,
|
|
MaxHit: g.MaxHit,
|
|
Take: g.Take,
|
|
Delta: g.Delta,
|
|
DirCh: g.DirCh,
|
|
LastComm: g.LastComm,
|
|
LastDir: g.LastDir,
|
|
LastPick: g.packIdx(g.LastPick),
|
|
Oldpos: g.Oldpos,
|
|
OldrpIdx: g.roomIdx(g.Oldrp),
|
|
Daemons: g.Daemons,
|
|
Options: g.Options,
|
|
Items: g.Items,
|
|
Bestiary: g.Monsters,
|
|
Huh: g.Msgs.Huh,
|
|
LastScore: g.LastScore,
|
|
AllScore: g.AllScore,
|
|
Screen: g.scr.Std.Contents(),
|
|
}
|
|
}
|
|
|
|
// snapshotPlayer captures the player, equipment as pack indices (the
|
|
// player half of snapshot).
|
|
func (g *RogueGame) snapshotPlayer() savedPlayer {
|
|
p := &g.Player
|
|
|
|
sp := savedPlayer{
|
|
Body: savedCreature{
|
|
Pos: p.Pos, Turn: p.Turn, Type: p.Type, Disguise: p.Disguise,
|
|
OldCh: p.OldCh, Flags: p.Flags, Stats: p.Stats,
|
|
RoomIdx: g.roomIdx(p.Room),
|
|
},
|
|
CurArmor: g.packIdx(p.CurArmor),
|
|
CurWeapon: g.packIdx(p.CurWeapon),
|
|
CurRing: [2]int{
|
|
g.packIdx(p.CurRing[Left]), g.packIdx(p.CurRing[Right]),
|
|
},
|
|
PackUsed: p.PackUsed, Inpack: p.Inpack, Purse: p.Purse,
|
|
FoodLeft: p.FoodLeft, HungryState: p.HungryState, NoFood: p.NoFood,
|
|
MaxStats: p.MaxStats, VfHit: p.VfHit,
|
|
}
|
|
for _, o := range p.Pack {
|
|
sp.Body.Pack = append(sp.Body.Pack, *o)
|
|
}
|
|
|
|
return sp
|
|
}
|
|
|
|
// destRefFor encodes a monster's chase target as a (kind, index)
|
|
// reference: the hero, another monster, a level object, or room gold
|
|
// (state.c rs_write_thing).
|
|
func (g *RogueGame) destRefFor(m *Monster, objAt map[*Object]int) destRef {
|
|
switch {
|
|
case m.Dest == nil:
|
|
return destRef{}
|
|
case m.Dest == &g.Player.Pos:
|
|
return destRef{Kind: 1}
|
|
}
|
|
|
|
for mi, om := range g.Level.Monsters {
|
|
if m.Dest == &om.Pos {
|
|
return destRef{Kind: 2, Idx: mi}
|
|
}
|
|
}
|
|
|
|
for _, oo := range g.Level.Objects {
|
|
if m.Dest == &oo.Pos {
|
|
return destRef{Kind: 3, Idx: objAt[oo]}
|
|
}
|
|
}
|
|
|
|
for ri := range g.Level.Rooms {
|
|
if m.Dest == &g.Level.Rooms[ri].Gold {
|
|
return destRef{Kind: 4, Idx: ri}
|
|
}
|
|
}
|
|
|
|
return destRef{}
|
|
}
|
|
|
|
// applySnapshot rebuilds live game state from a snapshot.
|
|
func (g *RogueGame) applySnapshot(st *SaveState) {
|
|
g.applyHeader(st)
|
|
|
|
for i := range g.Level.Places {
|
|
g.Level.Places[i] = Place{
|
|
Ch: st.Places[i].Ch,
|
|
Flags: st.Places[i].Flags,
|
|
}
|
|
}
|
|
|
|
// level objects
|
|
g.Level.Objects = nil
|
|
for i := range st.Objects {
|
|
o := st.Objects[i]
|
|
g.Level.Objects = append(g.Level.Objects, &o)
|
|
}
|
|
|
|
g.applyPlayer(st)
|
|
g.applyMonsters(st)
|
|
g.applyDests(st)
|
|
|
|
g.scr.Std.SetContents(st.Screen)
|
|
}
|
|
|
|
// applyHeader restores the scalar game state (the field list of
|
|
// applySnapshot).
|
|
func (g *RogueGame) applyHeader(st *SaveState) {
|
|
g.Rng.Seed = st.Seed
|
|
g.Dnum = st.Dnum
|
|
g.Whoami = st.Whoami
|
|
g.Fruit = st.Fruit
|
|
g.Home = st.Home
|
|
g.Wizard = st.Wizard
|
|
g.Depth = st.Depth
|
|
g.MaxDepth = st.MaxDepth
|
|
g.HasAmulet = st.HasAmulet
|
|
g.SeenStairs = st.SeenStairs
|
|
g.Level.Rooms = st.Rooms
|
|
g.Level.Passages = st.Passages
|
|
g.Level.Stairs = st.Stairs
|
|
g.Level.TrapCount = st.TrapCount
|
|
g.applyTurnState(st)
|
|
}
|
|
|
|
// applyTurnState restores the in-turn command state (the second half of
|
|
// applyHeader).
|
|
func (g *RogueGame) applyTurnState(st *SaveState) {
|
|
g.After = st.After
|
|
g.Again = st.Again
|
|
g.NoScore = st.NoScoreF
|
|
g.Count = st.Count
|
|
g.NoCommand = st.NoCommand
|
|
g.NoMove = st.NoMove
|
|
g.Quiet = st.Quiet
|
|
g.Running = st.Running
|
|
g.RunCh = st.RunCh
|
|
g.DoorStop = st.DoorStop
|
|
g.Firstmove = st.Firstmove
|
|
g.MoveOn = st.MoveOn
|
|
g.ToDeath = st.ToDeath
|
|
g.Kamikaze = st.Kamikaze
|
|
g.MaxHit = st.MaxHit
|
|
g.Take = st.Take
|
|
g.Delta = st.Delta
|
|
g.DirCh = st.DirCh
|
|
g.LastComm = st.LastComm
|
|
g.LastDir = st.LastDir
|
|
g.Oldpos = st.Oldpos
|
|
g.Oldrp = g.roomAt(st.OldrpIdx)
|
|
g.Daemons = st.Daemons
|
|
g.Options = st.Options
|
|
g.Items = st.Items
|
|
g.Monsters = st.Bestiary
|
|
g.Msgs.Huh = st.Huh
|
|
g.LastScore = st.LastScore
|
|
g.AllScore = st.AllScore
|
|
g.Playing = true
|
|
}
|
|
|
|
// applyPlayer restores the player, resolving equipment pack indices
|
|
// (the player half of applySnapshot).
|
|
func (g *RogueGame) applyPlayer(st *SaveState) {
|
|
p := &g.Player
|
|
sp := &st.Player
|
|
p.Pos = sp.Body.Pos
|
|
p.Turn = sp.Body.Turn
|
|
p.Type = sp.Body.Type
|
|
p.Disguise = sp.Body.Disguise
|
|
p.OldCh = sp.Body.OldCh
|
|
p.Flags = sp.Body.Flags
|
|
p.Stats = sp.Body.Stats
|
|
p.Room = g.roomAt(sp.Body.RoomIdx)
|
|
|
|
p.Pack = nil
|
|
for i := range sp.Body.Pack {
|
|
o := sp.Body.Pack[i]
|
|
p.Pack = append(p.Pack, &o)
|
|
}
|
|
|
|
pick := func(i int) *Object {
|
|
if i < 0 || i >= len(p.Pack) {
|
|
return nil
|
|
}
|
|
|
|
return p.Pack[i]
|
|
}
|
|
p.CurArmor = pick(sp.CurArmor)
|
|
p.CurWeapon = pick(sp.CurWeapon)
|
|
p.CurRing[Left] = pick(sp.CurRing[0])
|
|
p.CurRing[Right] = pick(sp.CurRing[1])
|
|
g.LastPick = pick(st.LastPick)
|
|
p.PackUsed = sp.PackUsed
|
|
p.Inpack = sp.Inpack
|
|
p.Purse = sp.Purse
|
|
p.FoodLeft = sp.FoodLeft
|
|
p.HungryState = sp.HungryState
|
|
p.NoFood = sp.NoFood
|
|
p.MaxStats = sp.MaxStats
|
|
p.VfHit = sp.VfHit
|
|
}
|
|
|
|
// applyMonsters rebuilds the monster list and its map index from a
|
|
// snapshot (the monster half of applySnapshot).
|
|
func (g *RogueGame) applyMonsters(st *SaveState) {
|
|
g.Level.Monsters = nil
|
|
for i := range st.Monsters {
|
|
sc := &st.Monsters[i]
|
|
|
|
m := &Monster{Creature: Creature{
|
|
Pos: sc.Pos, Turn: sc.Turn, Type: sc.Type, Disguise: sc.Disguise,
|
|
OldCh: sc.OldCh, Flags: sc.Flags, Stats: sc.Stats,
|
|
Room: g.roomAt(sc.RoomIdx),
|
|
}}
|
|
for j := range sc.Pack {
|
|
o := sc.Pack[j]
|
|
m.Pack = append(m.Pack, &o)
|
|
}
|
|
|
|
g.Level.Monsters = append(g.Level.Monsters, m)
|
|
g.Level.SetMonsterAt(m.Pos.Y, m.Pos.X, m)
|
|
}
|
|
}
|
|
|
|
// applyDests re-aims the monsters' chase targets from their (kind,
|
|
// index) references (the fixup half of applySnapshot).
|
|
func (g *RogueGame) applyDests(st *SaveState) {
|
|
for i, ref := range st.Dests {
|
|
m := g.Level.Monsters[i]
|
|
|
|
switch ref.Kind {
|
|
case 1:
|
|
m.Dest = &g.Player.Pos
|
|
case 2:
|
|
m.Dest = &g.Level.Monsters[ref.Idx].Pos
|
|
case 3:
|
|
m.Dest = &g.Level.Objects[ref.Idx].Pos
|
|
case 4:
|
|
m.Dest = &g.Level.Rooms[ref.Idx].Gold
|
|
}
|
|
}
|
|
}
|
|
|
|
// saveAnswer is a yes/no/escape prompt result in the save-game flow.
|
|
type saveAnswer int
|
|
|
|
// The saveGame prompt outcomes.
|
|
const (
|
|
saveYes saveAnswer = iota
|
|
saveNo
|
|
saveAbort
|
|
)
|
|
|
|
// saveGame implements the "save game" command (save.c save_game). The
|
|
// labeled prompt loop stands in for the C goto over/gotfile flow.
|
|
func (g *RogueGame) saveGame() {
|
|
g.Msgs.Mpos = 0
|
|
|
|
prompt:
|
|
for {
|
|
useDefault := false
|
|
|
|
if g.FileName != "" {
|
|
a := g.askDefaultSave()
|
|
if a == saveAbort {
|
|
return
|
|
}
|
|
|
|
useDefault = a == saveYes
|
|
}
|
|
|
|
for {
|
|
buf, ok := g.saveFileName(useDefault)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
useDefault = false
|
|
|
|
a := g.saveCheckOverwrite(buf)
|
|
if a == saveAbort {
|
|
return
|
|
}
|
|
|
|
if a == saveNo {
|
|
continue prompt // the C goto over: start again
|
|
}
|
|
|
|
g.FileName = buf
|
|
|
|
err := g.saveFile(g.FileName)
|
|
if err != nil {
|
|
g.msg("%s", err.Error())
|
|
|
|
continue
|
|
}
|
|
|
|
break prompt
|
|
}
|
|
}
|
|
|
|
g.myExit()
|
|
}
|
|
|
|
// askDefaultSave asks whether to save to the current file name (save.c
|
|
// save_game).
|
|
func (g *RogueGame) askDefaultSave() saveAnswer {
|
|
for {
|
|
g.msg("save file (%s)? ", g.FileName)
|
|
c := g.readchar()
|
|
|
|
g.Msgs.Mpos = 0
|
|
|
|
switch c {
|
|
case Escape:
|
|
g.msg("")
|
|
|
|
return saveAbort
|
|
case 'y', 'Y':
|
|
g.addstr("Yes\n")
|
|
g.refresh()
|
|
|
|
return saveYes
|
|
case 'n', 'N':
|
|
return saveNo
|
|
}
|
|
|
|
g.msg("please answer Y or N")
|
|
}
|
|
}
|
|
|
|
// saveFileName picks the save path: the default, or a prompted one; ok
|
|
// is false when the player quit the prompt (save.c save_game).
|
|
func (g *RogueGame) saveFileName(useDefault bool) (string, bool) {
|
|
if useDefault {
|
|
return g.FileName, true
|
|
}
|
|
|
|
g.Msgs.Mpos = 0
|
|
g.msg("file name: ")
|
|
|
|
buf := ""
|
|
if g.getStr(&buf, g.scr.Std) == Quit {
|
|
g.msg("")
|
|
|
|
return "", false
|
|
}
|
|
|
|
g.Msgs.Mpos = 0
|
|
|
|
return buf, true
|
|
}
|
|
|
|
// saveCheckOverwrite guards an existing file: saveNo restarts the whole
|
|
// prompt, saveAbort quits (save.c save_game).
|
|
func (g *RogueGame) saveCheckOverwrite(buf string) saveAnswer {
|
|
// test to see if the file exists
|
|
_, statErr := os.Stat(buf)
|
|
if statErr != nil {
|
|
return saveYes
|
|
}
|
|
|
|
answer := g.askOverwrite()
|
|
if answer != saveYes {
|
|
return answer
|
|
}
|
|
|
|
g.msg("file name: %s", buf)
|
|
_ = os.Remove(g.FileName) // best effort, as in C (md_unlink)
|
|
|
|
return saveYes
|
|
}
|
|
|
|
// askOverwrite asks whether to overwrite the existing file (save.c
|
|
// save_game).
|
|
func (g *RogueGame) askOverwrite() saveAnswer {
|
|
for {
|
|
g.msg("File exists. Do you wish to overwrite it?")
|
|
g.Msgs.Mpos = 0
|
|
|
|
switch g.readchar() {
|
|
case Escape:
|
|
g.msg("")
|
|
|
|
return saveAbort
|
|
case 'y', 'Y':
|
|
return saveYes
|
|
case 'n', 'N':
|
|
return saveNo
|
|
}
|
|
|
|
g.msg("Please answer Y or N")
|
|
}
|
|
}
|
|
|
|
// saveFile writes the saved game (save.c save_file).
|
|
//
|
|
// The snapshot goes to a temporary file in the target's own directory and
|
|
// is renamed over the target, so there is no instant at which the player
|
|
// has no save file: until the rename the old file is whole, and after it
|
|
// the new one is. C wrote straight over the target, and this port did the
|
|
// same with a remove in front of it (AutoSave), so a write that failed —
|
|
// or a signal-time save cut short by the process dying — could leave the
|
|
// player with neither the old save nor a usable new one (issue #24).
|
|
//
|
|
// The temporary file is fsynced before the rename so its contents reach
|
|
// the disk ahead of the directory entry that will point at it. The
|
|
// directory itself is not fsynced: that would only matter for a machine
|
|
// that loses power in the same instant, and the old save survives that
|
|
// case anyway. A process killed mid-encode leaves its temporary file
|
|
// behind, which is litter next to a destroyed save file, and the dot
|
|
// prefix keeps it out of the way.
|
|
func (g *RogueGame) saveFile(path string) error {
|
|
f, err := os.CreateTemp(filepath.Dir(path), ".rogue-save-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tmp := f.Name()
|
|
|
|
writeErr := encodeSnapshot(f, g.snapshot())
|
|
if writeErr != nil {
|
|
_ = os.Remove(tmp) // never leave a half-written file behind
|
|
|
|
return writeErr
|
|
}
|
|
|
|
renErr := os.Rename(tmp, path)
|
|
if renErr != nil {
|
|
_ = os.Remove(tmp)
|
|
|
|
return renErr
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// encodeSnapshot encodes the snapshot into an open temporary file and
|
|
// closes it, leaving it read-only as the C game's saves were (save.c
|
|
// save_file). It never removes the file: its caller owns the cleanup, so
|
|
// that one place decides what happens to a failed write.
|
|
func encodeSnapshot(f *os.File, st *SaveState) error {
|
|
encErr := gob.NewEncoder(f).Encode(st)
|
|
if encErr == nil {
|
|
encErr = f.Sync()
|
|
}
|
|
|
|
if encErr == nil {
|
|
encErr = f.Chmod(0o400)
|
|
}
|
|
|
|
closeErr := f.Close()
|
|
|
|
if encErr != nil {
|
|
return encErr
|
|
}
|
|
|
|
return closeErr
|
|
}
|
|
|
|
// autoSaveRequest is one signal-triggered autosave in flight: the signal
|
|
// goroutine posts it and waits, the game goroutine performs the save and
|
|
// closes done. ok is written before done is closed and read only after,
|
|
// so the close is the happens-before edge that publishes it.
|
|
type autoSaveRequest struct {
|
|
done chan struct{}
|
|
ok bool
|
|
}
|
|
|
|
// AutoSaveOnSignal asks the game goroutine to autosave and waits up to
|
|
// timeout for it to finish, reporting whether the save actually ran
|
|
// (save.c auto_save, the SIGHUP/SIGTERM handler). It is the only entry
|
|
// point the signal goroutine may use, and it deliberately touches no game
|
|
// state: the gob encoder used to walk the live game tree from the signal
|
|
// goroutine while the game goroutine was mid-turn mutating it (issue
|
|
// #24).
|
|
//
|
|
// Blocked on input is the case that matters, since a dropped connection
|
|
// is the whole reason the handler exists: the request is posted first and
|
|
// the input read is then interrupted, so a game goroutine parked in
|
|
// ReadChar wakes, saves in readchar, and reads again. A game goroutine
|
|
// that is running turns instead picks the request up between turns, in
|
|
// command; one parked in the `!` shell escape picks it up in
|
|
// runShellEscape.
|
|
//
|
|
// The wait is bounded because the signal goroutine's job is to get the
|
|
// process out. If the game goroutine is somewhere with no service point
|
|
// at all, the deadline expires, this reports false, and the caller
|
|
// restores the terminal and exits — leaving the player's previous save
|
|
// file exactly as it was, which is the point of the rename in saveFile.
|
|
func (g *RogueGame) AutoSaveOnSignal(timeout time.Duration) bool {
|
|
req := &autoSaveRequest{done: make(chan struct{})}
|
|
|
|
select {
|
|
case g.sigSave <- req:
|
|
default:
|
|
// A request is already queued and unserviced, or there is no
|
|
// game loop to service one; either way this one would not be
|
|
// answered either.
|
|
return false
|
|
}
|
|
|
|
g.scr.Interrupt()
|
|
|
|
timer := time.NewTimer(timeout)
|
|
defer timer.Stop()
|
|
|
|
select {
|
|
case <-req.done:
|
|
return req.ok
|
|
case <-timer.C:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// serviceAutoSaveRequest performs a pending signal-triggered autosave, if
|
|
// one is waiting, and otherwise returns at once. It runs on the game
|
|
// goroutine — that is the whole design — so it must only be called where
|
|
// that goroutine is not itself inside the encode: between turns, or while
|
|
// parked waiting for input or for the shell escape.
|
|
//
|
|
// What is guaranteed, exactly: the encode runs on the one goroutine that
|
|
// owns the state, so the snapshot is internally consistent and always
|
|
// restorable. It is *not* guaranteed to be a between-commands snapshot.
|
|
// Only one of the three service points gives that: the check at the top
|
|
// of command, which runs after the previous command returned and before
|
|
// this turn's DoDaemons(Before)/DoFuses(Before). The other two are both
|
|
// reached from inside a command call already under way, and both cost
|
|
// the same on restore.
|
|
//
|
|
// readchar is reached from prompts raised part-way through a command —
|
|
// --More-- on the second message of a turn, askOverwrite, getStr, the
|
|
// direction and pack prompts — and by then the command has already
|
|
// mutated state: fight sets g.Count and g.Quiet and runs runTo before
|
|
// any message, revealXeroc writes tp.Disguise before emitting one. The
|
|
// ordinary top-of-turn key read in readCommand is inside command too,
|
|
// after that turn's BEFORE daemons and turnUpkeep.
|
|
//
|
|
// runShellEscape is no safer. shell is an ordinary command handler ('!'
|
|
// in the tables.go dispatch table), reached through executeCommand, so a
|
|
// goroutine parked in the shell escape has already run this turn's
|
|
// DoDaemons(Before), DoFuses(Before), turnUpkeep and the last-command
|
|
// bookkeeping, and has not yet run DoDaemons(After), DoFuses(After) or
|
|
// ringTurnEffects.
|
|
//
|
|
// The cost, at both: restoring re-enters playit at the top of command,
|
|
// so the rest of that command never runs — its AFTER daemons and fuses
|
|
// and its ring effects are lost — and the restored game opens with a
|
|
// fresh BEFORE pass on top of the one already in the snapshot. That
|
|
// second BEFORE pass is not free: rollwand, a live Before daemon once
|
|
// swander has fired, ticks again and draws from the RNG every fourth
|
|
// tick, and any Before fuse is decremented again. The result is still a
|
|
// coherent game state, one turn's worth of effects off — strictly better
|
|
// than the torn encode this replaced, and the cost of being able to save
|
|
// a player whose line dropped mid-prompt, or who is away in a shell, at
|
|
// all.
|
|
func (g *RogueGame) serviceAutoSaveRequest() {
|
|
select {
|
|
case req := <-g.sigSave:
|
|
g.runAutoSaveRequest(req)
|
|
default:
|
|
}
|
|
}
|
|
|
|
// runAutoSaveRequest answers one request: save, then release the waiter.
|
|
func (g *RogueGame) runAutoSaveRequest(req *autoSaveRequest) {
|
|
req.ok = g.autoSave()
|
|
|
|
close(req.done)
|
|
}
|
|
|
|
// autoSave silently saves to the current file name (save.c auto_save),
|
|
// reporting whether it wrote a save. Game-goroutine only — reach it
|
|
// through AutoSaveOnSignal from anywhere else.
|
|
//
|
|
// The error is not surfaced: there is no player to tell, since the
|
|
// terminal is on its way out, and nothing sensible to do about it. It is
|
|
// reported to the waiting signal goroutine as a failed save rather than
|
|
// discarded outright, which is what the old `_ =` here used to do.
|
|
func (g *RogueGame) autoSave() bool {
|
|
if g.FileName == "" {
|
|
return false
|
|
}
|
|
|
|
return g.saveFile(g.FileName) == nil
|
|
}
|
|
|
|
// ErrSaveOutOfDate reports a save file from an incompatible version.
|
|
var ErrSaveOutOfDate = errors.New("sorry, saved game is out of date")
|
|
|
|
// ErrSaveCorrupt reports a save file whose contents are structurally
|
|
// impossible for a game this code could have written.
|
|
var ErrSaveCorrupt = errors.New("sorry, saved game is corrupt")
|
|
|
|
// validateSnapshotObjects rejects a snapshot carrying an item whose Which
|
|
// would index past the end of its kind's tables. The file is written by
|
|
// this program, so such a value can only come from corruption or
|
|
// tampering; refusing it at the door is what keeps a malformed object
|
|
// from reaching the effect tables in doZap, quaff, and readScroll, where
|
|
// the wizard-create bug (issue #10) used to put one.
|
|
func validateSnapshotObjects(st *SaveState) error {
|
|
lists := make([][]Object, 0, 2+len(st.Monsters))
|
|
lists = append(lists, st.Objects, st.Player.Body.Pack)
|
|
|
|
for i := range st.Monsters {
|
|
lists = append(lists, st.Monsters[i].Pack)
|
|
}
|
|
|
|
for _, list := range lists {
|
|
for i := range list {
|
|
obj := &list[i]
|
|
if !obj.hasValidWhich() {
|
|
return fmt.Errorf("%w: %s has out-of-range which %d",
|
|
ErrSaveCorrupt, obj.Kind, obj.Which)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Restore restores a saved game from a file (save.c restore). The file is
|
|
// deleted, as in C, to defeat restarting from the same save.
|
|
func Restore(path string, params Params) (*RogueGame, error) {
|
|
f, err := os.Open(path) //nolint:gosec // G304: the save path is user-chosen by design
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
defer func() { _ = f.Close() }() // read-only handle
|
|
|
|
var st SaveState
|
|
|
|
decErr := gob.NewDecoder(f).Decode(&st)
|
|
if decErr != nil {
|
|
return nil, fmt.Errorf("%s: corrupt or incompatible save file: %w",
|
|
path, decErr)
|
|
}
|
|
|
|
if st.Version != saveFormatVersion {
|
|
return nil, ErrSaveOutOfDate
|
|
}
|
|
|
|
valErr := validateSnapshotObjects(&st)
|
|
if valErr != nil {
|
|
return nil, valErr
|
|
}
|
|
|
|
g := &RogueGame{
|
|
data: newGameData(),
|
|
Rng: &Rng{},
|
|
Playing: true,
|
|
ScorePath: params.ScorePath,
|
|
FileName: path,
|
|
rogueOpts: params.RogueOpts,
|
|
restored: true,
|
|
sigSave: make(chan *autoSaveRequest, 1),
|
|
}
|
|
g.scr = NewScreen(params.Term)
|
|
g.Msgs.attach(g.scr, g.look, g.readchar)
|
|
g.applySnapshot(&st)
|
|
|
|
// defeat multiple restarting from the same place
|
|
rmErr := os.Remove(path)
|
|
if rmErr != nil {
|
|
return nil, fmt.Errorf("cannot unlink file: %w", rmErr)
|
|
}
|
|
|
|
return g, nil
|
|
}
|