diff options
| author | historia <[not public]> | 2026-07-07 05:50:37 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-07-07 05:50:37 -0400 |
| commit | 3f30fa3eec9c2e2acf9a984ccefb9529a25e688a (patch) | |
| tree | b6c146ec7f3dc2ea21dbf8ee7612c3889d93ca1a /internal/admin/api_room_insert_remove.go | |
| parent | a51f7a53aa2c487ebf94ba0363038574ae8bb590 (diff) | |
| download | thehouseoficarus-3f30fa3eec9c2e2acf9a984ccefb9529a25e688a.tar.gz | |
feat: add insert/remove room to context menu in admin web GUI to 'push' or 'pull' map rooms
Diffstat (limited to 'internal/admin/api_room_insert_remove.go')
| -rw-r--r-- | internal/admin/api_room_insert_remove.go | 466 |
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 +} |
