aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--data/.admin_history.json1
-rw-r--r--data/rooms/example_unused/town/9999915.yaml2
-rw-r--r--data/rooms/example_unused/town/9999916.yaml17
-rw-r--r--data/rooms/example_unused/underground2/9999951.yaml23
-rw-r--r--data/rooms/example_unused/underground2/9999952.yaml21
-rw-r--r--data/rooms/example_unused/underground2/9999953.yaml21
-rw-r--r--data/rooms/intro/1005.yaml14
-rw-r--r--data/rooms/intro/2001.yaml4
-rw-r--r--internal/admin/api_map.go119
-rw-r--r--internal/admin/static/map.js51
10 files changed, 201 insertions, 72 deletions
diff --git a/data/.admin_history.json b/data/.admin_history.json
new file mode 100644
index 0000000..a246035
--- /dev/null
+++ b/data/.admin_history.json
@@ -0,0 +1 @@
+{"history":[{"time":"2026-07-06T21:55:44-04:00","description":"create room 1006","file_path":"data/rooms/intro/1006.yaml","old_content":"","new_content":"exits:\n south: 1005\nid: 1006\nname: 'Room #1006'\n","is_delete":false,"is_create":true,"extra_files":[{"file_path":"data/rooms/intro/1005.yaml","old_content":"id: 1005\nname: 'Room #1005'\ncolor: \"\"\ndescription: []\nexits:\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1005\nname: 'Room #1005'\ncolor: \"\"\ndescription: []\nexits:\n north:\n room: 1006\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-06T21:55:47-04:00","description":"link 1005 north 1006","file_path":"data/rooms/intro/1005.yaml","old_content":"id: 1005\nname: 'Room #1005'\ncolor: \"\"\ndescription: []\nexits:\n north:\n room: 1006\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1005\nname: 'Room #1005'\ncolor: \"\"\ndescription: []\nexits:\n north:\n room: 1006\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-06T21:55:56-04:00","description":"link 1005 north 1006","file_path":"data/rooms/intro/1005.yaml","old_content":"id: 1005\nname: 'Room #1005'\ncolor: \"\"\ndescription: []\nexits:\n north:\n room: 1006\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1005\nname: 'Room #1005'\ncolor: \"\"\ndescription: []\nexits:\n north:\n room: 1006\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-06T21:55:58-04:00","description":"link 1005 north 1006","file_path":"data/rooms/intro/1005.yaml","old_content":"id: 1005\nname: 'Room #1005'\ncolor: \"\"\ndescription: []\nexits:\n north:\n room: 1006\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1005\nname: 'Room #1005'\ncolor: \"\"\ndescription: []\nexits:\n north:\n room: 1006\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-06T21:55:59-04:00","description":"unlink 1005 north 1006","file_path":"data/rooms/intro/1005.yaml","old_content":"id: 1005\nname: 'Room #1005'\ncolor: \"\"\ndescription: []\nexits:\n north:\n room: 1006\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1005\nname: 'Room #1005'\ncolor: \"\"\ndescription: []\nexits:\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-06T21:56:05-04:00","description":"delete room 1006 (cleaned 0 exits)","file_path":"data/rooms/intro/1006.yaml","old_content":"exits:\n south: 1005\nid: 1006\nname: 'Room #1006'\n","new_content":"","is_delete":true,"is_create":false}],"redo":null} \ No newline at end of file
diff --git a/data/rooms/example_unused/town/9999915.yaml b/data/rooms/example_unused/town/9999915.yaml
index 95c07cb..cef822b 100644
--- a/data/rooms/example_unused/town/9999915.yaml
+++ b/data/rooms/example_unused/town/9999915.yaml
@@ -4,8 +4,6 @@ color: ""
description:
- text: Shelves lined with glass vials, dried herbs, and bubbling cauldrons. This area is not yet accessible.
exits:
- east:
- room: 9999916
west:
room: 9999914
objects: []
diff --git a/data/rooms/example_unused/town/9999916.yaml b/data/rooms/example_unused/town/9999916.yaml
index 5d3c3c2..a915452 100644
--- a/data/rooms/example_unused/town/9999916.yaml
+++ b/data/rooms/example_unused/town/9999916.yaml
@@ -1,11 +1,16 @@
-name: "Construction Site"
-description: "A half-built structure surrounded by planks, nails, and blueprints. A workbench stands ready for crafting. Signs point north toward the Local Neighborhood and east toward the lumberyard."
-exits:
- east: 99999171
- west: 9999915
- north: 99999170
+id: 9999916
+name: Construction Site
+color: ""
+description:
+ - text: A half-built structure surrounded by planks, nails, and blueprints. A workbench stands ready for crafting. Signs point north toward the Local Neighborhood and east toward the lumberyard.
+exits: {}
objects:
- id: workbench
+item_spawns: []
mobs:
- id: greater_drone
- id: estate_broker
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/underground2/9999951.yaml b/data/rooms/example_unused/underground2/9999951.yaml
index 9c7707a..dd8440e 100644
--- a/data/rooms/example_unused/underground2/9999951.yaml
+++ b/data/rooms/example_unused/underground2/9999951.yaml
@@ -1,9 +1,18 @@
-name: "Sewer Tunnels"
-description: "Dark, damp tunnels beneath the settlement. The floor is slick with moisture and something {82}slimy{/}. The smell is indescribable."
+id: 9999951
+name: Sewer Tunnels
+color: ""
+description:
+ - text: Dark, damp tunnels beneath the settlement. The floor is slick with moisture and something {82}slimy{/}. The smell is indescribable.
exits:
- north: 9999950
- south: 9999952
+ north:
+ room: 9999950
+objects: []
+item_spawns: []
mobs:
- - "slug"
- - "slug"
- - "slug"
+ - id: slug
+ - id: slug
+ - id: slug
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/underground2/9999952.yaml b/data/rooms/example_unused/underground2/9999952.yaml
index 40c89a9..27ab51b 100644
--- a/data/rooms/example_unused/underground2/9999952.yaml
+++ b/data/rooms/example_unused/underground2/9999952.yaml
@@ -1,8 +1,15 @@
-name: "Abandoned Sector"
-description: "A decommissioned sector of the asteroid's infrastructure. Broken monitors flicker and exposed wiring {214}sparks{/} dangerously."
-exits:
- north: 9999951
- south: 9999953
+id: 9999952
+name: Abandoned Sector
+color: ""
+description:
+ - text: A decommissioned sector of the asteroid's infrastructure. Broken monitors flicker and exposed wiring {214}sparks{/} dangerously.
+exits: {}
+objects: []
+item_spawns: []
mobs:
- - "drone"
- - "drone"
+ - id: drone
+ - id: drone
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/underground2/9999953.yaml b/data/rooms/example_unused/underground2/9999953.yaml
index 4396cfb..3cabbe7 100644
--- a/data/rooms/example_unused/underground2/9999953.yaml
+++ b/data/rooms/example_unused/underground2/9999953.yaml
@@ -1,8 +1,17 @@
-name: "Deep Tunnels"
-description: "The tunnels descend deeper into the asteroid's core. Strange chitinous scraping echoes from the darkness. The walls are scarred with {130}claw marks{/}."
+id: 9999953
+name: Deep Tunnels
+color: ""
+description:
+ - text: The tunnels descend deeper into the asteroid's core. Strange chitinous scraping echoes from the darkness. The walls are scarred with {130}claw marks{/}.
exits:
- north: 9999952
- south: 9999954
+ south:
+ room: 9999954
+objects: []
+item_spawns: []
mobs:
- - "crawler"
- - "crawler"
+ - id: crawler
+ - id: crawler
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/1005.yaml b/data/rooms/intro/1005.yaml
new file mode 100644
index 0000000..4f31b7f
--- /dev/null
+++ b/data/rooms/intro/1005.yaml
@@ -0,0 +1,14 @@
+id: 1005
+name: 'Room #1005'
+color: ""
+description: []
+exits:
+ south:
+ room: 2001
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/2001.yaml b/data/rooms/intro/2001.yaml
index d809deb..0387c3e 100644
--- a/data/rooms/intro/2001.yaml
+++ b/data/rooms/intro/2001.yaml
@@ -6,12 +6,12 @@ description:
exits:
east:
room: 2002
+ north:
+ room: 1005
south:
room: 2006
southeast:
room: 2005
- west:
- room: 1001
objects:
- name: hidden guy
hidden: true
diff --git a/internal/admin/api_map.go b/internal/admin/api_map.go
index bb81e59..5168165 100644
--- a/internal/admin/api_map.go
+++ b/internal/admin/api_map.go
@@ -84,7 +84,6 @@ func (s *AdminServer) handleMap(w http.ResponseWriter, r *http.Request) {
if err != nil {
continue
}
- // Try adjacent to a connected neighbour already on grid.
for _, exit := range room.Exits {
if exit.Room <= 0 {
continue
@@ -93,32 +92,69 @@ func (s *AdminServer) handleMap(w http.ResponseWriter, r *http.Request) {
if !ok {
continue
}
- for dx := -1; dx <= 1; dx++ {
- for dy := -1; dy <= 1; dy++ {
- if dx == 0 && dy == 0 {
- continue
- }
- want := [3]int{nc[0] + dx, nc[1] + dy, nc[2]}
- if _, used := g.RoomAt[want]; !used {
- g.Coord[id] = want
- g.RoomAt[want] = id
- goto placed
- }
+ if tryPlaceAdjacent(&g, id, nc) {
+ goto placed
+ }
+ }
+ tryPlaceRoom(&g, id, [3]int{}, false, [3]int{0, 0, z})
+ placed:
+ }
+
+ // Place rooms that have a one-way exit into the already-placed set.
+ // These rooms are not reachable from the seed via outgoing exits but
+ // their one-way link points into the connected component, so they
+ // should appear on the map as connected (not disconnected).
+ {
+ roomIndex := s.world.RoomIndex()
+ sortedIDs := make([]int, 0, len(roomIndex))
+ for id := range roomIndex {
+ sortedIDs = append(sortedIDs, id)
+ }
+ sort.Ints(sortedIDs)
+
+ type revEdge struct {
+ src int
+ dir world.ExitDir
+ }
+ rev := make(map[int][]revEdge)
+ for _, id := range sortedIDs {
+ room, err := s.world.LoadRoom(id)
+ if err != nil {
+ continue
+ }
+ for _, dir := range world.ExitOrder {
+ exit, ok := room.Exits[dir]
+ if !ok || exit.Room <= 0 || exit.Room == id {
+ continue
}
+ rev[exit.Room] = append(rev[exit.Room], revEdge{src: id, dir: dir})
}
}
- // Fallback: scan a generous rectangle for a free cell.
- for sx := -10; sx <= 10; sx++ {
- for sy := -10; sy <= 10; sy++ {
- want := [3]int{sx, sy, z}
- if _, used := g.RoomAt[want]; !used {
- g.Coord[id] = want
- g.RoomAt[want] = id
- goto placed
+
+ queue := make([]int, 0, len(g.Coord))
+ for rid := range g.Coord {
+ queue = append(queue, rid)
+ }
+ sort.Ints(queue)
+
+ for len(queue) > 0 {
+ target := queue[0]
+ queue = queue[1:]
+ for _, e := range rev[target] {
+ if _, placed := g.Coord[e.src]; placed || e.src == target {
+ continue
+ }
+ tc := g.Coord[target]
+ delta := world.DirectionDeltas3D[world.OppositeExit[e.dir]]
+ want := [3]int{tc[0] + delta[0], tc[1] + delta[1], tc[2] + delta[2]}
+ if tryPlaceRoom(&g, e.src, want, true, tc) {
+ if d, ok := g.Dist[target]; ok {
+ g.Dist[e.src] = d + 1
+ }
+ queue = append(queue, e.src)
}
}
}
- placed:
}
type RoomEntry struct {
@@ -262,6 +298,47 @@ func (s *AdminServer) handleMap(w http.ResponseWriter, r *http.Request) {
})
}
+func tryPlaceAdjacent(g *world.RoomGrid, roomID int, center [3]int) bool {
+ for dx := -1; dx <= 1; dx++ {
+ for dy := -1; dy <= 1; dy++ {
+ if dx == 0 && dy == 0 {
+ continue
+ }
+ cell := [3]int{center[0] + dx, center[1] + dy, center[2]}
+ if _, used := g.RoomAt[cell]; !used {
+ g.Coord[roomID] = cell
+ g.RoomAt[cell] = roomID
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func tryPlaceRoom(g *world.RoomGrid, roomID int, want [3]int, allowWant bool, center [3]int) bool {
+ if allowWant {
+ if _, used := g.RoomAt[want]; !used {
+ g.Coord[roomID] = want
+ g.RoomAt[want] = roomID
+ return true
+ }
+ }
+ if tryPlaceAdjacent(g, roomID, center) {
+ return true
+ }
+ for sx := -10; sx <= 10; sx++ {
+ for sy := -10; sy <= 10; sy++ {
+ cell := [3]int{center[0] + sx, center[1] + sy, center[2]}
+ if _, used := g.RoomAt[cell]; !used {
+ g.Coord[roomID] = cell
+ g.RoomAt[cell] = roomID
+ return true
+ }
+ }
+ }
+ return false
+}
+
func getRoomDir(s *AdminServer, roomID int) string {
path, ok := s.world.GetRoomPath(roomID)
if !ok {
diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js
index 47dcb27..ba68d80 100644
--- a/internal/admin/static/map.js
+++ b/internal/admin/static/map.js
@@ -3,9 +3,9 @@ var currentZ = 0, currentDir = '', selectedRoom = null, mapData = null;
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 linkDrag = false, linkFromId = null, linkTargetId = null, linkTargetDir = null, linkGhostDir = null;
var linkDragOneWay = false;
-var shiftHeld = false;
+var ctrlHeld = false;
var delOneWay = false;
var delDrag = false, delFromId = null, delTargetId = null;
var roomMap = {}, occupied = {};
@@ -22,8 +22,8 @@ var upTarget = {}, downTarget = {};
var _mapMouseCleanup = false;
-function isShiftKey(e) {
- return e && (e.key === 'Shift' || e.code === 'ShiftLeft' || e.code === 'ShiftRight' || e.keyCode === 16);
+function isCtrlKey(e) {
+ return e && (e.key === 'Control' || e.code === 'ControlLeft' || e.code === 'ControlRight' || e.keyCode === 17);
}
function onMapMouseMove(e) {
@@ -32,7 +32,7 @@ function onMapMouseMove(e) {
if (delFromId && moved && !delDrag) { delDrag = true; }
if (delDrag && moved) {
- delOneWay = e.shiftKey;
+ delOneWay = e.ctrlKey;
updateDelDragTarget(e);
showDelDragBadge();
return;
@@ -47,7 +47,7 @@ function onMapMouseMove(e) {
if (linkFromId && moved) {
linkDrag = true;
- linkDragOneWay = e.shiftKey;
+ linkDragOneWay = e.ctrlKey;
showLinkDragBadge();
updateLinkDragTarget(e);
}
@@ -70,7 +70,7 @@ function onMapMouseUp(e) {
}
if (delDrag && delFromId && delTargetId) {
- deleteLink(delFromId, delTargetId, (delOneWay || e.shiftKey) ? 'from' : '');
+ deleteLink(delFromId, delTargetId, (delOneWay || e.ctrlKey) ? 'from' : '');
clearDelHighlight();
hideDragBadge();
dragging = false; dragRoom = false; delDrag = false; delFromId = null; delTargetId = null; delOneWay = false;
@@ -79,14 +79,14 @@ function onMapMouseUp(e) {
}
if (linkDrag && linkFromId) {
- var oneway = linkDragOneWay || e.shiftKey;
- if (linkTargetId) { createLink(linkFromId, linkTargetId, oneway); }
+ var oneway = linkDragOneWay || e.ctrlKey;
+ if (linkTargetId) { createLink(linkFromId, linkTargetId, oneway, linkTargetDir); }
else if (linkGhostDir) { createRoom(linkFromId, linkGhostDir, oneway); }
} else if (!moved && !delDrag) {
if (e.target.classList.contains('rm')) {
selectRoom(parseInt(e.target.getAttribute('data-id')));
} else if (e.target.classList.contains('ghost')) {
- createRoom(selectedRoom, e.target.getAttribute('data-dir'), e.shiftKey);
+ createRoom(selectedRoom, e.target.getAttribute('data-dir'), e.ctrlKey);
}
}
@@ -94,7 +94,7 @@ function onMapMouseUp(e) {
clearDelHighlight();
hideDragBadge();
dragging = false; dragRoom = false;
- linkDrag = false; linkFromId = null; linkTargetId = null; linkGhostDir = null; linkDragOneWay = false;
+ linkDrag = false; linkFromId = null; linkTargetId = null; linkTargetDir = null; linkGhostDir = null; linkDragOneWay = false;
delDrag = false; delFromId = null; delTargetId = null; delOneWay = false;
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
}
@@ -108,7 +108,7 @@ function cancelMapDrag() {
clearDelHighlight();
hideDragBadge();
dragging = false; dragRoom = false;
- linkDrag = false; linkFromId = null; linkTargetId = null; linkGhostDir = null; linkDragOneWay = false;
+ linkDrag = false; linkFromId = null; linkTargetId = null; linkTargetDir = null; linkGhostDir = null; linkDragOneWay = false;
delDrag = false; delFromId = null; delTargetId = null; delOneWay = false;
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
}
@@ -139,9 +139,9 @@ function showDelDragBadge() {
showDragBadge(label, delOneWay);
}
-function refreshDragMode(shiftDown) {
- if (linkDrag && linkFromId) { linkDragOneWay = shiftDown; showLinkDragBadge(); }
- if (delDrag && delFromId) { delOneWay = shiftDown; showDelDragBadge(); }
+function refreshDragMode(ctrlDown) {
+ if (linkDrag && linkFromId) { linkDragOneWay = ctrlDown; showLinkDragBadge(); }
+ if (delDrag && delFromId) { delOneWay = ctrlDown; showDelDragBadge(); }
}
function changeZ(dz) {
@@ -314,18 +314,24 @@ function renderMap(data) {
svg.oncontextmenu = function(e) { e.preventDefault(); };
+ if (!svg._ctxWired) {
+ svg.addEventListener('contextmenu', function(e) { e.preventDefault(); e.stopPropagation(); });
+ svg._ctxWired = true;
+ }
+
if (!svg._mapKeysWired) {
svg.setAttribute('tabindex', '0');
svg.addEventListener('keydown', function(e) {
- if (isShiftKey(e)) { shiftHeld = true; if (dragging) refreshDragMode(true); }
+ if (isCtrlKey(e)) { ctrlHeld = true; if (dragging) refreshDragMode(true); }
});
svg.addEventListener('keyup', function(e) {
- if (isShiftKey(e)) { shiftHeld = false; if (dragging) refreshDragMode(false); }
+ if (isCtrlKey(e)) { ctrlHeld = false; if (dragging) refreshDragMode(false); }
});
svg._mapKeysWired = true;
}
svg.onmousedown = function(e) {
+ if (e.button === 2) e.preventDefault();
var onRoom = e.target.classList.contains('rm');
var onGhost = e.target.classList.contains('ghost');
var onUdBtn = e.target.classList.contains('ud-btn');
@@ -373,7 +379,7 @@ function renderMap(data) {
linkDrag = false;
linkTargetId = null;
linkGhostDir = null;
- linkDragOneWay = onRoom ? (e.shiftKey || shiftHeld) : false;
+ linkDragOneWay = onRoom ? (e.ctrlKey || ctrlHeld) : false;
delDrag = false; delFromId = null; delTargetId = null;
clearLinkHighlight();
document.addEventListener('mousemove', onMapMouseMove);
@@ -1989,6 +1995,7 @@ function gridDeltaToDir(dx, dy) {
function updateLinkDragTarget(e) {
clearLinkHighlight();
linkTargetId = null;
+ linkTargetDir = null;
linkGhostDir = null;
var fromRoom = mapData.rooms ? mapData.rooms.find(function(r){return r.id === linkFromId;}) : null;
@@ -2007,6 +2014,7 @@ function updateLinkDragTarget(e) {
if (neighbor && neighbor !== linkFromId) {
linkTargetId = neighbor;
+ linkTargetDir = dir;
linkGhostDir = null;
applyLinkHighlight(linkFromId, linkTargetId);
} else if (!neighbor) {
@@ -2093,9 +2101,10 @@ function clearLinkHighlight() {
}
}
-function createLink(fromId, toId, oneway) {
+function createLink(fromId, toId, oneway, dir) {
var body = {from: fromId, to: toId};
if (oneway) body.oneway = true;
+ if (dir) body.dir = dir;
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');
@@ -2443,6 +2452,6 @@ function reloadRoomDirs(cb) {
}
document.addEventListener('DOMContentLoaded', function() { loadRoomDirs(function() { loadMap(); }); initPanelResize();
- window.addEventListener('keydown', function(e) { if (isShiftKey(e)) { shiftHeld = true; if (dragging) refreshDragMode(true); } });
- window.addEventListener('keyup', function(e) { if (isShiftKey(e)) { shiftHeld = false; if (dragging) refreshDragMode(false); } });
+ window.addEventListener('keydown', function(e) { if (isCtrlKey(e)) { ctrlHeld = true; if (dragging) refreshDragMode(true); } });
+ window.addEventListener('keyup', function(e) { if (isCtrlKey(e)) { ctrlHeld = false; if (dragging) refreshDragMode(false); } });
});