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 ` 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 } 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 } name := strings.TrimSpace(body.Name) if name == "" { name = fmt.Sprintf("Room #%d", newID) } // 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 ` 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\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 }