@blkc-code/mapgen 0.0.0-stage → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/plan.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * What a world grows from a project: which special rooms it has, what goes in them, and what a
3
+ * folder's room looks like. The growing itself is the same for every world.
4
+ */
5
+
6
+ export type RoomRole = "lobby" | "lounge" | "quests" | "vacant" | "hall";
7
+
8
+ export interface Theme { floor: string; items: string[]; decor: string[] }
9
+
10
+ /** One furnishing step, run in order. `top` falls back to anywhere in the room; `center` doesn't. */
11
+ export type FurnishStep = { scatter: string } | { wall: string } | { place: string; at: "center" | "top" };
12
+
13
+ export interface WorldPlan {
14
+ id: string;
15
+ /** Footprint in tiles, and where someone stands to use it. */
16
+ footprint: Record<string, { w: number; h: number; spot?: [number, number]; solid?: boolean }>;
17
+ /** Always there. The first is where the map starts. */
18
+ specials: { id: string; name: string; role: RoomRole; floor: string; w: number; h: number }[];
19
+ furnishing: Partial<Record<RoomRole, FurnishStep[]>>;
20
+ /** What a folder's room is filled with, one for each few files. */
21
+ workstation: string;
22
+ /** Matched against a folder's own name. */
23
+ themes: { match: RegExp; theme: Theme }[];
24
+ fallback: Theme & { floors: string[] };
25
+ /** For the biggest rooms. */
26
+ filler: [string, string];
27
+ /** What draws each kind of part in a building. Anything else uses the workstation. */
28
+ partItems?: Record<string, string>;
29
+ }
30
+
31
+ /** The office, and the world used when a map doesn't name one. */
32
+ export const OFFICE_PLAN: WorldPlan = {
33
+ id: "office",
34
+ footprint: {
35
+ desk: { w: 3, h: 2, spot: [1, 2] }, coffee: { w: 2, h: 1, spot: [0, 1] }, water: { w: 1, h: 1, spot: [0, 1] }, sofa: { w: 3, h: 1, spot: [1, 1] },
36
+ plant: { w: 1, h: 1 }, fern: { w: 1, h: 1 }, board: { w: 4, h: 1, spot: [1, 1] }, whiteboard: { w: 3, h: 1, spot: [1, 1] }, shelf: { w: 2, h: 1, spot: [0, 1] },
37
+ server: { w: 1, h: 1 }, table: { w: 3, h: 2, spot: [1, 2] }, rug: { w: 4, h: 3, solid: false }, printer: { w: 1, h: 1, spot: [0, 1] }, lamp: { w: 1, h: 1 },
38
+ beanbag: { w: 1, h: 1, spot: [0, 1] }, vending: { w: 1, h: 1, spot: [0, 1] }, fridge: { w: 1, h: 1, spot: [0, 1] }, arcade: { w: 1, h: 1, spot: [0, 1] },
39
+ },
40
+ specials: [
41
+ { id: "lobby", name: "Lobby", role: "lobby", floor: "stone", w: 9, h: 6 },
42
+ { id: "lounge", name: "Lounge", role: "lounge", floor: "carpet", w: 10, h: 6 },
43
+ { id: "quests", name: "Quests", role: "quests", floor: "tile", w: 9, h: 6 },
44
+ ],
45
+ furnishing: {
46
+ lobby: [{ place: "rug", at: "center" }, { scatter: "plant" }, { scatter: "fern" }, { scatter: "lamp" }, { wall: "clock" }, { wall: "poster" }],
47
+ lounge: [{ scatter: "coffee" }, { scatter: "water" }, { scatter: "sofa" }, { scatter: "fridge" }, { scatter: "plant" }, { scatter: "beanbag" }, { scatter: "lamp" }, { wall: "poster" }],
48
+ quests: [{ place: "board", at: "top" }, { scatter: "table" }, { scatter: "whiteboard" }, { scatter: "plant" }, { wall: "clock" }],
49
+ vacant: [{ scatter: "plant" }, { scatter: "shelf" }],
50
+ },
51
+ workstation: "desk",
52
+ themes: [
53
+ { match: /doc|guide|wiki|spec|readme|manual/, theme: { floor: "carpet", items: ["shelf", "shelf", "lamp"], decor: ["poster", "clock"] } },
54
+ { match: /test|spec|e2e|qa|fixture/, theme: { floor: "tile", items: ["whiteboard", "printer"], decor: ["clock", "poster"] } },
55
+ { match: /api|server|backend|service|worker|core|engine|kernel/, theme: { floor: "concrete", items: ["server", "server", "server", "printer"], decor: ["clock"] } },
56
+ { match: /db|data|migrat|schema|model|store|sql|supabase/, theme: { floor: "stone", items: ["server", "server", "shelf"], decor: ["clock"] } },
57
+ { match: /ui|web|front|client|component|page|view|style|site|app/, theme: { floor: "wood", items: ["fern", "lamp", "beanbag"], decor: ["poster", "poster"] } },
58
+ { match: /asset|public|image|media|static|font|icon/, theme: { floor: "grass", items: ["plant", "fern", "plant"], decor: [] } },
59
+ { match: /script|tool|ci|github|infra|deploy|ops|docker|bin/, theme: { floor: "stone", items: ["server", "printer", "whiteboard"], decor: ["clock"] } },
60
+ { match: /kit|block|circuit|plugin|ext|module|package/, theme: { floor: "carpet", items: ["whiteboard", "shelf"], decor: ["poster"] } },
61
+ ],
62
+ fallback: { floor: "", floors: ["wood", "carpet", "tile", "stone", "concrete"], items: ["plant", "shelf", "lamp"], decor: ["poster"] },
63
+ filler: ["plant", "fern"],
64
+ partItems: { Desk: "desk", "Server rack": "server", Workbench: "table", Whiteboard: "whiteboard", Bookshelf: "shelf", Toolbox: "printer", Plants: "plant", "Filing cabinet": "shelf" },
65
+ };
66
+
67
+ const plans = new Map<string, WorldPlan>([[OFFICE_PLAN.id, OFFICE_PLAN]]);
68
+
69
+ export function registerWorldPlan(plan: WorldPlan): void {
70
+ plans.set(plan.id, plan);
71
+ }
72
+
73
+ export const worldPlan = (id: string | undefined): WorldPlan => (id && plans.get(id)) || OFFICE_PLAN;
@@ -0,0 +1,39 @@
1
+ /** Reading what's in a project: names and counts, and package manifests. Never source code. */
2
+
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import path from "node:path";
5
+
6
+ type Json = Record<string, unknown>;
7
+
8
+ /** What marks a folder as an application, out of the box. */
9
+ export const DEFAULT_APP_MARKERS = ["block.json", "kit.json", "circuit.json", "package.json", "pyproject.toml", "setup.py", "requirements.txt", "go.mod", "Cargo.toml", "pom.xml", "build.gradle", "build.gradle.kts", "Gemfile", "composer.json", "mix.exs", "deno.json", "pubspec.yaml", "Package.swift", "CMakeLists.txt", "*.csproj", "*.sln"];
10
+
11
+ export const markerMatcher = (marker: string): RegExp =>
12
+ new RegExp(`^${marker.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")}$`);
13
+
14
+ export const IGNORE = new Set(["node_modules", ".git", "dist", "build", "out", "target", "coverage", ".next", ".turbo", ".cache", "vendor", "venv", "__pycache__", ".venv", ".idea", ".vscode", ".claude", ".blkc", ".gemini", ".cursor"]);
15
+
16
+ /** The plain folder names in a .gitignore. Patterns with wildcards aren't followed. */
17
+ export function gitignoredNames(root: string): Set<string> {
18
+ const names = new Set<string>();
19
+ try {
20
+ for (const line of readFileSync(path.join(root, ".gitignore"), "utf8").split("\n")) {
21
+ const name = line.trim().replace(/^\//, "").replace(/\/$/, "");
22
+ if (name && !/[*!#/\s]/.test(name)) names.add(name);
23
+ }
24
+ } catch {
25
+ // no .gitignore
26
+ }
27
+ return names;
28
+ }
29
+
30
+ /** A folder with its own .git is another project, even if it sits inside this one. */
31
+ export const isOwnRepo = (dir: string): boolean => existsSync(path.join(dir, ".git"));
32
+
33
+ export const readPackage = (dir: string): Json | undefined => {
34
+ try {
35
+ return JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8")) as Json;
36
+ } catch {
37
+ return undefined;
38
+ }
39
+ };
package/src/roads.ts ADDED
@@ -0,0 +1,326 @@
1
+ /**
2
+ * A map that grows from the project tree, with no size limit. Each road is sized from what's
3
+ * on it, and its parent road makes room. Buildings are as big as their contents need and
4
+ * never shrink. Once placed, nothing moves: a new folder takes a spare lot on its road, or
5
+ * a new stretch of road at the edge of the map.
6
+ */
7
+
8
+ import { deskCount, furnish, rng, slug, themeFor, titled, type MapDoor, type MapItem, type MapRoom, type OfficeMap } from "./index.js";
9
+ import { OFFICE_PLAN, worldPlan, type WorldPlan } from "./plan.js";
10
+ import type { ProjectTree, TreeNode } from "./tree.js";
11
+
12
+ const ROAD_H = 2;
13
+ const CELL = 4;
14
+ const SPARE = { w: 9, h: 6 };
15
+ const PILE_STEP = 8;
16
+ const MARGIN = 2;
17
+ const MARGIN_TOP = 3;
18
+
19
+ interface Placed { room: MapRoom; node?: TreeNode; side?: "n" | "s" }
20
+ interface Block { w: number; h: number; rooms: Placed[]; doors: MapDoor[] }
21
+ interface Lot { w: number; h: number; key: string; room?: () => MapRoom; node?: TreeNode; block?: Block }
22
+
23
+ const road = (id: string, name: string, x: number, y: number, w: number, h: number, paths: string[]): MapRoom =>
24
+ ({ id, name, x, y, w, h, floor: "hall", paths, role: "hall" });
25
+
26
+ const leadsToBuilding = (node: TreeNode): boolean =>
27
+ node.children.some((child) => child.kind === "building" || (child.kind === "road" && leadsToBuilding(child)));
28
+
29
+ /**
30
+ * Only a road that leads to an application is drawn. Other folders fold into a Folders group
31
+ * on their parent's road, so tooling and notes can't crowd out the project.
32
+ */
33
+ function foldFolders(node: TreeNode): TreeNode {
34
+ const folded = node.children.filter((child) => child.kind === "road" && !leadsToBuilding(child));
35
+ const kept = node.children.filter((child) => !folded.includes(child)).map((child) => (child.kind === "road" ? foldFolders(child) : child));
36
+ return {
37
+ ...node,
38
+ children: folded.length > 0 ? [...kept, { id: `group:${node.path}:Folders`, kind: "group", name: "Folders", path: node.path, children: folded }] : kept,
39
+ };
40
+ }
41
+
42
+ /** A project that is one application has its own folders as buildings. */
43
+ function asRoads(tree: ProjectTree): TreeNode {
44
+ if (tree.root.kind === "road") return foldFolders(tree.root);
45
+ return {
46
+ ...tree.root,
47
+ id: "road:",
48
+ kind: "road",
49
+ name: "/",
50
+ children: tree.root.children.map((child) => (child.kind === "part" ? { ...child, id: `building:${child.path}`, kind: "building" as const, children: [] } : child)),
51
+ };
52
+ }
53
+
54
+ const partsIn = (node: TreeNode): TreeNode[] => node.children.filter((child) => child.kind === "part");
55
+
56
+ /** Big enough for every part, in cells an agent can walk between. */
57
+ function buildingSize(node: TreeNode): { w: number; h: number } {
58
+ const parts = partsIn(node).length || deskCount(node.files ?? 1);
59
+ const across = Math.ceil(Math.sqrt(parts * 1.4));
60
+ const down = Math.ceil(parts / across);
61
+ return { w: Math.max(9, across * CELL + 1), h: Math.max(6, down * CELL + 1) };
62
+ }
63
+
64
+ /** One item for each real part, all reachable: every cell has a free row and column around it. */
65
+ function fillBuilding(room: MapRoom, node: TreeNode, plan: WorldPlan): MapItem[] {
66
+ const across = Math.max(1, Math.floor((room.w - 1) / CELL));
67
+ const down = Math.max(1, Math.floor((room.h - 1) / CELL));
68
+ const parts = partsIn(node);
69
+ const entries = parts.length > 0
70
+ ? parts.map((part) => ({ type: plan.partItems?.[part.item ?? ""] ?? plan.workstation, part: part.path }))
71
+ : Array.from({ length: deskCount(node.files ?? 1) }, () => ({ type: plan.workstation, part: node.path }));
72
+ return entries.slice(0, across * down).map((entry, index) => ({
73
+ type: entry.type,
74
+ x: room.x + 1 + (index % across) * CELL,
75
+ y: room.y + 1 + Math.floor(index / across) * CELL,
76
+ room: room.id,
77
+ part: entry.part,
78
+ }));
79
+ }
80
+
81
+ function buildBlock(node: TreeNode, seed: string, plan: WorldPlan, suffix: string, top: boolean, spares: boolean): Block {
82
+ const id = slug(node.path) || "root";
83
+ const lots: Lot[] = [];
84
+ if (top) {
85
+ plan.specials.forEach((special, index) => {
86
+ lots.push({ w: special.w, h: special.h, key: special.id, room: () => ({ id: special.id, name: special.name, role: special.role, floor: special.floor, paths: index === 0 ? [""] : [], road: "", x: 0, y: 0, w: special.w, h: special.h }) });
87
+ });
88
+ }
89
+ let buildings = 0;
90
+ for (const child of node.children) {
91
+ if (child.kind === "building") {
92
+ buildings += 1;
93
+ const size = buildingSize(child);
94
+ lots.push({
95
+ ...size,
96
+ key: child.id,
97
+ node: child,
98
+ room: () => ({ id: slug(child.path), name: titled(child.name), floor: themeFor(child.path, plan, rng(`${seed}:theme:${child.path}`)).floor, paths: [`${child.path}/`], files: child.files ?? 0, road: node.path, x: 0, y: 0, ...size }),
99
+ });
100
+ } else if (child.kind === "road") {
101
+ const block = buildBlock(child, seed, plan, suffix, false, spares);
102
+ lots.push({ w: block.w, h: block.h, key: child.id, block });
103
+ }
104
+ }
105
+ if (spares && buildings > 0) {
106
+ const count = Math.ceil(buildings / 4);
107
+ for (let n = 1; n <= count; n++) {
108
+ lots.push({ ...SPARE, key: `spare:${node.path}:${n}`, room: () => ({ id: `spare-${id}-${n}${suffix}`, name: "Empty", floor: "concrete", paths: [], role: "vacant", road: node.path, x: 0, y: 0, ...SPARE }) });
109
+ }
110
+ }
111
+
112
+ // Roads wrap into bands, so a long one stays roughly square rather than running forever.
113
+ const area = lots.reduce((sum, lot) => sum + (lot.w + 1) * (lot.h + 1), 0);
114
+ const limit = Math.max(44, Math.ceil(Math.sqrt(area) * 1.5), ...lots.map((lot) => lot.w + 4));
115
+ interface Band { north: Lot[]; south: Lot[]; n: number; s: number }
116
+ const bands: Band[] = [{ north: [], south: [], n: 3, s: 3 }];
117
+ for (const lot of lots) {
118
+ let band = bands[bands.length - 1] as Band;
119
+ if (Math.min(band.n, band.s) + lot.w > limit) {
120
+ band = { north: [], south: [], n: 3, s: 3 };
121
+ bands.push(band);
122
+ }
123
+ if (band.n <= band.s) { band.north.push(lot); band.n += lot.w + 1; }
124
+ else { band.south.push(lot); band.s += lot.w + 1; }
125
+ }
126
+
127
+ const piles = node.children.filter((child) => child.kind === "group");
128
+ const label = node.path ? `${node.path}/` : "";
129
+ const wanted = 4 + Math.ceil(label.length * 0.4) + piles.length * PILE_STEP;
130
+ const width = Math.max(wanted, ...bands.map((band) => Math.max(band.n, band.s) - 1));
131
+
132
+ const out: Block = { w: width, h: 0, rooms: [], doors: [] };
133
+ const place = (lot: Lot, x: number, y: number, side: "n" | "s", wall: number): void => {
134
+ const random = rng(`${seed}:door:${lot.key}`);
135
+ if (lot.block) {
136
+ for (const placed of lot.block.rooms) out.rooms.push({ ...placed, room: { ...placed.room, x: placed.room.x + x, y: placed.room.y + y } });
137
+ for (const door of lot.block.doors) out.doors.push({ x: door.x + x, y: door.y + y });
138
+ // The side road's spine runs through the wall to meet this road.
139
+ out.rooms.push({ room: road(`link-${slug(lot.key)}${suffix}`, "", x, wall, ROAD_H, 1, []) });
140
+ return;
141
+ }
142
+ const room = (lot.room as () => MapRoom)();
143
+ room.x = x;
144
+ room.y = y;
145
+ out.rooms.push({ room, ...(lot.node ? { node: lot.node } : {}), side });
146
+ out.doors.push({ x: x + 1 + Math.floor(random() * Math.max(1, lot.w - 2)), y: wall });
147
+ };
148
+
149
+ let y = 0;
150
+ bands.forEach((band, index) => {
151
+ const north = Math.max(0, ...band.north.map((lot) => lot.h));
152
+ const south = Math.max(0, ...band.south.map((lot) => lot.h));
153
+ const street = y + north + (north > 0 ? 1 : 0);
154
+ let x = 3;
155
+ for (const lot of band.north) {
156
+ place(lot, x, street - 1 - lot.h, "n", street - 1);
157
+ x += lot.w + 1;
158
+ }
159
+ x = 3;
160
+ for (const lot of band.south) {
161
+ place(lot, x, street + ROAD_H + 1, "s", street + ROAD_H);
162
+ x += lot.w + 1;
163
+ }
164
+ out.rooms.push({ room: road(`road-${id}-${index}${suffix}`, index === 0 ? label : "", 2, street, width - 2, ROAD_H, node.path ? [`${node.path}/`] : []) });
165
+ y = street + ROAD_H + 1 + (south > 0 ? south + 1 : 0);
166
+ });
167
+ out.h = Math.max(1, y - 1);
168
+ out.rooms.push({ room: road(`road-${id}-spine${suffix}`, "", 0, 0, ROAD_H, out.h, node.path ? [`${node.path}/`] : []) });
169
+ return out;
170
+ }
171
+
172
+ /** Loose files, in groups, on the first stretch of their own road. Redrawn whenever they change. */
173
+ function placePiles(map: OfficeMap, top: TreeNode): void {
174
+ map.furniture = map.furniture.filter((item) => item.type !== "pile");
175
+ const visit = (node: TreeNode): void => {
176
+ const street = map.rooms.find((room) => room.role === "hall" && room.id === `road-${slug(node.path) || "root"}-0`);
177
+ if (street) {
178
+ const start = street.x + 2 + Math.ceil((node.path ? node.path.length + 1 : 0) * 0.4);
179
+ const fits = Math.max(1, Math.floor((street.x + street.w - start) / PILE_STEP));
180
+ node.children.filter((child) => child.kind === "group").slice(0, fits).forEach((group, index) => {
181
+ map.furniture.push({ type: "pile", x: start + index * PILE_STEP, y: street.y, room: street.id, part: group.id, label: `${group.name} ${group.children.length}` });
182
+ });
183
+ }
184
+ for (const child of node.children) if (child.kind === "road") visit(child);
185
+ };
186
+ visit(top);
187
+ }
188
+
189
+ function shift(map: OfficeMap): void {
190
+ if (map.rooms.length === 0) return;
191
+ const minX = Math.min(...map.rooms.map((room) => room.x));
192
+ const minY = Math.min(...map.rooms.map((room) => room.y));
193
+ const dx = MARGIN - minX;
194
+ const dy = MARGIN_TOP - minY;
195
+ for (const room of map.rooms) { room.x += dx; room.y += dy; }
196
+ for (const door of map.doors) { door.x += dx; door.y += dy; }
197
+ for (const item of map.furniture) { item.x += dx; item.y += dy; }
198
+ map.cols = Math.max(...map.rooms.map((room) => room.x + room.w)) + MARGIN;
199
+ map.rows = Math.max(...map.rooms.map((room) => room.y + room.h)) + MARGIN;
200
+ }
201
+
202
+ function collectBuildings(node: TreeNode, into: Map<string, { node: TreeNode; road: TreeNode }> = new Map()): Map<string, { node: TreeNode; road: TreeNode }> {
203
+ for (const child of node.children) {
204
+ if (child.kind === "building") into.set(slug(child.path), { node: child, road: node });
205
+ else if (child.kind === "road") collectBuildings(child, into);
206
+ }
207
+ return into;
208
+ }
209
+
210
+ const setLinks = (map: OfficeMap, tree: ProjectTree): void => {
211
+ const on = new Set(map.rooms.map((room) => room.id));
212
+ const links = tree.links
213
+ .map((link) => ({ from: slug(link.from.replace(/^building:/, "")), to: slug(link.to.replace(/^building:/, "")), door: false }))
214
+ .filter((link) => on.has(link.from) && on.has(link.to));
215
+ if (links.length > 0) map.links = links;
216
+ else delete map.links;
217
+ };
218
+
219
+ /** Absorb a block into the map, furnishing what it holds. */
220
+ function addBlock(map: OfficeMap, block: Block, x: number, y: number, seed: string, plan: WorldPlan): void {
221
+ for (const placed of block.rooms) {
222
+ const room = { ...placed.room, x: placed.room.x + x, y: placed.room.y + y };
223
+ map.rooms.push(room);
224
+ if (placed.node) map.furniture.push(...fillBuilding(room, placed.node, plan));
225
+ }
226
+ for (const door of block.doors) map.doors.push({ x: door.x + x, y: door.y + y });
227
+ for (const placed of block.rooms) {
228
+ const { role, id } = placed.room;
229
+ if (!role || role === "hall") continue;
230
+ const room = map.rooms.find((candidate) => candidate.id === id) as MapRoom;
231
+ map.furniture.push(...furnish(room, map.doors, seed, plan));
232
+ }
233
+ }
234
+
235
+ export function layoutRoads(tree: ProjectTree, seed: string, plan: WorldPlan = OFFICE_PLAN): OfficeMap {
236
+ const top = asRoads(tree);
237
+ const map: OfficeMap = { version: 1, ...(plan === OFFICE_PLAN ? {} : { world: plan.id }), layout: "roads", seed, evolve: true, cols: 0, rows: 0, rooms: [], doors: [], furniture: [], log: [] };
238
+ addBlock(map, buildBlock(top, seed, plan, "", true, true), 0, 0, seed, plan);
239
+ // The first room stays first in the file.
240
+ map.rooms.sort((a, b) => Number(b.role === "lobby") - Number(a.role === "lobby"));
241
+ placePiles(map, top);
242
+ setLinks(map, tree);
243
+ shift(map);
244
+ return map;
245
+ }
246
+
247
+ export function evolveRoads(previous: OfficeMap | undefined, tree: ProjectTree, seed: string, now = new Date().toISOString()): { map: OfficeMap; changes: string[] } {
248
+ if (!previous) return { map: layoutRoads(tree, seed), changes: [] };
249
+ const plan = worldPlan(previous.world);
250
+ const map = JSON.parse(JSON.stringify(previous)) as OfficeMap;
251
+ const changes: string[] = [];
252
+ const top = asRoads(tree);
253
+ const wanted = collectBuildings(top);
254
+
255
+ const clearItems = (id: string): void => { map.furniture = map.furniture.filter((item) => item.room !== id); };
256
+ for (const room of map.rooms) {
257
+ if (room.role || room.locked || wanted.has(room.id)) continue;
258
+ changes.push(`${room.name} is now empty: its folder has gone`);
259
+ clearItems(room.id);
260
+ room.role = "vacant";
261
+ room.name = "Empty";
262
+ room.paths = [];
263
+ delete room.files;
264
+ map.furniture.push(...furnish(room, map.doors, map.seed, plan));
265
+ }
266
+
267
+ const newOnRoad = new Map<string, TreeNode[]>();
268
+ const signature = (items: MapItem[]): string => items.map((item) => `${item.type}:${item.part}`).sort().join("|");
269
+ for (const [id, { node, road: parent }] of wanted) {
270
+ const room = map.rooms.find((candidate) => candidate.id === id);
271
+ if (room?.locked) continue;
272
+ if (room) {
273
+ const back = room.role === "vacant";
274
+ if (back) {
275
+ delete room.role;
276
+ room.name = titled(node.name);
277
+ room.paths = [`${node.path}/`];
278
+ room.floor = themeFor(node.path, plan, rng(`${map.seed}:theme:${node.path}`)).floor;
279
+ changes.push(`${room.name} is back`);
280
+ }
281
+ room.files = node.files ?? 0;
282
+ const items = fillBuilding(room, node, plan);
283
+ if (back || signature(items) !== signature(map.furniture.filter((item) => item.room === id))) {
284
+ clearItems(id);
285
+ map.furniture.push(...items);
286
+ }
287
+ continue;
288
+ }
289
+ // A spare lot on its own road, if there's one big enough.
290
+ const size = buildingSize(node);
291
+ const lot = map.rooms.find((candidate) => candidate.role === "vacant" && !candidate.locked && candidate.road === parent.path && candidate.w >= size.w && candidate.h >= size.h);
292
+ if (lot) {
293
+ clearItems(lot.id);
294
+ lot.id = id;
295
+ delete lot.role;
296
+ lot.name = titled(node.name);
297
+ lot.floor = themeFor(node.path, plan, rng(`${map.seed}:theme:${node.path}`)).floor;
298
+ lot.paths = [`${node.path}/`];
299
+ lot.files = node.files ?? 0;
300
+ map.furniture.push(...fillBuilding(lot, node, plan));
301
+ changes.push(`New room: ${lot.name}`);
302
+ continue;
303
+ }
304
+ newOnRoad.set(parent.path, [...(newOnRoad.get(parent.path) ?? []), node]);
305
+ }
306
+
307
+ // No lot left: a new stretch of road, below everything, so nothing else has to move.
308
+ const spine = map.rooms.find((room) => room.id === "road-root-spine");
309
+ for (const [path, nodes] of newOnRoad) {
310
+ const bottom = Math.max(...map.rooms.map((room) => room.y + room.h));
311
+ const startX = spine?.x ?? Math.min(...map.rooms.map((room) => room.x));
312
+ const stretch = map.rooms.filter((room) => room.id.startsWith("road-") && room.id.endsWith("spine")).length;
313
+ const node: TreeNode = { id: `road:${path}`, kind: "road", name: `${path}/`, path, children: nodes };
314
+ const y = bottom + 1;
315
+ addBlock(map, buildBlock(node, map.seed, plan, `-more${stretch}`, false, false), startX, y, map.seed, plan);
316
+ if (spine) spine.h = y - spine.y;
317
+ for (const added of nodes) changes.push(`New room: ${titled(added.name)}`);
318
+ }
319
+
320
+ placePiles(map, top);
321
+
322
+ setLinks(map, tree);
323
+ shift(map);
324
+ if (changes.length > 0) map.log = [...map.log, ...changes.map((text) => ({ at: now, text }))].slice(-30);
325
+ return { map, changes };
326
+ }