aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/admin/api_rooms.go140
-rw-r--r--internal/admin/static/admin.css2
-rw-r--r--internal/admin/static/map.js99
-rw-r--r--internal/admin/templates/map.html1
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">