@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
|
@@ -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 (
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
|
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
|
|
218
|
-
if (a === viewer
|
|
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
|
|
264
|
-
|
|
265
|
-
|
|
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)
|