Skip to content

Commit fcb9083

Browse files
committed
- recompute mAreaExits on every writeMap / writeMapToBuffer call so cross-area routing data stays in sync with the room graph. Mirrors Mudlet's TArea::determineAreaExits: cardinal exits encode as [targetId, DIR_*] with DIR_NORTH=1..DIR_OUT=12, string-named special exits encode as [targetId, DIR_OTHER=13], and dangling or same-area exits are excluded.
- recompute per-area spatial extents (`zLevels`, `min_x`/`max_x`/`min_y`/`max_y`/`min_z`/`max_z`, and the per-Z `xminForZ`/`xmaxForZ`/`yminForZ`/`ymaxForZ` maps) on every `writeMap` / `writeMapToBuffer` call so Mudlet's renderer gets the right bounding box and Z-level list after any editor mutation. Mirrors `TArea::calcSpan` in Mudlet's `src/TArea.cpp`, including two C++ quirks: y is negated when stored on the area (`area.min_y = -room.y`), and the `span` / `pos` `QVector3D` fields are deliberately left untouched because C++ never writes them either. Areas with zero rooms have their per-Z maps and `zLevels` cleared but their min/max preserved, again matching C++. - recompute `mpRoomDbHashToRoomId` on every `writeMap` / `writeMapToBuffer` call so the room content-hash index stays in sync with the room graph. Mirrors `TRoomDB::hashToRoomID` in Mudlet's C++ source. On load, `room.hash` is populated from the inverse of the on-disk index (the binary format carries hashes only on the map, not per room), so callers can treat `room.hash` as the source of truth. On save, the index is rebuilt from each room's `hash` field: rooms with `undefined` / `''` / `null` are skipped, and colliding hashes log a warning with last-write-wins. `mRoomIdHash` (per-profile player cursor) is deliberately left alone — do not confuse the two. - callers that were maintaining `mAreaExits`, per-area spatial extents, or `mpRoomDbHashToRoomId` by hand will have their values overwritten — the computed values are now authoritative.
1 parent 8c3f9f0 commit fcb9083

5 files changed

