gochecknoglobals: all 37 package-level tables consolidated into the gameData struct (game/tables.go), built by newGameData() and carried on RogueGame as g.data (set in NewGame and Restore). ObjectKind Glyph()/objectKindForGlyph are now switches; the table-reading subtype Stringer methods are gone; isMagic is a RogueGame method. goconst: repeated words named (potionName/scrollName/ringName/goldName in object.go, wandName/staffName in sticks.go, ripWall in tables.go). exhaustive, testpackage: disabled in .golangci.yml with sneak's approval (2026-07-07). Also reverts misspell's silent corruption of the "ther" scroll-name syllable (it had become "there", changing generated scroll names vs C). Remaining red: cyclop/gocognit/nestif until refactor step 7; mnd awaits a ruling. TODO.md rotated; MEMORY.md lint notes updated.
628 lines
13 KiB
Go
628 lines
13 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 {
|
|
p := &g.Player
|
|
st := &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(),
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// the player
|
|
st.Player = 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 {
|
|
st.Player.Body.Pack = append(st.Player.Body.Pack, *o)
|
|
}
|
|
|
|
// 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)
|
|
|
|
ref := destRef{}
|
|
|
|
switch {
|
|
case m.Dest == nil:
|
|
case m.Dest == &p.Pos:
|
|
ref = destRef{Kind: 1}
|
|
default:
|
|
for mi, om := range g.Level.Monsters {
|
|
if m.Dest == &om.Pos {
|
|
ref = destRef{Kind: 2, Idx: mi}
|
|
}
|
|
}
|
|
|
|
if ref.Kind == 0 {
|
|
for _, oo := range g.Level.Objects {
|
|
if m.Dest == &oo.Pos {
|
|
ref = destRef{Kind: 3, Idx: objAt[oo]}
|
|
}
|
|
}
|
|
}
|
|
|
|
if ref.Kind == 0 {
|
|
for ri := range g.Level.Rooms {
|
|
if m.Dest == &g.Level.Rooms[ri].Gold {
|
|
ref = destRef{Kind: 4, Idx: ri}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
st.Dests = append(st.Dests, ref)
|
|
}
|
|
|
|
return st
|
|
}
|
|
|
|
// applySnapshot rebuilds live game state from a snapshot.
|
|
func (g *RogueGame) applySnapshot(st *SaveState) {
|
|
p := &g.Player
|
|
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.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
|
|
|
|
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)
|
|
}
|
|
|
|
// the 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
|
|
|
|
// monsters, their map index, and their chase targets
|
|
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)
|
|
}
|
|
|
|
for i, ref := range st.Dests {
|
|
m := g.Level.Monsters[i]
|
|
|
|
switch ref.Kind {
|
|
case 1:
|
|
m.Dest = &p.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
|
|
}
|
|
}
|
|
|
|
g.scr.Std.SetContents(st.Screen)
|
|
}
|
|
|
|
// saveGame implements the "save game" command (save.c save_game). The C
|
|
// goto over/gotfile flow becomes the useDefault flag.
|
|
func (g *RogueGame) saveGame() {
|
|
g.Msgs.Mpos = 0
|
|
|
|
over:
|
|
useDefault := false
|
|
|
|
if g.FileName != "" {
|
|
var c byte
|
|
|
|
for {
|
|
g.msg("save file (%s)? ", g.FileName)
|
|
c = g.readchar()
|
|
|
|
g.Msgs.Mpos = 0
|
|
if c == Escape {
|
|
g.msg("")
|
|
|
|
return
|
|
}
|
|
|
|
if c == 'n' || c == 'N' || c == 'y' || c == 'Y' {
|
|
break
|
|
}
|
|
|
|
g.msg("please answer Y or N")
|
|
}
|
|
|
|
if c == 'y' || c == 'Y' {
|
|
g.addstr("Yes\n")
|
|
g.refresh()
|
|
|
|
useDefault = true
|
|
}
|
|
}
|
|
|
|
for {
|
|
var buf string
|
|
if useDefault {
|
|
buf = g.FileName
|
|
useDefault = false
|
|
} else {
|
|
g.Msgs.Mpos = 0
|
|
g.msg("file name: ")
|
|
|
|
if g.getStr(&buf, g.scr.Std) == Quit {
|
|
g.msg("")
|
|
|
|
return
|
|
}
|
|
|
|
g.Msgs.Mpos = 0
|
|
}
|
|
// test to see if the file exists
|
|
_, statErr := os.Stat(buf)
|
|
if statErr == nil {
|
|
for {
|
|
g.msg("File exists. Do you wish to overwrite it?")
|
|
g.Msgs.Mpos = 0
|
|
|
|
c := g.readchar()
|
|
if c == Escape {
|
|
g.msg("")
|
|
|
|
return
|
|
}
|
|
|
|
if c == 'y' || c == 'Y' {
|
|
break
|
|
}
|
|
|
|
if c == 'n' || c == 'N' {
|
|
goto over
|
|
}
|
|
|
|
g.msg("Please answer Y or N")
|
|
}
|
|
|
|
g.msg("file name: %s", buf)
|
|
_ = os.Remove(g.FileName) // best effort, as in C (md_unlink)
|
|
}
|
|
|
|
g.FileName = buf
|
|
|
|
err := g.saveFile(g.FileName)
|
|
if err != nil {
|
|
g.msg("%s", err.Error())
|
|
|
|
continue
|
|
}
|
|
|
|
break
|
|
}
|
|
|
|
g.myExit()
|
|
}
|
|
|
|
// 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.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
|
|
}
|