Files
rgoue/game/save.go

724 lines
16 KiB
Go

package game
import (
"encoding/gob"
"errors"
"fmt"
"os"
)
// 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). A failed write means
// a corrupt save, so the file is removed before reporting the error.
func (g *RogueGame) saveFile(path string) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o400) //nolint:gosec,lll // G304: user-chosen save path
if err != nil {
return err
}
encErr := gob.NewEncoder(f).Encode(g.snapshot())
closeErr := f.Close()
if encErr != nil || closeErr != nil {
_ = os.Remove(path) // don't leave a corrupt save behind
if encErr != nil {
return encErr
}
return closeErr
}
return os.Chmod(path, 0o400)
}
// AutoSave silently saves to the current file name; used on SIGHUP/SIGTERM
// (save.c auto_save). Best effort by design: it runs on the way out of a
// dying process.
func (g *RogueGame) AutoSave() {
if g.FileName != "" {
_ = os.Remove(g.FileName)
_ = g.saveFile(g.FileName)
}
}
// ErrSaveOutOfDate reports a save file from an incompatible version.
var ErrSaveOutOfDate = errors.New("sorry, saved game is out of date")
// 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, cfg Config) (*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
}
g := &RogueGame{
data: newGameData(),
Rng: &Rng{},
Playing: true,
ScorePath: cfg.ScorePath,
FileName: path,
rogueOpts: cfg.RogueOpts,
restored: true,
}
g.scr = NewScreen(cfg.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
}