From 0b0b25222b3131ada79ff78c28eb8bf07d977deb Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Tue, 15 Sep 2026 15:35:52 -0400 Subject: feat: single-owner game state, transactional lifecycle, durable money on server stop --- internal/admin/undo.go | 178 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 135 insertions(+), 43 deletions(-) (limited to 'internal/admin/undo.go') diff --git a/internal/admin/undo.go b/internal/admin/undo.go index 5e1e6a3..0f9744c 100644 --- a/internal/admin/undo.go +++ b/internal/admin/undo.go @@ -29,6 +29,11 @@ type ChangeDesc struct { IsDelete bool `json:"is_delete"` IsCreate bool `json:"is_create"` ExtraFiles []ExtraFile `json:"extra_files,omitempty"` + // IDMap records a room-ID change (oldID -> newID) included in the + // change. Undo applies its inverse, redo the forward map, through the + // wired runtime migration so players, Hub membership, and casino room + // keys move back and forth with the file images. + IDMap map[int]int `json:"id_map,omitempty"` } func (c ChangeDesc) ShortDesc() string { @@ -63,6 +68,9 @@ type UndoStack struct { redo []ChangeDesc maxSize int filePath string + // migrateRoomIDs applies a room-ID map to runtime state (players, Hub + // membership, casino room keys) during undo/redo of room-ID changes. + migrateRoomIDs func(map[int]int) } func NewUndoStack(dataDir string) *UndoStack { @@ -73,6 +81,14 @@ func NewUndoStack(dataDir string) *UndoStack { } } +// SetMigrateRoomIDs wires the runtime room-ID migration used by undo/redo +// of room-ID changes. Called by the server after construction. +func (us *UndoStack) SetMigrateRoomIDs(fn func(map[int]int)) { + us.mu.Lock() + defer us.mu.Unlock() + us.migrateRoomIDs = fn +} + func (us *UndoStack) Push(desc ChangeDesc) { us.mu.Lock() defer us.mu.Unlock() @@ -98,6 +114,16 @@ func (us *UndoStack) Undo() *ChangeDesc { log.Printf("undo: failed %s: %v", last.ShortDesc(), err) return nil } + // Runtime migration moves with the file images: undo applies the + // inverse room-ID map so players, Hub membership, and casino room keys + // return to the old location together. + if len(last.IDMap) > 0 && us.migrateRoomIDs != nil { + inverse := make(map[int]int, len(last.IDMap)) + for from, to := range last.IDMap { + inverse[to] = from + } + us.migrateRoomIDs(inverse) + } us.history = us.history[:len(us.history)-1] us.redo = append(us.redo, last) @@ -118,6 +144,14 @@ func (us *UndoStack) Redo() *ChangeDesc { log.Printf("redo: failed %s: %v", last.ShortDesc(), err) return nil } + // Redo applies the forward room-ID map through the runtime migration. + if len(last.IDMap) > 0 && us.migrateRoomIDs != nil { + forward := make(map[int]int, len(last.IDMap)) + for from, to := range last.IDMap { + forward[from] = to + } + us.migrateRoomIDs(forward) + } us.redo = us.redo[:len(us.redo)-1] us.history = append(us.history, last) @@ -126,64 +160,80 @@ func (us *UndoStack) Redo() *ChangeDesc { return &last } +// applyUndo reverts a change: the primary file, then every extra file. +// Extra-file failures propagate: a multi-file undo that restored only part +// of a change must be reported as failed, never as success. func (us *UndoStack) applyUndo(c ChangeDesc) error { if c.NewFilePath != "" { - os.Remove(c.NewFilePath) + if err := os.Remove(c.NewFilePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove %s: %w", c.NewFilePath, err) + } } if c.IsDelete { if c.OldContent == nil { - os.MkdirAll(c.FilePath, 0755) + if err := os.MkdirAll(c.FilePath, 0755); err != nil { + return fmt.Errorf("recreate dir %s: %w", c.FilePath, err) + } } else { - os.MkdirAll(filepath.Dir(c.FilePath), 0755) + if err := os.MkdirAll(filepath.Dir(c.FilePath), 0755); err != nil { + return fmt.Errorf("mkdir: %w", err) + } if err := os.WriteFile(c.FilePath, c.OldContent, 0644); err != nil { return fmt.Errorf("restore deleted %s: %w", c.FilePath, err) } } - for _, ef := range c.ExtraFiles { - applyUndoExtraFile(ef) - } } else if c.IsCreate { - os.Remove(c.FilePath) - for _, ef := range c.ExtraFiles { - applyUndoExtraFile(ef) + if err := os.Remove(c.FilePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove created %s: %w", c.FilePath, err) } } else { - target := c.FilePath - content := c.OldContent - if c.NewFilePath != "" { - target = c.FilePath + if err := os.MkdirAll(filepath.Dir(c.FilePath), 0755); err != nil { + return fmt.Errorf("mkdir: %w", err) } - os.MkdirAll(filepath.Dir(target), 0755) - if err := os.WriteFile(target, content, 0644); err != nil { - return fmt.Errorf("revert %s: %w", target, err) + if err := os.WriteFile(c.FilePath, c.OldContent, 0644); err != nil { + return fmt.Errorf("revert %s: %w", c.FilePath, err) } - for _, ef := range c.ExtraFiles { - applyUndoExtraFile(ef) + } + for _, ef := range c.ExtraFiles { + if err := applyUndoExtraFile(ef); err != nil { + return err } } return nil } +// applyRedo re-applies a change: the primary file, then every extra file. +// Extra-file failures propagate like undo. func (us *UndoStack) applyRedo(c ChangeDesc) error { if c.NewFilePath != "" { - os.Remove(c.FilePath) + if err := os.Remove(c.FilePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove %s: %w", c.FilePath, err) + } } if c.IsCreate { target := c.FilePath if c.NewFilePath != "" { target = c.NewFilePath } - os.MkdirAll(filepath.Dir(target), 0755) + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return fmt.Errorf("mkdir: %w", err) + } if err := os.WriteFile(target, c.NewContent, 0644); err != nil { return fmt.Errorf("recreate %s: %w", target, err) } for _, ef := range c.ExtraFiles { - applyRedoExtraFile(ef) + if err := applyRedoExtraFile(ef); err != nil { + return err + } } } else if c.IsDelete { - os.Remove(c.FilePath) + if err := os.Remove(c.FilePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("re-delete %s: %w", c.FilePath, err) + } for _, ef := range c.ExtraFiles { - applyRedoExtraFile(ef) + if err := applyRedoExtraFile(ef); err != nil { + return err + } } } else { target := c.FilePath @@ -191,49 +241,87 @@ func (us *UndoStack) applyRedo(c ChangeDesc) error { if c.NewFilePath != "" { target = c.NewFilePath } - os.MkdirAll(filepath.Dir(target), 0755) + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return fmt.Errorf("mkdir: %w", err) + } if err := os.WriteFile(target, content, 0644); err != nil { return fmt.Errorf("reapply %s: %w", target, err) } for _, ef := range c.ExtraFiles { - applyRedoExtraFile(ef) + if err := applyRedoExtraFile(ef); err != nil { + return err + } } } return nil } -func applyUndoExtraFile(ef ExtraFile) { +func applyUndoExtraFile(ef ExtraFile) error { switch { case ef.IsCreate: - os.Remove(ef.FilePath) + if err := os.Remove(ef.FilePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("undo remove %s: %w", ef.FilePath, err) + } case ef.IsDelete: - os.MkdirAll(filepath.Dir(ef.FilePath), 0755) - os.WriteFile(ef.FilePath, ef.OldContent, 0644) + if err := os.MkdirAll(filepath.Dir(ef.FilePath), 0755); err != nil { + return fmt.Errorf("undo mkdir: %w", err) + } + if err := os.WriteFile(ef.FilePath, ef.OldContent, 0644); err != nil { + return fmt.Errorf("undo restore %s: %w", ef.FilePath, err) + } case ef.NewFilePath != "": - os.Remove(ef.NewFilePath) - os.MkdirAll(filepath.Dir(ef.FilePath), 0755) - os.WriteFile(ef.FilePath, ef.OldContent, 0644) + if err := os.Remove(ef.NewFilePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("undo remove %s: %w", ef.NewFilePath, err) + } + if err := os.MkdirAll(filepath.Dir(ef.FilePath), 0755); err != nil { + return fmt.Errorf("undo mkdir: %w", err) + } + if err := os.WriteFile(ef.FilePath, ef.OldContent, 0644); err != nil { + return fmt.Errorf("undo restore %s: %w", ef.FilePath, err) + } default: - os.MkdirAll(filepath.Dir(ef.FilePath), 0755) - os.WriteFile(ef.FilePath, ef.OldContent, 0644) + if err := os.MkdirAll(filepath.Dir(ef.FilePath), 0755); err != nil { + return fmt.Errorf("undo mkdir: %w", err) + } + if err := os.WriteFile(ef.FilePath, ef.OldContent, 0644); err != nil { + return fmt.Errorf("undo revert %s: %w", ef.FilePath, err) + } } + return nil } -func applyRedoExtraFile(ef ExtraFile) { +func applyRedoExtraFile(ef ExtraFile) error { switch { case ef.IsDelete: - os.Remove(ef.FilePath) + if err := os.Remove(ef.FilePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("redo remove %s: %w", ef.FilePath, err) + } case ef.IsCreate: - os.MkdirAll(filepath.Dir(ef.FilePath), 0755) - os.WriteFile(ef.FilePath, ef.NewContent, 0644) + if err := os.MkdirAll(filepath.Dir(ef.FilePath), 0755); err != nil { + return fmt.Errorf("redo mkdir: %w", err) + } + if err := os.WriteFile(ef.FilePath, ef.NewContent, 0644); err != nil { + return fmt.Errorf("redo create %s: %w", ef.FilePath, err) + } case ef.NewFilePath != "": - os.Remove(ef.FilePath) - os.MkdirAll(filepath.Dir(ef.NewFilePath), 0755) - os.WriteFile(ef.NewFilePath, ef.NewContent, 0644) + if err := os.Remove(ef.FilePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("redo remove %s: %w", ef.FilePath, err) + } + if err := os.MkdirAll(filepath.Dir(ef.NewFilePath), 0755); err != nil { + return fmt.Errorf("redo mkdir: %w", err) + } + if err := os.WriteFile(ef.NewFilePath, ef.NewContent, 0644); err != nil { + return fmt.Errorf("redo create %s: %w", ef.NewFilePath, err) + } default: - os.MkdirAll(filepath.Dir(ef.FilePath), 0755) - os.WriteFile(ef.FilePath, ef.NewContent, 0644) + if err := os.MkdirAll(filepath.Dir(ef.FilePath), 0755); err != nil { + return fmt.Errorf("redo mkdir: %w", err) + } + if err := os.WriteFile(ef.FilePath, ef.NewContent, 0644); err != nil { + return fmt.Errorf("redo reapply %s: %w", ef.FilePath, err) + } } + return nil } func (us *UndoStack) Info() UndoInfo { @@ -273,6 +361,7 @@ func (us *UndoStack) save() { IsDelete bool `json:"is_delete"` IsCreate bool `json:"is_create"` ExtraFiles []extraEntry `json:"extra_files,omitempty"` + IDMap map[int]int `json:"id_map,omitempty"` } type saveData struct { History []entry `json:"history"` @@ -302,6 +391,7 @@ func (us *UndoStack) save() { IsDelete: c.IsDelete, IsCreate: c.IsCreate, ExtraFiles: extra, + IDMap: c.IDMap, }) } return entries @@ -344,6 +434,7 @@ func (us *UndoStack) load() { IsDelete bool `json:"is_delete"` IsCreate bool `json:"is_create"` ExtraFiles []extraEntry `json:"extra_files,omitempty"` + IDMap map[int]int `json:"id_map,omitempty"` } var saveData struct { History []entry `json:"history"` @@ -377,6 +468,7 @@ func (us *UndoStack) load() { IsDelete: e.IsDelete, IsCreate: e.IsCreate, ExtraFiles: extra, + IDMap: e.IDMap, }) } return changes -- cgit v1.2.3