Files
rgoue/game/save.go
sneak 41fc1042fc go: port command loop, save/restore, tcell terminal, and the binary
- command.c in full: the command dispatcher with repeat counts, run
  prefixes, ctrl-run door-stop mode, fight-to-death targeting, and all
  wizard debug commands; search, help, identify, d_level/u_level, call,
  current
- main.c completed: Run()/playit() with the C double ROGUEOPTS parse;
  exit()-anywhere becomes a gameEnd panic recovered in Run
- save.c + state.c: gob SaveState snapshot with explicit pointer-to-
  index fixups for equipment, monster rooms, and chase targets (the
  rs_fix_thing role); save file deleted on restore as in C; SIGHUP/
  SIGTERM autosave hook
- wizard.c completed (create_obj, show_map), passages.c add_pass
- term/: tcell/v2 Terminal — the one third-party dependency — replacing
  curses and mdport's 900-line key decoder; shell escape via suspend
- cmd/rogue: flags -s/-d, SEED (wizard), ROGUEOPTS, ROGUE_WIZARD

Tests: full scripted sessions through Run (quit, descend stairs,
3200-move crash sweep, save-command round trip). Notable fixes found
by tests: gob silently drops embedded fields of unexported types, and
--More-- prompts swallow space-free input scripts.
2026-07-06 20:15:47 +02:00

554 lines
12 KiB
Go

package game
import (
"encoding/gob"
"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.
// 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
NTraps 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: Release,
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,
NTraps: g.Level.NTraps,
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.NTraps = st.NTraps
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
if _, err := os.Stat(buf); err == 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)
}
g.FileName = buf
if err := g.saveFile(g.FileName); err != nil {
g.msg("%s", err.Error())
continue
}
break
}
g.myExit(0)
}
// saveFile writes the saved game (save.c save_file).
func (g *RogueGame) saveFile(path string) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o400)
if err != nil {
return err
}
defer f.Close()
if err := gob.NewEncoder(f).Encode(g.snapshot()); err != nil {
os.Remove(path)
return err
}
return os.Chmod(path, 0o400)
}
// AutoSave silently saves to the current file name; used on SIGHUP/SIGTERM
// (save.c auto_save).
func (g *RogueGame) AutoSave() {
if g.FileName != "" {
os.Remove(g.FileName)
g.saveFile(g.FileName)
}
}
// 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)
if err != nil {
return nil, err
}
defer f.Close()
var st SaveState
if err := gob.NewDecoder(f).Decode(&st); err != nil {
return nil, fmt.Errorf("%s: corrupt or incompatible save file: %w", path, err)
}
if st.Version != Release {
return nil, fmt.Errorf("sorry, saved game is out of date")
}
g := &RogueGame{
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
if err := os.Remove(path); err != nil {
return nil, fmt.Errorf("cannot unlink file: %w", err)
}
return g, nil
}