@maka/maka-cli 5.49.0 → 5.51.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.
Files changed (100) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +20 -1
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js.map +1 -1
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.js +9 -0
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.js.map +1 -1
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/call.js +9 -8
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/call.js.map +1 -1
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/contacts.d.ts +5 -0
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/contacts.js +27 -6
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/contacts.js.map +1 -1
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/give.js +2 -2
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/give.js.map +1 -1
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +43 -3
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js.map +1 -1
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +6 -24
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js.map +1 -1
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/loot.d.ts +21 -0
  18. package/bundle/typescript/src/commands/game/sideQuest/commands/loot.js +93 -0
  19. package/bundle/typescript/src/commands/game/sideQuest/commands/loot.js.map +1 -0
  20. package/bundle/typescript/src/commands/game/sideQuest/commands/map.d.ts +4 -4
  21. package/bundle/typescript/src/commands/game/sideQuest/commands/map.js +23 -31
  22. package/bundle/typescript/src/commands/game/sideQuest/commands/map.js.map +1 -1
  23. package/bundle/typescript/src/commands/game/sideQuest/commands/move.d.ts +32 -3
  24. package/bundle/typescript/src/commands/game/sideQuest/commands/move.js +313 -17
  25. package/bundle/typescript/src/commands/game/sideQuest/commands/move.js.map +1 -1
  26. package/bundle/typescript/src/commands/game/sideQuest/commands/note.d.ts +25 -0
  27. package/bundle/typescript/src/commands/game/sideQuest/commands/note.js +57 -0
  28. package/bundle/typescript/src/commands/game/sideQuest/commands/note.js.map +1 -0
  29. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +20 -3
  30. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js.map +1 -1
  31. package/bundle/typescript/src/commands/game/sideQuest/commands/solve.js +9 -1
  32. package/bundle/typescript/src/commands/game/sideQuest/commands/solve.js.map +1 -1
  33. package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +1 -1
  34. package/bundle/typescript/src/commands/game/sideQuest/commands/take.js.map +1 -1
  35. package/bundle/typescript/src/commands/game/sideQuest/commands/talk.js +1 -1
  36. package/bundle/typescript/src/commands/game/sideQuest/commands/talk.js.map +1 -1
  37. package/bundle/typescript/src/commands/game/sideQuest/factories/room-factory.js +1 -0
  38. package/bundle/typescript/src/commands/game/sideQuest/factories/room-factory.js.map +1 -1
  39. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.d.ts +13 -1
  40. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +66 -9
  41. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js.map +1 -1
  42. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js +37 -1
  43. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js.map +1 -1
  44. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.d.ts +2 -2
  45. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js +2 -2
  46. package/bundle/typescript/src/commands/game/sideQuest/game.d.ts +66 -19
  47. package/bundle/typescript/src/commands/game/sideQuest/game.js +264 -91
  48. package/bundle/typescript/src/commands/game/sideQuest/game.js.map +1 -1
  49. package/bundle/typescript/src/commands/game/sideQuest/headless.js +17 -2
  50. package/bundle/typescript/src/commands/game/sideQuest/headless.js.map +1 -1
  51. package/bundle/typescript/src/commands/game/sideQuest/models/npc.d.ts +9 -0
  52. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +84 -3
  53. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js.map +1 -1
  54. package/bundle/typescript/src/commands/game/sideQuest/models/player.d.ts +31 -0
  55. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +28 -0
  56. package/bundle/typescript/src/commands/game/sideQuest/models/player.js.map +1 -1
  57. package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.d.ts +5 -1
  58. package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.js +24 -6
  59. package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.js.map +1 -1
  60. package/bundle/typescript/src/commands/game/sideQuest/models/room.d.ts +85 -2
  61. package/bundle/typescript/src/commands/game/sideQuest/models/room.js +182 -2
  62. package/bundle/typescript/src/commands/game/sideQuest/models/room.js.map +1 -1
  63. package/bundle/typescript/src/commands/game/sideQuest/models/scene.d.ts +4 -4
  64. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +17 -7
  65. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js.map +1 -1
  66. package/bundle/typescript/src/commands/game/sideQuest/scenes/scene2.json +17 -13
  67. package/bundle/typescript/src/commands/game/sideQuest/types/save-file.d.ts +29 -0
  68. package/bundle/typescript/src/commands/game/sideQuest/types/seed/puzzle-seed.d.ts +2 -0
  69. package/bundle/typescript/src/commands/game/sideQuest/types/seed/room-seed.d.ts +5 -1
  70. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.d.ts +9 -0
  71. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +87 -8
  72. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js.map +1 -1
  73. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +28 -0
  74. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js.map +1 -1
  75. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.d.ts +7 -0
  76. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +9 -0
  77. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js.map +1 -1
  78. package/bundle/typescript/src/commands/game/sideQuest/utilities/range.d.ts +22 -0
  79. package/bundle/typescript/src/commands/game/sideQuest/utilities/range.js +69 -0
  80. package/bundle/typescript/src/commands/game/sideQuest/utilities/range.js.map +1 -0
  81. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.d.ts +186 -0
  82. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.js +742 -0
  83. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.js.map +1 -0
  84. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-view.d.ts +34 -0
  85. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-view.js +536 -0
  86. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-view.js.map +1 -0
  87. package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.d.ts +273 -3
  88. package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js +890 -24
  89. package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js.map +1 -1
  90. package/bundle/typescript/src/commands/set/global.sub.cmd.js +18 -1
  91. package/bundle/typescript/src/commands/set/global.sub.cmd.js.map +1 -1
  92. package/bundle/typescript/src/tools/ai/ai.class.d.ts +1 -1
  93. package/bundle/typescript/src/tools/ai/ai.class.js +39 -7
  94. package/bundle/typescript/src/tools/ai/ai.class.js.map +1 -1
  95. package/bundle/typescript/src/tools/fsi/fsi.interfaces.d.ts +1 -0
  96. package/bundle/typescript/src/tools/fsi/fsi.interfaces.js.map +1 -1
  97. package/bundle/typescript/src/tools/https/https.class.d.ts +19 -0
  98. package/bundle/typescript/src/tools/https/https.class.js +36 -0
  99. package/bundle/typescript/src/tools/https/https.class.js.map +1 -1
  100. package/package.json +1 -1
