diff options
| author | historia <[not public]> | 2026-07-14 01:36:31 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-07-14 01:36:31 -0400 |
| commit | d72957499f1e25741b8f7a91746fbe132fa68161 (patch) | |
| tree | 115302045c5bce0b62b812bbfcbe3737c9163bb5 /internal | |
| parent | 5a6986a8c539364b7a166abdfb608b3cc00ecb3b (diff) | |
| download | thehouseoficarus-d72957499f1e25741b8f7a91746fbe132fa68161.tar.gz | |
feat(admin): copy paste rooms on map, swap rooms with adjacent rooms
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/admin/api_rooms.go | 136 | ||||
| -rw-r--r-- | internal/admin/server.go | 1 | ||||
| -rw-r--r-- | internal/admin/static/admin.css | 2 | ||||
| -rw-r--r-- | internal/admin/static/cardeditor.js | 4 | ||||
| -rw-r--r-- | internal/admin/static/intereditor.js | 4 | ||||
| -rw-r--r-- | internal/admin/static/map.js | 149 | ||||
| -rw-r--r-- | internal/admin/static/mobeditor.js | 1 |
7 files changed, 286 insertions, 11 deletions
diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go index 1339bcd..84c3348 100644 --- a/internal/admin/api_rooms.go +++ b/internal/admin/api_rooms.go @@ -841,7 +841,7 @@ func (s *AdminServer) handleRoomRename(w http.ResponseWriter, r *http.Request) { NewID int `json:"new_id"` } if err := readJSON(r, &body); err != nil || body.ID <= 0 || body.NewID <= 0 { - writeJSON(w, map[string]any{"error": "invalid"}) + writeJSONError(w, "invalid", http.StatusBadRequest) return } if body.ID == body.NewID { @@ -850,33 +850,33 @@ func (s *AdminServer) handleRoomRename(w http.ResponseWriter, r *http.Request) { } oldPath, ok := s.world.GetRoomPath(body.ID) if !ok { - writeJSON(w, map[string]any{"error": "room not found"}) + writeJSONError(w, "room not found", http.StatusBadRequest) return } if _, ok := s.world.GetRoomPath(body.NewID); ok { - writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d already exists", body.NewID)}) + writeJSONError(w, fmt.Sprintf("room %d already exists", body.NewID), http.StatusConflict) return } dir := filepath.Dir(oldPath) newPath := filepath.Join(dir, strconv.Itoa(body.NewID)+".yaml") if _, err := os.Stat(newPath); err == nil { - writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d already exists", body.NewID)}) + writeJSONError(w, fmt.Sprintf("room %d already exists", body.NewID), http.StatusConflict) return } data, err := os.ReadFile(oldPath) if err != nil { - writeJSON(w, map[string]any{"error": "read: " + err.Error()}) + writeJSONError(w, "read: "+err.Error(), http.StatusInternalServerError) return } if err := os.WriteFile(newPath, data, 0644); err != nil { - writeJSON(w, map[string]any{"error": "write: " + err.Error()}) + writeJSONError(w, "write: "+err.Error(), http.StatusInternalServerError) return } if err := os.Remove(oldPath); err != nil { - writeJSON(w, map[string]any{"error": "remove old: " + err.Error()}) + writeJSONError(w, "remove old: "+err.Error(), http.StatusInternalServerError) return } @@ -925,6 +925,128 @@ func (s *AdminServer) handleRoomRename(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"ok": true}) } +func (s *AdminServer) handleRoomSwap(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "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)) + if _, ok := world.OppositeExit[dir]; !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 <= 0 { + writeJSONError(w, fmt.Sprintf("room #%d %s exit is invalid", aID, dir), http.StatusBadRequest) + return + } + if bID == aID { + writeJSONError(w, "cannot swap a room with itself", http.StatusBadRequest) + return + } + + aPath, aOK := s.world.GetRoomPath(aID) + if !aOK { + writeJSONError(w, fmt.Sprintf("could not find file for room #%d", aID), http.StatusInternalServerError) + return + } + bPath, bOK := s.world.GetRoomPath(bID) + if !bOK { + writeJSONError(w, fmt.Sprintf("could not find file for room #%d", bID), http.StatusInternalServerError) + return + } + + oldA, _ := snapshotFile(aPath) + oldB, _ := snapshotFile(bPath) + + dataA, err := os.ReadFile(aPath) + if err != nil { + writeJSONError(w, "read A: "+err.Error(), http.StatusInternalServerError) + return + } + dataB, err := os.ReadFile(bPath) + if err != nil { + writeJSONError(w, "read B: "+err.Error(), http.StatusInternalServerError) + return + } + + mapA, err := yamlToMap(dataA) + if err != nil { + writeJSONError(w, "parse A: "+err.Error(), http.StatusInternalServerError) + return + } + mapB, err := yamlToMap(dataB) + if err != nil { + writeJSONError(w, "parse B: "+err.Error(), http.StatusInternalServerError) + return + } + + idA := mapA["id"] + exitsA := mapA["exits"] + idB := mapB["id"] + exitsB := mapB["exits"] + + newA := make(map[string]any, len(mapB)) + for k, v := range mapB { + newA[k] = v + } + newA["id"] = idA + newA["exits"] = exitsA + + newB := make(map[string]any, len(mapA)) + for k, v := range mapA { + newB[k] = v + } + newB["id"] = idB + newB["exits"] = exitsB + + newAContent, err := writeMapAsYAML(aPath, newA) + if err != nil { + writeJSONError(w, "write A: "+err.Error(), http.StatusInternalServerError) + return + } + newBContent, err := writeMapAsYAML(bPath, newB) + if err != nil { + os.WriteFile(aPath, oldA, 0644) + writeJSONError(w, "write B: "+err.Error(), http.StatusInternalServerError) + return + } + + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("swap room %d with %d (%s)", aID, bID, dir), + FilePath: aPath, + OldContent: oldA, + NewContent: newAContent, + ExtraFiles: []ExtraFile{{ + FilePath: bPath, + OldContent: oldB, + NewContent: newBContent, + }}, + }) + writeJSON(w, map[string]any{"ok": true, "a": aID, "b": bID}) +} + func (s *AdminServer) handleDuplicateRooms(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSON(w, map[string]any{"error": "method not allowed"}) diff --git a/internal/admin/server.go b/internal/admin/server.go index 61fcb09..9983a0b 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -125,6 +125,7 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor 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/swap", s.handleRoomSwap) apiMux.HandleFunc("/api/rooms/bulk-edit", s.handleBulkEdit) apiMux.HandleFunc("/api/rooms/bulk-delete", s.handleBulkDelete) apiMux.HandleFunc("/api/rooms", s.handleRooms) diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css index 63a12e5..3d54e54 100644 --- a/internal/admin/static/admin.css +++ b/internal/admin/static/admin.css @@ -199,6 +199,8 @@ g.ud-hover:hover text{font-weight:bold} .cm-menu{position:fixed;z-index:200;background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:4px 0;min-width:140px;box-shadow:0 4px 16px rgba(0,0,0,.4)} .cm-menu button:not(.btn){display:block;width:100%;padding:6px 12px;text-align:left;font-size:12px;font-family:monospace;background:none;border:none;color:var(--text);cursor:pointer} .cm-menu button:not(.btn):hover{background:var(--accent)} +.cm-menu button:not(.btn):disabled{opacity:.4;cursor:default;background:transparent} +.cm-menu button:not(.btn):disabled:hover{background:transparent} .cm-menu button.danger{color:#f55} .cm-menu button.danger:hover{background:var(--danger)} .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} diff --git a/internal/admin/static/cardeditor.js b/internal/admin/static/cardeditor.js index cf3a8b7..83eed2e 100644 --- a/internal/admin/static/cardeditor.js +++ b/internal/admin/static/cardeditor.js @@ -682,6 +682,10 @@ function ced_renderTagHelp(type) { h += 'Message fields support template variables:<br>%i1 = first ingredient name<br>%i2 = second ingredient name<br>%n = output item name.<br>Color tags are also supported: {NN}text{/} where NN is a hex color index (00–FF).'; } else if (type === 'gather') { h += 'Message fields support template variables: {name} = object name.<br/>Color tags are also supported: {NN}text{/} where NN is a hex color index (00–FF).'; + } else if (type === 'trigger') { + h += 'Supports %p (player) %v (flag value) and color tags: {NN}text{/} where NN = hex index 00–FF (bold, dim, underline).'; + } else if (type === 'talk') { + h += 'Supports color tags: {NN}text{/} where NN = hex index 00–FF (bold, dim, underline). Surround NPC speech in "quotes" for automatic dialog color.'; } h += '</div>'; return h; diff --git a/internal/admin/static/intereditor.js b/internal/admin/static/intereditor.js index da1812d..2023874 100644 --- a/internal/admin/static/intereditor.js +++ b/internal/admin/static/intereditor.js @@ -707,6 +707,9 @@ function ie_renderAct(stepObj, secId, idx, si, secPath) { rows += '<input class="ie-act-input" data-ie-act-key="' + escAttr(at.key) + '"' + (at.kind === 'number' ? ' type="number"' : '') + ' data-ie-act-kind="' + escAttr(at.kind) + '" value="' + escAttr(String(val)) + '" placeholder="' + escAttr(at.hint || '') + '" style="flex:1">'; rows += '<button class="btn btn-sm btn-danger ie-act-rm" onclick="ie_removeActEffect(this,\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',' + si + ',\'' + escAttr(at.key) + '\')">X</button>'; rows += '</div>'; + if (at.kind === 'text') { + rows += ced_renderTagHelp('trigger'); + } } }); @@ -739,6 +742,7 @@ function ie_renderMessages(step, secId, idx, si, secPath) { }); h += '</div>'; h += '<button class="btn btn-sm ie-add-btn" style="margin-top:2px" onclick="ie_addMessage(this,\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',' + si + ')">+ Add Message</button>'; + h += ced_renderTagHelp('trigger'); h += '</div>'; return h; } diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js index 259fac6..f8cee51 100644 --- a/internal/admin/static/map.js +++ b/internal/admin/static/map.js @@ -8,6 +8,7 @@ var linkDragOneWay = false; var ctrlHeld = false; var delOneWay = false; var delDrag = false, delFromId = null, delTargetId = null; +var roomClip = null; // { features:{...} } var roomMap = {}, occupied = {}; var multiSelectedIds = new Set(); var multiDrag = false, multiSubtract = false, multiStartSVGX = 0, multiStartSVGY = 0; @@ -2077,16 +2078,16 @@ async function doSavePanel() { var idChanged = false; var dirChanged = false; - if (dirEl && newDir !== currentDir) { - await API.post('/api/rooms/move', {id: id, dir: newDir}); - dirChanged = true; - } if (newID !== id) { await API.post('/api/rooms/rename', {id: id, new_id: newID}); selectedRoom = newID; id = newID; idChanged = true; } + if (dirEl && newDir !== currentDir) { + await API.post('/api/rooms/move', {id: id, dir: newDir}); + dirChanged = true; + } await API.put('/api/rooms/' + id, r); notify('Room #' + id + ' saved', 'success'); @@ -2098,10 +2099,39 @@ async function doSavePanel() { selectRoom(id); } } catch (e) { + if (selectedRoom) { + var idEl = $('#roomID'); + if (idEl) idEl.value = String(selectedRoom); + } notify('Save failed: ' + extractError(e), 'error'); } } +function copyRoomFeatures(id) { + API.get('/api/rooms/' + id).then(function(data) { + if (!data || !data.room) { notify('Copy failed: no room', 'error'); return; } + var feats = JSON.parse(JSON.stringify(data.room)); + delete feats.id; delete feats.exits; + roomClip = { features: feats, sourceId: id }; + notify('Copied Room #' + id, 'success'); + }).catch(function(e) { notify('Copy failed: ' + extractError(e), 'error'); }); +} + +function pasteRoomFeatures(targetId) { + if (!roomClip) { notify('Nothing to paste', 'error'); return; } + API.get('/api/rooms/' + targetId).then(function(data) { + if (!data || !data.room) { notify('Paste failed: target room not found', 'error'); return; } + var merged = JSON.parse(JSON.stringify(roomClip.features)); + merged.id = data.room.id; + merged.exits = data.room.exits || {}; + return API.put('/api/rooms/' + targetId, merged); + }).then(function() { + notify('Pasted onto Room #' + targetId, 'success'); + updateUndoBar(); + loadMap().then(function() { if (selectedRoom === targetId) selectRoom(targetId); }); + }).catch(function(e) { notify('Paste failed: ' + extractError(e), 'error'); }); +} + function deleteRoom(id) { var cb = document.getElementById('confirmDelete'); if (!cb || cb.checked) { @@ -2231,6 +2261,18 @@ function showRoomContextMenu(x, y, id) { menu.style.top = y + 'px'; menu.oncontextmenu = function(e) { e.preventDefault(); }; + var copyBtn = document.createElement('button'); + copyBtn.textContent = 'Copy'; + copyBtn.onclick = function() { closeAll(); copyRoomFeatures(id); }; + menu.appendChild(copyBtn); + + if (roomClip) { + var pasteBtn = document.createElement('button'); + pasteBtn.textContent = 'Paste (Room #' + roomClip.sourceId + ')'; + pasteBtn.onclick = function() { closeAll(); pasteRoomFeatures(id); }; + menu.appendChild(pasteBtn); + } + var delBtn = document.createElement('button'); delBtn.textContent = 'Delete Room'; delBtn.className = 'danger'; @@ -2242,6 +2284,7 @@ function showRoomContextMenu(x, y, id) { menu.appendChild(buildSubmenuItem('Insert Room', id, 'insert', closeAll, overlay, menu)); menu.appendChild(buildSubmenuItem('Remove Room', id, 'remove', closeAll, overlay, menu)); + menu.appendChild(buildSwapSubmenuItem('Swap', id, closeAll, overlay, menu)); var selBtn = document.createElement('button'); selBtn.textContent = 'Room Details'; @@ -2408,6 +2451,94 @@ function buildSubmenuItem(label, id, mode, closeAll, overlay, menu) { return item; } +// buildSwapSubmenuItem builds a "Swap" parent row whose nested submenu lists +// every direction the room has an exit in (matching roomDirs ordering). +function buildSwapSubmenuItem(label, id, closeAll, overlay, menu) { + var dirs = roomDirs(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) { + btn.onclick = function() { + notify('No exits to swap 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 = 'Swap 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(); + swapRooms(id, dir); + }; + })(d); + sub.appendChild(opt); + }); + item.appendChild(sub); + + var open = false; + function showSub() { + if (overlay._subHideTimer) { clearTimeout(overlay._subHideTimer); overlay._subHideTimer = null; } + 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); + 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; +} + // roomDirs returns the room's outward exits (horizontal + up/down) derived from // mapData.links and mapData.{upLinks,downLinks}. Horizontal exits are ordered by // gridDirs; up/down appear last. Empty when the room has no exits. @@ -2502,6 +2633,16 @@ function removeRoom(fromID, dir) { }).catch(function(e) { notify('Remove failed: ' + extractError(e), 'error'); }); } +// swapRooms swaps the room contents (everything except id and exits) of +// fromID with the room in the given direction. +function swapRooms(fromID, dir) { + API.post('/api/rooms/swap', { from: fromID, dir: dir }).then(function(r) { + notify('Swapped #' + fromID + ' with #' + r.b + ' (' + dirLabel(dir) + ')', 'success'); + updateUndoBar(); + loadMap().then(function() { selectRoom(fromID); }); + }).catch(function(e) { notify('Swap failed: ' + extractError(e), 'error'); }); +} + function mouseToGrid(e) { var svgEl = $('#mapSvg'); if (!svgEl) return null; diff --git a/internal/admin/static/mobeditor.js b/internal/admin/static/mobeditor.js index 2bef881..849dcf9 100644 --- a/internal/admin/static/mobeditor.js +++ b/internal/admin/static/mobeditor.js @@ -284,6 +284,7 @@ function renderMobCard(sec, data) { if (sec.id === 'talk') { h += '<div id="talktree"></div>'; + h += ced_renderTagHelp('talk'); } } h += '</div>'; |
