From 3f30fa3eec9c2e2acf9a984ccefb9529a25e688a Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Tue, 7 Jul 2026 05:50:37 -0400 Subject: feat: add insert/remove room to context menu in admin web GUI to 'push' or 'pull' map rooms --- internal/admin/api_map.go | 2 +- internal/admin/api_room_insert_remove.go | 466 ++++++++++++++++++++++ internal/admin/api_room_insert_remove_test.go | 535 ++++++++++++++++++++++++++ internal/admin/api_rooms.go | 2 +- internal/admin/id_alloc.go | 43 ++- internal/admin/server.go | 9 + internal/admin/static/admin.css | 12 + internal/admin/static/editor.js | 18 + internal/admin/static/map.js | 241 +++++++++++- 9 files changed, 1306 insertions(+), 22 deletions(-) create mode 100644 internal/admin/api_room_insert_remove.go create mode 100644 internal/admin/api_room_insert_remove_test.go (limited to 'internal/admin') 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 ` 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 ` 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 +} 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 = '

Room deleted

'; 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); -- cgit v1.2.3