@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
@@ -1,5 +1,7 @@
1
+ import { Direction } from '../types/shared/direction-enum.js';
1
2
  import { significantWords, wordsAreCloseEnough } from './fuzzy-match.js';
2
3
  import { hint } from './hints.js';
4
+ import { findPath, distanceMeters, coverBetween, blockingRegionFor, pathClimbSteps, levelFootprint, key, isCellBlocked, pathSqueezes, } from './room-grid.js';
3
5
  /**
4
6
  * Intra-room locations ("at" spots -- player request: every room felt
5
7
  * like everything stood at your elbow; walking into the bar had both
@@ -24,10 +26,6 @@ import { hint } from './hints.js';
24
26
  * Every spot comparison in the game goes through these helpers -- no
25
27
  * call site rolls its own.
26
28
  */
27
- /** Movement distance between spots, scaled by the room's size. */
28
- export const SIZE_COST = {
29
- small: 1, medium: 2, large: 4,
30
- };
31
29
  export function spotsActive(room) {
32
30
  return (room?.spots?.length ?? 0) > 0;
33
31
  }
@@ -69,10 +67,17 @@ export function resolveSpot(room, name) {
69
67
  */
70
68
  export function spotOf(actor) {
71
69
  const room = actor.currentLocation;
72
- if (!spotsActive(room) || actor.plane !== 'meat' || !actor.atSpot)
70
+ if (actor.plane !== 'meat' || !actor.atSpot)
73
71
  return undefined;
72
+ // The OPEN FLOOR is bare ground, and bare ground exists in every room
73
+ // -- including one with no named places at all. Checked BEFORE
74
+ // spotsActive so a street can position its people (every room is
75
+ // matrixed now; see Room.ensureGrid). Named spots still have to be
76
+ // real, which is what keeps a stale name from reading as a position.
74
77
  if (actor.atSpot === OPEN_FLOOR)
75
78
  return OPEN_FLOOR;
79
+ if (!spotsActive(room))
80
+ return undefined;
76
81
  return room.spots.some(s => s.name === actor.atSpot) ? actor.atSpot : undefined;
77
82
  }
78
83
  /** Whether two actors share a spot (callers already gate same-room). */
@@ -98,8 +103,627 @@ export function canReach(actor, targetSpot) {
98
103
  return true;
99
104
  return mine === targetSpot;
100
105
  }
101
- export function moveCost(room) {
102
- return SIZE_COST[room.size ?? 'medium'];
106
+ /**
107
+ * ROOM GRIDS (spatial wave 2026-08-25): the spot-name-level wrappers
108
+ * around utilities/room-grid.ts's coordinate primitives -- everything
109
+ * ABOVE this line still only ever touches spot NAMES; nothing but
110
+ * these three functions (and Room.ensureGrid itself) ever sees a
111
+ * GridCoord. All three return undefined/0 when the room has no grid
112
+ * (THE COMPAT RULE: a spotless room never synthesizes one) or the
113
+ * named spot doesn't exist in it.
114
+ */
115
+ export function spotCell(room, spotName) {
116
+ return room.ensureGrid()?.spotCells.get(spotName);
117
+ }
118
+ /**
119
+ * SEATING (player ruling 2026-08-25: "you can only be adjacent, and
120
+ * cannot move through, only around"). A spot's cell is the FURNITURE --
121
+ * the bar, the crates, the console. Nobody stands inside it, and no two
122
+ * bodies share a cell: each actor is seated on the nearest free ground
123
+ * to their spot, deterministically, so the map shows people ringed
124
+ * around a place instead of stacked on top of it.
125
+ *
126
+ * THE KEYSTONE IS INTACT. Nothing here is stored: `Player.atSpot` is
127
+ * still the only position an actor owns, and "at the bar" is still one
128
+ * interaction zone -- two people seated in different cells around the
129
+ * bar remain `sameSpot`, so talking, trading, and melee are untouched.
130
+ * This layer only answers "given who is where by NAME, where do their
131
+ * bodies physically sit", and it is recomputed on demand.
132
+ *
133
+ * Determinism: actors are seated in name order, so the same room state
134
+ * always produces the same seating on every machine and after any
135
+ * reload -- the same contract the grid itself keeps.
136
+ *
137
+ * THE OPEN FLOOR is not furniture (it is bare ground that moves, see
138
+ * reseatOpenFloor), so its cell seats a body like any other.
139
+ */
140
+ /** Whether this exit is shut -- a Door that reports itself locked.
141
+ * Duck-typed rather than `instanceof Door`: spots.ts is imported BY
142
+ * models/room.ts, and a value import back into models would close a
143
+ * runtime cycle. */
144
+ function exitIsLocked(room, direction) {
145
+ const exit = room.exits.get(direction);
146
+ return typeof exit === 'object' && typeof exit?.checkIfLocked === 'function' && exit.checkIfLocked();
147
+ }
148
+ /** The cells of OPEN doorways -- thresholds, which a body may stand in
149
+ * (player ruling 2026-08-25: "a player/npc can exist in the same
150
+ * location as an unlocked door... this is essentially the threshold").
151
+ * A LOCKED door is solid and stays furniture. */
152
+ function openThresholds(room) {
153
+ const out = new Set();
154
+ const grid = room.ensureGrid();
155
+ if (!grid)
156
+ return out;
157
+ for (const [dir, c] of grid.exitCells.entries()) {
158
+ if (!exitIsLocked(room, dir))
159
+ out.add(key(c));
160
+ }
161
+ return out;
162
+ }
163
+ /** Every cell physically filled by furniture -- the full footprint of
164
+ * each named place (a bar runs several cells), minus the open floor
165
+ * (bare ground) and minus any OPEN doorway (a threshold you stand in,
166
+ * see openThresholds). Nobody stands here and movement goes around. */
167
+ export function furnitureCells(room) {
168
+ const out = new Set();
169
+ const grid = room.ensureGrid();
170
+ if (!grid)
171
+ return out;
172
+ const thresholds = openThresholds(room);
173
+ for (const [name, cells] of grid.spotFootprints.entries()) {
174
+ if (name === OPEN_FLOOR)
175
+ continue;
176
+ for (const c of cells) {
177
+ if (!thresholds.has(key(c)))
178
+ out.add(key(c));
179
+ }
180
+ }
181
+ return out;
182
+ }
183
+ /** The ONE cell a doorway is reached from: directly opposite the door,
184
+ * one step in from its wall (player ruling 2026-08-25 -- "a south door
185
+ * requires you can move through the cell one unit north of it"). This
186
+ * is what makes a doorway a chokepoint worth holding. Undefined for
187
+ * up/down exits, which have no wall to stand off, and for an approach
188
+ * that would fall outside the room. */
189
+ export function approachCellFor(room, direction) {
190
+ const grid = room.ensureGrid();
191
+ const door = grid?.exitCells.get(direction);
192
+ if (!grid || !door)
193
+ return undefined;
194
+ const inward = {
195
+ [Direction.NORTH]: [0, 1], [Direction.SOUTH]: [0, -1],
196
+ [Direction.WEST]: [1, 0], [Direction.EAST]: [-1, 0],
197
+ };
198
+ const step = inward[direction];
199
+ if (!step)
200
+ return undefined;
201
+ const c = { x: door.x + step[0], y: door.y + step[1], z: door.z };
202
+ if (c.x < 0 || c.y < 0 || c.x >= grid.dims.x || c.y >= grid.dims.y)
203
+ return undefined;
204
+ return c;
205
+ }
206
+ /**
207
+ * Where a body standing "at" this spot wants to be (player ruling
208
+ * 2026-08-25): an UNLOCKED door you stand IN -- the threshold is the
209
+ * place -- while a LOCKED one you stand BEFORE, on its single approach
210
+ * cell, which is exactly where you'd have to be to pick or force it.
211
+ * Every other spot seats on its own anchor. Callers still fall through
212
+ * nearestSeat, so a taken preference just rings outward from here.
213
+ */
214
+ function seatAnchorFor(room, spotName) {
215
+ const grid = room.ensureGrid();
216
+ const anchor = grid?.spotCells.get(spotName);
217
+ if (!grid || !anchor)
218
+ return undefined;
219
+ for (const [dir, c] of grid.exitCells.entries()) {
220
+ if (key(c) !== key(anchor))
221
+ continue;
222
+ return exitIsLocked(room, dir) ? (approachCellFor(room, dir) ?? anchor) : anchor;
223
+ }
224
+ return anchor;
225
+ }
226
+ /**
227
+ * Who is standing in the way of this doorway -- in the threshold itself
228
+ * or in its single approach cell. A body in either one holds the door
229
+ * (the 2026-08-25 ruling): squeezing past is not on offer here the way
230
+ * it is in open floor, because there is no second route to a doorway.
231
+ *
232
+ * `mover` never blocks themselves, and neither do their OWN companions:
233
+ * a spirit that follows you at your heel gets seated beside you, which
234
+ * would routinely park it on your approach cell and wall you into the
235
+ * room you just walked into.
236
+ */
237
+ /**
238
+ * A FIXTURE sitting in a doorway or on its single approach cell -- the
239
+ * non-living half of doorwayBlocker (playtest 2026-08-25: "I shouldn't
240
+ * be able to 'go' a direction if something is in the way of it").
241
+ * Synthesis reserves those cells so this should never fire on a
242
+ * generated room; it stays as the guard for hand-authored seeds, which
243
+ * can declare a spot anywhere they like.
244
+ */
245
+ export function doorwayFurniture(room, direction) {
246
+ const grid = room.ensureGrid();
247
+ const door = grid?.exitCells.get(direction);
248
+ if (!grid || !door)
249
+ return undefined;
250
+ const approach = approachCellFor(room, direction);
251
+ const guarded = [key(door), ...(approach ? [key(approach)] : [])];
252
+ for (const [name, cells] of grid.spotFootprints.entries()) {
253
+ if (name === OPEN_FLOOR)
254
+ continue;
255
+ for (const c of cells) {
256
+ // A spot sitting ON an open threshold is the doorway itself
257
+ // (Mama Wu's "door"), not an obstruction.
258
+ if (key(c) === key(door))
259
+ continue;
260
+ if (guarded.includes(key(c)))
261
+ return name;
262
+ }
263
+ }
264
+ return undefined;
265
+ }
266
+ export function doorwayBlocker(room, direction, mover) {
267
+ const grid = room.ensureGrid();
268
+ if (!grid)
269
+ return undefined;
270
+ const door = grid.exitCells.get(direction);
271
+ if (!door)
272
+ return undefined;
273
+ const approach = approachCellFor(room, direction);
274
+ const guarded = new Set([key(door), ...(approach ? [key(approach)] : [])]);
275
+ const seats = seatingIn(room);
276
+ for (const a of room.getActors()) {
277
+ const other = a;
278
+ if (other === mover)
279
+ continue;
280
+ if (other.allyOf === mover.name)
281
+ continue;
282
+ const seat = seats.get(other.name);
283
+ if (seat && guarded.has(key(seat)))
284
+ return other;
285
+ }
286
+ return undefined;
287
+ }
288
+ export function seatingIn(room) {
289
+ const seats = new Map();
290
+ const grid = room.ensureGrid();
291
+ if (!grid)
292
+ return seats;
293
+ const standable = new Set();
294
+ for (let z = 0; z < grid.dims.z; z++) {
295
+ for (const c of levelFootprint(grid, z)) {
296
+ if (!isCellBlocked(grid, c))
297
+ standable.add(key(c));
298
+ }
299
+ }
300
+ const furniture = furnitureCells(room);
301
+ const taken = new Set();
302
+ const seated = [...room.getActors()]
303
+ .filter(a => spotOf(a) !== undefined)
304
+ .sort((a, b) => a.name.localeCompare(b.name));
305
+ // Anyone who STEPPED somewhere exact keeps that cell (Player.atCell)
306
+ // and is seated first, so a derived seat can never be handed the
307
+ // square they are already standing in.
308
+ for (const a of seated) {
309
+ const exact = a.atCell;
310
+ if (!exact)
311
+ continue;
312
+ const k = key(exact);
313
+ if (!standable.has(k) || furniture.has(k) || taken.has(k))
314
+ continue;
315
+ taken.add(k);
316
+ seats.set(a.name, exact);
317
+ }
318
+ for (const a of seated) {
319
+ if (seats.has(a.name))
320
+ continue;
321
+ const spot = spotOf(a);
322
+ const anchor = seatAnchorFor(room, spot);
323
+ if (!anchor)
324
+ continue;
325
+ // Staff stand BEHIND their fixture, customers in front of it.
326
+ const bias = serviceBias(room, spot, a.homeSpot === spot);
327
+ const seat = nearestSeat(anchor, standable, furniture, taken, bias);
328
+ if (!seat)
329
+ continue;
330
+ taken.add(key(seat));
331
+ seats.set(a.name, seat);
332
+ }
333
+ return seats;
334
+ }
335
+ /**
336
+ * Which way to lean when seating someone at a fixture (player nitpick
337
+ * 2026-08-25: "when furniture is placed in a room, the bartender should
338
+ * be on one side of it"). The service side is the one facing the
339
+ * NEAREST WALL -- that is where a bar's staff stand, with the room in
340
+ * front of them -- so staff bias that way and everyone else the
341
+ * opposite way. Returns undefined for the open floor and for anything
342
+ * with no footprint to have sides.
343
+ */
344
+ function serviceBias(room, spotName, isStaff) {
345
+ if (spotName === OPEN_FLOOR)
346
+ return undefined;
347
+ const grid = room.ensureGrid();
348
+ const cells = grid?.spotFootprints.get(spotName);
349
+ if (!grid || !cells || cells.length === 0)
350
+ return undefined;
351
+ const cx = cells.reduce((s, c) => s + c.x, 0) / cells.length;
352
+ const cy = cells.reduce((s, c) => s + c.y, 0) / cells.length;
353
+ // Distance to each wall; the closest one is "behind" the fixture.
354
+ const gaps = [
355
+ [cx, { x: -1, y: 0, z: 0 }],
356
+ [grid.dims.x - 1 - cx, { x: 1, y: 0, z: 0 }],
357
+ [cy, { x: 0, y: -1, z: 0 }],
358
+ [grid.dims.y - 1 - cy, { x: 0, y: 1, z: 0 }],
359
+ ];
360
+ gaps.sort((p, q) => p[0] - q[0]);
361
+ const behind = gaps[0][1];
362
+ return isStaff ? behind : { x: -behind.x, y: -behind.y, z: 0 };
363
+ }
364
+ /**
365
+ * One tactical step (player request 2026-08-25: "move south 2"). Walks
366
+ * up to `count` cells in a cardinal direction from wherever the actor's
367
+ * body actually is, stopping at the first square it cannot enter -- a
368
+ * wall, furniture, uncleared terrain, or somebody standing there. No
369
+ * squeezing: this is a deliberate step, so it stops rather than
370
+ * shouldering through.
371
+ *
372
+ * Returns the cell reached and how many steps were actually taken, plus
373
+ * what stopped it short. Nothing is mutated -- commands/move.ts commits
374
+ * the result.
375
+ */
376
+ export function stepFrom(room, mover, direction, count) {
377
+ const grid = room.ensureGrid();
378
+ if (!grid)
379
+ return { taken: 0 };
380
+ const start = actorCell(room, mover);
381
+ if (!start)
382
+ return { taken: 0 };
383
+ const delta = {
384
+ [Direction.NORTH]: [0, -1], [Direction.SOUTH]: [0, 1],
385
+ [Direction.WEST]: [-1, 0], [Direction.EAST]: [1, 0],
386
+ };
387
+ const step = delta[direction];
388
+ if (!step)
389
+ return { taken: 0 };
390
+ const standable = new Set();
391
+ for (const c of levelFootprint(grid, start.z)) {
392
+ if (!isCellBlocked(grid, c))
393
+ standable.add(key(c));
394
+ }
395
+ const furniture = furnitureCells(room);
396
+ const bodies = occupiedCells(room, mover);
397
+ let cur = start;
398
+ let taken = 0;
399
+ for (let i = 0; i < count; i++) {
400
+ const next = { x: cur.x + step[0], y: cur.y + step[1], z: cur.z };
401
+ const k = key(next);
402
+ if (!standable.has(k)) {
403
+ // Out of the footprint is a wall; inside it but unstandable is
404
+ // terrain (isCellBlocked is the only other thing that removes a
405
+ // cell from `standable`).
406
+ const inBounds = next.x >= 0 && next.y >= 0 && next.x < grid.dims.x && next.y < grid.dims.y;
407
+ return { cell: cur, taken, blockedBy: inBounds ? 'terrain' : 'wall' };
408
+ }
409
+ if (furniture.has(k))
410
+ return { cell: cur, taken, blockedBy: 'furniture' };
411
+ if (bodies.has(k))
412
+ return { cell: cur, taken, blockedBy: 'someone' };
413
+ cur = next;
414
+ taken++;
415
+ }
416
+ return { cell: cur, taken };
417
+ }
418
+ /**
419
+ * The named place a stepped-to cell counts as being "at" -- whichever
420
+ * fixture the cell touches (its own footprint or any neighbour of it),
421
+ * else the open floor. This is what keeps `atSpot` authoritative for
422
+ * every interaction while the body stands on an arbitrary square:
423
+ * step up beside the bar and you are AT the bar, step away and you are
424
+ * on open ground.
425
+ */
426
+ export function spotForCell(room, cell) {
427
+ const grid = room.ensureGrid();
428
+ if (!grid)
429
+ return OPEN_FLOOR;
430
+ let best;
431
+ let bestDist = Infinity;
432
+ for (const [name, cells] of grid.spotFootprints.entries()) {
433
+ if (name === OPEN_FLOOR)
434
+ continue;
435
+ for (const c of cells) {
436
+ if (c.z !== cell.z)
437
+ continue;
438
+ const d = Math.max(Math.abs(c.x - cell.x), Math.abs(c.y - cell.y));
439
+ if (d <= 1 && d < bestDist) {
440
+ bestDist = d;
441
+ best = name;
442
+ }
443
+ }
444
+ }
445
+ return best ?? OPEN_FLOOR;
446
+ }
447
+ /** Closest free ground to `anchor`, rings outward, deterministic within
448
+ * a ring. Returns undefined only if the room has no free ground at all
449
+ * -- callers treat that as "no body to draw", never as an error. */
450
+ function nearestSeat(anchor, standable, furniture, taken, bias) {
451
+ const free = (c) => {
452
+ const k = key(c);
453
+ return standable.has(k) && !furniture.has(k) && !taken.has(k);
454
+ };
455
+ if (free(anchor))
456
+ return anchor;
457
+ for (let r = 1; r <= 12; r++) {
458
+ const ring = [];
459
+ for (let dx = -r; dx <= r; dx++) {
460
+ for (let dy = -r; dy <= r; dy++) {
461
+ if (Math.max(Math.abs(dx), Math.abs(dy)) !== r)
462
+ continue;
463
+ ring.push({ x: anchor.x + dx, y: anchor.y + dy, z: anchor.z });
464
+ }
465
+ }
466
+ // Within a ring, prefer the side the caller asked for -- higher dot
467
+ // product with the bias vector wins. Ties keep ring order, so the
468
+ // result stays deterministic.
469
+ const ordered = bias
470
+ ? [...ring].sort((p, q) => ((q.x - anchor.x) * bias.x + (q.y - anchor.y) * bias.y)
471
+ - ((p.x - anchor.x) * bias.x + (p.y - anchor.y) * bias.y))
472
+ : ring;
473
+ const hit = ordered.find(free);
474
+ if (hit)
475
+ return hit;
476
+ }
477
+ return undefined;
478
+ }
479
+ /**
480
+ * A cell `mover` could stand in at or beside `target` -- the general
481
+ * form of seating, for walking to something that is not a named place.
482
+ * Used by "move to <body>": the fallen keep a cell but no spot, and you
483
+ * still need to be able to walk over and go through their pockets.
484
+ */
485
+ export function standingRoomNear(room, target, mover) {
486
+ const grid = room.ensureGrid();
487
+ if (!grid)
488
+ return undefined;
489
+ const standable = new Set();
490
+ for (let z = 0; z < grid.dims.z; z++) {
491
+ for (const c of levelFootprint(grid, z)) {
492
+ if (!isCellBlocked(grid, c))
493
+ standable.add(key(c));
494
+ }
495
+ }
496
+ const taken = new Set();
497
+ for (const [name, c] of seatingIn(room).entries()) {
498
+ if (name !== mover.name)
499
+ taken.add(key(c));
500
+ }
501
+ return nearestSeat(target, standable, furnitureCells(room), taken);
502
+ }
503
+ /** Where this actor's BODY sits (see seatingIn) -- undefined when they
504
+ * are circulating, off-meat, or the room has no grid. Pass a seating
505
+ * map when resolving several actors, so it's computed once. */
506
+ export function actorCell(room, actor, seats) {
507
+ if (spotOf(actor) === undefined)
508
+ return undefined;
509
+ return (seats ?? seatingIn(room)).get(actor.name);
510
+ }
511
+ /** Everything you have to walk AROUND rather than through: other
512
+ * people's bodies and the furniture itself. findPath treats these as a
513
+ * soft block (see its pass order) so they steer a route without ever
514
+ * making a destination unreachable. `mover` is always excluded -- you
515
+ * never block yourself. */
516
+ export function occupiedCells(room, mover) {
517
+ const out = furnitureCells(room);
518
+ // THE FALLEN DON'T BLOCK (player ruling 2026-08-25: "bodies don't
519
+ // block movement, they can just be walked over") -- neither corpses
520
+ // nor the knocked-out, who are lying in the same square for the same
521
+ // reason. Only people still on their feet are something to go around.
522
+ const down = new Set(room.getActors().filter(a => a.isIncapacitated()).map(a => a.name));
523
+ for (const [name, c] of seatingIn(room).entries()) {
524
+ if (mover && name === mover.name)
525
+ continue;
526
+ if (down.has(name))
527
+ continue;
528
+ out.add(key(c));
529
+ }
530
+ return out;
531
+ }
532
+ /** The cell `mover` would end up in on arriving at `spotName` -- their
533
+ * seat around that fixture, with everyone else's seats already taken.
534
+ * This, not the fixture's anchor, is where movement actually paths to:
535
+ * the anchor is furniture now, and nobody stands inside the bar. */
536
+ export function seatFor(room, spotName, mover) {
537
+ const grid = room.ensureGrid();
538
+ const anchor = seatAnchorFor(room, spotName);
539
+ if (!grid || !anchor)
540
+ return undefined;
541
+ const taken = new Set();
542
+ for (const [name, c] of seatingIn(room).entries()) {
543
+ if (name !== mover.name)
544
+ taken.add(key(c));
545
+ }
546
+ const standable = new Set();
547
+ for (let z = 0; z < grid.dims.z; z++) {
548
+ for (const c of levelFootprint(grid, z)) {
549
+ if (!isCellBlocked(grid, c))
550
+ standable.add(key(c));
551
+ }
552
+ }
553
+ return nearestSeat(anchor, standable, furnitureCells(room), taken);
554
+ }
555
+ /** Straight-line meters between two actors' BODIES -- the distance
556
+ * combat should use, now that people stand beside their furniture
557
+ * rather than inside it. Falls back to their spots' own separation
558
+ * when either has no seat (circulating, or a gridless room). */
559
+ export function actorDistanceMeters(room, a, b) {
560
+ const seats = seatingIn(room);
561
+ const ca = actorCell(room, a, seats);
562
+ const cb = actorCell(room, b, seats);
563
+ if (ca && cb)
564
+ return distanceMeters(ca, cb);
565
+ const sa = spotOf(a);
566
+ const sb = spotOf(b);
567
+ return sa && sb ? spotDistanceMeters(room, sa, sb) : 0;
568
+ }
569
+ /** The walkable route between two spots, or undefined when terrain
570
+ * blocks every route (see move.ts / ensureAtSpot for the refusal).
571
+ * Purely geometric -- bodies and furniture are NOT considered, because
572
+ * the only thing that may hard-refuse a crossing is terrain; see
573
+ * routeForMove for the version movement actually walks. */
574
+ export function pathBetweenSpots(room, from, to) {
575
+ const grid = room.ensureGrid();
576
+ const a = grid?.spotCells.get(from);
577
+ const b = grid?.spotCells.get(to);
578
+ if (!grid || !a || !b)
579
+ return undefined;
580
+ return findPath(grid, a, b);
581
+ }
582
+ /**
583
+ * The route a body actually walks: seat to seat, steering around other
584
+ * people and around the furniture (player ruling 2026-08-25 -- "cannot
585
+ * move through, only around"). Reports whether it had to SQUEEZE past
586
+ * somebody, which move.ts narrates.
587
+ *
588
+ * Bodies and furniture are soft blocks by design (see findPath's pass
589
+ * order): a crowded room slows you down and makes you go the long way,
590
+ * but it can never strand you. Only terrain hard-refuses, and that
591
+ * check stays in pathBetweenSpots where it always was.
592
+ */
593
+ export function routeForMove(room, mover, toSpot) {
594
+ const grid = room.ensureGrid();
595
+ const mine = spotOf(mover);
596
+ if (!grid || mine === undefined)
597
+ return { squeezed: false };
598
+ const from = actorCell(room, mover) ?? grid.spotCells.get(mine);
599
+ const to = seatFor(room, toSpot, mover) ?? grid.spotCells.get(toSpot);
600
+ if (!from || !to)
601
+ return { squeezed: false };
602
+ const bodies = occupiedCells(room, mover);
603
+ bodies.delete(key(to)); // your own destination is never in your way
604
+ const path = findPath(grid, from, to, bodies);
605
+ return { path, squeezed: path ? pathSqueezes(path, bodies) : false };
606
+ }
607
+ export function spotDistanceMeters(room, from, to) {
608
+ const grid = room.ensureGrid();
609
+ const a = grid?.spotCells.get(from);
610
+ const b = grid?.spotCells.get(to);
611
+ if (!grid || !a || !b)
612
+ return 0;
613
+ return distanceMeters(a, b);
614
+ }
615
+ /** Cover between two spots (see room-grid.ts coverBetween) -- honors a
616
+ * cover-tagged DEFENDER spot first (the same check combat-exchange.ts
617
+ * makes), falling back to a terrain occluder on the line between. */
618
+ export function spotCoverBetween(room, from, to) {
619
+ const grid = room.ensureGrid();
620
+ const tagged = (room.spots ?? []).find(s => s.name === to)?.tags?.find(t => t.startsWith('cover'));
621
+ if (tagged)
622
+ return tagged === 'cover-good' ? 'good' : 'partial';
623
+ const a = grid?.spotCells.get(from);
624
+ const b = grid?.spotCells.get(to);
625
+ if (!grid || !a || !b)
626
+ return undefined;
627
+ return coverBetween(grid, a, b);
628
+ }
629
+ /** How many z-levels the route between two spots climbs via a risky
630
+ * 'climb' link (stairs/ladder never roll anything) -- undefined when
631
+ * the crossing never touches one. See move.ts's Athletics check. */
632
+ export function spotClimbSteps(room, from, to) {
633
+ const grid = room.ensureGrid();
634
+ const a = grid?.spotCells.get(from);
635
+ const b = grid?.spotCells.get(to);
636
+ if (!grid || !a || !b)
637
+ return undefined;
638
+ const path = findPath(grid, a, b);
639
+ return path ? pathClimbSteps(grid, path) : undefined;
640
+ }
641
+ /**
642
+ * Re-seats the OPEN FLOOR on the emptiest ground in the room, and
643
+ * returns whether it moved (playtest 2026-08-25: "'move away' is an
644
+ * actual location" -- it was pinned to one synthesized cell near the
645
+ * centroid, so peeling off always put you in the same square no matter
646
+ * who was standing where). "Away" should mean AWAY: the cell that
647
+ * maximises distance from the people you're stepping back from, then
648
+ * from the room's fixtures.
649
+ *
650
+ * Scored lexicographically -- people first, furniture as the
651
+ * tie-break -- over cells that exist, aren't sealed behind terrain,
652
+ * and that `mover` can actually reach. Anyone ALREADY on the open
653
+ * floor is excluded from the avoid-set: they're the ones this cell
654
+ * belongs to, and counting them would have it flee itself.
655
+ *
656
+ * Callers must invoke this BEFORE setting `atSpot = OPEN_FLOOR`; the
657
+ * grid is transient display/geometry state (never serialized), so a
658
+ * reload simply re-derives the synthesized default -- see the
659
+ * determinism contract in room-grid.ts.
660
+ */
661
+ export function reseatOpenFloor(room, mover) {
662
+ const grid = room.ensureGrid();
663
+ if (!grid)
664
+ return false;
665
+ const current = grid.spotCells.get(OPEN_FLOOR);
666
+ const fromSpot = spotOf(mover);
667
+ const from = fromSpot ? grid.spotCells.get(fromSpot) : undefined;
668
+ const spotCells = [];
669
+ for (const [name, c] of grid.spotCells.entries()) {
670
+ if (name !== OPEN_FLOOR)
671
+ spotCells.push(c);
672
+ }
673
+ const peopleCells = [];
674
+ for (const a of room.getActors()) {
675
+ const other = a;
676
+ if (other === mover)
677
+ continue;
678
+ const s = spotOf(other);
679
+ if (!s || s === OPEN_FLOOR)
680
+ continue;
681
+ const c = grid.spotCells.get(s);
682
+ if (c)
683
+ peopleCells.push(c);
684
+ }
685
+ const minTo = (c, set) => set.length === 0 ? Infinity : Math.min(...set.map(o => distanceMeters(c, o)));
686
+ let best;
687
+ let bestScore = [-1, -1];
688
+ for (const c of levelFootprint(grid, 0)) {
689
+ if (spotCells.some(s => key(s) === key(c)))
690
+ continue; // a named place isn't open floor
691
+ if (from && !findPath(grid, from, c))
692
+ continue; // sealed off, or unreachable
693
+ const score = [minTo(c, peopleCells), minTo(c, spotCells)];
694
+ if (score[0] > bestScore[0] || (score[0] === bestScore[0] && score[1] > bestScore[1])) {
695
+ bestScore = score;
696
+ best = c;
697
+ }
698
+ }
699
+ if (!best)
700
+ return false;
701
+ grid.spotCells.set(OPEN_FLOOR, best);
702
+ return current === undefined || key(current) !== key(best);
703
+ }
704
+ /** Which terrain region is sealing the route from `from` to `to`, and
705
+ * the Puzzle that clears it (for the bypass verb) -- undefined when
706
+ * the room has no grid, or (a synthesis edge case) a region blocks
707
+ * the path but its puzzle can no longer be found. */
708
+ export function blockingRegion(room, from, to) {
709
+ const grid = room.ensureGrid();
710
+ const a = grid?.spotCells.get(from);
711
+ const b = grid?.spotCells.get(to);
712
+ if (!grid || !a || !b)
713
+ return undefined;
714
+ const region = blockingRegionFor(grid, a, b);
715
+ if (!region)
716
+ return undefined;
717
+ const target = room.getPuzzleTargets().find(t => t.puzzle.name === region.puzzleName);
718
+ return target ? { region, puzzle: target.puzzle } : undefined;
719
+ }
720
+ /** One refusal voice for a terrain-blocked crossing -- always names
721
+ * the obstacle AND the mechanism (the safe "solve" route, plus the
722
+ * puzzle's own bypass verb when it has one). */
723
+ export function terrainRefusal(region, targetLabel, puzzle) {
724
+ const verb = puzzle?.getBypassVerb();
725
+ const altRoute = verb ? `, or "${verb}" past it` : '';
726
+ return `The ${region.label} seals the way to the ${targetLabel} -- clear it ("solve")${altRoute}.`;
103
727
  }
104
728
  /**
105
729
  * The spot an exit is reachable from. Seed-declared (room.exitSpots)
@@ -112,24 +736,88 @@ export function exitSpot(room, direction) {
112
736
  const declared = room.exitSpots.get(direction);
113
737
  if (declared && room.spots.some(s => s.name === declared))
114
738
  return declared;
115
- const doorish = room.spots.find(s => /door|entrance|entry|threshold|gate/i.test(s.name));
116
- return doorish?.name;
739
+ // One doorway-named spot, one exit (Room.namedDoorwayFor). Answering
740
+ // "door" for EVERY direction is what put Circuit Bazaar's door in the
741
+ // middle of the floor; an exit this spot doesn't belong to is simply
742
+ // ungated, which is the pre-spots behaviour and safe.
743
+ return room.namedDoorwayFor(direction);
117
744
  }
118
745
  /** Where an arrival through `direction` (the direction WALKED, from the
119
746
  * far side) stands: at this room's matching exit spot; else the room's
120
747
  * doorway-named spot; else its first spot (a positioned room always
121
748
  * positions the arriving player -- that's what makes gating bite). */
749
+ /**
750
+ * Where you physically land walking in through `arrivedFrom`: IN the
751
+ * doorway you just came through (player ruling 2026-08-25 -- "when I
752
+ * enter the night market from the east, I should arrive on the western
753
+ * entrance"). The threshold is a standable cell now, so arrival no
754
+ * longer has to be rounded to whichever named place happens to own that
755
+ * exit -- Night Market's seed puts three of its exits "at the stalls",
756
+ * which is right for LEAVING (that's the spot you must stand at to go)
757
+ * but wrong for arriving, and used to materialise you ten metres inside
758
+ * the room.
759
+ *
760
+ * Returns the exact cell plus the spot it counts as, or undefined when
761
+ * the room has no grid, the direction has no doorway, or someone is
762
+ * already standing in it -- all of which fall back to entrySpotFor.
763
+ */
764
+ export function arrivalAt(room, arrivedFrom) {
765
+ if (arrivedFrom === undefined)
766
+ return undefined;
767
+ const grid = room.ensureGrid();
768
+ if (!grid)
769
+ return undefined;
770
+ const door = grid.exitCells.get(room.getOppositeDirection(arrivedFrom));
771
+ if (!door)
772
+ return undefined;
773
+ const k = key(door);
774
+ if (isCellBlocked(grid, door) || furnitureCells(room).has(k))
775
+ return undefined;
776
+ for (const c of seatingIn(room).values()) {
777
+ if (key(c) === k)
778
+ return undefined; // someone's in the doorway
779
+ }
780
+ return { cell: door, spot: spotForCell(room, door) };
781
+ }
122
782
  export function entrySpotFor(room, arrivedFrom) {
123
783
  if (!spotsActive(room))
124
784
  return undefined;
125
785
  if (arrivedFrom !== undefined) {
126
- const viaExit = exitSpot(room, room.getOppositeDirection(arrivedFrom));
786
+ const back = room.getOppositeDirection(arrivedFrom);
787
+ const viaExit = exitSpot(room, back);
127
788
  if (viaExit)
128
789
  return viaExit;
790
+ // No spot owns the door you just came through -- so land at
791
+ // whichever place is NEAREST it, rather than at some unrelated
792
+ // doorway on the far wall (playtest 2026-08-25: walking west into
793
+ // Circuit Bazaar dropped the player at the north of the room).
794
+ const nearest = nearestSpotToExit(room, back);
795
+ if (nearest)
796
+ return nearest;
129
797
  }
130
798
  const doorish = room.spots.find(s => /door|entrance|entry|threshold|gate/i.test(s.name));
131
799
  return doorish?.name ?? room.spots[0]?.name;
132
800
  }
801
+ /** The named place closest to a given exit's doorway cell. */
802
+ function nearestSpotToExit(room, direction) {
803
+ const grid = room.ensureGrid();
804
+ const door = grid?.exitCells.get(direction);
805
+ if (!grid || !door)
806
+ return undefined;
807
+ let best;
808
+ let bestDist = Infinity;
809
+ for (const spot of room.spots ?? []) {
810
+ const c = grid.spotCells.get(spot.name);
811
+ if (!c)
812
+ continue;
813
+ const d = distanceMeters(c, door);
814
+ if (d < bestDist) {
815
+ bestDist = d;
816
+ best = spot.name;
817
+ }
818
+ }
819
+ return best;
820
+ }
133
821
  /** One refusal voice for every spot gate -- always names the cure. */
134
822
  export function spotRefusal(targetLabel, spotName) {
135
823
  return `${targetLabel} is across the room at the ${spotName} -- "move to ${spotName}" first.`;
@@ -140,16 +828,34 @@ export function spotRefusal(targetLabel, spotName) {
140
828
  * that you CAN'T do it from across the room, not that you must type two
141
829
  * commands). Mid-exchange nobody strolls: refuse like go.ts does, and
142
830
  * the Phase-2 crossing machinery will make it cost instead.
831
+ *
832
+ * ROOM GRIDS (spatial wave 2026-08-25): checks the route FIRST -- an
833
+ * uncleared barrier refuses the crossing even out of combat, always
834
+ * naming the obstacle and the mechanism (utilities/room-grid.ts /
835
+ * terrainRefusal). Rooms with no synthesized grid (still the common
836
+ * case -- most spotted rooms carry no terrain hints) never pay for the
837
+ * pathfind: spotDistanceMeters/pathBetweenSpots short-circuit to
838
+ * "reachable" the same instant canReach already would have.
143
839
  */
144
840
  export function ensureAtSpot(actor, targetSpot) {
145
841
  if (canReach(actor, targetSpot))
146
842
  return {};
843
+ const room = actor.currentLocation;
844
+ const mine = spotOf(actor); // canReach's own check guarantees this is defined here
845
+ if (room.ensureGrid() && !pathBetweenSpots(room, mine, targetSpot)) {
846
+ const blocked = blockingRegion(room, mine, targetSpot);
847
+ if (blocked)
848
+ return { refusal: terrainRefusal(blocked.region, targetSpot, blocked.puzzle) };
849
+ }
147
850
  if (actor.inExchange) {
148
851
  return { refusal: `That's over at the ${targetSpot} -- and you're mid-exchange. Crossing open floor now means eating lead.` };
149
852
  }
853
+ if (targetSpot === OPEN_FLOOR)
854
+ reseatOpenFloor(room, actor);
855
+ const long = room.ensureGrid() && spotDistanceMeters(room, mine, targetSpot) >= 12;
150
856
  actor.atSpot = targetSpot;
151
857
  actor.performAction('moves to', `the ${targetSpot}`);
152
- return { line: `You cross to the ${targetSpot}.` };
858
+ return { line: long ? `You cross the long way to the ${targetSpot}.` : `You cross to the ${targetSpot}.` };
153
859
  }
154
860
  // Place names render in this color wherever they appear -- the classic
155
861
  // MUD "interactive keyword" convention (player request: places should
@@ -201,6 +907,166 @@ export function highlightSpots(room, text) {
201
907
  // People render in this color wherever prose names them -- green, the
202
908
  // color NPC speech already wears.
203
909
  export const ACTOR_COLOR = 'green';
910
+ /** The RAW cover threshold guarding a spot (p.135-136: Normal 2 for
911
+ * partial cover, Hidden 4 for good) -- 0 when nothing tucks there. */
912
+ export function coverThreshold(room, spotName) {
913
+ if (spotName === undefined || spotName === OPEN_FLOOR)
914
+ return 0;
915
+ const tags = (room.spots ?? []).find(s => s.name === spotName)?.tags ?? [];
916
+ if (tags.includes('cover-good'))
917
+ return 4;
918
+ return tags.some(t => t.startsWith('cover')) ? 2 : 0;
919
+ }
920
+ /**
921
+ * PERCEPTION AT RANGE (SR5 p.135-136 Perception Test Modifiers,
922
+ * RAG-verified): "object/sound not in immediate vicinity" -2, "object/
923
+ * sound far away" -3. Canon names the bands but never puts meters on
924
+ * them, so the metre cuts are OURS -- pinned to the scale the game
925
+ * already speaks in, SR5 p.185's pistol brackets: short range 5 m is
926
+ * "immediate vicinity", medium 20 m is where "far away" begins.
927
+ *
928
+ * This became load-bearing with the 2026-08-25 room resize: a `huge`
929
+ * room is 32x22 m, so a stallholder clocking a runner the instant they
930
+ * step through the far door was the room being too small, not the NPC
931
+ * being sharp (playtest: "would Skitter even notice me?").
932
+ */
933
+ export const IMMEDIATE_VICINITY_M = 5;
934
+ export const FAR_AWAY_M = 20;
935
+ export function perceptionRangeModifier(meters) {
936
+ if (meters <= IMMEDIATE_VICINITY_M)
937
+ return 0;
938
+ return meters >= FAR_AWAY_M ? -3 : -2;
939
+ }
940
+ /**
941
+ * The Perception threshold to pick someone out: p.136's "Normal" 2 for
942
+ * a pedestrian, 4 for anyone in good cover -- and ZERO for a person
943
+ * standing in the open inside `FAR_AWAY_M`, where seeing them is not a
944
+ * test at all.
945
+ *
946
+ * That last clause is deliberate calibration, not laziness. Requiring a
947
+ * test past `IMMEDIATE_VICINITY_M` was tried first and was far too
948
+ * blind: it had the regulars in a 24x16 m bar failing to register
949
+ * someone walking in twelve metres away. Canon's -2/-3 are about
950
+ * resolving DETAIL at range, not about whether a human body registers
951
+ * at all, so the test starts where "far away" does. A 32x22 m market
952
+ * still hides you from the far stall; a bar does not.
953
+ */
954
+ export function noticeThreshold(room, target, meters) {
955
+ const cover = coverThreshold(room, spotOf(target));
956
+ if (cover > 0)
957
+ return cover;
958
+ return meters < FAR_AWAY_M ? 0 : 2;
959
+ }
960
+ /**
961
+ * Whether `observer` would register `target` at all right now, WITHOUT
962
+ * rolling -- the passive read used by NPC reactions and by the room
963
+ * map. Their Perception pool stands in for an average roll (pool/4,
964
+ * SR5's own rule of thumb of one hit per four dice) so a sharp-eyed
965
+ * fixer notices what a distracted teenager misses, and the range
966
+ * modifier from p.135-136 applies.
967
+ *
968
+ * Deliberately NOT a dice roll: this fires on every movement event in
969
+ * the room, and a random result would have NPCs blinking in and out of
970
+ * awareness between one step and the next.
971
+ */
972
+ export function wouldNotice(observer, target) {
973
+ if (!observer.canPerceive(target))
974
+ return false;
975
+ const room = observer.currentLocation;
976
+ if (!spotsActive(room) || room !== target.currentLocation)
977
+ return true;
978
+ if (spotOf(observer) === undefined || spotOf(target) === undefined)
979
+ return true;
980
+ const meters = actorDistanceMeters(room, observer, target);
981
+ const threshold = noticeThreshold(room, target, meters);
982
+ if (threshold === 0)
983
+ return true;
984
+ const perception = observer.skillRating('perception');
985
+ const pool = Math.max(1, observer.intuition + (perception > 0 ? perception : -1)
986
+ + observer.augBonus('perception'));
987
+ return Math.floor(pool / 4) + perceptionRangeModifier(meters) >= threshold;
988
+ }
989
+ export function voiceBandFor(meters) {
990
+ if (meters <= IMMEDIATE_VICINITY_M)
991
+ return 'conversation';
992
+ if (meters <= 12)
993
+ return 'raised';
994
+ if (meters < FAR_AWAY_M)
995
+ return 'shout';
996
+ return 'yell';
997
+ }
998
+ /** The tag appended to speech that has to travel -- empty at
999
+ * conversational range, where it would just be noise. */
1000
+ export function voiceTag(band) {
1001
+ switch (band) {
1002
+ case 'conversation': return '';
1003
+ case 'raised': return ' (raising their voice across the gap)';
1004
+ case 'shout': return ' (shouting across the room)';
1005
+ case 'yell': return ' (bellowing the length of the room)';
1006
+ }
1007
+ }
1008
+ /** Records that `viewer` has picked `target` out of their cover here --
1009
+ * a spot they keep (Room.revealedBy) so every later read, the room
1010
+ * map included, knows they've been seen. */
1011
+ export function revealActor(room, viewer, target) {
1012
+ const seen = room.revealedBy.get(viewer.name) ?? new Set();
1013
+ seen.add(target.name);
1014
+ room.revealedBy.set(viewer.name, seen);
1015
+ }
1016
+ /** Everyone `viewer` has already picked out of cover in this room. */
1017
+ export function revealedTo(room, viewer) {
1018
+ return room.revealedBy.get(viewer.name) ?? new Set();
1019
+ }
1020
+ /** Runs a Perception result against every covered occupant, banking
1021
+ * whoever it beats. Called by the verbs that constitute LOOKING (look,
1022
+ * search) so the room map reflects what the player has actually made
1023
+ * out. Returns the newly-revealed actors, for callers that narrate. */
1024
+ export function revealWithPerception(room, viewer, hits) {
1025
+ if (!spotsActive(room))
1026
+ return [];
1027
+ const already = revealedTo(room, viewer);
1028
+ const found = [];
1029
+ for (const a of room.getActors()) {
1030
+ const other = a;
1031
+ if (other === viewer || already.has(other.name) || !viewer.canPerceive(other))
1032
+ continue;
1033
+ const meters = actorDistanceMeters(room, viewer, other);
1034
+ const threshold = noticeThreshold(room, other, meters);
1035
+ // Range is a DICE modifier in canon (p.135-136), applied here to
1036
+ // the hits already rolled -- one roll covers the whole sweep, and
1037
+ // re-rolling per target would let a single "look" produce a
1038
+ // different Perception result for every person in the room.
1039
+ if (threshold === 0 || hits + perceptionRangeModifier(meters) < threshold)
1040
+ continue;
1041
+ revealActor(room, viewer, other);
1042
+ found.push(other);
1043
+ }
1044
+ return found;
1045
+ }
1046
+ /**
1047
+ * Actors in `room` visible to `viewer` without a fresh roll: the viewer
1048
+ * first, then anyone perceivable who is either standing in the open or
1049
+ * has ALREADY been picked out of their cover (Room.revealedBy -- see
1050
+ * revealWithPerception). Shared by highlightActors (prose) and
1051
+ * utilities/room-view.ts (the room minimap). Coloring or drawing
1052
+ * someone still unspotted would hand over a presence the Perception
1053
+ * test is there to guard.
1054
+ */
1055
+ export function visibleActorsIn(room, viewer) {
1056
+ const out = [viewer];
1057
+ const revealed = revealedTo(room, viewer);
1058
+ for (const a of room.getActors()) {
1059
+ if (a === viewer || !viewer.canPerceive(a))
1060
+ continue;
1061
+ const other = a;
1062
+ // Already picked out once, stays picked out -- you don't un-see
1063
+ // someone. Otherwise they must be close enough (and clear enough of
1064
+ // cover) to register without a deliberate look.
1065
+ if (revealed.has(other.name) || wouldNotice(viewer, other))
1066
+ out.push(other);
1067
+ }
1068
+ return out;
1069
+ }
204
1070
  /**
205
1071
  * Colors the names of PRESENT, OBVIOUS people in a room's prose (player
206
1072
  * request: places got the keyword treatment; actual NPCs deserve one
@@ -214,16 +1080,9 @@ export function highlightActors(room, text, viewer) {
214
1080
  let out = text;
215
1081
  const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
216
1082
  const paint = (m) => `{${ACTOR_COLOR}-fg}${m}{/${ACTOR_COLOR}-fg}`;
217
- for (const a of room.getActors()) {
218
- if (a === viewer || !viewer.canPerceive(a))
1083
+ for (const a of visibleActorsIn(room, viewer)) {
1084
+ if (a === viewer)
219
1085
  continue;
220
- // Obvious only: circulating, on the open floor, or at an untagged spot.
221
- const s = spotOf(a);
222
- if (s !== undefined && s !== OPEN_FLOOR) {
223
- const covered = (room.spots ?? []).some(sp => sp.name === s && (sp.tags ?? []).some(t => t.startsWith('cover')));
224
- if (covered)
225
- continue;
226
- }
227
1086
  const full = new RegExp(`\\b(${esc(a.name)})\\b`);
228
1087
  if (full.test(out)) {
229
1088
  out = out.replace(full, paint);
@@ -254,16 +1113,23 @@ export function highlightActors(room, text, viewer) {
254
1113
  export function whoIsWhereLine(room, viewer, hits) {
255
1114
  if (!spotsActive(room))
256
1115
  return '';
1116
+ // A read that CLEARS the threshold is banked, not just printed: the
1117
+ // room map has no roll of its own and reads Room.revealedBy instead
1118
+ // (player request 2026-08-25 -- someone you've spotted should stay
1119
+ // spotted). Anyone already revealed stays visible on later looks even
1120
+ // if this particular roll was poor -- you don't un-see a sniper.
1121
+ if (hits !== undefined)
1122
+ revealWithPerception(room, viewer, hits);
1123
+ const revealed = revealedTo(room, viewer);
257
1124
  const actors = room.getActors().filter(a => a === viewer || viewer.canPerceive(a));
258
1125
  const viewerSpot = spotOf(viewer);
259
1126
  const parts = [];
260
1127
  for (const s of room.spots) {
261
1128
  let here = actors.filter(a => a.atSpot === s.name);
262
1129
  if (hits !== undefined && s.name !== viewerSpot) {
263
- const covered = (s.tags ?? []).some(t => t.startsWith('cover'));
264
- const threshold = (s.tags ?? []).includes('cover-good') ? 4 : covered ? 2 : 0;
265
- if (threshold > 0 && hits < threshold) {
266
- here = here.filter(a => a === viewer);
1130
+ const threshold = coverThreshold(room, s.name);
1131
+ if (threshold > 0) {
1132
+ here = here.filter(a => a === viewer || revealed.has(a.name));
267
1133
  }
268
1134
  }
269
1135
  if (here.length === 0)