diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/admin/api_rooms.go | 140 | ||||
| -rw-r--r-- | internal/admin/static/admin.css | 2 | ||||
| -rw-r--r-- | internal/admin/static/map.js | 99 | ||||
| -rw-r--r-- | internal/admin/templates/map.html | 1 |
4 files changed, 155 insertions, 87 deletions
diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go index 448503d..b5353c2 100644 --- a/internal/admin/api_rooms.go +++ b/internal/admin/api_rooms.go @@ -415,19 +415,20 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: var body struct { - From int `json:"from"` - To int `json:"to"` - Dir string `json:"dir"` + From int `json:"from"` + To int `json:"to"` + Dir string `json:"dir"` + OneWay bool `json:"oneway"` } if err := readJSON(r, &body); err != nil || body.From <= 0 || body.To <= 0 { - writeJSON(w, map[string]any{"error": "invalid body, need from and to"}) + writeJSONError(w, "invalid body, need from and to", http.StatusBadRequest) return } roomA, errA := s.world.LoadRoom(body.From) roomB, errB := s.world.LoadRoom(body.To) if errA != nil || errB != nil { - writeJSON(w, map[string]any{"error": "one or both rooms not found"}) + writeJSONError(w, "one or both rooms not found", http.StatusBadRequest) return } @@ -437,7 +438,7 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) { if opp, ok := world.OppositeExit[dir]; ok { oppDir = opp } else { - writeJSON(w, map[string]any{"error": "invalid direction: " + body.Dir}) + writeJSONError(w, "invalid direction: "+body.Dir, http.StatusBadRequest) return } } else { @@ -453,11 +454,11 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) { cA, okA := grid.Coord[body.From] cB, okB := grid.Coord[body.To] if !okA || !okB { - writeJSON(w, map[string]any{"error": "one or both rooms are not reachable from the seed room"}) + writeJSONError(w, "one or both rooms are not reachable from the seed room", http.StatusBadRequest) return } if cA[2] != cB[2] { - writeJSON(w, map[string]any{"error": "rooms must be on same z-level"}) + writeJSONError(w, "rooms must be on same z-level", http.StatusBadRequest) return } dx, dy := cB[0]-cA[0], cB[1]-cA[1] @@ -469,7 +470,7 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) { } } if dir == "" { - writeJSON(w, map[string]any{"error": "rooms are not adjacent"}) + writeJSONError(w, "rooms are not adjacent", http.StatusBadRequest) return } } @@ -491,6 +492,21 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) { } } + if roomA.Exits != nil { + if existing, ok := roomA.Exits[dir]; ok && existing.Room != body.To { + writeJSONError(w, fmt.Sprintf("%s in #%d already links to #%d", dir, body.From, existing.Room), http.StatusConflict) + return + } + } + if !body.OneWay { + if roomB.Exits != nil { + if existing, ok := roomB.Exits[oppDir]; ok && existing.Room != body.From { + writeJSONError(w, fmt.Sprintf("%s in #%d already links to #%d", oppDir, body.To, existing.Room), http.StatusConflict) + return + } + } + } + conflicts := 0 testGrid := world.BuildGrid(s.cfg.StartingRoom, func(id int) (*world.Room, bool) { room, err := s.world.LoadRoom(id) @@ -513,7 +529,7 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) { if id == body.From { roomCopy.Exits[dir] = world.ExitDef{Room: body.To} } - if id == body.To { + if id == body.To && !body.OneWay { roomCopy.Exits[oppDir] = world.ExitDef{Room: body.From} } return &roomCopy, true @@ -532,28 +548,23 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) { } roomA.Exits[dir] = world.ExitDef{Room: body.To} - if roomB.Exits == nil { - roomB.Exits = make(map[world.ExitDir]world.ExitDef) + if !body.OneWay { + if roomB.Exits == nil { + roomB.Exits = make(map[world.ExitDir]world.ExitDef) + } + roomB.Exits[oppDir] = world.ExitDef{Room: body.From} } - roomB.Exits[oppDir] = world.ExitDef{Room: body.From} pathA, okA2 := s.world.GetRoomPath(body.From) - pathB, okB2 := s.world.GetRoomPath(body.To) - if !okA2 || !okB2 { - writeJSON(w, map[string]any{"error": "room path not found"}) + if !okA2 { + writeJSONError(w, "room path not found", http.StatusInternalServerError) return } oldA, _ := snapshotFile(pathA) - oldB, _ := snapshotFile(pathB) newA, err := writeYAMLFile(pathA, roomA) if err != nil { - writeJSON(w, map[string]any{"error": "write A: " + err.Error()}) - return - } - newB, err := writeYAMLFile(pathB, roomB) - if err != nil { - writeJSON(w, map[string]any{"error": "write B: " + err.Error()}) + writeJSONError(w, "write A: "+err.Error(), http.StatusInternalServerError) return } s.undoStack.Push(ChangeDesc{ @@ -562,24 +573,39 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) { OldContent: oldA, NewContent: newA, }) - s.undoStack.Push(ChangeDesc{ - Description: fmt.Sprintf("link %d %s %d (reverse)", body.To, oppDir, body.From), - FilePath: pathB, - OldContent: oldB, - NewContent: newB, - }) + + if !body.OneWay { + pathB, okB2 := s.world.GetRoomPath(body.To) + if !okB2 { + writeJSONError(w, "room B path not found", http.StatusInternalServerError) + return + } + oldB, _ := snapshotFile(pathB) + newB, err := writeYAMLFile(pathB, roomB) + if err != nil { + writeJSONError(w, "write B: "+err.Error(), http.StatusInternalServerError) + return + } + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("link %d %s %d (reverse)", body.To, oppDir, body.From), + FilePath: pathB, + OldContent: oldB, + NewContent: newB, + }) + } writeJSON(w, map[string]any{"ok": true}) case http.MethodDelete: var body struct { - From int `json:"from"` - To int `json:"to"` + From int `json:"from"` + To int `json:"to"` + Side string `json:"side"` } if err := readJSON(r, &body); err != nil || body.From <= 0 || body.To <= 0 { if r.Body != nil { r.Body.Close() } - writeJSON(w, map[string]any{"error": "invalid body"}) + writeJSONError(w, "invalid body", http.StatusBadRequest) return } @@ -587,73 +613,83 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) { roomB, errB := s.world.LoadRoom(body.To) if errA != nil && errB != nil { - writeJSON(w, map[string]any{"error": "both rooms not found"}) + writeJSONError(w, "both rooms not found", http.StatusBadRequest) return } - var dir, oppDir world.ExitDir + var dirA, dirB world.ExitDir if roomA != nil { for _, d := range world.ExitOrder { if exit, ok := roomA.Exits[d]; ok && exit.Room == body.To { - dir = d - oppDir = world.OppositeExit[d] + dirA = d + dirB = world.OppositeExit[d] break } } } - if dir == "" && roomB != nil { + if dirA == "" && roomB != nil { for _, d := range world.ExitOrder { if exit, ok := roomB.Exits[d]; ok && exit.Room == body.From { - oppDir = d - dir = world.OppositeExit[d] + dirB = d + dirA = world.OppositeExit[d] break } } } - if roomA != nil && roomA.Exits != nil { - delete(roomA.Exits, dir) - } - if roomB != nil && roomB.Exits != nil { - delete(roomB.Exits, oppDir) + if body.Side == "from" { + if roomA != nil && roomA.Exits != nil { + delete(roomA.Exits, dirA) + } + } else if body.Side == "to" { + if roomB != nil && roomB.Exits != nil { + delete(roomB.Exits, dirB) + } + } else { + if roomA != nil && roomA.Exits != nil { + delete(roomA.Exits, dirA) + } + if roomB != nil && roomB.Exits != nil { + delete(roomB.Exits, dirB) + } } var pushEntries []ChangeDesc - if roomA != nil { + if roomA != nil && (body.Side == "" || body.Side == "from") { pathA, okA2 := s.world.GetRoomPath(body.From) if !okA2 { - writeJSON(w, map[string]any{"error": "room A path not found"}) + writeJSONError(w, "room A path not found", http.StatusInternalServerError) return } oldA, _ := snapshotFile(pathA) newA, err := writeYAMLFile(pathA, roomA) if err != nil { - writeJSON(w, map[string]any{"error": "write A: " + err.Error()}) + writeJSONError(w, "write A: "+err.Error(), http.StatusInternalServerError) return } pushEntries = append(pushEntries, ChangeDesc{ - Description: fmt.Sprintf("unlink %d %s %d", body.From, dir, body.To), + Description: fmt.Sprintf("unlink %d %s %d", body.From, dirA, body.To), FilePath: pathA, OldContent: oldA, NewContent: newA, }) } - if roomB != nil { + if roomB != nil && (body.Side == "" || body.Side == "to") { pathB, okB2 := s.world.GetRoomPath(body.To) if !okB2 { - writeJSON(w, map[string]any{"error": "room B path not found"}) + writeJSONError(w, "room B path not found", http.StatusInternalServerError) return } oldB, _ := snapshotFile(pathB) newB, err := writeYAMLFile(pathB, roomB) if err != nil { - writeJSON(w, map[string]any{"error": "write B: " + err.Error()}) + writeJSONError(w, "write B: "+err.Error(), http.StatusInternalServerError) return } pushEntries = append(pushEntries, ChangeDesc{ - Description: fmt.Sprintf("unlink %d %s %d (reverse)", body.To, oppDir, body.From), + Description: fmt.Sprintf("unlink %d %s %d (reverse)", body.To, dirB, body.From), FilePath: pathB, OldContent: oldB, NewContent: newB, diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css index 376bf23..c620b46 100644 --- a/internal/admin/static/admin.css +++ b/internal/admin/static/admin.css @@ -51,6 +51,8 @@ body{font-family:monospace;background:var(--bg);color:var(--text);display:flex;f .z-controls .z-sep{width:1px;height:20px;background:var(--border);flex-shrink:0} .z-dir-form{display:none;position:absolute;left:0;top:calc(100% + 4px);background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:8px 10px;z-index:11;gap:6px;align-items:center;white-space:nowrap} .room-tooltip{position:absolute;background:var(--panel);border:1px solid var(--border);padding:6px 10px;border-radius:3px;font-size:11px;pointer-events:none;z-index:20;white-space:nowrap} +.link-mode-badge{position:absolute;bottom:12px;right:12px;padding:6px 12px;border-radius:4px;font-size:12px;font-family:monospace;pointer-events:none;z-index:20;background:rgba(30,30,60,0.9);color:#ccc;border:1px solid var(--border)} +.link-mode-badge.oneway{background:rgba(80,50,20,0.92);color:#fca;border:1px solid #a64} .notification{position:fixed;bottom:16px;right:16px;background:var(--panel);border:1px solid var(--border);padding:10px 16px;border-radius:4px;font-size:12px;z-index:100;animation:fadeIn .2s} .notification.error{border-color:var(--danger)} .notification.success{border-color:var(--success)} diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js index f0b4d87..f89a434 100644 --- a/internal/admin/static/map.js +++ b/internal/admin/static/map.js @@ -4,6 +4,8 @@ var panX = 0, panY = 0, scale = 1; var dragging = false, startX = 0, startY = 0, prevX = 0, prevY = 0; var dragRoom = false; var linkDrag = false, linkFromId = null, linkTargetId = null, linkGhostDir = null; +var linkDragOneWay = false; +var shiftHeld = false; var delDrag = false, delFromId = null, delTargetId = null; var roomMap = {}, occupied = {}; var dirChangedByUser = false; @@ -35,6 +37,7 @@ function onMapMouseMove(e) { if (linkFromId && moved) { linkDrag = true; + showDragBadge(linkDragOneWay ? 'One-way link' : 'Two-way link'); updateLinkDragTarget(e); } } @@ -62,7 +65,7 @@ function onMapMouseUp(e) { } if (linkDrag && linkFromId) { - if (linkTargetId) { createLink(linkFromId, linkTargetId); } + if (linkTargetId) { createLink(linkFromId, linkTargetId, linkDragOneWay); } else if (linkGhostDir) { createRoom(linkFromId, linkGhostDir); } } else if (!moved && !delDrag) { if (e.target.classList.contains('rm')) { @@ -74,8 +77,9 @@ function onMapMouseUp(e) { clearLinkHighlight(); clearDelHighlight(); + hideDragBadge(); dragging = false; dragRoom = false; - linkDrag = false; linkFromId = null; linkTargetId = null; linkGhostDir = null; + linkDrag = false; linkFromId = null; linkTargetId = null; linkGhostDir = null; linkDragOneWay = false; delDrag = false; delFromId = null; delTargetId = null; } @@ -86,11 +90,25 @@ function cancelMapDrag() { _mapMouseCleanup = false; clearLinkHighlight(); clearDelHighlight(); + hideDragBadge(); dragging = false; dragRoom = false; - linkDrag = false; linkFromId = null; linkTargetId = null; linkGhostDir = null; + linkDrag = false; linkFromId = null; linkTargetId = null; linkGhostDir = null; linkDragOneWay = false; delDrag = false; delFromId = null; delTargetId = null; } +function showDragBadge(text) { + var badge = document.getElementById('linkModeBadge'); + if (!badge) return; + badge.textContent = text; + badge.className = 'link-mode-badge' + (linkDragOneWay ? ' oneway' : ''); + badge.style.display = ''; +} + +function hideDragBadge() { + var badge = document.getElementById('linkModeBadge'); + if (badge) badge.style.display = 'none'; +} + function changeZ(dz) { if (dz === 0) currentZ = 0; else currentZ += dz; panX = 0; panY = 0; scale = 1; @@ -299,6 +317,7 @@ function renderMap(data) { return; } + e.preventDefault(); dragRoom = onRoom || onGhost; dragging = true; startX = e.clientX; startY = e.clientY; @@ -307,6 +326,7 @@ function renderMap(data) { linkDrag = false; linkTargetId = null; linkGhostDir = null; + linkDragOneWay = onRoom ? (e.shiftKey || shiftHeld) : false; delDrag = false; delFromId = null; delTargetId = null; clearLinkHighlight(); document.addEventListener('mousemove', onMapMouseMove); @@ -1160,13 +1180,16 @@ function renderExitsSection(r) { var setPlayerFlags = (typeof exit === 'object' && exit.set_player_flags) ? exit.set_player_flags : null; var hidden = (typeof exit === 'object' && exit.hidden) ? exit.hidden : false; var isConditional = !!(cond || bmsg || setFlags || setPlayerFlags || hidden); + var isBidi = mapData && mapData.links && mapData.links.some(function(l) { + return l.bidirectional && ((l.from === r.id && l.to === target) || (l.from === target && l.to === r.id)); + }); h += '<div class="exit-row" data-dir="' + d + '">'; h += '<span class="exit-dir"' + (isConditional ? ' style="color:#f55"' : '') + '>' + d + '</span>'; h += '<span class="exit-target" onclick="selectRoom(' + target + ')"># ' + target + '</span>'; - h += '<label style="font-size:11px;margin-left:4px;cursor:pointer;color:#aaa"><input type="checkbox" class="exit-hidden" style="vertical-align:middle"' + (hidden ? ' checked' : '') + ' onchange="toggleExitHidden(\'' + d + '\')"> Hidden</label>'; if (bmsg) h += '<span class="exit-meta">Blocked: ' + esc(bmsg) + '</span>'; if (cond) h += '<span class="exit-meta">Conditional</span>'; h += '<button class="btn btn-sm" onclick="editExitCondition(\'' + d + '\')">Edit</button>'; + if (isBidi) h += '<button class="btn btn-sm" onclick="deleteLink(' + target + ',' + r.id + ',\'from\')" title="Remove reverse exit to make one-way">\u21C6</button>'; h += '<button class="btn btn-sm btn-danger" onclick="deleteExit(\'' + d + '\', ' + target + ')">X</button>'; h += '</div>'; }); @@ -1175,6 +1198,7 @@ function renderExitsSection(r) { exitDirs.forEach(function(d) { h += '<option value="' + d + '">' + d + '</option>'; }); h += '</select>'; h += '<input id="exitTargetID" type="number" placeholder="Target ID">'; + h += '<label style="font-size:11px;cursor:pointer;color:#aaa;margin:0 4px;display:flex;align-items:center;gap:3px"><input type="checkbox" id="exitReciprocal" checked style="vertical-align:middle"> Reciprocal</label>'; h += '<button class="exit-add-btn" onclick="addExit()">Add</button>'; h += '</div>'; h += '</div>'; @@ -1421,8 +1445,13 @@ function addExit() { var dir = $('#exitDirSelect').value; var target = parseInt($('#exitTargetID').value); if (!dir || !target) { notify('Enter target room ID', 'error'); return; } - API.post('/api/rooms/link', {from: selectedRoom, to: target, dir: dir}).then(function() { - notify('Exit added: ' + dir + ' #' + target, 'success'); + var reciprocalEl = $('#exitReciprocal'); + var reciprocal = reciprocalEl ? reciprocalEl.checked : true; + var body = {from: selectedRoom, to: target, dir: dir}; + if (!reciprocal) body.oneway = true; + API.post('/api/rooms/link', body).then(function(res) { + if (res && res.error) { notify('Add exit failed: ' + res.error, 'error'); return; } + notify('Exit added: ' + dir + ' #' + target + (reciprocal ? '' : ' (one-way)'), 'success'); updateUndoBar(); loadMap().then(function() { selectRoom(selectedRoom); }); }).catch(function(e) { notify('Add exit failed: ' + extractError(e), 'error'); }); @@ -1430,15 +1459,17 @@ function addExit() { function deleteExit(dir, target) { if (!selectedRoom || !currentRoomData) return; - if (!confirm('Delete exit ' + dir + ' to #' + target + '?')) return; fetch('/api/rooms/link', { method: 'DELETE', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({from: selectedRoom, to: target}), credentials: 'same-origin' }).then(function(r) { - if (!r.ok) return r.json().then(function(j) { throw new Error(j.error || 'request failed'); }); - return r.json(); + return r.json().then(function(j) { + if (!r.ok) throw new Error(j.error || 'request failed'); + if (j.error) throw new Error(j.error); + return j; + }); }).then(function() { notify('Removed exit ' + dir + ' to #' + target, 'success'); updateUndoBar(); @@ -2014,21 +2045,16 @@ function clearLinkHighlight() { } } -function createLink(fromId, toId) { - API.post('/api/rooms/link', {from: fromId, to: toId}).then(function() { - notify('Linked rooms #'+fromId+' '+toId, 'success'); +function createLink(fromId, toId, oneway) { + var body = {from: fromId, to: toId}; + if (oneway) body.oneway = true; + API.post('/api/rooms/link', body).then(function(res) { + if (res && res.error) { notify('Link failed: ' + res.error, 'error'); return; } + notify('Linked rooms #'+fromId+' '+toId + (oneway ? ' (one-way)' : ''), 'success'); updateUndoBar(); - if (mapData && mapData.rooms) { - var fr = mapData.rooms.find(function(r) { return r.id === fromId; }); - var tr = mapData.rooms.find(function(r) { return r.id === toId; }); - if (fr && tr) { - mapData.links = mapData.links || []; - mapData.links.push({from: fromId, to: toId, dir: gridDeltaToDir(tr.x - fr.x, tr.y - fr.y) || '', bidirectional: true}); - renderMap(mapData); - } - } + loadMap().then(function() { selectRoom(selectedRoom); }); }).catch(function(e) { - notify('Link failed: ' + e.message, 'error'); + notify('Link failed: ' + extractError(e), 'error'); }); } @@ -2105,26 +2131,26 @@ function clearDelHighlight() { } } -function deleteLink(fromId, toId) { +function deleteLink(fromId, toId, side) { + var body = {from: fromId, to: toId}; + if (side) body.side = side; fetch('/api/rooms/link', { method: 'DELETE', headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({from: fromId, to: toId}), + body: JSON.stringify(body), credentials: 'same-origin' }).then(function(r) { - if (!r.ok) throw new Error('unlink failed'); - return r.json(); + return r.json().then(function(j) { + if (!r.ok) throw new Error(j.error || 'unlink failed'); + if (j.error) throw new Error(j.error); + return j; + }); }).then(function() { - notify('Removed link #'+fromId+' '+toId, 'success'); + notify('Removed link #'+fromId+' '+toId + (side ? ' (' + side + ' side only)' : ''), 'success'); updateUndoBar(); - if (mapData && mapData.links) { - mapData.links = mapData.links.filter(function(l) { - return !((l.from === fromId && l.to === toId) || (l.from === toId && l.to === fromId)); - }); - renderMap(mapData); - } + loadMap().then(function() { selectRoom(selectedRoom); }); }).catch(function(e) { - notify('Delete link failed: ' + e.message, 'error'); + notify('Delete link failed: ' + extractError(e), 'error'); }); } @@ -2368,4 +2394,7 @@ function reloadRoomDirs(cb) { }); } -document.addEventListener('DOMContentLoaded', function() { loadRoomDirs(function() { loadMap(); }); initPanelResize(); }); +document.addEventListener('DOMContentLoaded', function() { loadRoomDirs(function() { loadMap(); }); initPanelResize(); + document.addEventListener('keydown', function(e) { if (e.key === 'Shift') shiftHeld = true; }); + document.addEventListener('keyup', function(e) { if (e.key === 'Shift') shiftHeld = false; }); +}); diff --git a/internal/admin/templates/map.html b/internal/admin/templates/map.html index 1e45f8e..eb95db7 100644 --- a/internal/admin/templates/map.html +++ b/internal/admin/templates/map.html @@ -42,6 +42,7 @@ </div> <svg id="mapSvg" style="width:100%;height:100%"></svg> <div class="room-tooltip" id="tooltip" style="display:none"></div> + <div id="linkModeBadge" class="link-mode-badge" style="display:none"></div> </div> <div class="panel" id="sidePanel"> <div id="panelContent"> |
