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.
This commit is contained in:
553
game/save.go
Normal file
553
game/save.go
Normal file
@@ -0,0 +1,553 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user