Lines changed: 642 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
# 0.11.0
2+
- recompute `mAreaExits` on every `writeMap` / `writeMapToBuffer` call so cross-area routing data stays in sync with the room graph. Mirrors Mudlet's `TArea::determineAreaExits`: cardinal exits encode as `[targetId, DIR_*]` with DIR_NORTH=1..DIR_OUT=12, string-named special exits encode as `[targetId, DIR_OTHER=13]`, and dangling or same-area exits are excluded.
3+
- recompute per-area spatial extents (`zLevels`, `min_x`/`max_x`/`min_y`/`max_y`/`min_z`/`max_z`, and the per-Z `xminForZ`/`xmaxForZ`/`yminForZ`/`ymaxForZ` maps) on every `writeMap` / `writeMapToBuffer` call so Mudlet's renderer gets the right bounding box and Z-level list after any editor mutation. Mirrors `TArea::calcSpan` in Mudlet's `src/TArea.cpp`, including two C++ quirks: y is negated when stored on the area (`area.min_y = -room.y`), and the `span` / `pos` `QVector3D` fields are deliberately left untouched because C++ never writes them either. Areas with zero rooms have their per-Z maps and `zLevels` cleared but their min/max preserved, again matching C++.
4+
- recompute `mpRoomDbHashToRoomId` on every `writeMap` / `writeMapToBuffer` call so the room content-hash index stays in sync with the room graph. Mirrors `TRoomDB::hashToRoomID` in Mudlet's C++ source. On load, `room.hash` is populated from the inverse of the on-disk index (the binary format carries hashes only on the map, not per room), so callers can treat `room.hash` as the source of truth. On save, the index is rebuilt from each room's `hash` field: rooms with `undefined` / `''` / `null` are skipped, and colliding hashes log a warning with last-write-wins. `mRoomIdHash` (per-profile player cursor) is deliberately left alone — do not confuse the two.
5+
- callers that were maintaining `mAreaExits`, per-area spatial extents, or `mpRoomDbHashToRoomId` by hand will have their values overwritten — the computed values are now authoritative.
6+
17
# 0.8.0
28
- remove `fs` dependency from core modules; file I/O is now the caller's responsibility
39
- add `readMapFromBuffer` and `writeMapToBuffer` for buffer-based read/write without touching the filesystem

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "mudlet-map-binary-reader",
3-
"version": "0.8.0",
3+
"version": "0.9.0",
44
"keywords": [
55
"mudlet",
66
"map",

src/map-operations.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,208 @@ const { ReadBuffer } = buffer;
66

77
type RawRoom = MudletRoom & { rawSpecialExits: Record<number, string[]> };
88

9+
// Mirrors Mudlet's DIR_* constants from src/TMap.h. Used as the second element
10+
// of each QPair<int,int> entry in TArea::mAreaExits.
11+
const CARDINAL_DIRS = [
12+
'north',
13+
'northeast',
14+
'east',
15+
'southeast',
16+
'south',
17+
'southwest',
18+
'west',
19+
'northwest',
20+
'up',
21+
'down',
22+
'in',
23+
'out',
24+
] as const satisfies readonly (keyof MudletRoom)[];
25+
26+
const DIR_INDEX: Record<(typeof CARDINAL_DIRS)[number], number> = {
27+
north: 1,
28+
northeast: 2,
29+
northwest: 3,
30+
east: 4,
31+
west: 5,
32+
south: 6,
33+
southeast: 7,
34+
southwest: 8,
35+
up: 9,
36+
down: 10,
37+
in: 11,
38+
out: 12,
39+
};
40+
41+
const DIR_OTHER = 13;
42+
43+
/**
44+
* Rebuild every area's `mAreaExits` from the current room graph. This field
45+
* is a cache Mudlet's autowalker/pathfinder uses to navigate between zones,
46+
* and mirrors TArea::determineAreaExits in Mudlet's C++ source. Any editor
47+
* that mutates rooms / exits and re-saves must regenerate it — otherwise the
48+
* cache goes stale and Mudlet's routing breaks.
49+
*/
50+
function rebuildAreaExits(map: MudletMap): void {
51+
for (const areaId in map.areas) {
52+
if (!Object.hasOwn(map.areas, areaId)) continue;
53+
map.areas[areaId as unknown as number].mAreaExits = {};
54+
}
55+
56+
for (const idStr in map.rooms) {
57+
if (!Object.hasOwn(map.rooms, idStr)) continue;
58+
const roomId = Number(idStr);
59+
const room = map.rooms[roomId];
60+
const srcArea = map.areas[room.area];
61+
if (!srcArea) continue;
62+
63+
const entries: [number, number][] = [];
64+
65+
for (const dir of CARDINAL_DIRS) {
66+
const targetId = room[dir] as number;
67+
if (!targetId || targetId < 1) continue;
68+
const target = map.rooms[targetId];
69+
if (!target || target.area === room.area) continue;
70+
entries.push([targetId, DIR_INDEX[dir]]);
71+
}
72+
73+
const specials = room.mSpecialExits ?? {};
74+
const specialNames = Object.keys(specials).sort();
75+
for (const name of specialNames) {
76+
const targetId = specials[name];
77+
if (!targetId || targetId < 1) continue;
78+
const target = map.rooms[targetId];
79+
if (!target || target.area === room.area) continue;
80+
entries.push([targetId, DIR_OTHER]);
81+
}
82+
83+
if (entries.length > 0) {
84+
srcArea.mAreaExits[roomId] = entries;
85+
}
86+
}
87+
}
88+
89+
/**
90+
* Rebuild every area's spatial-extent cache (zLevels, min/max x/y/z, and the
91+
* per-Z xminForZ/xmaxForZ/yminForZ/ymaxForZ maps) from the current room graph.
92+
* Mirrors Mudlet's TArea::calcSpan in src/TArea.cpp.
93+
*
94+
* Two quirks inherited from C++:
95+
* 1. y is negated when stored on the area. room.y is plain, but
96+
* area.min_y / max_y / yminForZ / ymaxForZ all hold `-room.y`.
97+
* 2. span and pos are NOT recomputed. calcSpan never touches them, and
98+
* the TArea.h header flags them both as effectively dead. We leave
99+
* them as-is so round-tripping an untouched map stays byte-identical.
100+
* 3. For an area with zero valid rooms, the per-Z maps and zLevels are
101+
* cleared but min/max are preserved — again, matching C++.
102+
*/
103+
function rebuildAreaExtents(map: MudletMap): void {
104+
for (const areaIdStr in map.areas) {
105+
if (!Object.hasOwn(map.areas, areaIdStr)) continue;
106+
const area = map.areas[areaIdStr as unknown as number];
107+
108+
area.xminForZ = {};
109+
area.xmaxForZ = {};
110+
area.yminForZ = {};
111+
area.ymaxForZ = {};
112+
area.zLevels = [];
113+
114+
let first = true;
115+
for (const roomId of area.rooms) {
116+
const room = map.rooms[roomId];
117+
if (!room) continue;
118+
const x = room.x;
119+
const y = -room.y;
120+
const z = room.z;
121+
122+
if (first) {
123+
area.min_x = x;
124+
area.max_x = x;
125+
area.min_y = y;
126+
area.max_y = y;
127+
area.min_z = z;
128+
area.max_z = z;
129+
area.zLevels.push(z);
130+
area.xminForZ[z] = x;
131+
area.xmaxForZ[z] = x;
132+
area.yminForZ[z] = y;
133+
area.ymaxForZ[z] = y;
134+
first = false;
135+
continue;
136+
}
137+
138+
if (!area.zLevels.includes(z)) area.zLevels.push(z);
139+
140+
if (area.xminForZ[z] === undefined || x < area.xminForZ[z]) area.xminForZ[z] = x;
141+
if (x < area.min_x) area.min_x = x;
142+
if (area.xmaxForZ[z] === undefined || x > area.xmaxForZ[z]) area.xmaxForZ[z] = x;
143+
if (x > area.max_x) area.max_x = x;
144+
145+
if (area.yminForZ[z] === undefined || y < area.yminForZ[z]) area.yminForZ[z] = y;
146+
if (y < area.min_y) area.min_y = y;
147+
if (y > area.max_y) area.max_y = y;
148+
if (area.ymaxForZ[z] === undefined || y > area.ymaxForZ[z]) area.ymaxForZ[z] = y;
149+
150+
if (z < area.min_z) area.min_z = z;
151+
if (z > area.max_z) area.max_z = z;
152+
}
153+
154+
if (area.zLevels.length > 1) area.zLevels.sort((a, b) => a - b);
155+
}
156+
}
157+
158+
/**
159+
* Populate each `room.hash` from the inverse of `map.mpRoomDbHashToRoomId`.
160+
* The binary format doesn't carry a per-room hash field — the hash lives
161+
* only in the map-level index. Copying it onto the room on load lets
162+
* editors treat `room.hash` as the source of truth, and lets
163+
* {@link rebuildRoomHashIndex} rebuild the index cleanly on save.
164+
*
165+
* NOTE: this is specifically about the content hash index
166+
* (`mpRoomDbHashToRoomId` — mirrors Mudlet's `TRoomDB::hashToRoomID`).
167+
* The similarly-named `mRoomIdHash` is something else entirely: it maps
168+
* Mudlet profile name → player's current room ID. Do not touch it here.
169+
*/
170+
function populateRoomHashes(map: MudletMap): void {
171+
for (const hash in map.mpRoomDbHashToRoomId) {
172+
if (!Object.hasOwn(map.mpRoomDbHashToRoomId, hash)) continue;
173+
const roomId = map.mpRoomDbHashToRoomId[hash];
174+
const room = map.rooms[roomId];
175+
if (room) room.hash = hash;
176+
}
177+
}
178+
179+
/**
180+
* Rebuild `map.mpRoomDbHashToRoomId` from the `hash` field on each room.
181+
* Mirrors Mudlet's `TRoomDB::hashToRoomID`. Callers can freely mutate
182+
* `room.hash` (or add/remove rooms) between load and save; this helper
183+
* keeps the on-disk index consistent.
184+
*
185+
* Rooms with `hash === undefined`, `''`, or `null` are skipped. If two
186+
* rooms declare the same non-empty hash (should not happen in a sane
187+
* map) the later iteration wins and a warning is logged — the underlying
188+
* data is already inconsistent at that point.
189+
*
190+
* NOTE: does not touch `mRoomIdHash`. That's the per-profile player
191+
* cursor (profile name → room ID) and is not derivable from rooms.
192+
*/
193+
function rebuildRoomHashIndex(map: MudletMap): void {
194+
map.mpRoomDbHashToRoomId = {};
195+
for (const idStr in map.rooms) {
196+
if (!Object.hasOwn(map.rooms, idStr)) continue;
197+
const room = map.rooms[idStr as unknown as number];
198+
const hash = room.hash;
199+
if (typeof hash !== 'string' || hash.length === 0) continue;
200+
const roomId = Number(idStr);
201+
if (Object.hasOwn(map.mpRoomDbHashToRoomId, hash)) {
202+
const prev = map.mpRoomDbHashToRoomId[hash];
203+
console.warn(
204+
`[mudlet-map-binary-reader] duplicate room hash "${hash}" on rooms ${prev} and ${roomId}; last wins`
205+
);
206+
}
207+
map.mpRoomDbHashToRoomId[hash] = roomId;
208+
}
209+
}
210+
9211
/**
10212
* Hydrate the `mSpecialExits` / `mSpecialExitLocks` fields on every room
11213
* by parsing the `rawSpecialExits` layout Qt stores on disk.
@@ -64,6 +266,7 @@ export function readMapFromBuffer(buf: Buffer): MudletMap {
64266
const rb = new ReadBuffer(buf);
65267
const map = QUserType.read(rb, 'MudletMap') as MudletMap;
66268
hydrateSpecialExits(map);
269+
populateRoomHashes(map);
67270
return map;
68271
}
69272

@@ -75,6 +278,9 @@ export function readMapFromBuffer(buf: Buffer): MudletMap {
75278
*/
76279
export function writeMapToBuffer(map: MudletMap): Buffer {
77280
dehydrateSpecialExits(map);
281+
rebuildAreaExits(map);
282+
rebuildAreaExtents(map);
283+
rebuildRoomHashIndex(map);
78284
return QUserType.get('MudletMap').from(map).toBuffer(true);
79285
}
80286

test/fixtures.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,58 @@ export const makeMultiAreaMap = (): MudletMap => {
142142
return map;
143143
};
144144

145+
// Map with 2 areas: A=1 (rooms 1,2,3) and B=2 (rooms 10,11).
146+
// Cross-area exits: room 2 (area 1) east → room 10 (area 2);
147+
// room 10 (area 2) west → room 2 (area 1).
148+
// Intra-area exits left as stubs/unset so they don't muddy mAreaExits.
149+
export const makeCrossAreaMap = (): MudletMap => {
150+
const map = makeMinimalMap();
151+
map.areaNames[2] = 'Area B';
152+
map.areas[1] = makeMinimalArea([1, 2, 3]);
153+
map.areas[2] = makeMinimalArea([10, 11]);
154+
map.rooms[1] = makeMinimalRoom(1, 1);
155+
map.rooms[2] = makeMinimalRoom(2, 1);
156+
map.rooms[3] = makeMinimalRoom(3, 1);
157+
map.rooms[10] = makeMinimalRoom(10, 2);
158+
map.rooms[11] = makeMinimalRoom(11, 2);
159+
map.rooms[2].east = 10;
160+
map.rooms[10].west = 2;
161+
return map;
162+
};
163+
164+
// Map with 4 rooms in area 1 spread across 2 z-levels with varying x/y,
165+
// for exercising rebuildAreaExtents. Coordinates are picked so every
166+
// min/max and every per-Z bound is distinct and so the y-sign inversion
167+
// is observable (areas store -room.y; see TArea::calcSpan in Mudlet).
168+
//
169+
// z=0: room 1 at (1, 2), room 2 at (5, -3)
170+
// z=1: room 3 at (0, 0), room 4 at (7, 4)
171+
export const makeExtentsFixture = (): MudletMap => {
172+
const map = makeMinimalMap();
173+
map.areas[1] = makeMinimalArea([1, 2, 3, 4]);
174+
map.rooms[1] = { ...makeMinimalRoom(1, 1), x: 1, y: 2, z: 0 };
175+
map.rooms[2] = { ...makeMinimalRoom(2, 1), x: 5, y: -3, z: 0 };
176+
map.rooms[3] = { ...makeMinimalRoom(3, 1), x: 0, y: 0, z: 1 };
177+
map.rooms[4] = { ...makeMinimalRoom(4, 1), x: 7, y: 4, z: 1 };
178+
return map;
179+
};
180+
181+
// Map with 3 rooms in area 1: rooms 1 and 2 carry distinct non-empty hashes,
182+
// room 3 has no hash. Used to exercise rebuildRoomHashIndex — after a round
183+
// trip, mpRoomDbHashToRoomId should contain the two hash keys and nothing else.
184+
export const makeMapWithHashes = (): MudletMap => {
185+
const map = makeMinimalMap();
186+
map.areas[1] = makeMinimalArea([1, 2, 3]);
187+
map.rooms[1] = { ...makeMinimalRoom(1, 1), hash: 'hash-room-1' };
188+
map.rooms[2] = { ...makeMinimalRoom(2, 1), hash: 'hash-room-2' };
189+
map.rooms[3] = makeMinimalRoom(3, 1);
190+
map.mpRoomDbHashToRoomId = {
191+
'hash-room-1': 1,
192+
'hash-room-2': 2,
193+
};
194+
return map;
195+
};
196+
145197
export const makeMinimalLabel = (): MudletLabel => ({
146198
id: 1,
147199
pos: [0, 0, 0],

0 commit comments

Comments
 (0)