From 8fe9e3c3fef0049df82281a62afe35dda516dfe2 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 17:48:26 -0400 Subject: fix: close combat ghost, admin traversal, login throttle, journal double-pay, and quality/economy gaps --- internal/admin/api_rooms.go | 70 +++++++++++++++++++++++++++++++++------------ 1 file changed, 52 insertions(+), 18 deletions(-) (limited to 'internal/admin/api_rooms.go') diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go index d84449b..7f317cc 100644 --- a/internal/admin/api_rooms.go +++ b/internal/admin/api_rooms.go @@ -343,6 +343,11 @@ func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) { s.world.RebuildRoomIndex(s.dataDir) s.world.ClearRoomState(id) + // Drop the room's live mob instances too, or they keep wandering and + // are returned by MobsInRoom for a room that no longer exists. + if s.mobStore != nil { + s.mobStore.RemoveMobsInRoom(id) + } // Drop any cached in-room games (tables/machines) and refund their // outstanding wagers — same eviction semantics as bulk delete. if s.evictRoom != nil { @@ -490,6 +495,11 @@ func (s *AdminServer) handleRenameRoomDir(w http.ResponseWriter, r *http.Request failJSON(w, "rename failed: "+err.Error()) return } + // Undo history stores absolute paths: re-prefix them so a later undo + // does not recreate the old directory and write a duplicate room file. + if s.undoStack != nil { + s.undoStack.RewritePaths(oldPath, newPath) + } s.world.RebuildRoomIndex(s.dataDir) writeJSON(w, map[string]any{"ok": true, "new_dir": newRel}) } @@ -674,39 +684,63 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) { writeJSONError(w, "room path not found", http.StatusInternalServerError) return } - - oldA, _ := snapshotFile(pathA) - newA, err := writeYAMLFile(pathA, roomA) + newA, err := marshalYAML(roomA) if err != nil { - writeJSONError(w, "write A: "+err.Error(), http.StatusInternalServerError) + writeJSONError(w, "serialize A: "+err.Error(), http.StatusInternalServerError) return } - s.undoStack.Push(ChangeDesc{ - Description: fmt.Sprintf("link %d %s %d", body.From, dir, body.To), - FilePath: pathA, - OldContent: oldA, - NewContent: newA, - }) + oldA, _ := snapshotFile(pathA) + pathB := "" + var oldB, newB []byte if !body.OneWay { - pathB, okB2 := s.world.GetRoomPath(body.To) + var okB2 bool + pathB, okB2 = s.world.GetRoomPath(body.To) if !okB2 { writeJSONError(w, "room B path not found", http.StatusInternalServerError) return } - oldB, _ := snapshotFile(pathB) - newB, err := writeYAMLFile(pathB, roomB) + newB, err = marshalYAML(roomB) if err != nil { + writeJSONError(w, "serialize B: "+err.Error(), http.StatusInternalServerError) + return + } + oldB, _ = snapshotFile(pathB) + } + + // Both sides are resolved and serialized before any write, and both + // are applied in one journaled transaction so a failure cannot leave + // an asymmetric link, and one Undo reverses the whole action. + txn := persistence.NewTransaction(filepath.Join(s.dataDir, ".journal")) + if err := txn.Replace(pathA, newA, 0644); err != nil { + writeJSONError(w, "write A: "+err.Error(), http.StatusInternalServerError) + return + } + if !body.OneWay { + if err := txn.Replace(pathB, newB, 0644); err != nil { writeJSONError(w, "write B: "+err.Error(), http.StatusInternalServerError) return } - s.undoStack.Push(ChangeDesc{ - Description: fmt.Sprintf("link %d %s %d (reverse)", body.To, oppDir, body.From), - FilePath: pathB, - OldContent: oldB, - NewContent: newB, + } + if err := txn.Commit(); err != nil { + writeJSONError(w, "commit link: "+err.Error(), http.StatusInternalServerError) + return + } + + desc := ChangeDesc{ + Description: fmt.Sprintf("link %d %s %d", body.From, dir, body.To), + FilePath: pathA, + OldContent: oldA, + NewContent: newA, + } + if !body.OneWay { + desc.ExtraFiles = append(desc.ExtraFiles, ExtraFile{ + FilePath: pathB, + OldContent: oldB, + NewContent: newB, }) } + s.undoStack.Push(desc) writeJSON(w, map[string]any{"ok": true}) case http.MethodDelete: -- cgit v1.2.3