@blkc-code/mapgen 0.0.0-stage → 0.3.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/README.md +10 -2
- package/dist/index.d.ts +106 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +737 -0
- package/dist/index.js.map +1 -0
- package/dist/plan.d.ts +56 -0
- package/dist/plan.d.ts.map +1 -0
- package/dist/plan.js +44 -0
- package/dist/plan.js.map +1 -0
- package/package.json +46 -4
- package/src/index.ts +790 -0
- package/src/plan.ts +70 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BLKC Mapgen. Grows a building from a project. Every project gets its own map: the seed comes from its
|
|
3
|
+
* name, and the rooms from its folders. The map evolves rather than regenerates: rooms that
|
|
4
|
+
* exist stay where they are, new folders add rooms, bigger folders get bigger rooms, and a
|
|
5
|
+
* room someone has edited by hand is never touched again.
|
|
6
|
+
*
|
|
7
|
+
* Folder names, file counts and project manifests (package.json, pnpm-workspace.yaml) are read
|
|
8
|
+
* here, never source code.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { OFFICE_PLAN, worldPlan, type RoomRole, type Theme, type WorldPlan } from "./plan.js";
|
|
14
|
+
|
|
15
|
+
export * from "./plan.js";
|
|
16
|
+
|
|
17
|
+
export interface MapRoom {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
x: number;
|
|
21
|
+
y: number;
|
|
22
|
+
w: number;
|
|
23
|
+
h: number;
|
|
24
|
+
floor: string;
|
|
25
|
+
paths: string[];
|
|
26
|
+
/** lobby, lounge and quests are always there. hall is a corridor. vacant is an empty room, free for the next folder. */
|
|
27
|
+
role?: RoomRole;
|
|
28
|
+
/** Edited by hand: the generator leaves it alone. */
|
|
29
|
+
locked?: boolean;
|
|
30
|
+
files?: number;
|
|
31
|
+
}
|
|
32
|
+
export interface MapItem { type: string; x: number; y: number; room?: string }
|
|
33
|
+
export interface MapDoor { x: number; y: number }
|
|
34
|
+
export interface OfficeMap {
|
|
35
|
+
version: 1;
|
|
36
|
+
/** Which world this map is for. The office if left out. */
|
|
37
|
+
world?: string;
|
|
38
|
+
/** halls: rooms in even rows along corridors. Left out on older maps, whose rooms were placed one against another. */
|
|
39
|
+
layout?: "halls";
|
|
40
|
+
/** How tall the rooms are in the first pair of rows. */
|
|
41
|
+
roomHeight?: number;
|
|
42
|
+
seed: string;
|
|
43
|
+
/** Follow the project as it changes. */
|
|
44
|
+
evolve: boolean;
|
|
45
|
+
cols: number;
|
|
46
|
+
rows: number;
|
|
47
|
+
rooms: MapRoom[];
|
|
48
|
+
doors: MapDoor[];
|
|
49
|
+
furniture: MapItem[];
|
|
50
|
+
log: { at: string; text: string }[];
|
|
51
|
+
/** Which rooms depend on which, and whether a door joins them. */
|
|
52
|
+
links?: MapLink[];
|
|
53
|
+
}
|
|
54
|
+
export interface MapLink { from: string; to: string; door: boolean }
|
|
55
|
+
|
|
56
|
+
/** A map, for any world. */
|
|
57
|
+
export type WorldMap = OfficeMap;
|
|
58
|
+
|
|
59
|
+
export interface ProjectArea {
|
|
60
|
+
id: string;
|
|
61
|
+
name: string;
|
|
62
|
+
path: string;
|
|
63
|
+
files: number;
|
|
64
|
+
/** How many other areas depend on this one. */
|
|
65
|
+
dependents?: number;
|
|
66
|
+
}
|
|
67
|
+
export interface ProjectScan {
|
|
68
|
+
areas: ProjectArea[];
|
|
69
|
+
rootFiles: number;
|
|
70
|
+
/** `from` depends on `to`. */
|
|
71
|
+
links?: { from: string; to: string }[];
|
|
72
|
+
/** Folders that didn't make the map. */
|
|
73
|
+
omitted?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const MAX_COLS = 64;
|
|
77
|
+
const MAX_ROWS = 40;
|
|
78
|
+
const MAX_AREAS = 20;
|
|
79
|
+
/** Past these many rooms, they stay small so that more of the project fits. */
|
|
80
|
+
const COMPACT_ABOVE = 10;
|
|
81
|
+
const TIGHT_ABOVE = 14;
|
|
82
|
+
const MAX_ENTRIES = 200000;
|
|
83
|
+
/** One huge folder can't use up the scan and leave the rest uncounted. */
|
|
84
|
+
const MAX_ENTRIES_PER_AREA = 20000;
|
|
85
|
+
const MAX_DEPTH = 8;
|
|
86
|
+
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"]);
|
|
87
|
+
const CONTAINERS = new Set(["src", "packages", "apps", "services", "libs", "lib", "modules", "crates", "cmd", "internal", "app"]);
|
|
88
|
+
|
|
89
|
+
const slug = (value: string): string => value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
90
|
+
const titled = (value: string): string =>
|
|
91
|
+
value.replace(/^\./, "").split(/[-_]/).filter(Boolean).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ").slice(0, 18) || value.slice(0, 18);
|
|
92
|
+
|
|
93
|
+
export function hashText(text: string): number {
|
|
94
|
+
let value = 2166136261;
|
|
95
|
+
for (let i = 0; i < text.length; i++) value = Math.imul(value ^ text.charCodeAt(i), 16777619);
|
|
96
|
+
return value >>> 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A small seeded generator, so the same project always grows the same way. */
|
|
100
|
+
function rng(seed: string): () => number {
|
|
101
|
+
let state = hashText(seed) || 1;
|
|
102
|
+
return () => {
|
|
103
|
+
state ^= state << 13;
|
|
104
|
+
state ^= state >>> 17;
|
|
105
|
+
state ^= state << 5;
|
|
106
|
+
state >>>= 0;
|
|
107
|
+
return state / 4294967296;
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The plain folder names in a .gitignore. Patterns with wildcards aren't followed. */
|
|
112
|
+
export function gitignoredNames(root: string): Set<string> {
|
|
113
|
+
const names = new Set<string>();
|
|
114
|
+
try {
|
|
115
|
+
for (const line of readFileSync(path.join(root, ".gitignore"), "utf8").split("\n")) {
|
|
116
|
+
const name = line.trim().replace(/^\//, "").replace(/\/$/, "");
|
|
117
|
+
if (name && !/[*!#/\s]/.test(name)) names.add(name);
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// no .gitignore
|
|
121
|
+
}
|
|
122
|
+
return names;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A folder with its own .git is another project, even if it sits inside this one. */
|
|
126
|
+
export const isOwnRepo = (dir: string): boolean => existsSync(path.join(dir, ".git"));
|
|
127
|
+
|
|
128
|
+
type Json = Record<string, unknown>;
|
|
129
|
+
|
|
130
|
+
const readPackage = (dir: string): Json | undefined => {
|
|
131
|
+
try {
|
|
132
|
+
return JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8")) as Json;
|
|
133
|
+
} catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** Top-level folders a workspace config says hold packages, such as tools from "tools/*". */
|
|
139
|
+
function workspaceFolders(root: string): string[] {
|
|
140
|
+
const globs: string[] = [];
|
|
141
|
+
const workspaces = readPackage(root)?.["workspaces"];
|
|
142
|
+
const listed = Array.isArray(workspaces) ? workspaces : (workspaces as Json | undefined)?.["packages"];
|
|
143
|
+
if (Array.isArray(listed)) globs.push(...listed.filter((item): item is string => typeof item === "string"));
|
|
144
|
+
try {
|
|
145
|
+
for (const line of readFileSync(path.join(root, "pnpm-workspace.yaml"), "utf8").split("\n")) {
|
|
146
|
+
const match = /^\s*-\s*['"]?([^'"#\s]+)/.exec(line);
|
|
147
|
+
if (match?.[1]) globs.push(match[1]);
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// not pnpm
|
|
151
|
+
}
|
|
152
|
+
return globs.flatMap((glob) => /^[^/!*]+\/\*{1,2}$/.test(glob) ? [glob.split("/")[0] as string] : []);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Who depends on whom, from each area's package.json. */
|
|
156
|
+
function findLinks(root: string, areas: ProjectArea[]): { from: string; to: string }[] {
|
|
157
|
+
const owners = new Map<string, string>();
|
|
158
|
+
const manifests = new Map<string, Json>();
|
|
159
|
+
for (const area of areas) {
|
|
160
|
+
const manifest = readPackage(path.join(root, area.path));
|
|
161
|
+
if (!manifest) continue;
|
|
162
|
+
manifests.set(area.id, manifest);
|
|
163
|
+
if (typeof manifest["name"] === "string") owners.set(manifest["name"], area.id);
|
|
164
|
+
}
|
|
165
|
+
const links: { from: string; to: string }[] = [];
|
|
166
|
+
for (const [from, manifest] of manifests) {
|
|
167
|
+
const seen = new Set<string>();
|
|
168
|
+
for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
169
|
+
for (const name of Object.keys((manifest[field] ?? {}) as Json)) {
|
|
170
|
+
const to = owners.get(name);
|
|
171
|
+
if (to && to !== from && !seen.has(to)) {
|
|
172
|
+
seen.add(to);
|
|
173
|
+
links.push({ from, to });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return links;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Folder names, file counts and how the folders depend on each other. */
|
|
182
|
+
export function scanProject(root: string): ProjectScan {
|
|
183
|
+
const ignored = new Set([...IGNORE, ...gitignoredNames(root)]);
|
|
184
|
+
|
|
185
|
+
let budget = MAX_ENTRIES;
|
|
186
|
+
const countFiles = (dir: string, depth: number): number => {
|
|
187
|
+
if (depth > MAX_DEPTH || budget <= 0) return 0;
|
|
188
|
+
let entries;
|
|
189
|
+
try {
|
|
190
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
191
|
+
} catch {
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
let files = 0;
|
|
195
|
+
for (const entry of entries) {
|
|
196
|
+
if (--budget <= 0) break;
|
|
197
|
+
if (entry.isDirectory()) {
|
|
198
|
+
const child = path.join(dir, entry.name);
|
|
199
|
+
if (!ignored.has(entry.name) && !isOwnRepo(child)) files += countFiles(child, depth + 1);
|
|
200
|
+
} else if (entry.isFile()) {
|
|
201
|
+
files += 1;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return files;
|
|
205
|
+
};
|
|
206
|
+
const subdirs = (dir: string): string[] => {
|
|
207
|
+
try {
|
|
208
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
209
|
+
.filter((entry) => entry.isDirectory() && !ignored.has(entry.name) && (!entry.name.startsWith(".") || entry.name === ".github") && !isOwnRepo(path.join(dir, entry.name)))
|
|
210
|
+
.map((entry) => entry.name)
|
|
211
|
+
.sort();
|
|
212
|
+
} catch {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const containers = new Set([...CONTAINERS, ...workspaceFolders(root)]);
|
|
218
|
+
const areas: ProjectArea[] = [];
|
|
219
|
+
let remaining = MAX_ENTRIES;
|
|
220
|
+
const add = (rel: string): void => {
|
|
221
|
+
budget = Math.min(MAX_ENTRIES_PER_AREA, remaining);
|
|
222
|
+
const before = budget;
|
|
223
|
+
const files = countFiles(path.join(root, rel), 0);
|
|
224
|
+
remaining -= before - Math.max(budget, 0);
|
|
225
|
+
if (files > 0) areas.push({ id: slug(rel), name: titled(path.basename(rel)), path: `${rel}/`, files });
|
|
226
|
+
};
|
|
227
|
+
for (const name of subdirs(root)) {
|
|
228
|
+
const children = containers.has(name) ? subdirs(path.join(root, name)) : [];
|
|
229
|
+
// One package in packages/ is still a package, but one folder in src/ is just a folder.
|
|
230
|
+
const open = children.length >= 2 || children.some((child) => readPackage(path.join(root, name, child)));
|
|
231
|
+
if (open) for (const child of children) add(`${name}/${child}`);
|
|
232
|
+
else add(name);
|
|
233
|
+
}
|
|
234
|
+
let rootFiles = 0;
|
|
235
|
+
try {
|
|
236
|
+
rootFiles = readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isFile()).length;
|
|
237
|
+
} catch {
|
|
238
|
+
// leave it at 0
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Code that's linked to other code comes first, and a package many use before a big folder few do.
|
|
242
|
+
const links = findLinks(root, areas);
|
|
243
|
+
const linked = new Set(links.flatMap((link) => [link.from, link.to]));
|
|
244
|
+
for (const area of areas) {
|
|
245
|
+
const dependents = links.filter((link) => link.to === area.id).length;
|
|
246
|
+
if (dependents > 0) area.dependents = dependents;
|
|
247
|
+
}
|
|
248
|
+
const score = (area: ProjectArea): number => Math.log2(area.files + 1) + (area.dependents ?? 0) * 2 + (linked.has(area.id) ? 20 : 0);
|
|
249
|
+
areas.sort((a, b) => score(b) - score(a) || a.id.localeCompare(b.id));
|
|
250
|
+
|
|
251
|
+
const kept = areas.slice(0, MAX_AREAS);
|
|
252
|
+
const keptIds = new Set(kept.map((area) => area.id));
|
|
253
|
+
return {
|
|
254
|
+
areas: kept,
|
|
255
|
+
rootFiles,
|
|
256
|
+
...(links.length > 0 ? { links: links.filter((link) => keptIds.has(link.from) && keptIds.has(link.to)) } : {}),
|
|
257
|
+
...(areas.length > MAX_AREAS ? { omitted: areas.length - MAX_AREAS } : {}),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// --- What a folder looks like ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
/** The look comes from a folder's own name, so everything in apps/ doesn't look like a front end. */
|
|
264
|
+
function themeFor(folder: string, plan: WorldPlan, random: () => number): Theme {
|
|
265
|
+
const own = folder.replace(/\/$/, "").split("/").pop() ?? folder;
|
|
266
|
+
const found = plan.themes.find(({ match }) => match.test(own));
|
|
267
|
+
if (found) return found.theme;
|
|
268
|
+
const { floors, items, decor } = plan.fallback;
|
|
269
|
+
return { floor: floors[Math.floor(random() * floors.length)] as string, items, decor };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Room size follows the folder: slow growth, so a huge folder doesn't swallow the map. */
|
|
273
|
+
function targetSize(id: string, files: number, seed: string, compact: false | number = false): { w: number; h: number } {
|
|
274
|
+
const scale = Math.log2(Math.max(files, 1) + 1);
|
|
275
|
+
const random = rng(`${seed}:size:${id}`);
|
|
276
|
+
return {
|
|
277
|
+
w: Math.min(compact || 14, Math.max(7, Math.round(6 + scale * 0.9 + random() * 2))),
|
|
278
|
+
h: Math.min(compact ? 6 : 9, Math.max(5, Math.round(4 + scale * 0.55 + random() * 2))),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const deskCount = (files: number): number => Math.min(9, Math.max(1, Math.round(Math.log2(files + 1) - 1)));
|
|
283
|
+
|
|
284
|
+
// --- Geometry -------------------------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
type Box = { x: number; y: number; w: number; h: number };
|
|
287
|
+
|
|
288
|
+
/** Rooms need a wall between them: one tile, which is what a door sits in. */
|
|
289
|
+
const clashes = (a: Box, b: Box): boolean => a.x < b.x + b.w + 1 && b.x < a.x + a.w + 1 && a.y < b.y + b.h + 1 && b.y < a.y + a.h + 1;
|
|
290
|
+
|
|
291
|
+
function bounds(rooms: Box[]): { minX: number; minY: number; maxX: number; maxY: number } {
|
|
292
|
+
return {
|
|
293
|
+
minX: Math.min(...rooms.map((r) => r.x)),
|
|
294
|
+
minY: Math.min(...rooms.map((r) => r.y)),
|
|
295
|
+
maxX: Math.max(...rooms.map((r) => r.x + r.w)),
|
|
296
|
+
maxY: Math.max(...rooms.map((r) => r.y + r.h)),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Margins around the building: walls, plus space above for the top wall's face and the score.
|
|
301
|
+
const MARGIN_X = 2;
|
|
302
|
+
const MARGIN_TOP = 3;
|
|
303
|
+
const MARGIN_BOTTOM = 2;
|
|
304
|
+
|
|
305
|
+
const fits = (rooms: Box[]): boolean => {
|
|
306
|
+
const b = bounds(rooms);
|
|
307
|
+
return b.maxX - b.minX + MARGIN_X * 2 <= MAX_COLS && b.maxY - b.minY + MARGIN_TOP + MARGIN_BOTTOM <= MAX_ROWS;
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
/** Find a spot against an existing room, with a door between them. */
|
|
311
|
+
function attach(
|
|
312
|
+
size: { w: number; h: number },
|
|
313
|
+
rooms: MapRoom[],
|
|
314
|
+
prefer: MapRoom[],
|
|
315
|
+
random: () => number,
|
|
316
|
+
pull = 8,
|
|
317
|
+
): { box: Box; door: MapDoor; host: MapRoom } | undefined {
|
|
318
|
+
const center = (() => {
|
|
319
|
+
const b = bounds(rooms);
|
|
320
|
+
return { x: (b.minX + b.maxX) / 2, y: (b.minY + b.maxY) / 2 };
|
|
321
|
+
})();
|
|
322
|
+
let best: { box: Box; door: MapDoor; host: MapRoom; score: number } | undefined;
|
|
323
|
+
// A door can't be cut into a room edited by hand: its furniture may be in the way.
|
|
324
|
+
for (const host of rooms.filter((room) => !room.locked)) {
|
|
325
|
+
const near = prefer.includes(host) ? -pull : 0;
|
|
326
|
+
const consider = (box: Box, door: MapDoor): void => {
|
|
327
|
+
if (rooms.some((room) => clashes(box, room))) return;
|
|
328
|
+
if (!fits([...rooms, box])) return;
|
|
329
|
+
const cx = box.x + box.w / 2;
|
|
330
|
+
const cy = box.y + box.h / 2;
|
|
331
|
+
const score = Math.abs(cx - center.x) * 0.7 + Math.abs(cy - center.y) * 1.2 + near + random() * 7;
|
|
332
|
+
if (!best || score < best.score) best = { box, door, host, score };
|
|
333
|
+
};
|
|
334
|
+
for (let offset = -size.h + 3; offset <= host.h - 3; offset++) {
|
|
335
|
+
const y = host.y + offset;
|
|
336
|
+
const low = Math.max(y, host.y) + 1;
|
|
337
|
+
const high = Math.min(y + size.h, host.y + host.h) - 2;
|
|
338
|
+
if (low > high) continue;
|
|
339
|
+
const doorY = low + Math.floor(random() * (high - low + 1));
|
|
340
|
+
consider({ x: host.x + host.w + 1, y, w: size.w, h: size.h }, { x: host.x + host.w, y: doorY });
|
|
341
|
+
consider({ x: host.x - size.w - 1, y, w: size.w, h: size.h }, { x: host.x - 1, y: doorY });
|
|
342
|
+
}
|
|
343
|
+
for (let offset = -size.w + 3; offset <= host.w - 3; offset++) {
|
|
344
|
+
const x = host.x + offset;
|
|
345
|
+
const low = Math.max(x, host.x) + 1;
|
|
346
|
+
const high = Math.min(x + size.w, host.x + host.w) - 2;
|
|
347
|
+
if (low > high) continue;
|
|
348
|
+
const doorX = low + Math.floor(random() * (high - low + 1));
|
|
349
|
+
consider({ x, y: host.y + host.h + 1, w: size.w, h: size.h }, { x: doorX, y: host.y + host.h });
|
|
350
|
+
consider({ x, y: host.y - size.h - 1, w: size.w, h: size.h }, { x: doorX, y: host.y - 1 });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return best && { box: best.box, door: best.door, host: best.host };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// --- Floor plans --------------------------------------------------------------------------------------
|
|
357
|
+
//
|
|
358
|
+
// A building: a corridor with a row of rooms on each side, sharing walls and with one door each.
|
|
359
|
+
// When that's as wide as it can be, stairs lead to a second corridor below. Rows are squared off
|
|
360
|
+
// with empty rooms, so the outline stays a rectangle, and the next new folder moves into one.
|
|
361
|
+
|
|
362
|
+
const HALL_H = 2;
|
|
363
|
+
const MIN_ROOM_W = 7;
|
|
364
|
+
const SPARE_W = 9;
|
|
365
|
+
const LOWER_ROOM_H = 6;
|
|
366
|
+
/** The widest a corridor's rooms can reach, leaving space for stairs and the margins. */
|
|
367
|
+
const MAX_ROW_END = MAX_COLS - MARGIN_X * 2 - 3;
|
|
368
|
+
|
|
369
|
+
interface HallRow { hall: MapRoom; y: number; h: number; doorY: number; rooms: MapRoom[]; end: number; limit: number; edge?: number }
|
|
370
|
+
|
|
371
|
+
const corridors = (map: OfficeMap): MapRoom[] =>
|
|
372
|
+
map.rooms.filter((room) => room.role === "hall" && room.id.startsWith("hall-")).sort((a, b) => a.y - b.y);
|
|
373
|
+
|
|
374
|
+
const stairsOf = (map: OfficeMap): MapRoom | undefined => map.rooms.find((room) => room.id === "stairs");
|
|
375
|
+
|
|
376
|
+
function hallRows(map: OfficeMap): HallRow[] {
|
|
377
|
+
const rows: HallRow[] = [];
|
|
378
|
+
const stairs = stairsOf(map);
|
|
379
|
+
corridors(map).forEach((hall, band, halls) => {
|
|
380
|
+
const h = band === 0 ? (map.roomHeight ?? LOWER_ROOM_H) : LOWER_ROOM_H;
|
|
381
|
+
for (const [y, doorY, faces] of [[hall.y - 1 - h, hall.y - 1, band], [hall.y + HALL_H + 1, hall.y + HALL_H, band === halls.length - 1 ? 0 : 1]] as const) {
|
|
382
|
+
// Rows between the corridors stop at the stairs. The outer ones run past them, for a straight edge.
|
|
383
|
+
const inner = stairs !== undefined && faces !== 0;
|
|
384
|
+
const edge = stairs ? (inner ? stairs.x - 1 : stairs.x + HALL_H) : undefined;
|
|
385
|
+
const rooms = map.rooms.filter((room) => room.role !== "hall" && room.y === y).sort((a, b) => a.x - b.x);
|
|
386
|
+
const last = rooms[rooms.length - 1];
|
|
387
|
+
rows.push({ hall, y, h, doorY, rooms, end: last ? last.x + last.w + 1 : hall.x, limit: edge ?? MAX_ROW_END, ...(edge === undefined ? {} : { edge }) });
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
return rows;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const doorInto = (x: number, w: number, y: number, random: () => number): MapDoor =>
|
|
394
|
+
({ x: x + 1 + Math.floor(random() * Math.max(1, w - 2)), y });
|
|
395
|
+
|
|
396
|
+
/** Where a new room goes: an empty room, or the end of a row, near what it's linked to. */
|
|
397
|
+
function placeInHalls(
|
|
398
|
+
map: OfficeMap,
|
|
399
|
+
id: string,
|
|
400
|
+
width: number,
|
|
401
|
+
related: MapRoom[],
|
|
402
|
+
family: MapRoom[],
|
|
403
|
+
random: () => number,
|
|
404
|
+
): { box: Box; door?: MapDoor; replaces?: MapRoom } | undefined {
|
|
405
|
+
const beside = (x: number, w: number, y: number, room: MapRoom): boolean => room.y === y && (room.x + room.w + 1 === x || x + w + 1 === room.x);
|
|
406
|
+
const empty = map.rooms
|
|
407
|
+
.filter((room) => room.role === "vacant" && !room.locked && room.w >= MIN_ROOM_W)
|
|
408
|
+
.map((room) => ({ room, score: Math.abs(room.w - width) - (related.some((other) => beside(room.x, room.w, room.y, other)) ? 20 : 0) - (family.some((other) => other.y === room.y) ? 4 : 0) }))
|
|
409
|
+
.sort((a, b) => a.score - b.score || a.room.x - b.room.x)[0]?.room;
|
|
410
|
+
if (empty) return { box: { x: empty.x, y: empty.y, w: empty.w, h: empty.h }, replaces: empty };
|
|
411
|
+
|
|
412
|
+
const stairs = stairsOf(map);
|
|
413
|
+
for (const w of [width, MIN_ROOM_W]) {
|
|
414
|
+
const open = hallRows(map).filter((row) => row.end + w <= row.limit);
|
|
415
|
+
const score = (row: HallRow): number => row.end
|
|
416
|
+
- (related.some((other) => other.y === row.y) ? 20 : 0)
|
|
417
|
+
- (family.some((other) => other.y === row.y) ? 6 : 0)
|
|
418
|
+
+ random() * 3;
|
|
419
|
+
const row = open.map((candidate) => ({ candidate, value: score(candidate) })).sort((a, b) => a.value - b.value)[0]?.candidate;
|
|
420
|
+
if (row) return { box: { x: row.end, y: row.y, w, h: row.h }, door: doorInto(row.end, w, row.doorY, random) };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// This corridor is full. Stairs to a second one below.
|
|
424
|
+
const halls = corridors(map);
|
|
425
|
+
const top = halls[0];
|
|
426
|
+
if (!top || halls.length > 1 || stairs) return undefined;
|
|
427
|
+
const upperHeight = map.roomHeight ?? LOWER_ROOM_H;
|
|
428
|
+
const y = top.y + HALL_H + 1 + upperHeight + 1 + LOWER_ROOM_H + 1;
|
|
429
|
+
const span = bounds(map.rooms.filter((room) => room.role !== "hall"));
|
|
430
|
+
const landing = span.maxX + 1;
|
|
431
|
+
const total = y + HALL_H + 1 + LOWER_ROOM_H - (top.y - 1 - upperHeight);
|
|
432
|
+
if (landing + HALL_H > MAX_COLS - MARGIN_X * 2 || total + MARGIN_TOP + MARGIN_BOTTOM > MAX_ROWS) return undefined;
|
|
433
|
+
map.rooms.push({ id: "stairs", name: "", x: landing, y: top.y + HALL_H + 1, w: HALL_H, h: y - 1 - (top.y + HALL_H + 1), floor: "hall", paths: [], role: "hall" });
|
|
434
|
+
map.rooms.push({ id: "hall-1", name: "", x: top.x, y, w: landing + HALL_H - top.x, h: HALL_H, floor: "hall", paths: [], role: "hall" });
|
|
435
|
+
map.doors.push({ x: landing, y: top.y + HALL_H }, { x: landing, y: y - 1 });
|
|
436
|
+
return placeInHalls(map, id, width, related, family, random);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Corridors reach the end of their rows, and rows fill out to a straight edge with empty rooms. */
|
|
440
|
+
function squareUp(map: OfficeMap, seed: string, touched: Set<string>): void {
|
|
441
|
+
const stairs = stairsOf(map);
|
|
442
|
+
const rows = hallRows(map);
|
|
443
|
+
const widest = Math.max(...rows.map((row) => row.end - 1), corridors(map)[0]?.x ?? 0);
|
|
444
|
+
for (const row of rows) {
|
|
445
|
+
let gap = (row.edge ?? widest) - row.end;
|
|
446
|
+
if (gap <= 0) continue;
|
|
447
|
+
const last = row.rooms[row.rooms.length - 1];
|
|
448
|
+
if (gap < MIN_ROOM_W + 1 && last && !last.locked && row.rooms.length > 0) {
|
|
449
|
+
last.w += gap + 1;
|
|
450
|
+
touched.add(last.id);
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
let x = row.end;
|
|
454
|
+
while (gap >= MIN_ROOM_W) {
|
|
455
|
+
const w = gap - SPARE_W - 1 >= MIN_ROOM_W ? SPARE_W : gap;
|
|
456
|
+
let n = 1;
|
|
457
|
+
while (map.rooms.some((room) => room.id === `spare-${n}`)) n += 1;
|
|
458
|
+
const id = `spare-${n}`;
|
|
459
|
+
map.rooms.push({ id, name: "Empty", x, y: row.y, w, h: row.h, floor: "concrete", paths: [], role: "vacant" });
|
|
460
|
+
map.doors.push(doorInto(x, w, row.doorY, rng(`${seed}:door:${id}`)));
|
|
461
|
+
touched.add(id);
|
|
462
|
+
x += w + 1;
|
|
463
|
+
gap -= w + 1;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
for (const hall of corridors(map)) {
|
|
467
|
+
const w = (stairs ? stairs.x + HALL_H : widest) - hall.x;
|
|
468
|
+
if (w !== hall.w) {
|
|
469
|
+
hall.w = w;
|
|
470
|
+
touched.add(hall.id);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** A door opens into a room only along a side of it, not at a corner. */
|
|
476
|
+
const touches = (room: Box, door: MapDoor): boolean =>
|
|
477
|
+
((door.x === room.x - 1 || door.x === room.x + room.w) && door.y >= room.y && door.y < room.y + room.h) ||
|
|
478
|
+
((door.y === room.y - 1 || door.y === room.y + room.h) && door.x >= room.x && door.x < room.x + room.w);
|
|
479
|
+
|
|
480
|
+
function reachable(rooms: Box[], doors: MapDoor[], from: Box): Set<Box> {
|
|
481
|
+
const seen = new Set<Box>([from]);
|
|
482
|
+
const queue = [from];
|
|
483
|
+
while (queue.length > 0) {
|
|
484
|
+
const room = queue.pop() as Box;
|
|
485
|
+
for (const door of doors) {
|
|
486
|
+
if (!touches(room, door)) continue;
|
|
487
|
+
for (const other of rooms) {
|
|
488
|
+
if (!seen.has(other) && touches(other, door)) {
|
|
489
|
+
seen.add(other);
|
|
490
|
+
queue.push(other);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return seen;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// --- Furnishing ------------------------------------------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
function furnish(room: MapRoom, doors: MapDoor[], seed: string, plan: WorldPlan): MapItem[] {
|
|
501
|
+
const random = rng(`${seed}:furnish:${room.id}:${room.w}x${room.h}`);
|
|
502
|
+
const blocked = new Set<string>();
|
|
503
|
+
const reserved = new Set<string>();
|
|
504
|
+
const key = (x: number, y: number): string => `${x},${y}`;
|
|
505
|
+
const inside = (x: number, y: number): boolean => x >= room.x && y >= room.y && x < room.x + room.w && y < room.y + room.h;
|
|
506
|
+
|
|
507
|
+
// Keep the way in clear.
|
|
508
|
+
for (const door of doors) {
|
|
509
|
+
for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1], [2, 0], [-2, 0], [0, 2], [0, -2], [1, 1], [-1, 1], [1, -1], [-1, -1]] as const) {
|
|
510
|
+
if (inside(door.x + dx, door.y + dy)) reserved.add(key(door.x + dx, door.y + dy));
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const connected = (): boolean => {
|
|
515
|
+
const open: string[] = [];
|
|
516
|
+
for (let y = room.y; y < room.y + room.h; y++) for (let x = room.x; x < room.x + room.w; x++) if (!blocked.has(key(x, y))) open.push(key(x, y));
|
|
517
|
+
if (open.length === 0) return false;
|
|
518
|
+
const seen = new Set<string>([open[0] as string]);
|
|
519
|
+
const queue = [open[0] as string];
|
|
520
|
+
while (queue.length > 0) {
|
|
521
|
+
const [x, y] = (queue.pop() as string).split(",").map(Number) as [number, number];
|
|
522
|
+
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) {
|
|
523
|
+
const next = key(x + dx, y + dy);
|
|
524
|
+
if (inside(x + dx, y + dy) && !blocked.has(next) && !seen.has(next)) {
|
|
525
|
+
seen.add(next);
|
|
526
|
+
queue.push(next);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return seen.size === open.length;
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
const items: MapItem[] = [];
|
|
534
|
+
const place = (type: string, x: number, y: number): boolean => {
|
|
535
|
+
const spec = plan.footprint[type];
|
|
536
|
+
if (!spec) return false;
|
|
537
|
+
const cells: string[] = [];
|
|
538
|
+
for (let dy = 0; dy < spec.h; dy++) {
|
|
539
|
+
for (let dx = 0; dx < spec.w; dx++) {
|
|
540
|
+
const cell = key(x + dx, y + dy);
|
|
541
|
+
if (!inside(x + dx, y + dy) || reserved.has(cell) || blocked.has(cell)) return false;
|
|
542
|
+
cells.push(cell);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (spec.spot && (!inside(x + spec.spot[0], y + spec.spot[1]) || blocked.has(key(x + spec.spot[0], y + spec.spot[1])))) return false;
|
|
546
|
+
if (spec.solid !== false) {
|
|
547
|
+
for (const cell of cells) blocked.add(cell);
|
|
548
|
+
if (!connected()) {
|
|
549
|
+
for (const cell of cells) blocked.delete(cell);
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
if (spec.spot) reserved.add(key(x + spec.spot[0], y + spec.spot[1]));
|
|
553
|
+
}
|
|
554
|
+
items.push({ type, x, y, room: room.id });
|
|
555
|
+
return true;
|
|
556
|
+
};
|
|
557
|
+
const scatter = (type: string, tries = 24): boolean => {
|
|
558
|
+
const spec = plan.footprint[type];
|
|
559
|
+
if (!spec) return false;
|
|
560
|
+
for (let i = 0; i < tries; i++) {
|
|
561
|
+
const x = room.x + Math.floor(random() * Math.max(1, room.w - spec.w + 1));
|
|
562
|
+
const y = room.y + Math.floor(random() * Math.max(1, room.h - spec.h + 1));
|
|
563
|
+
if (place(type, x, y)) return true;
|
|
564
|
+
}
|
|
565
|
+
return false;
|
|
566
|
+
};
|
|
567
|
+
const wallItem = (type: string): void => {
|
|
568
|
+
for (let i = 0; i < 8; i++) {
|
|
569
|
+
const x = room.x + Math.floor(random() * room.w);
|
|
570
|
+
const y = room.y - 1;
|
|
571
|
+
if (x % 4 === 3 || doors.some((d) => d.x === x && d.y === y) || items.some((it) => it.x === x && it.y === y)) continue;
|
|
572
|
+
items.push({ type, x, y, room: room.id });
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
if (room.role) {
|
|
578
|
+
for (const step of plan.furnishing[room.role] ?? []) {
|
|
579
|
+
if ("wall" in step) wallItem(step.wall);
|
|
580
|
+
else if ("scatter" in step) scatter(step.scatter);
|
|
581
|
+
else if (step.at === "center") {
|
|
582
|
+
const spec = plan.footprint[step.place];
|
|
583
|
+
if (spec) place(step.place, room.x + Math.max(0, (room.w - spec.w) >> 1), room.y + Math.max(0, (room.h - spec.h) >> 1));
|
|
584
|
+
} else if (!place(step.place, room.x + 1, room.y)) scatter(step.place);
|
|
585
|
+
}
|
|
586
|
+
return items;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const theme = themeFor(room.paths[0] ?? room.id, plan, random);
|
|
590
|
+
let placed = 0;
|
|
591
|
+
const wanted = deskCount(room.files ?? 1);
|
|
592
|
+
const stepX = 4 + (random() < 0.3 ? 1 : 0);
|
|
593
|
+
for (let y = room.y + 1; y + 2 < room.y + room.h && placed < wanted; y += 4) {
|
|
594
|
+
for (let x = room.x + 1; x + 2 < room.x + room.w && placed < wanted; x += stepX) {
|
|
595
|
+
if (place(plan.workstation, x, y)) placed += 1;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
for (let i = placed; i < wanted; i++) if (scatter(plan.workstation)) placed += 1;
|
|
599
|
+
for (const type of theme.items) scatter(type);
|
|
600
|
+
if (room.w * room.h > 70) scatter(random() < 0.5 ? plan.filler[0] : plan.filler[1]);
|
|
601
|
+
for (const type of theme.decor) wallItem(type);
|
|
602
|
+
return items;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// --- Growing ---------------------------------------------------------------------------------------------
|
|
606
|
+
|
|
607
|
+
function normalize(map: OfficeMap): void {
|
|
608
|
+
if (map.rooms.length === 0) return;
|
|
609
|
+
const b = bounds(map.rooms);
|
|
610
|
+
const dx = MARGIN_X - b.minX;
|
|
611
|
+
const dy = MARGIN_TOP - b.minY;
|
|
612
|
+
for (const room of map.rooms) { room.x += dx; room.y += dy; }
|
|
613
|
+
for (const door of map.doors) { door.x += dx; door.y += dy; }
|
|
614
|
+
for (const item of map.furniture) { item.x += dx; item.y += dy; }
|
|
615
|
+
map.cols = Math.min(MAX_COLS, b.maxX - b.minX + MARGIN_X * 2);
|
|
616
|
+
map.rows = Math.min(MAX_ROWS, b.maxY - b.minY + MARGIN_TOP + MARGIN_BOTTOM);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* The map after the project's latest changes, or a new one. Returns what changed in words.
|
|
621
|
+
* Pure: the same map, scan and seed always give the same result.
|
|
622
|
+
*/
|
|
623
|
+
export function evolveMap(previous: OfficeMap | undefined, scan: ProjectScan, seed: string, now = new Date().toISOString(), newWorld: WorldPlan = OFFICE_PLAN): { map: OfficeMap; changes: string[] } {
|
|
624
|
+
const map: OfficeMap = previous
|
|
625
|
+
? (JSON.parse(JSON.stringify(previous)) as OfficeMap)
|
|
626
|
+
: { version: 1, ...(newWorld === OFFICE_PLAN ? {} : { world: newWorld.id }), layout: "halls", roomHeight: scan.areas.length > 8 ? LOWER_ROOM_H : LOWER_ROOM_H + 1, seed, evolve: true, cols: 0, rows: 0, rooms: [], doors: [], furniture: [], log: [] };
|
|
627
|
+
// A map stays the world it was made for.
|
|
628
|
+
const plan = previous ? worldPlan(previous.world) : newWorld;
|
|
629
|
+
const changes: string[] = [];
|
|
630
|
+
const touched = new Set<string>();
|
|
631
|
+
|
|
632
|
+
if (!previous) {
|
|
633
|
+
if (plan.specials.length === 0) throw new Error(`The ${plan.id} world has no first room.`);
|
|
634
|
+
map.rooms.push({ id: "hall-0", name: "", x: 0, y: 0, w: 1, h: HALL_H, floor: "hall", paths: [], role: "hall" });
|
|
635
|
+
// The first room faces the others across the corridor.
|
|
636
|
+
plan.specials.forEach((special, index) => {
|
|
637
|
+
const rows = hallRows(map);
|
|
638
|
+
const row = rows[index === 0 ? 1 : 0] as HallRow;
|
|
639
|
+
map.rooms.push({ id: special.id, name: special.name, role: special.role, floor: special.floor, paths: index === 0 ? [""] : [], x: row.end, y: row.y, w: special.w, h: row.h });
|
|
640
|
+
map.doors.push(doorInto(row.end, special.w, row.doorY, rng(`${map.seed}:place:${special.id}`)));
|
|
641
|
+
touched.add(special.id);
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const wanted = new Map(scan.areas.map((area) => [area.id, area]));
|
|
646
|
+
|
|
647
|
+
// Folders that have gone. A room others walk through stays, as an empty corridor.
|
|
648
|
+
for (const room of [...map.rooms]) {
|
|
649
|
+
if (room.role || room.locked || wanted.has(room.id)) continue;
|
|
650
|
+
const others = map.rooms.filter((candidate) => candidate !== room);
|
|
651
|
+
const keptDoors = map.doors.filter((door) => others.filter((other) => touches(other, door)).length >= 2);
|
|
652
|
+
const start = others[0];
|
|
653
|
+
const stillJoined = start ? reachable(others, keptDoors, start).size === others.length : true;
|
|
654
|
+
// In a floor plan an empty room stays, ready for the next folder.
|
|
655
|
+
if (stillJoined && map.layout !== "halls") {
|
|
656
|
+
map.rooms = others;
|
|
657
|
+
map.doors = keptDoors;
|
|
658
|
+
map.furniture = map.furniture.filter((item) => item.room !== room.id);
|
|
659
|
+
changes.push(`${room.name} closed: its folder has gone, or is no longer one of the main ones`);
|
|
660
|
+
} else {
|
|
661
|
+
changes.push(map.layout === "halls" ? `${room.name} is now empty: its folder has gone, or is no longer one of the main ones` : `${room.name} is now empty: its folder has gone, and it stays as a way through`);
|
|
662
|
+
room.role = "vacant";
|
|
663
|
+
room.paths = [];
|
|
664
|
+
room.name = `Old ${room.name}`.slice(0, 18);
|
|
665
|
+
touched.add(room.id);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Folders that exist: update those we know, and add new ones. Biggest first, so if the map
|
|
670
|
+
// ever runs out of space, it's the smallest folders that share the lobby.
|
|
671
|
+
const compact = scan.areas.length > TIGHT_ABOVE ? 8 : scan.areas.length > COMPACT_ABOVE ? 10 : false;
|
|
672
|
+
const links = scan.links ?? [];
|
|
673
|
+
const isLinked = (area: ProjectArea): number => links.some((link) => link.from === area.id || link.to === area.id) ? 1 : 0;
|
|
674
|
+
// Linked rooms go first, while there's still space beside what they depend on.
|
|
675
|
+
const ordered = scan.areas.slice().sort((a, b) => isLinked(b) - isLinked(a) || (b.dependents ?? 0) - (a.dependents ?? 0) || b.files - a.files || hashText(`${map.seed}:${a.id}`) - hashText(`${map.seed}:${b.id}`));
|
|
676
|
+
for (const area of ordered) {
|
|
677
|
+
const room = map.rooms.find((candidate) => candidate.id === area.id);
|
|
678
|
+
if (room) {
|
|
679
|
+
if (room.locked) continue;
|
|
680
|
+
if (room.role === "vacant") {
|
|
681
|
+
room.role = undefined;
|
|
682
|
+
room.name = area.name;
|
|
683
|
+
room.paths = [area.path];
|
|
684
|
+
touched.add(room.id);
|
|
685
|
+
changes.push(`${room.name} is back`);
|
|
686
|
+
}
|
|
687
|
+
const beforeDesks = deskCount(room.files ?? 1);
|
|
688
|
+
room.files = area.files;
|
|
689
|
+
const target = targetSize(room.id, area.files, map.seed, compact);
|
|
690
|
+
const others = map.rooms.filter((candidate) => candidate !== room);
|
|
691
|
+
const doorStaysOutside = (box: Box): boolean => map.doors.every((door) => !(door.x >= box.x && door.x < box.x + box.w && door.y >= box.y && door.y < box.y + box.h));
|
|
692
|
+
let grew = false;
|
|
693
|
+
// Rows keep one height, so a room in a floor plan only grows along its row.
|
|
694
|
+
for (const change of map.layout === "halls" ? [{ w: target.w, h: room.h }] : [{ w: target.w, h: room.h }, { w: room.w, h: target.h }] as const) {
|
|
695
|
+
if (change.w <= room.w && change.h <= room.h) continue;
|
|
696
|
+
const box = { x: room.x, y: room.y, w: Math.max(room.w, change.w), h: Math.max(room.h, change.h) };
|
|
697
|
+
if (!others.some((other) => clashes(box, other)) && doorStaysOutside(box) && fits([...others, box])) {
|
|
698
|
+
room.w = box.w;
|
|
699
|
+
room.h = box.h;
|
|
700
|
+
grew = true;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
if (grew) {
|
|
704
|
+
touched.add(room.id);
|
|
705
|
+
changes.push(`${room.name} grew`);
|
|
706
|
+
}
|
|
707
|
+
if (deskCount(area.files) !== beforeDesks) touched.add(room.id);
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
const parent = area.path.split("/").slice(0, -2).join("/");
|
|
711
|
+
const family = map.rooms.filter((candidate) => candidate.paths.some((p) => parent && p.startsWith(`${parent}/`)));
|
|
712
|
+
const wantedSize = targetSize(area.id, area.files, map.seed, compact);
|
|
713
|
+
// A room goes beside what it depends on, if there's space, so a door there means something.
|
|
714
|
+
const partners = links.flatMap((link) => link.from === area.id ? [link.to] : link.to === area.id ? [link.from] : []);
|
|
715
|
+
const related = map.rooms.filter((candidate) => partners.includes(candidate.id));
|
|
716
|
+
const prefer = related.length > 0 ? related : family;
|
|
717
|
+
const pull = related.length > 0 ? 40 : 8;
|
|
718
|
+
if (map.layout === "halls") {
|
|
719
|
+
const spot = placeInHalls(map, area.id, wantedSize.w, related, family, rng(`${map.seed}:place:${area.id}`));
|
|
720
|
+
if (!spot) continue; // the building is full; this folder's agents use the lobby
|
|
721
|
+
if (spot.replaces) {
|
|
722
|
+
const old = spot.replaces.id;
|
|
723
|
+
map.rooms = map.rooms.filter((candidate) => candidate !== spot.replaces);
|
|
724
|
+
map.furniture = map.furniture.filter((item) => item.room !== old);
|
|
725
|
+
}
|
|
726
|
+
if (spot.door) map.doors.push(spot.door);
|
|
727
|
+
const random = rng(`${map.seed}:theme:${area.id}`);
|
|
728
|
+
const added: MapRoom = { id: area.id, name: area.name, floor: themeFor(area.path, plan, random).floor, paths: [area.path], files: area.files, ...spot.box };
|
|
729
|
+
map.rooms.push(added);
|
|
730
|
+
touched.add(area.id);
|
|
731
|
+
// A door straight through to a neighbour it depends on.
|
|
732
|
+
for (const other of related) {
|
|
733
|
+
const wall = other.y !== added.y ? undefined : other.x + other.w + 1 === added.x ? added.x - 1 : added.x + added.w + 1 === other.x ? added.x + added.w : undefined;
|
|
734
|
+
if (wall === undefined || other.locked) continue;
|
|
735
|
+
map.doors.push({ x: wall, y: added.y + (added.h >> 1) });
|
|
736
|
+
touched.add(other.id);
|
|
737
|
+
}
|
|
738
|
+
if (previous) changes.push(`New room: ${area.name}`);
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
const spot = attach(wantedSize, map.rooms, prefer, rng(`${map.seed}:place:${area.id}`), pull)
|
|
742
|
+
?? attach({ w: 7, h: 5 }, map.rooms, prefer, rng(`${map.seed}:place:${area.id}`), pull);
|
|
743
|
+
if (!spot) continue; // the map is full; this folder's agents use the lobby
|
|
744
|
+
const random = rng(`${map.seed}:theme:${area.id}`);
|
|
745
|
+
map.rooms.push({ id: area.id, name: area.name, floor: themeFor(area.path, plan, random).floor, paths: [area.path], files: area.files, ...spot.box });
|
|
746
|
+
map.doors.push(spot.door);
|
|
747
|
+
touched.add(area.id);
|
|
748
|
+
touched.add(spot.host.id);
|
|
749
|
+
if (previous) changes.push(`New room: ${area.name}`);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (map.layout === "halls") squareUp(map, map.seed, touched);
|
|
753
|
+
|
|
754
|
+
// A new door or a bigger room can change what fits, so those rooms are furnished again.
|
|
755
|
+
for (const id of touched) {
|
|
756
|
+
const room = map.rooms.find((candidate) => candidate.id === id);
|
|
757
|
+
if (!room || room.locked) continue;
|
|
758
|
+
map.furniture = map.furniture.filter((item) => item.room !== id);
|
|
759
|
+
map.furniture.push(...furnish(room, map.doors, map.seed, plan));
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const onMap = (id: string): MapRoom | undefined => map.rooms.find((room) => room.id === id);
|
|
763
|
+
const mapped = links.flatMap((link) => {
|
|
764
|
+
const from = onMap(link.from);
|
|
765
|
+
const to = onMap(link.to);
|
|
766
|
+
return from && to ? [{ ...link, door: map.doors.some((door) => touches(from, door) && touches(to, door)) }] : [];
|
|
767
|
+
});
|
|
768
|
+
if (mapped.length > 0) map.links = mapped;
|
|
769
|
+
else delete map.links;
|
|
770
|
+
|
|
771
|
+
normalize(map);
|
|
772
|
+
if (changes.length > 0) map.log = [...map.log, ...changes.map((text) => ({ at: now, text }))].slice(-30);
|
|
773
|
+
return { map, changes };
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** The room a project file belongs to: the longest matching folder. */
|
|
777
|
+
export function roomForPath(rooms: { id: string; paths?: string[] }[], relative: string): string | undefined {
|
|
778
|
+
const file = relative.split("\\").join("/").replace(/^\.\//, "");
|
|
779
|
+
let best: string | undefined;
|
|
780
|
+
let bestLength = -1;
|
|
781
|
+
for (const room of rooms) {
|
|
782
|
+
for (const prefix of room.paths ?? []) {
|
|
783
|
+
if (prefix && file.startsWith(prefix) && prefix.length > bestLength) {
|
|
784
|
+
best = room.id;
|
|
785
|
+
bestLength = prefix.length;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return best;
|
|
790
|
+
}
|