aboutsummaryrefslogtreecommitdiff
path: root/internal/admin
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-07 05:50:37 -0400
committerhistoria <[not public]>2026-07-07 05:50:37 -0400
commit3f30fa3eec9c2e2acf9a984ccefb9529a25e688a (patch)
treeb6c146ec7f3dc2ea21dbf8ee7612c3889d93ca1a /internal/admin
parenta51f7a53aa2c487ebf94ba0363038574ae8bb590 (diff)
downloadthehouseoficarus-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')
-rw-r--r--internal/admin/api_map.go2
-rw-r--r--internal/admin/api_room_insert_remove.go466
-rw-r--r--internal/admin/api_room_insert_remove_test.go535
-rw-r--r--internal/admin/api_rooms.go2
-rw-r--r--internal/admin/id_alloc.go43
-rw-r--r--internal/admin/server.go9
-rw-r--r--internal/admin/static/admin.css12
-rw-r--r--internal/admin/static/editor.js18
-rw-r--r--internal/admin/static/map.js241
9 files changed, 1306 insertions, 22 deletions
diff --git a/internal/admin/api_map.go b/internal/admin/api_map.go
index a7802f9..3705cde 100644
--- a/internal/admin/api_map.go
+++ b/internal/admin/api_map.go
@@ -442,7 +442,7 @@ func (s *AdminServer) handleNextRoomID(w http.ResponseWriter, r *http.Request) {
if fromStr != "" {
fromID, _ = strconv.Atoi(fromStr)
}
- id, _, err := nextRoomIDInDir(s.dataDir, fromID)
+ id, _, err := s.nextRoomID(fromID)
if err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
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
+}
diff --git a/internal/admin/api_room_insert_remove_test.go b/internal/admin/api_room_insert_remove_test.go
new file mode 100644
index 0000000..4f04a99
--- /dev/null
+++ b/internal/admin/api_room_insert_remove_test.go
@@ -0,0 +1,535 @@
+package admin
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/world"
+)
+
+// newTestAdminServer builds an AdminServer wired to a temp data dir containing
+// the given room bodies (map of id -> YAML body), with a live World, MobStore,
+// and UndoStack. Only the fields the insert/remove handlers touch are set.
+func newTestAdminServer(t *testing.T, rooms map[int]string) *AdminServer {
+ t.Helper()
+ dir := t.TempDir()
+ roomsDir := filepath.Join(dir, "rooms")
+ if err := os.MkdirAll(roomsDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ for id, body := range rooms {
+ if err := os.WriteFile(filepath.Join(roomsDir, strconv.Itoa(id)+".yaml"), []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return &AdminServer{
+ world: world.New(dir),
+ mobStore: world.NewMobStore(dir),
+ dataDir: dir,
+ undoStack: NewUndoStack(dir),
+ }
+}
+
+func writeRoomFile(t *testing.T, dir string, id int, body string) {
+ t.Helper()
+ roomsDir := filepath.Join(dir, "rooms")
+ if err := os.MkdirAll(roomsDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(roomsDir, strconv.Itoa(id)+".yaml"), []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func roomExists(s *AdminServer, id int) bool {
+ _, err := s.world.LoadRoom(id)
+ return err == nil
+}
+
+func roomExitTarget(s *AdminServer, id int, dir world.ExitDir) (int, bool) {
+ r, err := s.world.LoadRoom(id)
+ if err != nil {
+ return 0, false
+ }
+ e, ok := r.Exits[dir]
+ if !ok {
+ return 0, false
+ }
+ return e.Room, true
+}
+
+// postInsert drives the insert endpoint with the given body and returns the
+// decoded response alongside the response status code.
+func postInsert(t *testing.T, s *AdminServer, body string) (map[string]any, int) {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPost, "/api/rooms/insert", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rr := httptest.NewRecorder()
+ s.handleRoomInsert(rr, req)
+ var resp map[string]any
+ _ = json.Unmarshal(rr.Body.Bytes(), &resp)
+ return resp, rr.Code
+}
+
+func postRemove(t *testing.T, s *AdminServer, body string) (map[string]any, int) {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPost, "/api/rooms/remove", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rr := httptest.NewRecorder()
+ s.handleRoomRemove(rr, req)
+ var resp map[string]any
+ _ = json.Unmarshal(rr.Body.Bytes(), &resp)
+ return resp, rr.Code
+}
+
+// TestInsertSuccess verifies a clean two-way insert creates the new room,
+// rewires A and B onto it, and pushes an undo entry that restores everything.
+func TestInsertSuccess(t *testing.T) {
+ s := newTestAdminServer(t, map[int]string{
+ 1: "name: A\nexits:\n east: 2\n",
+ 2: "name: B\nexits:\n west: 1\n",
+ })
+
+ resp, code := postInsert(t, s, `{"from":1,"dir":"east","name":"Mid"}`)
+ if code != http.StatusOK {
+ t.Fatalf("insert: expected 200, got %d: %v", code, resp)
+ }
+ room := resp["room"].(map[string]any)
+ newID := int(room["id"].(float64))
+ if room["name"] != "Mid" {
+ t.Errorf("insert: expected name Mid, got %v", room["name"])
+ }
+
+ // A→east→new, new→east→2, new→west→1, B→west→new.
+ if tgt, _ := roomExitTarget(s, 1, world.East); tgt != newID {
+ t.Errorf("A east: expected %d, got %d", newID, tgt)
+ }
+ if tgt, _ := roomExitTarget(s, newID, world.East); tgt != 2 {
+ t.Errorf("new east: expected 2, got %d", tgt)
+ }
+ if tgt, _ := roomExitTarget(s, newID, world.West); tgt != 1 {
+ t.Errorf("new west: expected 1, got %d", tgt)
+ }
+ if tgt, _ := roomExitTarget(s, 2, world.West); tgt != newID {
+ t.Errorf("B west: expected %d, got %d", newID, tgt)
+ }
+
+ // Undo restores the original two-room world.
+ change := s.undoStack.Undo()
+ if change == nil {
+ t.Fatal("undo: expected a change, got nil")
+ }
+ s.rebuildAfterUndo(change)
+ if roomExists(s, newID) {
+ t.Error("undo: new room file should be gone")
+ }
+ if tgt, _ := roomExitTarget(s, 1, world.East); tgt != 2 {
+ t.Errorf("undo: A east should be 2, got %d", tgt)
+ }
+ if tgt, _ := roomExitTarget(s, 2, world.West); tgt != 1 {
+ t.Errorf("undo: B west should be 1, got %d", tgt)
+ }
+
+ // Redo re-applies the insert.
+ redoChange := s.undoStack.Redo()
+ if redoChange == nil {
+ t.Fatal("redo: expected a change, got nil")
+ }
+ s.rebuildAfterUndo(redoChange)
+ if !roomExists(s, newID) {
+ t.Error("redo: new room file should be back")
+ }
+ if tgt, _ := roomExitTarget(s, 1, world.East); tgt != newID {
+ t.Errorf("redo: A east should be %d, got %d", newID, tgt)
+ }
+ if tgt, _ := roomExitTarget(s, 2, world.West); tgt != newID {
+ t.Errorf("redo: B west should be %d, got %d", newID, tgt)
+ }
+}
+
+// TestInsertDefaultName confirms an empty name falls back to "New Room".
+func TestInsertDefaultName(t *testing.T) {
+ s := newTestAdminServer(t, map[int]string{
+ 1: "name: A\nexits:\n east: 2\n",
+ 2: "name: B\nexits:\n west: 1\n",
+ })
+ resp, code := postInsert(t, s, `{"from":1,"dir":"east","name":""}`)
+ if code != http.StatusOK {
+ t.Fatalf("insert: expected 200, got %d: %v", code, resp)
+ }
+ if resp["room"].(map[string]any)["name"] != "New Room" {
+ t.Errorf("insert: expected default name 'New Room', got %v", resp["room"].(map[string]any)["name"])
+ }
+}
+
+// TestInsertGridConflict verifies a conflicting insert returns 409 with a
+// human-readable message (not raw JSON, not the old generic string).
+func TestInsertGridConflict(t *testing.T) {
+ // 1→east→2→east→3 and 1→south→4→east→5→east→6→east→7→north→8 at (3,0,0).
+ // Inserting between 1 and 2 pushes 3 to (3,0,0), colliding with 8.
+ s := newTestAdminServer(t, map[int]string{
+ 1: "name: A\nexits:\n east: 2\n south: 4\n",
+ 2: "name: B\nexits:\n east: 3\n west: 1\n",
+ 3: "name: C\nexits:\n west: 2\n",
+ 4: "name: D\nexits:\n east: 5\n north: 1\n",
+ 5: "name: E\nexits:\n east: 6\n west: 4\n",
+ 6: "name: F\nexits:\n east: 7\n west: 5\n",
+ 7: "name: G\nexits:\n north: 8\n west: 6\n",
+ 8: "name: H\nexits:\n south: 7\n",
+ })
+ resp, code := postInsert(t, s, `{"from":1,"dir":"east","name":"X"}`)
+ if code != http.StatusConflict {
+ t.Fatalf("insert conflict: expected 409, got %d: %v", code, resp)
+ }
+ msg, _ := resp["error"].(string)
+ if !strings.Contains(msg, "cannot insert:") {
+ t.Errorf("insert conflict: expected 'cannot insert:' prefix, got %q", msg)
+ }
+ if !strings.Contains(msg, "collision") {
+ t.Errorf("insert conflict: expected overlap wording, got %q", msg)
+ }
+ if !strings.Contains(msg, "#3") || !strings.Contains(msg, "#8") {
+ t.Errorf("insert conflict: expected both colliding room ids (#3 and #8), got %q", msg)
+ }
+ // No partial writes: the world is unchanged.
+ if tgt, _ := roomExitTarget(s, 1, world.East); tgt != 2 {
+ t.Errorf("insert conflict: A east should still be 2, got %d", tgt)
+ }
+}
+
+// TestRemoveSuccess verifies a clean remove deletes B, pulls C back to A, and
+// undo/redo round-trip.
+func TestRemoveSuccess(t *testing.T) {
+ s := newTestAdminServer(t, map[int]string{
+ 1: "name: A\nexits:\n east: 2\n",
+ 2: "name: B\nexits:\n east: 3\n west: 1\n",
+ 3: "name: C\nexits:\n west: 2\n",
+ })
+ resp, code := postRemove(t, s, `{"from":1,"dir":"east"}`)
+ if code != http.StatusOK {
+ t.Fatalf("remove: expected 200, got %d: %v", code, resp)
+ }
+ if int(resp["removed"].(float64)) != 2 {
+ t.Errorf("remove: expected removed=2, got %v", resp["removed"])
+ }
+ if int(resp["pulled_to"].(float64)) != 3 {
+ t.Errorf("remove: expected pulled_to=3, got %v", resp["pulled_to"])
+ }
+ if roomExists(s, 2) {
+ t.Error("remove: B file should be gone")
+ }
+ if tgt, _ := roomExitTarget(s, 1, world.East); tgt != 3 {
+ t.Errorf("remove: A east should be 3, got %d", tgt)
+ }
+ if tgt, _ := roomExitTarget(s, 3, world.West); tgt != 1 {
+ t.Errorf("remove: C west should be 1, got %d", tgt)
+ }
+
+ // Undo restores B and reverts A and C.
+ change := s.undoStack.Undo()
+ if change == nil {
+ t.Fatal("undo: expected a change, got nil")
+ }
+ s.rebuildAfterUndo(change)
+ if !roomExists(s, 2) {
+ t.Error("undo: B file should be back")
+ }
+ if tgt, _ := roomExitTarget(s, 1, world.East); tgt != 2 {
+ t.Errorf("undo: A east should be 2, got %d", tgt)
+ }
+ if tgt, _ := roomExitTarget(s, 3, world.West); tgt != 2 {
+ t.Errorf("undo: C west should be 2, got %d", tgt)
+ }
+ if tgt, _ := roomExitTarget(s, 2, world.West); tgt != 1 {
+ t.Errorf("undo: B west should be 1, got %d", tgt)
+ }
+ if tgt, _ := roomExitTarget(s, 2, world.East); tgt != 3 {
+ t.Errorf("undo: B east should be 3, got %d", tgt)
+ }
+
+ // Redo re-deletes B and repulls C.
+ redoChange := s.undoStack.Redo()
+ if redoChange == nil {
+ t.Fatal("redo: expected a change, got nil")
+ }
+ s.rebuildAfterUndo(redoChange)
+ if roomExists(s, 2) {
+ t.Error("redo: B file should be gone again")
+ }
+ if tgt, _ := roomExitTarget(s, 1, world.East); tgt != 3 {
+ t.Errorf("redo: A east should be 3, got %d", tgt)
+ }
+}
+
+// TestRemoveGuards exercises each structural guard, checking the 400 status and
+// a human-readable, specific error message.
+func TestRemoveGuards(t *testing.T) {
+ tests := []struct {
+ name string
+ rooms map[int]string
+ body string
+ want string
+ }{
+ {
+ name: "no such exit",
+ rooms: map[int]string{
+ 1: "name: A\nexits:\n east: 2\n",
+ 2: "name: B\nexits:\n west: 1\n",
+ },
+ body: `{"from":1,"dir":"north"}`,
+ want: "has no north exit",
+ },
+ {
+ name: "self loop",
+ rooms: map[int]string{
+ 1: "name: A\nexits:\n east: 1\n",
+ },
+ body: `{"from":1,"dir":"east"}`,
+ want: "loops back",
+ },
+ {
+ name: "not an insert chain (B does not lead back)",
+ rooms: map[int]string{
+ 1: "name: A\nexits:\n east: 2\n",
+ 2: "name: B\nexits:\n east: 3\n",
+ 3: "name: C\nexits:\n west: 2\n",
+ },
+ body: `{"from":1,"dir":"east"}`,
+ want: "not an insert chain",
+ },
+ {
+ name: "dead end (B has no forward exit)",
+ rooms: map[int]string{
+ 1: "name: A\nexits:\n east: 2\n",
+ 2: "name: B\nexits:\n west: 1\n",
+ },
+ body: `{"from":1,"dir":"east"}`,
+ want: "no room beyond",
+ },
+ {
+ name: "B has extra exits",
+ rooms: map[int]string{
+ 1: "name: A\nexits:\n east: 2\n",
+ 2: "name: B\nexits:\n east: 3\n west: 1\n north: 4\n",
+ 3: "name: C\nexits:\n west: 2\n",
+ 4: "name: D\nexits:\n south: 2\n",
+ },
+ body: `{"from":1,"dir":"east"}`,
+ want: "other exits besides",
+ },
+ {
+ name: "C does not lead back to B",
+ rooms: map[int]string{
+ 1: "name: A\nexits:\n east: 2\n",
+ 2: "name: B\nexits:\n east: 3\n west: 1\n",
+ 3: "name: C\nexits:\n east: 4\n",
+ 4: "name: D\nexits:\n west: 3\n",
+ },
+ body: `{"from":1,"dir":"east"}`,
+ want: "does not lead back to",
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ s := newTestAdminServer(t, tc.rooms)
+ resp, code := postRemove(t, s, tc.body)
+ if code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d: %v", code, resp)
+ }
+ msg, _ := resp["error"].(string)
+ if !strings.Contains(msg, tc.want) {
+ t.Errorf("expected message containing %q, got %q", tc.want, msg)
+ }
+ if strings.HasPrefix(msg, "{") || strings.Contains(msg, `"error"`) {
+ t.Errorf("error message looks like raw JSON: %q", msg)
+ }
+ })
+ }
+}
+
+// TestRemoveGridConflict verifies a conflicting remove returns 409 with rich
+// wording (overlap) instead of the old generic message.
+func TestRemoveGridConflict(t *testing.T) {
+ // 1→east→2→east→3 (3 has south→6), 1→south→4→east→5. Removing 2 pulls 3
+ // to (1,0,0); 6 (at 3's old south neighbor (2,1,0)) follows to (1,1,0)? No
+ // — the pull shifts the whole beyond-component; 6 lands on 5's cell (1,1,0).
+ s := newTestAdminServer(t, map[int]string{
+ 1: "name: A\nexits:\n east: 2\n south: 4\n",
+ 2: "name: B\nexits:\n east: 3\n west: 1\n",
+ 3: "name: C\nexits:\n south: 6\n west: 2\n",
+ 4: "name: D\nexits:\n east: 5\n north: 1\n",
+ 5: "name: E\nexits:\n west: 4\n",
+ 6: "name: F\nexits:\n north: 3\n",
+ })
+ resp, code := postRemove(t, s, `{"from":1,"dir":"east"}`)
+ if code != http.StatusConflict {
+ t.Fatalf("remove conflict: expected 409, got %d: %v", code, resp)
+ }
+ msg, _ := resp["error"].(string)
+ if !strings.Contains(msg, "cannot remove:") {
+ t.Errorf("remove conflict: expected 'cannot remove:' prefix, got %q", msg)
+ }
+ if !strings.Contains(msg, "collision") {
+ t.Errorf("remove conflict: expected overlap wording, got %q", msg)
+ }
+ // No partial writes: B is still there, A and C unchanged.
+ if !roomExists(s, 2) {
+ t.Error("remove conflict: B file should still exist")
+ }
+ if tgt, _ := roomExitTarget(s, 1, world.East); tgt != 2 {
+ t.Errorf("remove conflict: A east should still be 2, got %d", tgt)
+ }
+}
+
+// TestRemoveExtraInbound verifies the extra-inbound guard fires when a third
+// room points into B, and that the message names the count.
+func TestRemoveExtraInbound(t *testing.T) {
+ s := newTestAdminServer(t, map[int]string{
+ 1: "name: A\nexits:\n east: 2\n",
+ 2: "name: B\nexits:\n east: 3\n west: 1\n",
+ 3: "name: C\nexits:\n west: 2\n",
+ 9: "name: X\nexits:\n north: 2\n", // extra inbound edge into B
+ })
+ resp, code := postRemove(t, s, `{"from":1,"dir":"east"}`)
+ if code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d: %v", code, resp)
+ }
+ msg, _ := resp["error"].(string)
+ if !strings.Contains(msg, "other exit(s) point into") {
+ t.Errorf("expected extra-inbound message, got %q", msg)
+ }
+}
+
+// TestNextRoomIDNoCrossSubdirCollision confirms the allocator does not return an
+// ID that already exists in a different subdirectory.
+func TestNextRoomIDNoCrossSubdirCollision(t *testing.T) {
+ dir := t.TempDir()
+ roomsDir := filepath.Join(dir, "rooms")
+ if err := os.MkdirAll(roomsDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ // Room 1 in root, room 2 in a subdirectory "zone".
+ writeRoomFile(t, dir, 1, "name: A\nexits:\n east: 2\n")
+ zoneDir := filepath.Join(roomsDir, "zone")
+ if err := os.MkdirAll(zoneDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(zoneDir, "2.yaml"), []byte("name: B\nexits:\n west: 1\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ s := &AdminServer{
+ world: world.New(dir),
+ mobStore: world.NewMobStore(dir),
+ dataDir: dir,
+ undoStack: NewUndoStack(dir),
+ }
+ // Allocating near room 1 (root) must skip 2 (which lives in zone/).
+ id, _, err := s.nextRoomID(1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if id == 2 {
+ t.Fatalf("nextRoomID returned %d, which collides with the zone/ room", id)
+ }
+ if roomExists(s, id) {
+ t.Errorf("nextRoomID returned an already-used id %d", id)
+ }
+}
+
+// testMobDef returns a minimal mob def with enough HP that MobsInRoom (which
+// filters HP > 0) reports it as present.
+func testMobDef() *world.MobDef {
+ return &world.MobDef{
+ ID: "testmob",
+ Name: "Test",
+ Combat: &world.MobCombat{
+ Stats: world.MobCombatStats{HP: 10, MaxMeleeHit: 1},
+ },
+ }
+}
+
+// TestUndoInsertClearsMobs verifies that undoing an insert drops in-memory mob
+// instances for the (now-deleted) new room, via rebuildAfterUndo's stat check.
+func TestUndoInsertClearsMobs(t *testing.T) {
+ s := newTestAdminServer(t, map[int]string{
+ 1: "name: A\nexits:\n east: 2\n",
+ 2: "name: B\nexits:\n west: 1\n",
+ })
+ resp, code := postInsert(t, s, `{"from":1,"dir":"east","name":"Mid"}`)
+ if code != http.StatusOK {
+ t.Fatalf("insert: expected 200, got %d: %v", code, resp)
+ }
+ newID := int(resp["room"].(map[string]any)["id"].(float64))
+
+ // Spawn a transient mob in the new room so the store has something to clear.
+ inst := s.mobStore.SpawnTransient(testMobDef(), &world.SpawnMobConfig{ID: "testmob"}, newID, "")
+ if inst == nil {
+ t.Fatal("SpawnTransient returned nil")
+ }
+ if got := len(s.mobStore.MobsInRoom(newID)); got != 1 {
+ t.Fatalf("precondition: expected 1 mob in new room, got %d", got)
+ }
+
+ change := s.undoStack.Undo()
+ if change == nil {
+ t.Fatal("undo: expected a change, got nil")
+ }
+ s.rebuildAfterUndo(change)
+
+ if got := len(s.mobStore.MobsInRoom(newID)); got != 0 {
+ t.Errorf("undo: expected mobs for deleted room to be cleared, got %d", got)
+ }
+}
+
+// TestRedoRemoveClearsMobs verifies that redoing a remove drops mob instances
+// for the re-deleted room.
+func TestRedoRemoveClearsMobs(t *testing.T) {
+ s := newTestAdminServer(t, map[int]string{
+ 1: "name: A\nexits:\n east: 2\n",
+ 2: "name: B\nexits:\n east: 3\n west: 1\n",
+ 3: "name: C\nexits:\n west: 2\n",
+ })
+ if _, code := postRemove(t, s, `{"from":1,"dir":"east"}`); code != http.StatusOK {
+ t.Fatal("remove failed")
+ }
+ // Undo to bring B back, then seed a mob in B before redoing.
+ change := s.undoStack.Undo()
+ if change == nil {
+ t.Fatal("undo: expected a change, got nil")
+ }
+ s.rebuildAfterUndo(change)
+ if !roomExists(s, 2) {
+ t.Fatal("precondition: B should exist after undo")
+ }
+ s.mobStore.SpawnTransient(testMobDef(), &world.SpawnMobConfig{ID: "testmob"}, 2, "")
+ if got := len(s.mobStore.MobsInRoom(2)); got != 1 {
+ t.Fatalf("precondition: expected 1 mob in B, got %d", got)
+ }
+
+ redoChange := s.undoStack.Redo()
+ if redoChange == nil {
+ t.Fatal("redo: expected a change, got nil")
+ }
+ s.rebuildAfterUndo(redoChange)
+
+ if got := len(s.mobStore.MobsInRoom(2)); got != 0 {
+ t.Errorf("redo: expected mobs for re-deleted B to be cleared, got %d", got)
+ }
+}
+
+// TestInsertInvalidBody confirms malformed input yields 400, not a panic.
+func TestInsertInvalidBody(t *testing.T) {
+ s := newTestAdminServer(t, map[int]string{1: "name: A\n"})
+ for _, body := range []string{`{}`, `{"from":0,"dir":"east"}`, `{"from":1,"dir":""}`, `{"from":1,"dir":"sideways"}`} {
+ resp, code := postInsert(t, s, body)
+ if code != http.StatusBadRequest {
+ t.Errorf("insert %q: expected 400, got %d: %v", body, code, resp)
+ }
+ }
+}
diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go
index 124b093..8b4a28f 100644
--- a/internal/admin/api_rooms.go
+++ b/internal/admin/api_rooms.go
@@ -73,7 +73,7 @@ func (s *AdminServer) handleRooms(w http.ResponseWriter, r *http.Request) {
if linkFrom != nil {
fromID = *linkFrom
}
- id, subdir, allocErr := nextRoomIDInDir(s.dataDir, fromID)
+ id, subdir, allocErr := s.nextRoomID(fromID)
if allocErr != nil {
writeJSON(w, map[string]any{"error": fmt.Sprintf("alloc id: %v", allocErr)})
return
diff --git a/internal/admin/id_alloc.go b/internal/admin/id_alloc.go
index d79718b..3a3c973 100644
--- a/internal/admin/id_alloc.go
+++ b/internal/admin/id_alloc.go
@@ -6,24 +6,43 @@ import (
"strconv"
)
-func nextRoomIDInDir(dataDir string, fromRoomID int) (int, string, error) {
- base := filepath.Join(dataDir, "rooms")
+// nextRoomID allocates a free room ID for a new room that will live in the same
+// directory as fromRoomID (so new IDs cluster near their neighbors). Unlike the
+// old per-subdir scan, the candidate is checked against EVERY room ID on disk
+// (via listRoomIDs, which walks the whole data/rooms tree), so it can never
+// collide with an ID that lives in a different subdirectory. The starting point
+// is the local subdir's minimum existing ID (clustering), then the first
+// globally-free ID scanning upward.
+func (s *AdminServer) nextRoomID(fromRoomID int) (int, string, error) {
+ base := filepath.Join(s.dataDir, "rooms")
subdir, err := findRoomSubdir(base, fromRoomID)
if err != nil {
subdir = base
}
- used := map[int]bool{}
- scanDir(subdir, used)
- if len(used) == 0 {
- return 1, subdir, nil
- }
- minID := 1<<31 - 1
- for id := range used {
- if id < minID {
- minID = id
+
+ localUsed := map[int]bool{}
+ scanDir(subdir, localUsed)
+ start := 1
+ if len(localUsed) > 0 {
+ minID := 1<<31 - 1
+ for id := range localUsed {
+ if id < minID {
+ minID = id
+ }
}
+ start = minID
+ }
+
+ globalUsed, _ := listRoomIDs(s.dataDir)
+ used := make(map[int]bool, len(globalUsed))
+ for _, id := range globalUsed {
+ used[id] = true
+ }
+
+ id := start
+ if id < 1 {
+ id = 1
}
- id := minID
for used[id] {
id++
}
diff --git a/internal/admin/server.go b/internal/admin/server.go
index 6ad20f5..17835db 100644
--- a/internal/admin/server.go
+++ b/internal/admin/server.go
@@ -20,6 +20,7 @@ import (
"net"
"net/http"
"path/filepath"
+ "os"
"strconv"
"strings"
"time"
@@ -120,6 +121,8 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor
apiMux.HandleFunc("/api/room-dirs/rename", s.handleRenameRoomDir)
apiMux.HandleFunc("/api/room-dirs/create", s.handleCreateRoomDir)
apiMux.HandleFunc("/api/rooms/link", s.handleRoomLink)
+ apiMux.HandleFunc("/api/rooms/insert", s.handleRoomInsert)
+ apiMux.HandleFunc("/api/rooms/remove", s.handleRoomRemove)
apiMux.HandleFunc("/api/rooms/move", s.handleRoomMove)
apiMux.HandleFunc("/api/rooms/rename", s.handleRoomRename)
apiMux.HandleFunc("/api/rooms", s.handleRooms)
@@ -619,6 +622,12 @@ func (s *AdminServer) rebuildAfterUndo(change *ChangeDesc) {
if idStr := strings.TrimSuffix(name, ".yaml"); idStr != name {
if id, err := strconv.Atoi(idStr); err == nil && id > 0 {
s.world.ClearRoomState(id)
+ // If the room file is now gone (true after undo-of-create and
+ // redo-of-delete), drop any in-memory mob instances for it so
+ // they don't keep ticking against a room that no longer exists.
+ if _, statErr := os.Stat(p); os.IsNotExist(statErr) && s.mobStore != nil {
+ s.mobStore.RemoveMobsInRoom(id)
+ }
}
}
}
diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css
index 6822646..d878db5 100644
--- a/internal/admin/static/admin.css
+++ b/internal/admin/static/admin.css
@@ -235,6 +235,18 @@ g.ud-hover:hover text{font-weight:bold}
.cm-menu button:hover{background:var(--accent)}
.cm-menu button.danger{color:#f55}
.cm-menu button.danger:hover{background:var(--danger)}
+.cm-menu button.muted{color:#888}
+.cm-menu .cm-title{font-size:11px;color:#aaa;text-transform:uppercase;letter-spacing:.5px;padding:4px 12px 6px;border-bottom:1px solid var(--border);margin-bottom:4px}
+.cm-item-has-sub{position:relative}
+.cm-item-has-sub>button{display:block;width:100%;padding:6px 24px 6px 12px;text-align:left;font-size:12px;font-family:monospace;background:none;border:none;color:var(--text);cursor:pointer}
+.cm-item-has-sub>button:hover{background:var(--accent)}
+.cm-item-has-sub .cm-arrow{position:absolute;right:8px;top:6px;font-size:9px;color:#888;pointer-events:none}
+.cm-item-has-sub.disabled{opacity:.5}
+.cm-item-has-sub.disabled>button{cursor:not-allowed}
+.cm-submenu{position:absolute;left:100%;top:-5px;display:none;min-width:120px;background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:4px 0;box-shadow:0 4px 16px rgba(0,0,0,.4);z-index:201}
+.cm-submenu.open{display:block}
+.cm-submenu.flip-left{left:auto;right:100%}
+.cm-submenu.flip-up{top:auto;bottom:-5px}
.rename-header{display:flex;align-items:center;gap:6px}
.rename-header-text{margin-bottom:0!important;padding-bottom:0!important;border-bottom:none!important}
.rename-pencil{font-size:14px;cursor:pointer;color:#888;padding:2px 4px;border-radius:3px;transition:color .15s,transform .15s}
diff --git a/internal/admin/static/editor.js b/internal/admin/static/editor.js
index 50d86f1..1bd6872 100644
--- a/internal/admin/static/editor.js
+++ b/internal/admin/static/editor.js
@@ -278,12 +278,21 @@ function showDirContextMenu(e, dir) {
overlay.className = 'cm-overlay';
overlay.id = '_ctxMenuOverlay';
overlay.onclick = function() { closeContextMenu(); };
+ overlay.addEventListener('contextmenu', function(ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ var cx = ev.clientX, cy = ev.clientY;
+ closeContextMenu();
+ var el = document.elementFromPoint(cx, cy);
+ if (el) el.dispatchEvent(new MouseEvent('contextmenu', {bubbles:true, cancelable:true, clientX:cx, clientY:cy, button:2}));
+ });
var menu = document.createElement('div');
menu.className = 'cm-menu';
menu.id = '_ctxMenu';
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
+ menu.oncontextmenu = function(ev) { ev.preventDefault(); };
function addBtn(label, cls, handler) {
var btn = document.createElement('button');
@@ -324,12 +333,21 @@ function showItemContextMenu(e, id) {
overlay.className = 'cm-overlay';
overlay.id = '_ctxMenuOverlay';
overlay.onclick = function() { closeContextMenu(); };
+ overlay.addEventListener('contextmenu', function(ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ var cx = ev.clientX, cy = ev.clientY;
+ closeContextMenu();
+ var el = document.elementFromPoint(cx, cy);
+ if (el) el.dispatchEvent(new MouseEvent('contextmenu', {bubbles:true, cancelable:true, clientX:cx, clientY:cy, button:2}));
+ });
var menu = document.createElement('div');
menu.className = 'cm-menu';
menu.id = '_ctxMenu';
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
+ menu.oncontextmenu = function(ev) { ev.preventDefault(); };
function addBtn(label, cls, handler) {
var btn = document.createElement('button');
diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js
index 3fdd543..9044286 100644
--- a/internal/admin/static/map.js
+++ b/internal/admin/static/map.js
@@ -17,6 +17,11 @@ var gridDirs = [
{dx:1,dy:-1,dir:'northeast'},{dx:-1,dy:-1,dir:'northwest'},
{dx:1,dy:1,dir:'southeast'},{dx:-1,dy:1,dir:'southwest'}
];
+var oppositeDir = {
+ north:'south', south:'north', east:'west', west:'east',
+ northeast:'southwest', northwest:'southeast',
+ southeast:'northwest', southwest:'northeast'
+};
var saveTimer = null;
var upTarget = {}, downTarget = {};
@@ -1926,7 +1931,7 @@ function deleteRoom(id) {
selectedRoom = null;
$('#panelContent').innerHTML = '<p style="color:#888;text-align:center;margin-top:40px">Room deleted</p>';
loadMap();
- }).catch(function(e) { notify('Delete failed: ' + e.message, 'error'); });
+ }).catch(function(e) { notify('Delete failed: ' + extractError(e), 'error'); });
}
function createRoom(fromID, dir, oneway) {
@@ -1942,39 +1947,249 @@ function createRoom(fromID, dir, oneway) {
notify('Room #' + createdId + ' created', 'success');
updateUndoBar();
loadMap().then(function() { selectRoom(createdId); });
- }).catch(function(e) { notify('Create failed: ' + e.message, 'error'); });
+ }).catch(function(e) { notify('Create failed: ' + extractError(e), 'error'); });
});
}
function showRoomContextMenu(x, y, id) {
var overlay = document.createElement('div');
overlay.className = 'cm-overlay';
- overlay.onclick = function() { overlay.remove(); menu.remove(); };
+ // Closing the menu also kills any open submenu and pending hide timer.
+ var closeAll = function() {
+ if (overlay._subHideTimer) { clearTimeout(overlay._subHideTimer); overlay._subHideTimer = null; }
+ overlay.remove(); menu.remove();
+ };
+ overlay.onclick = closeAll;
+ overlay.addEventListener('contextmenu', function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ var cx = e.clientX, cy = e.clientY;
+ closeAll();
+ var el = document.elementFromPoint(cx, cy);
+ while (el && el !== document.body && !(el.classList && el.classList.contains('rm'))) {
+ el = el.parentElement;
+ }
+ if (el && el.classList && el.classList.contains('rm')) {
+ var rid = parseInt(el.getAttribute('data-id'));
+ if (rid) showRoomContextMenu(cx, cy, rid);
+ }
+ });
var menu = document.createElement('div');
menu.className = 'cm-menu';
menu.style.left = x + 'px';
menu.style.top = y + 'px';
+ menu.oncontextmenu = function(e) { e.preventDefault(); };
var delBtn = document.createElement('button');
delBtn.textContent = 'Delete Room';
delBtn.className = 'danger';
delBtn.onclick = function() {
- overlay.remove(); menu.remove();
+ closeAll();
deleteRoom(id);
};
menu.appendChild(delBtn);
+ menu.appendChild(buildSubmenuItem('Insert Room', id, 'insert', closeAll, overlay, menu));
+ menu.appendChild(buildSubmenuItem('Remove Room', id, 'remove', closeAll, overlay, menu));
+
var selBtn = document.createElement('button');
selBtn.textContent = 'Room Details';
selBtn.onclick = function() {
- overlay.remove(); menu.remove();
+ closeAll();
selectRoom(id);
};
menu.appendChild(selBtn);
document.body.appendChild(overlay);
document.body.appendChild(menu);
+
+ // Clamp inside the viewport so the menu never spills off-screen.
+ clampMenuToViewport(menu);
+}
+
+// buildSubmenuItem constructs a parent menu row ("Insert Room"/"Remove Room")
+// that carries a nested submenu of the room's horizontal exits. The submenu is
+// revealed by both hover (Windows-style: mouseenter with a small dismiss delay
+// on mouseleave) and click (keyboard/mobile accessibility), mirroring how
+// Windows cascading menus behave. closeAll closes the whole context menu;
+// overlay carries the shared hide-timer slot.
+function buildSubmenuItem(label, id, mode, closeAll, overlay, menu) {
+ var dirs = roomHorizontalDirs(id);
+ var empty = dirs.length === 0;
+
+ var item = document.createElement('div');
+ item.className = 'cm-item-has-sub' + (empty ? ' disabled' : '');
+
+ var btn = document.createElement('button');
+ btn.textContent = label;
+ var arrow = document.createElement('span');
+ arrow.className = 'cm-arrow';
+ arrow.textContent = '\u25B6';
+ item.appendChild(btn);
+ item.appendChild(arrow);
+
+ if (empty) {
+ // Still surface the "no exits" reason on click so it's discoverable.
+ btn.onclick = function() {
+ notify(mode === 'insert'
+ ? 'No horizontal exits to insert into from #' + id
+ : 'No horizontal exits to remove from #' + id, 'error');
+ };
+ return item;
+ }
+
+ var sub = document.createElement('div');
+ sub.className = 'cm-submenu';
+ var title = document.createElement('div');
+ title.className = 'cm-title';
+ title.textContent = mode === 'insert' ? 'Insert Room' : 'Remove Room';
+ sub.appendChild(title);
+ dirs.forEach(function(d) {
+ var opt = document.createElement('button');
+ opt.textContent = dirLabel(d);
+ (function(dir) {
+ opt.onclick = function(e) {
+ e.stopPropagation();
+ closeAll();
+ if (mode === 'insert') insertRoom(id, dir);
+ else removeRoom(id, dir);
+ };
+ })(d);
+ sub.appendChild(opt);
+ });
+ item.appendChild(sub);
+
+ var open = false;
+ function showSub() {
+ if (overlay._subHideTimer) { clearTimeout(overlay._subHideTimer); overlay._subHideTimer = null; }
+ // Only one submenu open at a time.
+ var sibs = menu.querySelectorAll('.cm-submenu.open');
+ sibs.forEach(function(s) {
+ if (s !== sub) { s.classList.remove('open'); s.classList.remove('flip-left'); s.classList.remove('flip-up'); }
+ });
+ sub.classList.add('open');
+ flipSubmenu(sub);
+ open = true;
+ }
+ function hideSubSoon() {
+ if (overlay._subHideTimer) clearTimeout(overlay._subHideTimer);
+ overlay._subHideTimer = setTimeout(function() {
+ sub.classList.remove('open');
+ sub.classList.remove('flip-left');
+ sub.classList.remove('flip-up');
+ open = false;
+ overlay._subHideTimer = null;
+ }, 250);
+ }
+
+ item.addEventListener('mouseenter', showSub);
+ item.addEventListener('mouseleave', hideSubSoon);
+ // Keep the submenu alive while the cursor is inside it (crosses the gap).
+ sub.addEventListener('mouseenter', function() {
+ if (overlay._subHideTimer) { clearTimeout(overlay._subHideTimer); overlay._subHideTimer = null; }
+ });
+ sub.addEventListener('mouseleave', hideSubSoon);
+
+ btn.onclick = function(e) {
+ e.stopPropagation();
+ if (open) {
+ sub.classList.remove('open');
+ sub.classList.remove('flip-left');
+ sub.classList.remove('flip-up');
+ open = false;
+ } else {
+ showSub();
+ }
+ };
+
+ return item;
+}
+
+// roomHorizontalDirs returns the room's outward horizontal exits on the current
+// z-level, in gridDirs order, derived from mapData.links. Empty when the room
+// has no horizontal exits to insert into / remove from.
+function roomHorizontalDirs(id) {
+ if (!mapData || !mapData.links) return [];
+ var horizontal = {};
+ gridDirs.forEach(function(d) { horizontal[d.dir] = true; });
+ var seen = {};
+ var dirs = [];
+ mapData.links.forEach(function(l) {
+ if (l.from === id && horizontal[l.dir] && !seen[l.dir]) {
+ seen[l.dir] = true;
+ dirs.push(l.dir);
+ } else if (l.to === id && l.bidirectional && horizontal[l.dir]) {
+ var opp = oppositeDir[l.dir];
+ if (opp && !seen[opp]) {
+ seen[opp] = true;
+ dirs.push(opp);
+ }
+ }
+ });
+ var ordered = [];
+ gridDirs.forEach(function(d) {
+ if (seen[d.dir]) ordered.push(d.dir);
+ });
+ return ordered;
+}
+
+// flipSubmenu toggles flip classes so the submenu stays on-screen when the
+// parent is near the right or bottom edge of the viewport.
+function flipSubmenu(sub) {
+ sub.classList.remove('flip-left');
+ sub.classList.remove('flip-up');
+ var rect = sub.getBoundingClientRect();
+ if (rect.right > window.innerWidth - 4) sub.classList.add('flip-left');
+ if (rect.bottom > window.innerHeight - 4) sub.classList.add('flip-up');
+}
+
+// clampMenuToViewport nudges a top-level context menu inside the viewport.
+function clampMenuToViewport(menu) {
+ var rect = menu.getBoundingClientRect();
+ if (rect.right > window.innerWidth - 4) {
+ menu.style.left = Math.max(4, window.innerWidth - rect.width - 4) + 'px';
+ }
+ if (rect.bottom > window.innerHeight - 4) {
+ menu.style.top = Math.max(4, window.innerHeight - rect.height - 4) + 'px';
+ }
+}
+
+// dirLabel turns a direction key ("north", "northeast", ...) into a display
+// label ("North", "Northeast", ...). Falls back to title-casing the input.
+function dirLabel(d) {
+ var labels = {
+ north: 'North', south: 'South', east: 'East', west: 'West',
+ northeast: 'Northeast', northwest: 'Northwest',
+ southeast: 'Southeast', southwest: 'Southwest',
+ up: 'Up', down: 'Down'
+ };
+ return labels[d] || d.charAt(0).toUpperCase() + d.slice(1);
+}
+
+// insertRoom calls the admin insert endpoint to push a new room into id's dir
+// exit, then refreshes the map and selects the new room. The backend fills in
+// the default name ("New Room") when none is supplied.
+function insertRoom(fromID, dir) {
+ API.post('/api/rooms/insert', { from: fromID, dir: dir, name: '' }).then(function(r) {
+ var nid = r && r.room && r.room.id;
+ notify('Inserted room #' + nid + ' to the ' + dirLabel(dir), 'success');
+ updateUndoBar();
+ loadMap().then(function() { if (nid) selectRoom(nid); });
+ }).catch(function(e) { notify('Insert failed: ' + extractError(e), 'error'); });
+}
+
+// removeRoom calls the admin remove endpoint to delete id's dir exit's room
+// and pull the far room back, then refreshes the map.
+function removeRoom(fromID, dir) {
+ if (!confirm('Remove the room to the ' + dirLabel(dir) + ' of #' + fromID +
+ ' and pull the far end back?')) return;
+ API.post('/api/rooms/remove', { from: fromID, dir: dir }).then(function(r) {
+ var pulled = r && r.pulled_to;
+ notify('Removed room; far end pulled to #' + pulled, 'success');
+ updateUndoBar();
+ loadMap().then(function() { selectRoom(fromID); });
+ }).catch(function(e) { notify('Remove failed: ' + extractError(e), 'error'); });
}
function mouseToGrid(e) {
@@ -2256,18 +2471,28 @@ function loadDisconnectedRooms(dir) {
function showDcContextMenu(e, id) {
var overlay = document.createElement('div');
overlay.className = 'cm-overlay';
- overlay.onclick = function() { overlay.remove(); menu.remove(); };
+ var closeAll = function() { overlay.remove(); menu.remove(); };
+ overlay.onclick = closeAll;
+ overlay.addEventListener('contextmenu', function(ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ var cx = ev.clientX, cy = ev.clientY;
+ closeAll();
+ var el = document.elementFromPoint(cx, cy);
+ if (el) el.dispatchEvent(new MouseEvent('contextmenu', {bubbles:true, cancelable:true, clientX:cx, clientY:cy, button:2}));
+ });
var menu = document.createElement('div');
menu.className = 'cm-menu';
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
+ menu.oncontextmenu = function(ev) { ev.preventDefault(); };
var delBtn = document.createElement('button');
delBtn.textContent = 'Delete Room';
delBtn.className = 'danger';
delBtn.onclick = function() {
- overlay.remove(); menu.remove();
+ closeAll();
deleteRoom(id);
};
menu.appendChild(delBtn);
@@ -2275,7 +2500,7 @@ function showDcContextMenu(e, id) {
var selBtn = document.createElement('button');
selBtn.textContent = 'Room Details';
selBtn.onclick = function() {
- overlay.remove(); menu.remove();
+ closeAll();
selectRoom(id);
};
menu.appendChild(selBtn);