@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.
- package/bundle/typescript/package.json +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +20 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.js +9 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/call.js +9 -8
- package/bundle/typescript/src/commands/game/sideQuest/commands/call.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/contacts.d.ts +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/contacts.js +27 -6
- package/bundle/typescript/src/commands/game/sideQuest/commands/contacts.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/give.js +2 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/give.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +43 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/go.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +6 -24
- package/bundle/typescript/src/commands/game/sideQuest/commands/look.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/loot.d.ts +21 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/loot.js +93 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/loot.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/map.d.ts +4 -4
- package/bundle/typescript/src/commands/game/sideQuest/commands/map.js +23 -31
- package/bundle/typescript/src/commands/game/sideQuest/commands/map.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/move.d.ts +32 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/move.js +313 -17
- package/bundle/typescript/src/commands/game/sideQuest/commands/move.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/note.d.ts +25 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/note.js +57 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/note.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +20 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/search.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/solve.js +9 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/solve.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/take.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/talk.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/talk.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/room-factory.js +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/factories/room-factory.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.d.ts +13 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +66 -9
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js +37 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.d.ts +2 -2
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js +2 -2
- package/bundle/typescript/src/commands/game/sideQuest/game.d.ts +66 -19
- package/bundle/typescript/src/commands/game/sideQuest/game.js +264 -91
- package/bundle/typescript/src/commands/game/sideQuest/game.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/headless.js +17 -2
- package/bundle/typescript/src/commands/game/sideQuest/headless.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.d.ts +9 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +84 -3
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/player.d.ts +31 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/player.js +28 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/player.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.d.ts +5 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.js +24 -6
- package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/room.d.ts +85 -2
- package/bundle/typescript/src/commands/game/sideQuest/models/room.js +182 -2
- package/bundle/typescript/src/commands/game/sideQuest/models/room.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.d.ts +4 -4
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +17 -7
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/scenes/scene2.json +17 -13
- package/bundle/typescript/src/commands/game/sideQuest/types/save-file.d.ts +29 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/seed/puzzle-seed.d.ts +2 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/seed/room-seed.d.ts +5 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.d.ts +9 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +87 -8
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +28 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.d.ts +7 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +9 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/range.d.ts +22 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/range.js +69 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/range.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.d.ts +186 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.js +742 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/room-view.d.ts +34 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/room-view.js +536 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/room-view.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.d.ts +273 -3
- package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js +890 -24
- package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js.map +1 -1
- package/bundle/typescript/src/commands/set/global.sub.cmd.js +18 -1
- package/bundle/typescript/src/commands/set/global.sub.cmd.js.map +1 -1
- package/bundle/typescript/src/tools/ai/ai.class.d.ts +1 -1
- package/bundle/typescript/src/tools/ai/ai.class.js +39 -7
- package/bundle/typescript/src/tools/ai/ai.class.js.map +1 -1
- package/bundle/typescript/src/tools/fsi/fsi.interfaces.d.ts +1 -0
- package/bundle/typescript/src/tools/fsi/fsi.interfaces.js.map +1 -1
- package/bundle/typescript/src/tools/https/https.class.d.ts +19 -0
- package/bundle/typescript/src/tools/https/https.class.js +36 -0
- package/bundle/typescript/src/tools/https/https.class.js.map +1 -1
- 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
|