package admin import ( "encoding/json" "fmt" "log" "os" "path/filepath" "sync" "time" ) type ExtraFile struct { FilePath string `json:"file_path"` OldContent []byte `json:"-"` NewContent []byte `json:"-"` NewFilePath string `json:"new_file_path,omitempty"` IsDelete bool `json:"is_delete,omitempty"` IsCreate bool `json:"is_create,omitempty"` } type ChangeDesc struct { Time string `json:"time"` Description string `json:"description"` FilePath string `json:"file_path"` NewFilePath string `json:"new_file_path,omitempty"` OldContent []byte `json:"old_content"` NewContent []byte `json:"new_content"` 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 { switch { case c.IsCreate: return fmt.Sprintf("Created %s", filepath.Base(c.FilePath)) case c.IsDelete: return fmt.Sprintf("Deleted %s", filepath.Base(c.FilePath)) case c.NewFilePath != "": if filepath.Dir(c.FilePath) == filepath.Dir(c.NewFilePath) { return fmt.Sprintf("Renamed %s", filepath.Base(c.NewFilePath)) } return fmt.Sprintf("Moved %s", filepath.Base(c.NewFilePath)) default: return fmt.Sprintf("Edited %s", filepath.Base(c.FilePath)) } } type UndoInfo struct { CanUndo bool `json:"can_undo"` CanRedo bool `json:"can_redo"` UndoDesc string `json:"undo_desc"` RedoDesc string `json:"redo_desc"` StackSize int `json:"stack_size"` RedoSize int `json:"redo_size"` } // UndoStack protects history with a mutex, not live game state. Production // callers must execute Push/Undo/Redo/Clear within the editor's owner task. // Undo/Redo and the subsequent rebuildAfterUndo are one task, not separate // dispatches; migrateRoomIDs is an owner-only callback. type UndoStack struct { mu sync.Mutex dataDir string history []ChangeDesc 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 { return &UndoStack{ dataDir: dataDir, maxSize: 100, filePath: filepath.Join(dataDir, ".admin_history.json"), } } // 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() desc.Time = time.Now().Format(time.RFC3339) us.history = append(us.history, desc) us.redo = nil if len(us.history) > us.maxSize { us.history = us.history[len(us.history)-us.maxSize:] } us.save() log.Printf("undo: push %s", desc.ShortDesc()) } func (us *UndoStack) Undo() *ChangeDesc { us.mu.Lock() defer us.mu.Unlock() if len(us.history) == 0 { return nil } last := us.history[len(us.history)-1] if err := us.applyUndo(last); err != nil { 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) us.save() log.Printf("undo: undid %s", last.ShortDesc()) return &last } func (us *UndoStack) Redo() *ChangeDesc { us.mu.Lock() defer us.mu.Unlock() if len(us.redo) == 0 { return nil } last := us.redo[len(us.redo)-1] if err := us.applyRedo(last); err != nil { 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) us.save() log.Printf("redo: redid %s", last.ShortDesc()) 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 != "" { 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 { if err := os.MkdirAll(c.FilePath, 0755); err != nil { return fmt.Errorf("recreate dir %s: %w", c.FilePath, err) } } else { 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) } } } else if c.IsCreate { if err := os.Remove(c.FilePath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove created %s: %w", c.FilePath, err) } } else { 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("revert %s: %w", c.FilePath, err) } } 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 != "" { 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 } 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 { if err := applyRedoExtraFile(ef); err != nil { return err } } } else if c.IsDelete { 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 { if err := applyRedoExtraFile(ef); err != nil { return err } } } else { target := c.FilePath content := c.NewContent if c.NewFilePath != "" { target = c.NewFilePath } 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 { if err := applyRedoExtraFile(ef); err != nil { return err } } } return nil } func applyUndoExtraFile(ef ExtraFile) error { switch { case ef.IsCreate: if err := os.Remove(ef.FilePath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("undo remove %s: %w", ef.FilePath, err) } case ef.IsDelete: 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 != "": 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: 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) error { switch { case ef.IsDelete: if err := os.Remove(ef.FilePath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("redo remove %s: %w", ef.FilePath, err) } case ef.IsCreate: 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 != "": 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: 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 { us.mu.Lock() defer us.mu.Unlock() info := UndoInfo{ StackSize: len(us.history), RedoSize: len(us.redo), } if len(us.history) > 0 { info.CanUndo = true info.UndoDesc = us.history[len(us.history)-1].ShortDesc() } if len(us.redo) > 0 { info.CanRedo = true info.RedoDesc = us.redo[len(us.redo)-1].ShortDesc() } return info } func (us *UndoStack) save() { type extraEntry struct { FilePath string `json:"file_path"` NewFilePath string `json:"new_file_path,omitempty"` OldContent string `json:"old_content"` NewContent string `json:"new_content"` IsDelete bool `json:"is_delete,omitempty"` IsCreate bool `json:"is_create,omitempty"` } type entry struct { Time string `json:"time"` Description string `json:"description"` FilePath string `json:"file_path"` NewFilePath string `json:"new_file_path,omitempty"` OldContent string `json:"old_content"` NewContent string `json:"new_content"` 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"` Redo []entry `json:"redo"` } toEntries := func(changes []ChangeDesc) []entry { var entries []entry for _, c := range changes { var extra []extraEntry for _, ef := range c.ExtraFiles { extra = append(extra, extraEntry{ FilePath: ef.FilePath, NewFilePath: ef.NewFilePath, OldContent: string(ef.OldContent), NewContent: string(ef.NewContent), IsDelete: ef.IsDelete, IsCreate: ef.IsCreate, }) } entries = append(entries, entry{ Time: c.Time, Description: c.Description, FilePath: c.FilePath, NewFilePath: c.NewFilePath, OldContent: string(c.OldContent), NewContent: string(c.NewContent), IsDelete: c.IsDelete, IsCreate: c.IsCreate, ExtraFiles: extra, IDMap: c.IDMap, }) } return entries } data := saveData{ History: toEntries(us.history), Redo: toEntries(us.redo), } b, err := json.Marshal(data) if err != nil { log.Printf("undo: failed to marshal history: %v", err) return } if err := os.WriteFile(us.filePath, b, 0644); err != nil { log.Printf("undo: failed to write history file: %v", err) } } func (us *UndoStack) load() { data, err := os.ReadFile(us.filePath) if err != nil { return } log.Printf("undo: loaded history from %s", us.filePath) type extraEntry struct { FilePath string `json:"file_path"` NewFilePath string `json:"new_file_path,omitempty"` OldContent string `json:"old_content"` NewContent string `json:"new_content"` IsDelete bool `json:"is_delete,omitempty"` IsCreate bool `json:"is_create,omitempty"` } type entry struct { Time string `json:"time"` Description string `json:"description"` FilePath string `json:"file_path"` NewFilePath string `json:"new_file_path,omitempty"` OldContent string `json:"old_content"` NewContent string `json:"new_content"` 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"` Redo []entry `json:"redo"` } if err := json.Unmarshal(data, &saveData); err != nil { log.Printf("undo: failed to unmarshal history: %v", err) return } fromEntries := func(entries []entry) []ChangeDesc { var changes []ChangeDesc for _, e := range entries { var extra []ExtraFile for _, ef := range e.ExtraFiles { extra = append(extra, ExtraFile{ FilePath: ef.FilePath, NewFilePath: ef.NewFilePath, OldContent: []byte(ef.OldContent), NewContent: []byte(ef.NewContent), IsDelete: ef.IsDelete, IsCreate: ef.IsCreate, }) } changes = append(changes, ChangeDesc{ Time: e.Time, Description: e.Description, FilePath: e.FilePath, NewFilePath: e.NewFilePath, OldContent: []byte(e.OldContent), NewContent: []byte(e.NewContent), IsDelete: e.IsDelete, IsCreate: e.IsCreate, ExtraFiles: extra, IDMap: e.IDMap, }) } return changes } us.history = fromEntries(saveData.History) log.Printf("undo: loaded %d history entries", len(us.history)) } func (us *UndoStack) Clear() { us.mu.Lock() defer us.mu.Unlock() us.history = nil us.redo = nil os.Remove(us.filePath) log.Printf("undo: cleared history") }