@@ -0,0 +1,742 @@
1
+ import { Direction } from '../types/shared/direction-enum.js';
2
+ export const METERS_PER_CELL = 2;
3
+ export const METERS_PER_LEVEL = 3;
4
+ export const key = (c) => `${c.x},${c.y},${c.z}`;
5
+ /**
6
+ * x,y cell counts per size class -- 2 m/cell.
7
+ *
8
+ * RESIZED 2026-08-25 (playtest: "room sizes need to be tuned -- they
9
+ * are generally too small"). The original table was drawn when a spot
10
+ * was a single cell and bodies could stack on it; now furniture holds a
11
+ * real footprint (a bar runs three cells) and every actor needs a cell
12
+ * of its own, so the old `small` 4x3 spent two thirds of the room on
13
+ * fixtures and left four squares of floor. Each class moved up roughly
14
+ * one step:
15
+ * tiny 4x3 = 8x6 m -- a crypt, a cubicle, a back office
16
+ * small 6x4 = 12x8 m -- a shop, a hut, a back room
17
+ * medium 8x6 = 16x12 m -- a clinic, a chapel, a workshop
18
+ * large 12x8 = 24x16 m -- a bar, a turf hall, a market floor
19
+ * huge 16x11 = 32x22 m -- a street, a cavern, an atrium
20
+ * These also make SR5 p.185's range brackets bite indoors: a large
21
+ * room's diagonal is ~29 m, so a pistol (short 5, medium 20) is
22
+ * genuinely reaching across it.
23
+ */
24
+ const SIZE_DIMS = {
25
+ tiny: { x: 4, y: 3 },
26
+ small: { x: 6, y: 4 },
27
+ medium: { x: 8, y: 6 },
28
+ large: { x: 12, y: 8 },
29
+ huge: { x: 16, y: 11 },
30
+ };
31
+ /** The x,y cell footprint a size class implies -- the same table
32
+ * synthesis uses, exposed for DISPLAY-ONLY consumers that have no
33
+ * grid to read (utilities/room-view.ts draws a spotless room's shell
34
+ * from this; a spotless room never synthesizes a grid, THE COMPAT
35
+ * RULE, and must not start doing so just to be drawn). */
36
+ export function dimsForSize(size) {
37
+ return SIZE_DIMS[size ?? 'medium'];
38
+ }
39
+ // ============================== RNG ===================================
40
+ /** FNV-1a, 32-bit. Deterministic string -> uint32. */
41
+ export function hashFnv1a(str) {
42
+ let h = 0x811c9dc5;
43
+ for (let i = 0; i < str.length; i++) {
44
+ h ^= str.charCodeAt(i);
45
+ h = Math.imul(h, 0x01000193);
46
+ }
47
+ return h >>> 0;
48
+ }
49
+ /** mulberry32: tiny, fast, deterministic PRNG -- () => [0,1). */
50
+ export function mulberry32(seed) {
51
+ let a = seed >>> 0;
52
+ return () => {
53
+ a |= 0;
54
+ a = (a + 0x6d2b79f5) | 0;
55
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
56
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
57
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
58
+ };
59
+ }
60
+ // =========================== Grid geometry =============================
61
+ function cellExists(grid, c, levelCells) {
62
+ if (c.x < 0 || c.y < 0 || c.x >= grid.dims.x || c.y >= grid.dims.y)
63
+ return false;
64
+ if (c.z === 0)
65
+ return true;
66
+ return levelCells.get(c.z)?.has(`${c.x},${c.y}`) ?? false;
67
+ }
68
+ /** Whether `c` sits inside an uncleared BLOCKING terrain region. */
69
+ function isBlocked(grid, c) {
70
+ const k = key(c);
71
+ for (const region of grid.terrain.values()) {
72
+ if (!region.blocking || region.cleared)
73
+ continue;
74
+ if (region.cells.some(rc => key(rc) === k))
75
+ return true;
76
+ }
77
+ return false;
78
+ }
79
+ /** Whether an uncleared blocking region seals this cell -- the same
80
+ * test pathing uses, exposed so spots.ts can seat bodies only on
81
+ * ground they could actually stand on. */
82
+ export function isCellBlocked(grid, c) {
83
+ return isBlocked(grid, c);
84
+ }
85
+ /** 8-connected same-level neighbors plus any vertical links touching
86
+ * this cell (both directions -- climb difficulty is move.ts's job). */
87
+ function neighborsOf(grid, c, levelCells, rules) {
88
+ const out = [];
89
+ const open = (n) => cellExists(grid, n, levelCells) && !isBlocked(grid, n) && !rules.occupied?.has(key(n));
90
+ for (let dx = -1; dx <= 1; dx++) {
91
+ for (let dy = -1; dy <= 1; dy++) {
92
+ if (dx === 0 && dy === 0)
93
+ continue;
94
+ const n = { x: c.x + dx, y: c.y + dy, z: c.z };
95
+ if (open(n))
96
+ out.push(n);
97
+ }
98
+ }
99
+ const up = grid.vertical.get(key(c));
100
+ if (up && !(rules.avoidClimb && up.kind === 'climb')) {
101
+ const target = { x: c.x, y: c.y, z: up.toZ };
102
+ if (open(target))
103
+ out.push(target);
104
+ }
105
+ for (const [fromKey, link] of grid.vertical.entries()) {
106
+ if (link.toZ !== c.z)
107
+ continue;
108
+ if (rules.avoidClimb && link.kind === 'climb')
109
+ continue;
110
+ const [fx, fy, fz] = fromKey.split(',').map(Number);
111
+ if (fx === c.x && fy === c.y) {
112
+ const from = { x: fx, y: fy, z: fz };
113
+ if (open(from))
114
+ out.push(from);
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+ function bfs(grid, from, to, levelCells, rules) {
120
+ const cameFrom = new Map();
121
+ const visited = new Set([key(from)]);
122
+ const queue = [from];
123
+ let qi = 0;
124
+ while (qi < queue.length) {
125
+ const cur = queue[qi++];
126
+ for (const n of neighborsOf(grid, cur, levelCells, rules)) {
127
+ const nk = key(n);
128
+ if (visited.has(nk))
129
+ continue;
130
+ visited.add(nk);
131
+ cameFrom.set(nk, cur);
132
+ if (nk === key(to)) {
133
+ const path = [to];
134
+ let walk = nk;
135
+ while (walk !== key(from)) {
136
+ const prev = cameFrom.get(walk);
137
+ path.push(prev);
138
+ walk = key(prev);
139
+ }
140
+ return path.reverse();
141
+ }
142
+ queue.push(n);
143
+ }
144
+ }
145
+ return undefined;
146
+ }
147
+ /** BFS shortest path between two cells, or undefined if unreachable
148
+ * through open (non-blocked, in-bounds) cells. `levelCells` recomputed
149
+ * from the grid's synthesis metadata (kept on the grid via a WeakMap
150
+ * so callers never have to pass it around -- see synthesizeGrid).
151
+ *
152
+ * FOUR PASSES, cheapest-and-safest first. Two things are avoided when
153
+ * anything else will do, and they are NOT equally bad:
154
+ * 1. a 'climb' link is the RISKY alternative to a level's own stairs
155
+ * or ladder (move.ts rolls Athletics on it, and a failure is a
156
+ * fall), so it is the LAST thing given up;
157
+ * 2. a cell holding somebody's body is merely in the way -- squeezing
158
+ * past a bystander beats free-climbing a wall every time.
159
+ * Hence: (no climb, no squeeze) -> (no climb, squeeze) -> (climb, no
160
+ * squeeze) -> (climb, squeeze). Bodies never make a destination
161
+ * unreachable on their own; the worst they can do is force a squeeze,
162
+ * which move.ts narrates. `pathSqueezes` tells a caller which
163
+ * happened. */
164
+ export function findPath(grid, from, to, occupied) {
165
+ const levelCells = LEVEL_CELLS.get(grid) ?? new Map();
166
+ if (key(from) === key(to))
167
+ return [from];
168
+ // Never let the mover's own destination count as blocked by a body.
169
+ const bodies = occupied && occupied.size > 0 ? occupied : undefined;
170
+ return bfs(grid, from, to, levelCells, { avoidClimb: true, occupied: bodies })
171
+ ?? bfs(grid, from, to, levelCells, { avoidClimb: true })
172
+ ?? bfs(grid, from, to, levelCells, { avoidClimb: false, occupied: bodies })
173
+ ?? bfs(grid, from, to, levelCells, { avoidClimb: false });
174
+ }
175
+ /** Whether this route threads a cell somebody is standing in -- i.e.
176
+ * findPath had to fall back past the no-squeeze passes. */
177
+ export function pathSqueezes(path, occupied) {
178
+ if (!occupied || occupied.size === 0)
179
+ return false;
180
+ return path.some(c => occupied.has(key(c)));
181
+ }
182
+ /** Straight-line 3D distance in meters between two cells. */
183
+ export function distanceMeters(a, b) {
184
+ const dx = (a.x - b.x) * METERS_PER_CELL;
185
+ const dy = (a.y - b.y) * METERS_PER_CELL;
186
+ const dz = (a.z - b.z) * METERS_PER_LEVEL;
187
+ return Math.sqrt(dx * dx + dy * dy + dz * dz);
188
+ }
189
+ /** Every cell that exists at z-level `z`: ground (z=0) is implicitly the
190
+ * whole x*y footprint; an upper level is only its declared strip. The
191
+ * shape utilities/room-view.ts needs to draw each floor without
192
+ * reaching into LEVEL_CELLS itself (module-private -- see
193
+ * synthesizeGrid). Declared before LEVEL_CELLS below; fine at call
194
+ * time (the const is bound long before any consumer runs). */
195
+ export function levelFootprint(grid, z) {
196
+ if (z === 0) {
197
+ const out = [];
198
+ for (let x = 0; x < grid.dims.x; x++) {
199
+ for (let y = 0; y < grid.dims.y; y++)
200
+ out.push({ x, y, z: 0 });
201
+ }
202
+ return out;
203
+ }
204
+ const cells = LEVEL_CELLS.get(grid)?.get(z);
205
+ if (!cells)
206
+ return [];
207
+ return [...cells].map(k => { const [x, y] = k.split(',').map(Number); return { x, y, z }; });
208
+ }
209
+ /** Integer 3D Bresenham/DDA line between two cells (inclusive), used
210
+ * for the supercover cover check -- deterministic and symmetric. */
211
+ function line3D(a, b) {
212
+ const out = [];
213
+ let [x, y, z] = [a.x, a.y, a.z];
214
+ const dx = Math.abs(b.x - x), dy = Math.abs(b.y - y), dz = Math.abs(b.z - z);
215
+ const sx = Math.sign(b.x - x), sy = Math.sign(b.y - y), sz = Math.sign(b.z - z);
216
+ const steps = Math.max(dx, dy, dz);
217
+ if (steps === 0)
218
+ return [{ x, y, z }];
219
+ let errX = steps / 2, errY = steps / 2, errZ = steps / 2;
220
+ out.push({ x, y, z });
221
+ for (let i = 0; i < steps; i++) {
222
+ errX -= dx;
223
+ errY -= dy;
224
+ errZ -= dz;
225
+ if (errX < 0) {
226
+ x += sx;
227
+ errX += steps;
228
+ }
229
+ if (errY < 0) {
230
+ y += sy;
231
+ errY += steps;
232
+ }
233
+ if (errZ < 0) {
234
+ z += sz;
235
+ errZ += steps;
236
+ }
237
+ out.push({ x, y, z });
238
+ }
239
+ return out;
240
+ }
241
+ /** Cover between two cells: any uncleared terrain region (blocking OR
242
+ * degraded-to-scenery -- both still occlude) whose cells intersect
243
+ * the line of fire, excluding the two endpoints themselves. 'good'
244
+ * wins over 'partial' when both are present. */
245
+ export function coverBetween(grid, from, to) {
246
+ const line = line3D(from, to).slice(1, -1);
247
+ if (line.length === 0)
248
+ return undefined;
249
+ const lineKeys = new Set(line.map(key));
250
+ let best;
251
+ for (const region of grid.terrain.values()) {
252
+ if (region.cleared)
253
+ continue;
254
+ if (region.cells.some(c => lineKeys.has(key(c)))) {
255
+ if (region.cover === 'good')
256
+ return 'good';
257
+ best = 'partial';
258
+ }
259
+ }
260
+ return best;
261
+ }
262
+ /** Which uncleared blocking region is sealing the path from `from` to
263
+ * `to`: re-runs BFS with each candidate region's cells excluded, and
264
+ * returns the smallest one whose removal opens a path. Undefined when
265
+ * no path exists even with every region cleared (a genuinely
266
+ * disconnected room -- a synthesis bug, not a player-facing state). */
267
+ export function blockingRegionFor(grid, from, to) {
268
+ const candidates = [...grid.terrain.values()]
269
+ .filter(r => r.blocking && !r.cleared)
270
+ .sort((a, b) => a.cells.length - b.cells.length);
271
+ for (const region of candidates) {
272
+ const trial = { ...grid, terrain: new Map(grid.terrain) };
273
+ trial.terrain.set(region.id, { ...region, cleared: true });
274
+ LEVEL_CELLS.set(trial, LEVEL_CELLS.get(grid) ?? new Map());
275
+ if (findPath(trial, from, to))
276
+ return region;
277
+ }
278
+ return undefined;
279
+ }
280
+ /** SR5 p.172 (RAG-verified): DV = meters fallen, only beyond 3m; AP-4
281
+ * applies at the caller (resisted with Body+Armor -- move.ts's job). */
282
+ export function fallDamageDV(zSteps) {
283
+ const meters = zSteps * METERS_PER_LEVEL;
284
+ return meters > 3 ? meters : 0;
285
+ }
286
+ /** Does this path cross a 'climb' vertical link (the riskier route --
287
+ * stairs/ladder never roll anything)? Returns the z-delta of the
288
+ * riskiest crossing (for fallDamageDV), or undefined when the path
289
+ * never touches one. */
290
+ export function pathClimbSteps(grid, path) {
291
+ let steps;
292
+ for (let i = 0; i < path.length - 1; i++) {
293
+ const a = path[i], b = path[i + 1];
294
+ if (a.z === b.z)
295
+ continue;
296
+ const [lo, hi] = a.z < b.z ? [a, b] : [b, a];
297
+ const link = grid.vertical.get(key(lo));
298
+ if (link && link.toZ === hi.z && link.kind === 'climb') {
299
+ steps = Math.max(steps ?? 0, hi.z - lo.z);
300
+ }
301
+ }
302
+ return steps;
303
+ }
304
+ // ========================== Synthesis ===================================
305
+ /** Per-grid metadata (which (x,y) cells exist at z>=1) that findPath's
306
+ * cellExists needs but doesn't belong on the public IRoomGrid shape --
307
+ * associated by identity so callers never juggle it themselves. */
308
+ const LEVEL_CELLS = new WeakMap();
309
+ const WALL_REGIONS = {
310
+ north: (d) => ({ x0: 0, x1: d.x - 1, y0: 0, y1: 0 }),
311
+ south: (d) => ({ x0: 0, x1: d.x - 1, y0: d.y - 1, y1: d.y - 1 }),
312
+ west: (d) => ({ x0: 0, x1: 0, y0: 0, y1: d.y - 1 }),
313
+ east: (d) => ({ x0: d.x - 1, x1: d.x - 1, y0: 0, y1: d.y - 1 }),
314
+ 'northwest corner': (d) => ({ x0: 0, x1: Math.min(1, d.x - 1), y0: 0, y1: Math.min(1, d.y - 1) }),
315
+ 'northeast corner': (d) => ({ x0: Math.max(0, d.x - 2), x1: d.x - 1, y0: 0, y1: Math.min(1, d.y - 1) }),
316
+ 'southwest corner': (d) => ({ x0: 0, x1: Math.min(1, d.x - 1), y0: Math.max(0, d.y - 2), y1: d.y - 1 }),
317
+ 'southeast corner': (d) => ({ x0: Math.max(0, d.x - 2), x1: d.x - 1, y0: Math.max(0, d.y - 2), y1: d.y - 1 }),
318
+ center: (d) => ({
319
+ x0: Math.max(0, Math.floor(d.x / 2) - 1), x1: Math.min(d.x - 1, Math.ceil(d.x / 2)),
320
+ y0: Math.max(0, Math.floor(d.y / 2) - 1), y1: Math.min(d.y - 1, Math.ceil(d.y / 2)),
321
+ }),
322
+ };
323
+ const FOOTPRINT_CELLS = { small: 2, medium: 4, large: 6 };
324
+ /** Deterministic synthesis -- see the module doc for the contract.
325
+ * Order of operations below IS the contract; never reorder without
326
+ * bumping every consumer of the determinism guarantee. */
327
+ export function synthesizeGrid(input) {
328
+ const rng = mulberry32(hashFnv1a(input.roomName));
329
+ const base = SIZE_DIMS[input.size ?? 'medium'];
330
+ const levels = (input.levels ?? []).slice(0, 2);
331
+ const dims = { x: base.x, y: base.y, z: levels.length + 1 }; // z=0 ground + each declared level
332
+ const levelCells = new Map();
333
+ const vertical = new Map();
334
+ const occupied = new Set(); // z=0 cells already claimed by a feature
335
+ levels.forEach((lvl, i) => {
336
+ const z = i + 1;
337
+ const region = WALL_REGIONS[lvl.side](base);
338
+ const cells = new Set();
339
+ for (let x = region.x0; x <= region.x1; x++) {
340
+ for (let y = region.y0; y <= region.y1; y++)
341
+ cells.add(`${x},${y}`);
342
+ }
343
+ levelCells.set(z, cells);
344
+ const stripCells = [...cells].map(k => { const [x, y] = k.split(',').map(Number); return { x, y }; });
345
+ if (stripCells.length === 0)
346
+ return;
347
+ // Stairs/ladder at one end of the strip (canon: stairwells sit in
348
+ // corners), a riskier "climb" cell at the other -- both keyed at
349
+ // GROUND (z=0), so multi-level rooms never depend on one level's
350
+ // strip overlapping another's.
351
+ const sorted = [...stripCells].sort((a, b) => (a.x + a.y) - (b.x + b.y));
352
+ const first = sorted[0];
353
+ const last = sorted[sorted.length - 1];
354
+ const kind = lvl.kind === 'catwalk' ? 'ladder' : 'stairs';
355
+ vertical.set(`${first.x},${first.y},0`, { kind, toZ: z });
356
+ occupied.add(`${first.x},${first.y},0`);
357
+ if (last.x !== first.x || last.y !== first.y) {
358
+ vertical.set(`${last.x},${last.y},0`, { kind: 'climb', toZ: z });
359
+ occupied.add(`${last.x},${last.y},0`);
360
+ }
361
+ });
362
+ const grid = {
363
+ dims, vertical, spotCells: new Map(), spotFootprints: new Map(),
364
+ exitCells: new Map(), terrain: new Map(),
365
+ };
366
+ LEVEL_CELLS.set(grid, levelCells);
367
+ const exists = (c) => cellExists(grid, c, levelCells);
368
+ const freeCellNear = (region, z) => {
369
+ const candidates = [];
370
+ for (let x = region.x0; x <= region.x1; x++) {
371
+ for (let y = region.y0; y <= region.y1; y++) {
372
+ const c = { x, y, z };
373
+ if (exists(c) && !occupied.has(key(c)))
374
+ candidates.push(c);
375
+ }
376
+ }
377
+ if (candidates.length === 0)
378
+ return undefined;
379
+ return candidates[Math.floor(rng() * candidates.length)];
380
+ };
381
+ const claim = (c) => occupied.add(key(c));
382
+ // --- Exit cells: wall midpoint, jittered, collision-scanned. ---
383
+ const WALL_MID = {
384
+ north: () => ({ x: clamp(Math.floor(dims.x / 2) + jitter(rng), 0, dims.x - 1), y: 0, z: 0 }),
385
+ south: () => ({ x: clamp(Math.floor(dims.x / 2) + jitter(rng), 0, dims.x - 1), y: dims.y - 1, z: 0 }),
386
+ west: () => ({ x: 0, y: clamp(Math.floor(dims.y / 2) + jitter(rng), 0, dims.y - 1), z: 0 }),
387
+ east: () => ({ x: dims.x - 1, y: clamp(Math.floor(dims.y / 2) + jitter(rng), 0, dims.y - 1), z: 0 }),
388
+ };
389
+ const cardinalWall = { north: 'north', south: 'south', east: 'east', west: 'west' };
390
+ /** Exit cells each named spot serves -- seated after the loop below. */
391
+ const exitCellsBySpot = new Map();
392
+ for (const exit of input.exits) {
393
+ const wallSide = cardinalWall[exit.direction];
394
+ let cell;
395
+ if (wallSide) {
396
+ cell = WALL_MID[wallSide]();
397
+ cell = nearestFree(cell, occupied, dims, exists);
398
+ }
399
+ else {
400
+ // up/down (and anything exotic): no natural wall -- an open
401
+ // z=0 cell near the centroid, same free-cell picker as unplaced
402
+ // spots below.
403
+ const region = WALL_REGIONS.center(dims);
404
+ cell = freeCellNear(region, 0) ?? { x: 0, y: 0, z: 0 };
405
+ }
406
+ claim(cell);
407
+ grid.exitCells.set(exit.direction, cell);
408
+ if (exit.spotName) {
409
+ const served = exitCellsBySpot.get(exit.spotName) ?? [];
410
+ served.push(cell);
411
+ exitCellsBySpot.set(exit.spotName, served);
412
+ }
413
+ }
414
+ // KEEP THE DOORWAYS CLEAR. A doorway has exactly ONE cell it is
415
+ // reached from -- directly opposite it, one step in from its wall --
416
+ // so a fixture landing there does not decorate the room, it seals the
417
+ // exit (playtest 2026-08-25: "the counter is in the way of the door").
418
+ // Claiming the approach here puts it out of reach of every later
419
+ // placement pass: spot anchors, the off-wall nudge, and furniture
420
+ // growth all skip occupied cells.
421
+ const APPROACH_IN = {
422
+ [Direction.NORTH]: [0, 1], [Direction.SOUTH]: [0, -1],
423
+ [Direction.WEST]: [1, 0], [Direction.EAST]: [-1, 0],
424
+ };
425
+ for (const [direction, cell] of grid.exitCells.entries()) {
426
+ const step = APPROACH_IN[direction];
427
+ if (!step)
428
+ continue;
429
+ const approach = { x: cell.x + step[0], y: cell.y + step[1], z: cell.z };
430
+ if (exists(approach))
431
+ claim(approach);
432
+ }
433
+ // Seat each exit-serving spot, now that we know how many exits it
434
+ // serves. ONE exit: the spot IS that doorway, so it takes the cell
435
+ // (Mama Wu's "door" -> its east wall). SEVERAL: the spot is the hub
436
+ // they all open off -- Night Market's seed puts north, east AND south
437
+ // at the "stalls", and its prose says so ("the real shops open off
438
+ // the stalls") -- so it belongs in the MIDDLE of them, not welded to
439
+ // whichever exit the seed happened to list first (playtest
440
+ // 2026-08-25: "the stalls are to the north, but east and south
441
+ // directions also come off the stalls"). The doorways stay on their
442
+ // own walls; the hub sits where all of them are plausibly reachable.
443
+ for (const [spotName, served] of exitCellsBySpot.entries()) {
444
+ if (served.length === 1) {
445
+ grid.spotCells.set(spotName, served[0]);
446
+ continue;
447
+ }
448
+ const mid = {
449
+ x: clamp(Math.round(served.reduce((s, c) => s + c.x, 0) / served.length), 0, dims.x - 1),
450
+ y: clamp(Math.round(served.reduce((s, c) => s + c.y, 0) / served.length), 0, dims.y - 1),
451
+ z: 0,
452
+ };
453
+ const hub = nearestFree(mid, occupied, dims, exists);
454
+ claim(hub);
455
+ grid.spotCells.set(spotName, hub);
456
+ }
457
+ // --- Placed spots (place hint). ---
458
+ const unplaced = [];
459
+ for (const spot of input.spots) {
460
+ if (grid.spotCells.has(spot.name))
461
+ continue; // already an exit cell
462
+ if (!spot.place) {
463
+ unplaced.push(spot);
464
+ continue;
465
+ }
466
+ const z = clamp(spot.level ?? 0, 0, dims.z);
467
+ const region = WALL_REGIONS[spot.place](base);
468
+ const cell = freeCellNear(region, z) ?? freeCellNear(WALL_REGIONS.center(base), 0);
469
+ if (!cell) {
470
+ unplaced.push(spot);
471
+ continue;
472
+ }
473
+ claim(cell);
474
+ grid.spotCells.set(spot.name, cell);
475
+ }
476
+ // --- Unplaced spots: spread out (max-min distance, rejection sampled). ---
477
+ for (const spot of unplaced) {
478
+ let best;
479
+ let bestScore = -1;
480
+ for (let attempt = 0; attempt < 8; attempt++) {
481
+ const x = Math.floor(rng() * dims.x);
482
+ const y = Math.floor(rng() * dims.y);
483
+ const c = { x, y, z: 0 };
484
+ if (occupied.has(key(c)))
485
+ continue;
486
+ const placed = [...grid.spotCells.values()];
487
+ const minDist = placed.length === 0 ? Infinity : Math.min(...placed.map(p => chebyshev(p, c)));
488
+ if (minDist > bestScore) {
489
+ bestScore = minDist;
490
+ best = c;
491
+ }
492
+ }
493
+ if (!best)
494
+ best = fallbackFreeCell(dims, occupied);
495
+ if (best) {
496
+ claim(best);
497
+ grid.spotCells.set(spot.name, best);
498
+ }
499
+ }
500
+ // --- OPEN_FLOOR: nearest open z=0 cell to the centroid. ---
501
+ {
502
+ const cx = (dims.x - 1) / 2, cy = (dims.y - 1) / 2;
503
+ let best;
504
+ let bestDist = Infinity;
505
+ for (let x = 0; x < dims.x; x++) {
506
+ for (let y = 0; y < dims.y; y++) {
507
+ const c = { x, y, z: 0 };
508
+ const d = Math.hypot(x - cx, y - cy) + (occupied.has(key(c)) ? 0.01 : 0);
509
+ if (d < bestDist) {
510
+ bestDist = d;
511
+ best = c;
512
+ }
513
+ }
514
+ }
515
+ if (best)
516
+ grid.spotCells.set(OPEN_FLOOR_NAME, best);
517
+ }
518
+ // --- Furniture footprints: give each named place its real extent. ---
519
+ // Exit-bound spots stay ONE cell: that spot IS a doorway, and a
520
+ // doorway three cells wide would wall off its own threshold. The open
521
+ // floor is bare ground, never furniture.
522
+ const exitKeys = new Set([...grid.exitCells.values()].map(key));
523
+ for (const [name, anchor] of grid.spotCells.entries()) {
524
+ if (name === OPEN_FLOOR_NAME || exitKeys.has(key(anchor))) {
525
+ grid.spotFootprints.set(name, [anchor]);
526
+ continue;
527
+ }
528
+ const footprint = growFurniture(name, anchor, dims, occupied, exists, rng);
529
+ grid.spotFootprints.set(name, footprint);
530
+ // A served fixture may have been nudged off its wall -- the anchor
531
+ // IS the footprint's first cell, so keep the two in step.
532
+ grid.spotCells.set(name, footprint[0]);
533
+ }
534
+ const furnitureKeys = new Set([...grid.spotFootprints.entries()]
535
+ .filter(([n]) => n !== OPEN_FLOOR_NAME)
536
+ .flatMap(([, cells]) => cells.map(key)));
537
+ // --- Terrain regions: seal the guarded cell from the primary entry. ---
538
+ const primaryEntry = grid.exitCells.get(input.exits[0]?.direction) ?? grid.spotCells.get(OPEN_FLOOR_NAME);
539
+ for (const hint of input.terrainHints) {
540
+ const guarded = hint.guards.kind === 'exit'
541
+ ? grid.exitCells.get(hint.guards.direction)
542
+ : grid.spotCells.get(hint.guards.spotName);
543
+ if (!guarded || !primaryEntry)
544
+ continue;
545
+ const cover = hint.footprint === 'large' ? 'good' : 'partial';
546
+ let blocked = [];
547
+ let sealed = false;
548
+ // Rubble PREFERS to pile against the furniture rather than on top of
549
+ // it -- burying the bar would delete a place the room's prose names
550
+ // and the player can still "move to". But sealing the way is the
551
+ // contract and tidiness is not: if the clean pour can't close the
552
+ // ring (a fixture standing right where the rubble needed to go),
553
+ // the second pass is allowed to swallow furniture.
554
+ const openGround = (c) => exists(c) && !furnitureKeys.has(key(c));
555
+ for (const ground of [openGround, exists]) {
556
+ let cap = FOOTPRINT_CELLS[hint.footprint];
557
+ for (let round = 0; round < 3 && !sealed; round++) {
558
+ blocked = growBlob(grid, guarded, primaryEntry, cap, ground);
559
+ const region = {
560
+ id: `terrain:${hint.puzzleName}`, label: hint.label, puzzleName: hint.puzzleName,
561
+ cells: blocked, cleared: false, cover, blocking: true,
562
+ };
563
+ grid.terrain.set(region.id, region);
564
+ sealed = findPath(grid, primaryEntry, guarded) === undefined;
565
+ if (!sealed)
566
+ cap += 2;
567
+ if (!sealed)
568
+ grid.terrain.delete(region.id);
569
+ }
570
+ if (sealed)
571
+ break;
572
+ }
573
+ const finalRegion = {
574
+ id: `terrain:${hint.puzzleName}`, label: hint.label, puzzleName: hint.puzzleName,
575
+ cells: blocked, cleared: false, cover, blocking: sealed,
576
+ };
577
+ grid.terrain.set(finalRegion.id, finalRegion);
578
+ if (!sealed) {
579
+ console.error(`[room-grid] "${input.roomName}": terrain "${hint.puzzleName}" could not seal the guarded cell -- degraded to cover-only scenery.`);
580
+ }
581
+ }
582
+ return grid;
583
+ }
584
+ // Literal-coupled to spots.ts's OPEN_FLOOR (also 'open floor') --
585
+ // deliberately not imported (this module stays spots.ts/Room-free, see
586
+ // the module doc); Room.ensureGrid queries this cell by that same
587
+ // string. A rename on either side must update both.
588
+ export const OPEN_FLOOR_NAME = 'open floor';
589
+ function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); }
590
+ function jitter(rng) { return Math.floor(rng() * 3) - 1; }
591
+ function chebyshev(a, b) { return Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y)); }
592
+ function nearestFree(start, occupied, dims, exists) {
593
+ if (exists(start) && !occupied.has(key(start)))
594
+ return start;
595
+ for (let r = 1; r < Math.max(dims.x, dims.y); r++) {
596
+ for (let dx = -r; dx <= r; dx++) {
597
+ for (let dy = -r; dy <= r; dy++) {
598
+ const c = { x: start.x + dx, y: start.y + dy, z: start.z };
599
+ if (exists(c) && !occupied.has(key(c)))
600
+ return c;
601
+ }
602
+ }
603
+ }
604
+ return start;
605
+ }
606
+ function fallbackFreeCell(dims, occupied) {
607
+ for (let x = 0; x < dims.x; x++) {
608
+ for (let y = 0; y < dims.y; y++) {
609
+ const c = { x, y, z: 0 };
610
+ if (!occupied.has(key(c)))
611
+ return c;
612
+ }
613
+ }
614
+ return undefined;
615
+ }
616
+ /** How many cells a fixture fills, by what it IS. A bar runs the length
617
+ * of its wall; a crate does not. Keyed on the words seeds actually
618
+ * use; anything unrecognised stays a single cell, so an unknown spot
619
+ * can never accidentally wall off a room. */
620
+ const LONG_FIXTURES = /\b(bar|counter|rack|racks|shelv\w*|stall|stalls|booth|booths|bench|benches|workbench|pew|pews|row|rows|conveyor|catwalk)\b/i;
621
+ const BULKY_FIXTURES = /\b(table|desk|console|terminal|crate|crates|cabinet|locker|lockers|bed|cot|pool|altar|forge|vat|tank)\b/i;
622
+ /** Fixtures somebody stands BEHIND to work: they need a service lane,
623
+ * so they never sit flush against a wall. See offWall/growFurniture. */
624
+ const SERVED_FIXTURES = /\b(bar|counter|stall|stalls|desk|workbench|forge|altar)\b/i;
625
+ /** Pulls a cell one square in from whichever wall it sits on, if that
626
+ * square is free -- leaving a walkable lane on the far side. */
627
+ function offWall(c, dims, occupied, exists) {
628
+ const inward = c.x === 0 ? [1, 0]
629
+ : c.x === dims.x - 1 ? [-1, 0]
630
+ : c.y === 0 ? [0, 1]
631
+ : c.y === dims.y - 1 ? [0, -1]
632
+ : undefined;
633
+ if (!inward)
634
+ return c;
635
+ const moved = { x: c.x + inward[0], y: c.y + inward[1], z: c.z };
636
+ if (!exists(moved) || occupied.has(key(moved)))
637
+ return c;
638
+ occupied.delete(key(c));
639
+ occupied.add(key(moved));
640
+ return moved;
641
+ }
642
+ function furnitureExtent(name, dims) {
643
+ const room = Math.min(dims.x, dims.y);
644
+ const want = LONG_FIXTURES.test(name) ? 3 : BULKY_FIXTURES.test(name) ? 2 : 1;
645
+ // Never let furniture dominate a cramped room: a tiny room (3x2) has
646
+ // no business holding a three-cell bar.
647
+ return Math.max(1, Math.min(want, room - 1));
648
+ }
649
+ /**
650
+ * Grows a fixture from its anchor into a straight run of free cells
651
+ * (player ruling 2026-08-25: "bar, table, booths can have larger
652
+ * geometry than just one cell"). Straight, not blobby: real fixtures
653
+ * are lines against walls and slabs in the open, and a ragged blob
654
+ * would read as terrain rather than furniture. Picks the cardinal
655
+ * direction with the most free room -- which is what puts a bar ALONG
656
+ * its wall rather than jutting into the floor -- with fixed-order
657
+ * tie-breaks and one rng draw so the choice stays deterministic.
658
+ */
659
+ function growFurniture(name, anchorIn, dims, occupied, exists, rng) {
660
+ const want = furnitureExtent(name, dims);
661
+ // A SERVED fixture needs two sides: one for whoever works it, one for
662
+ // whoever it serves (playtest 2026-08-25: "when furniture is placed
663
+ // in a room, the bartender should be on one side of it"). Flush
664
+ // against a wall there is only ever one side, and staff and customers
665
+ // pile onto it together -- so nudge it one cell off the wall and
666
+ // leave the service lane behind it.
667
+ const anchor = SERVED_FIXTURES.test(name) ? offWall(anchorIn, dims, occupied, exists) : anchorIn;
668
+ const cells = [anchor];
669
+ if (want <= 1)
670
+ return cells;
671
+ const DIRS = [[1, 0], [0, 1], [-1, 0], [0, -1]];
672
+ const runLength = (dx, dy) => {
673
+ let n = 0;
674
+ for (let i = 1; i < want; i++) {
675
+ const c = { x: anchor.x + dx * i, y: anchor.y + dy * i, z: anchor.z };
676
+ if (!exists(c) || occupied.has(key(c)))
677
+ break;
678
+ n++;
679
+ }
680
+ return n;
681
+ };
682
+ const scored = DIRS.map(([dx, dy]) => ({ dx, dy, run: runLength(dx, dy) }))
683
+ .sort((a, b) => b.run - a.run);
684
+ const best = scored[0];
685
+ if (!best || best.run === 0)
686
+ return cells;
687
+ // Break an exact tie with the room's own seed, so two identical
688
+ // fixtures don't always lie the same way.
689
+ const tied = scored.filter(s => s.run === best.run);
690
+ const pick = tied[Math.floor(rng() * tied.length)] ?? best;
691
+ for (let i = 1; i <= pick.run; i++) {
692
+ const c = { x: anchor.x + pick.dx * i, y: anchor.y + pick.dy * i, z: anchor.z };
693
+ occupied.add(key(c));
694
+ cells.push(c);
695
+ }
696
+ return cells;
697
+ }
698
+ const RING_OFFSETS = [
699
+ [-1, -1], [0, -1], [1, -1], [-1, 0], [1, 0], [-1, 1], [0, 1], [1, 1],
700
+ ];
701
+ /** Grows a connected blob of up to `cap` open cells around `guarded`,
702
+ * RING BY RING (the whole immediate 8-neighborhood before any 2nd-
703
+ * ring cell) -- sealing a choke point means fully surrounding it, and
704
+ * letting a farther-but-entry-closer cell jump the queue (a distance-
705
+ * only priority queue would do exactly that) leaves a diagonal gap a
706
+ * player just steps through. `entry`-distance only breaks ties WITHIN
707
+ * a ring, for determinism. */
708
+ function growBlob(grid, guarded, entry, cap, exists) {
709
+ const seen = new Set([key(guarded)]);
710
+ const blob = [];
711
+ let ring = RING_OFFSETS
712
+ .map(([dx, dy]) => ({ x: guarded.x + dx, y: guarded.y + dy, z: guarded.z }))
713
+ .filter(c => exists(c) && !seen.has(key(c)));
714
+ while (ring.length > 0 && blob.length < cap) {
715
+ ring.sort((a, b) => distanceMeters(a, entry) - distanceMeters(b, entry));
716
+ const nextRingSeed = [];
717
+ for (const c of ring) {
718
+ const k = key(c);
719
+ if (seen.has(k))
720
+ continue;
721
+ seen.add(k);
722
+ if (blob.length < cap)
723
+ blob.push(c);
724
+ nextRingSeed.push(c);
725
+ }
726
+ const nextSeen = new Set();
727
+ const next = [];
728
+ for (const c of nextRingSeed) {
729
+ for (const [dx, dy] of RING_OFFSETS) {
730
+ const n = { x: c.x + dx, y: c.y + dy, z: c.z };
731
+ const k = key(n);
732
+ if (exists(n) && !seen.has(k) && !nextSeen.has(k)) {
733
+ nextSeen.add(k);
734
+ next.push(n);
735
+ }
736
+ }
737
+ }
738
+ ring = next;
739
+ }
740
+ return blob;
741
+ }
742
+ //# sourceMappingURL=room-grid.js.map