aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/api_room_insert_remove.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/admin/api_room_insert_remove.go')
-rw-r--r--internal/admin/api_room_insert_remove.go466
1 files changed, 466 insertions, 0 deletions
diff --git a/internal/admin/api_room_insert_remove.go b/internal/admin/api_room_insert_remove.go
new file mode 100644
index 0000000..c5ed54a
--- /dev/null
+++ b/internal/admin/api_room_insert_remove.go
@@ -0,0 +1,466 @@
+package admin
+
+import (
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+ "thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/world"
+)
+
+// handleRoomInsert models the in-game `room insert <direction>` for the admin
+// web GUI. Body: {from, dir, name?}. It creates a new room between `from` (A)
+// and the room A's dir exit leads to (B), rewiring A→dir→new, new→dir→B and
+// (when B's opposite exit points back at A) B→oppositeDir→new, preserving exit
+// properties on both sides. A grid-conflict check (world.InsertGridConflicts)
+// rejects inserts that would cause an overlap or twist. Returns the new room.
+//
+// The write sequence is transactional: the new room is written first, then A,
+// then B. If any write after the first fails, the already-applied writes are
+// rolled back (new room file deleted, A/B restored) so the on-disk world stays
+// consistent and no partial undo entry is pushed. The room index is only
+// touched after all writes succeed.
+func (s *AdminServer) handleRoomInsert(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
+ return
+ }
+ var body struct {
+ From int `json:"from"`
+ Dir string `json:"dir"`
+ Name string `json:"name"`
+ }
+ if err := readJSON(r, &body); err != nil || body.From <= 0 || body.Dir == "" {
+ writeJSONError(w, "invalid body, need from and dir", http.StatusBadRequest)
+ return
+ }
+
+ dir := world.ExitDir(strings.ToLower(body.Dir))
+ oppositeDir, ok := world.OppositeExit[dir]
+ if !ok {
+ writeJSONError(w, "invalid direction: "+body.Dir, http.StatusBadRequest)
+ return
+ }
+
+ aID := body.From
+ aRoom, err := s.world.LoadRoom(aID)
+ if err != nil {
+ writeJSONError(w, fmt.Sprintf("could not load room #%d: %v", aID, err), http.StatusBadRequest)
+ return
+ }
+ aExit, hasExit := aRoom.Exits[dir]
+ if !hasExit {
+ writeJSONError(w, fmt.Sprintf("room #%d has no %s exit", aID, dir), http.StatusBadRequest)
+ return
+ }
+ bID := aExit.Room
+ bRoom, err := s.world.LoadRoom(bID)
+ if err != nil {
+ writeJSONError(w, fmt.Sprintf("could not load the room to the %s (#%d): %v", dir, bID, err), http.StatusBadRequest)
+ return
+ }
+
+ name := strings.TrimSpace(body.Name)
+ if name == "" {
+ name = "New Room"
+ }
+
+ aPath, aPathOK := s.world.GetRoomPath(aID)
+ if !aPathOK {
+ writeJSONError(w, fmt.Sprintf("could not find the file for room #%d", aID), http.StatusInternalServerError)
+ return
+ }
+
+ newID, subdir, allocErr := s.nextRoomID(aID)
+ if allocErr != nil {
+ writeJSONError(w, fmt.Sprintf("could not allocate a room id: %v", allocErr), http.StatusInternalServerError)
+ return
+ }
+
+ // Grid-conflict check on a hypothetical post-insert world.
+ conflicts := world.InsertGridConflicts(aID, dir, bID, newID, func(id int) (*world.Room, bool) {
+ r, err := s.world.LoadRoom(id)
+ if err != nil {
+ return nil, false
+ }
+ return r, true
+ })
+ if len(conflicts) > 0 {
+ writeJSONError(w, s.formatGridConflicts(conflicts, "insert"), http.StatusConflict)
+ return
+ }
+
+ // --- Transactional write sequence -------------------------------------
+ newPath := filepath.Join(subdir, strconv.Itoa(newID)+".yaml")
+ newRoom := &world.Room{
+ Name: name,
+ Description: behavior.DescList{
+ {Text: "A featureless room."},
+ },
+ Exits: map[world.ExitDir]world.ExitDef{
+ dir: {Room: bID},
+ oppositeDir: {Room: aID},
+ },
+ }
+ newContent, werr := writeYAMLFile(newPath, newRoom)
+ if werr != nil {
+ writeJSONError(w, "could not write the new room: "+werr.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Rewire A's dir exit to the new room, preserving A's dir-exit props.
+ oldA, _ := snapshotFile(aPath)
+ aRoom.Exits[dir] = world.RewirePreserving(aExit, newID)
+ newA, werr := writeYAMLFile(aPath, aRoom)
+ if werr != nil {
+ rollbackRemove(newPath)
+ writeJSONError(w, "could not update room #"+strconv.Itoa(aID)+": "+werr.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Rewire B's reciprocal exit to the new room, preserving B's own exit
+ // props, only when B's opposite exit already points back at A (lenient
+ // one-way insertion when it does not).
+ var extraFiles []ExtraFile
+ bPath, bPathOK := s.world.GetRoomPath(bID)
+ var oldB, newB []byte
+ bRewired := false
+ if bPathOK {
+ if bExit, ok := bRoom.Exits[oppositeDir]; ok && bExit.Room == aID {
+ oldB, _ = snapshotFile(bPath)
+ bRoom.Exits[oppositeDir] = world.RewirePreserving(bExit, newID)
+ var berr error
+ newB, berr = writeYAMLFile(bPath, bRoom)
+ if berr != nil {
+ rollbackWrite(aPath, oldA)
+ rollbackRemove(newPath)
+ writeJSONError(w, "could not update the far room #"+strconv.Itoa(bID)+": "+berr.Error(), http.StatusInternalServerError)
+ return
+ }
+ bRewired = true
+ }
+ }
+
+ // All writes succeeded: register the new room and record the change.
+ s.world.AddRoomPath(newID, newPath)
+ extraFiles = append(extraFiles, ExtraFile{
+ FilePath: aPath,
+ OldContent: oldA,
+ NewContent: newA,
+ })
+ if bRewired {
+ extraFiles = append(extraFiles, ExtraFile{
+ FilePath: bPath,
+ OldContent: oldB,
+ NewContent: newB,
+ })
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("insert room %d %s %d (new %d)", aID, dir, bID, newID),
+ FilePath: newPath,
+ NewContent: newContent,
+ IsCreate: true,
+ ExtraFiles: extraFiles,
+ })
+
+ writeJSON(w, map[string]any{"room": map[string]any{"id": newID, "name": name}})
+}
+
+// handleRoomRemove models the in-game `room remove <direction>` for the admin
+// web GUI. Body: {from, dir}. It deletes the room reached via `from`'s dir exit
+// (B) and pulls the far room C (B's dir exit's target) back to `from`, rewiring
+// from→dir→C and C→oppositeDir→from, preserving exit properties on both sides.
+// It fails (400/409) under the same conditions as the in-game command. The GUI
+// cannot relocate live players standing in B (a documented limitation of the
+// admin/game decoupling — same as the existing Delete Room endpoint); it does
+// clear room state and mob instances for B.
+//
+// The write sequence is transactional: A is rewritten first, then C, then B is
+// deleted. If any step after the first fails, the already-applied writes are
+// rolled back (A/C restored, B's file left intact) so the on-disk world stays
+// consistent and no partial undo entry is pushed. Index/state cleanup runs only
+// after the delete succeeds.
+func (s *AdminServer) handleRoomRemove(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
+ return
+ }
+ var body struct {
+ From int `json:"from"`
+ Dir string `json:"dir"`
+ }
+ if err := readJSON(r, &body); err != nil || body.From <= 0 || body.Dir == "" {
+ writeJSONError(w, "invalid body, need from and dir", http.StatusBadRequest)
+ return
+ }
+
+ dir := world.ExitDir(strings.ToLower(body.Dir))
+ oppositeDir, ok := world.OppositeExit[dir]
+ if !ok {
+ writeJSONError(w, "invalid direction: "+body.Dir, http.StatusBadRequest)
+ return
+ }
+
+ aID := body.From
+ aRoom, err := s.world.LoadRoom(aID)
+ if err != nil {
+ writeJSONError(w, fmt.Sprintf("could not load room #%d: %v", aID, err), http.StatusBadRequest)
+ return
+ }
+ aExit, hasExit := aRoom.Exits[dir]
+ if !hasExit {
+ writeJSONError(w, fmt.Sprintf("room #%d has no %s exit", aID, dir), http.StatusBadRequest)
+ return
+ }
+ bID := aExit.Room
+ if bID == aID {
+ writeJSONError(w, "that exit loops back to the same room", http.StatusBadRequest)
+ return
+ }
+ bRoom, err := s.world.LoadRoom(bID)
+ if err != nil {
+ writeJSONError(w, fmt.Sprintf("could not load the room to the %s (#%d): %v", dir, bID, err), http.StatusBadRequest)
+ return
+ }
+ bLabel := s.roomLabel(bRoom, bID)
+
+ // Guard 1: B leads back to A via oppositeDir.
+ backExit, hasBack := bRoom.Exits[oppositeDir]
+ if !hasBack || backExit.Room != aID {
+ writeJSONError(w, fmt.Sprintf("cannot remove: %s does not lead back here via %s (not an insert chain)", bLabel, oppositeDir), http.StatusBadRequest)
+ return
+ }
+ // Guard 2: B has a forward dir exit.
+ fwdExit, hasFwd := bRoom.Exits[dir]
+ if !hasFwd {
+ writeJSONError(w, fmt.Sprintf("cannot remove: %s has no room beyond to pull (use Delete Room for a dead-end)", bLabel), http.StatusBadRequest)
+ return
+ }
+ cID := fwdExit.Room
+ if cID == bID || cID == aID {
+ writeJSONError(w, "cannot remove: the far room is not a distinct room", http.StatusBadRequest)
+ return
+ }
+ // Guard 3: B has exactly two exits.
+ if len(bRoom.Exits) != 2 {
+ writeJSONError(w, fmt.Sprintf("cannot remove: %s has other exits besides %s and %s", bLabel, oppositeDir, dir), http.StatusBadRequest)
+ return
+ }
+ // Guard 4: C's opposite exit points back at B.
+ cRoom, err := s.world.LoadRoom(cID)
+ if err != nil {
+ writeJSONError(w, fmt.Sprintf("could not load the far room #%d: %v", cID, err), http.StatusBadRequest)
+ return
+ }
+ cOppExit, hasCOpp := cRoom.Exits[oppositeDir]
+ if !hasCOpp || cOppExit.Room != bID {
+ writeJSONError(w, fmt.Sprintf("cannot remove: the far room #%d does not lead back to %s via %s", cID, bLabel, oppositeDir), http.StatusBadRequest)
+ return
+ }
+ // Guard 5: no extra inbound edges to B.
+ if extra := s.countExtraInbound(bID, aID, cID, dir, oppositeDir); extra > 0 {
+ writeJSONError(w, fmt.Sprintf("cannot remove: %d other exit(s) point into %s (it was modified since insertion)", extra, bLabel), http.StatusBadRequest)
+ return
+ }
+ // Guard 6: grid-conflict check on a hypothetical post-remove world.
+ conflicts := world.RemoveGridConflicts(aID, dir, func(id int) (*world.Room, bool) {
+ r, err := s.world.LoadRoom(id)
+ if err != nil {
+ return nil, false
+ }
+ return r, true
+ })
+ if len(conflicts) > 0 {
+ writeJSONError(w, s.formatGridConflicts(conflicts, "remove"), http.StatusConflict)
+ return
+ }
+
+ // --- Transactional write sequence -------------------------------------
+ aPath, aPathOK := s.world.GetRoomPath(aID)
+ if !aPathOK {
+ writeJSONError(w, fmt.Sprintf("could not find the file for room #%d", aID), http.StatusInternalServerError)
+ return
+ }
+ oldA, _ := snapshotFile(aPath)
+ aRoom.Exits[dir] = world.RewirePreserving(aExit, cID)
+ newA, werr := writeYAMLFile(aPath, aRoom)
+ if werr != nil {
+ writeJSONError(w, "could not update room #"+strconv.Itoa(aID)+": "+werr.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ cPath, cPathOK := s.world.GetRoomPath(cID)
+ oldC, _ := snapshotFile(cPath)
+ var newC []byte
+ cRewired := false
+ if cPathOK {
+ cRoom.Exits[oppositeDir] = world.RewirePreserving(cOppExit, aID)
+ var cerr error
+ newC, cerr = writeYAMLFile(cPath, cRoom)
+ if cerr != nil {
+ rollbackWrite(aPath, oldA)
+ writeJSONError(w, "could not update the far room #"+strconv.Itoa(cID)+": "+cerr.Error(), http.StatusInternalServerError)
+ return
+ }
+ cRewired = true
+ }
+
+ bPath, bPathOK := s.world.GetRoomPath(bID)
+ if !bPathOK {
+ rollbackWrite(aPath, oldA)
+ if cRewired {
+ rollbackWrite(cPath, oldC)
+ }
+ writeJSONError(w, fmt.Sprintf("could not find the file for room #%d", bID), http.StatusInternalServerError)
+ return
+ }
+ oldB, _ := snapshotFile(bPath)
+ if rerr := os.Remove(bPath); rerr != nil {
+ rollbackWrite(aPath, oldA)
+ if cRewired {
+ rollbackWrite(cPath, oldC)
+ }
+ writeJSONError(w, "could not delete room #"+strconv.Itoa(bID)+": "+rerr.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // All writes succeeded: rebuild the index and clear B's live state.
+ var extraFiles []ExtraFile
+ extraFiles = append(extraFiles, ExtraFile{
+ FilePath: aPath,
+ OldContent: oldA,
+ NewContent: newA,
+ })
+ if cRewired {
+ extraFiles = append(extraFiles, ExtraFile{
+ FilePath: cPath,
+ OldContent: oldC,
+ NewContent: newC,
+ })
+ }
+ s.world.RebuildRoomIndex(s.dataDir)
+ s.world.ClearRoomState(bID)
+ if s.mobStore != nil {
+ s.mobStore.RemoveMobsInRoom(bID)
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("remove room %d %s (pull %d to %d)", bID, dir, cID, aID),
+ FilePath: bPath,
+ OldContent: oldB,
+ IsDelete: true,
+ ExtraFiles: extraFiles,
+ })
+
+ writeJSON(w, map[string]any{"ok": true, "removed": bID, "pulled_to": cID})
+}
+
+// rollbackWrite restores a file's previous content. Used to undo an already-
+// applied write when a later step in a transactional handler fails. Errors are
+// logged (not returned) because there is no further recovery available.
+func rollbackWrite(path string, oldContent []byte) {
+ if oldContent == nil {
+ return
+ }
+ if err := os.WriteFile(path, oldContent, 0644); err != nil {
+ fmt.Printf("admin: rollback write %s failed: %v\n", path, err)
+ }
+}
+
+// rollbackRemove deletes a file that was just created. Used to undo a new-room
+// write when a later step in insert fails. Errors are logged (not returned).
+func rollbackRemove(path string) {
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ fmt.Printf("admin: rollback remove %s failed: %v\n", path, err)
+ }
+}
+
+// roomLabel returns "#id" or "#id (Name)" for human-readable error messages.
+func (s *AdminServer) roomLabel(r *world.Room, id int) string {
+ if r != nil && r.Name != "" {
+ return fmt.Sprintf("#%d (%s)", id, r.Name)
+ }
+ return fmt.Sprintf("#%d", id)
+}
+
+// formatGridConflicts renders the conflicts returned by Insert/RemoveGridConflicts
+// as a single human-readable string, mirroring the in-game command's messages:
+// twists become "room #N (Name) would have an ambiguous grid position" and
+// overlaps become "would collide with room #N (Name)". mode is "insert" or
+// "remove" and selects the leading verb.
+func (s *AdminServer) formatGridConflicts(cs []world.GridConflict, mode string) string {
+ verb := "insert"
+ if mode == "remove" {
+ verb = "remove"
+ }
+ var parts []string
+ for _, c := range cs {
+ switch c.Kind {
+ case "twist":
+ r, _ := s.world.LoadRoom(c.Target)
+ parts = append(parts, fmt.Sprintf("room %s would have an ambiguous grid position after %s", s.roomLabel(r, c.Target), verb))
+ case "overlap":
+ tgt, _ := s.world.LoadRoom(c.Target)
+ occ, _ := s.world.LoadRoom(c.Occupier)
+ parts = append(parts, fmt.Sprintf("%s would cause a grid collision: room %s cannot be placed because room %s is already there", verb, s.roomLabel(tgt, c.Target), s.roomLabel(occ, c.Occupier)))
+ }
+ }
+ if len(parts) == 0 {
+ return fmt.Sprintf("cannot %s: it would cause map conflicts", verb)
+ }
+ return "cannot " + verb + ": " + strings.Join(parts, "; ")
+}
+
+// countExtraInbound mirrors Game.countExtraInbound for the admin API: it
+// counts exits in rooms other than A and C that point into B, plus any A/C
+// exits to B other than the expected (A→dir→B, C→oppositeDir→B). It walks
+// data/rooms/ and matches \b<id>\b against raw file contents to skip rooms
+// that don't mention bID at all, then unmarshals only the matching files.
+func (s *AdminServer) countExtraInbound(bID, aID, cID int, dir, oppositeDir world.ExitDir) int {
+ roomsDir := filepath.Join(s.dataDir, "rooms")
+ re := regexp.MustCompile(fmt.Sprintf(`\b%d\b`, bID))
+ var extra int
+ _ = filepath.WalkDir(roomsDir, func(path string, d os.DirEntry, err error) error {
+ if err != nil || d.IsDir() || filepath.Ext(path) != ".yaml" {
+ return nil
+ }
+ data, rerr := os.ReadFile(path)
+ if rerr != nil {
+ return nil
+ }
+ if !re.MatchString(string(data)) {
+ return nil
+ }
+ idStr := strings.TrimSuffix(filepath.Base(path), ".yaml")
+ rid, serr := strconv.Atoi(idStr)
+ if serr != nil || rid == bID {
+ return nil
+ }
+ var room world.Room
+ if yerr := yaml.Unmarshal(data, &room); yerr != nil {
+ return nil
+ }
+ if room.Exits == nil {
+ return nil
+ }
+ for ed, exitDef := range room.Exits {
+ if exitDef.Room != bID {
+ continue
+ }
+ if rid == aID && ed == dir {
+ continue
+ }
+ if rid == cID && ed == oppositeDir {
+ continue
+ }
+ extra++
+ }
+ return nil
+ })
+ return extra
+}