@maka/maka-cli 5.390.0 → 5.392.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 (68) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/assense.js +1 -1
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +60 -1
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js +1 -1
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/breach.js +5 -2
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.js +8 -2
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/call.js +4 -2
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +16 -150
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/crack.js +76 -0
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/disable.js +42 -6
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/disarm.js +64 -0
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/dispel.js +23 -46
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/download.js +23 -6
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/edit-file.js +15 -3
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +55 -17
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +15 -1
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/leap.js +23 -0
  18. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +40 -0
  19. package/bundle/typescript/src/commands/game/sideQuest/commands/move.js +16 -15
  20. package/bundle/typescript/src/commands/game/sideQuest/commands/tap.js +13 -3
  21. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +17 -1
  22. package/bundle/typescript/src/commands/game/sideQuest/factories/authored-run.js +632 -0
  23. package/bundle/typescript/src/commands/game/sideQuest/factories/catalog-item-seed.js +78 -0
  24. package/bundle/typescript/src/commands/game/sideQuest/factories/job-order.js +7 -44
  25. package/bundle/typescript/src/commands/game/sideQuest/factories/repro-scene.js +8 -10
  26. package/bundle/typescript/src/commands/game/sideQuest/factories/room-factory.js +12 -0
  27. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +30 -52
  28. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js +112 -0
  29. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js +123 -3
  30. package/bundle/typescript/src/commands/game/sideQuest/factories/seed-migration.js +52 -4
  31. package/bundle/typescript/src/commands/game/sideQuest/game.js +17 -4
  32. package/bundle/typescript/src/commands/game/sideQuest/models/camera.js +8 -0
  33. package/bundle/typescript/src/commands/game/sideQuest/models/device.js +14 -21
  34. package/bundle/typescript/src/commands/game/sideQuest/models/host.js +8 -0
  35. package/bundle/typescript/src/commands/game/sideQuest/models/item.js +14 -0
  36. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +62 -9
  37. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +11 -0
  38. package/bundle/typescript/src/commands/game/sideQuest/models/room.js +141 -3
  39. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +3 -0
  40. package/bundle/typescript/src/commands/game/sideQuest/utilities/affordances.js +18 -11
  41. package/bundle/typescript/src/commands/game/sideQuest/utilities/alarmed-staff.js +3 -0
  42. package/bundle/typescript/src/commands/game/sideQuest/utilities/ar.js +4 -2
  43. package/bundle/typescript/src/commands/game/sideQuest/utilities/astral-move.js +7 -2
  44. package/bundle/typescript/src/commands/game/sideQuest/utilities/barrier-combat.js +1 -1
  45. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-turn.js +9 -0
  46. package/bundle/typescript/src/commands/game/sideQuest/utilities/district-plane.js +109 -27
  47. package/bundle/typescript/src/commands/game/sideQuest/utilities/door-watch.js +70 -0
  48. package/bundle/typescript/src/commands/game/sideQuest/utilities/falling.js +27 -0
  49. package/bundle/typescript/src/commands/game/sideQuest/utilities/file-guard.js +120 -0
  50. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-names.js +10 -1
  51. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-view.js +12 -3
  52. package/bundle/typescript/src/commands/game/sideQuest/utilities/host-combat.js +23 -5
  53. package/bundle/typescript/src/commands/game/sideQuest/utilities/ic.js +29 -2
  54. package/bundle/typescript/src/commands/game/sideQuest/utilities/ledge-jump.js +86 -0
  55. package/bundle/typescript/src/commands/game/sideQuest/utilities/map-view.js +2 -1
  56. package/bundle/typescript/src/commands/game/sideQuest/utilities/npc-combat-brain.js +29 -0
  57. package/bundle/typescript/src/commands/game/sideQuest/utilities/owner-guard.js +77 -0
  58. package/bundle/typescript/src/commands/game/sideQuest/utilities/path-run.js +4 -2
  59. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +3 -1
  60. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.js +66 -3
  61. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-view.js +8 -1
  62. package/bundle/typescript/src/commands/game/sideQuest/utilities/spider.js +50 -0
  63. package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js +20 -6
  64. package/bundle/typescript/src/commands/game/sideQuest/utilities/surveillance.js +193 -8
  65. package/bundle/typescript/src/commands/game/sideQuest/utilities/use-target.js +1 -1
  66. package/bundle/typescript/src/commands/game/sideQuest/utilities/verb-index.js +2 -2
  67. package/bundle/typescript/src/commands/game/sideQuest/utilities/wards.js +116 -0
  68. package/package.json +1 -1
@@ -6,6 +6,14 @@ export function doorwayKindOf(spotName) {
6
6
  return 'lateral';
7
7
  return undefined;
8
8
  }
9
+ export function wallFixtureKindOf(spotName) {
10
+ const head = spotName.trim().toLowerCase().split(/\s+/).pop() ?? '';
11
+ if (/^(door|doors|doorway|gate|gates|entrance|entry|archway|airlock)$/.test(head))
12
+ return 'door';
13
+ if (/^(window|windows)$/.test(head))
14
+ return 'window';
15
+ return undefined;
16
+ }
9
17
  export const METERS_PER_CELL = 2;
10
18
  /**
11
19
  * How tall one storey is. A CHOICE, not a citation -- SR5 never says how
@@ -237,8 +245,9 @@ export function mulberry32(seed) {
237
245
  function cellExists(grid, c, levelCells) {
238
246
  if (c.x < 0 || c.y < 0 || c.x >= grid.dims.x || c.y >= grid.dims.y)
239
247
  return false;
248
+ // A HOLE in an outdoor area is a cell something else owns (GROUND_HOLES).
240
249
  if (c.z === 0)
241
- return true;
250
+ return !GROUND_HOLES.get(grid)?.has(`${c.x},${c.y}`);
242
251
  return levelCells.get(c.z)?.has(`${c.x},${c.y}`) ?? false;
243
252
  }
244
253
  /** Whether `c` is a cell of this grid at all -- in bounds, and on a
@@ -392,10 +401,12 @@ export function distanceMeters(a, b) {
392
401
  * time (the const is bound long before any consumer runs). */
393
402
  export function levelFootprint(grid, z) {
394
403
  if (z === 0) {
404
+ const holes = GROUND_HOLES.get(grid);
395
405
  const out = [];
396
406
  for (let x = 0; x < grid.dims.x; x++) {
397
407
  for (let y = 0; y < grid.dims.y; y++)
398
- out.push({ x, y, z: 0 });
408
+ if (!holes?.has(`${x},${y}`))
409
+ out.push({ x, y, z: 0 });
399
410
  }
400
411
  return out;
401
412
  }
@@ -565,6 +576,8 @@ export function pathClimbSteps(grid, path) {
565
576
  * cellExists needs but doesn't belong on the public IRoomGrid shape --
566
577
  * associated by identity so callers never juggle it themselves. */
567
578
  const LEVEL_CELLS = new WeakMap();
579
+ /** An outdoor area's holes, per grid (IGridSynthesisInput.holes). */
580
+ const GROUND_HOLES = new WeakMap();
568
581
  const WALL_REGIONS = {
569
582
  north: (d) => ({ x0: 0, x1: d.x - 1, y0: 0, y1: 0 }),
570
583
  south: (d) => ({ x0: 0, x1: d.x - 1, y0: d.y - 1, y1: d.y - 1 }),
@@ -639,6 +652,8 @@ export function synthesizeGrid(input) {
639
652
  exitCells: new Map(), terrain: new Map(),
640
653
  };
641
654
  LEVEL_CELLS.set(grid, levelCells);
655
+ if (input.holes && input.holes.size > 0)
656
+ GROUND_HOLES.set(grid, input.holes);
642
657
  const exists = (c) => cellExists(grid, c, levelCells);
643
658
  const freeCellNear = (region, z) => {
644
659
  const candidates = [];
@@ -698,6 +713,11 @@ export function synthesizeGrid(input) {
698
713
  // a room's width away from.
699
714
  cell = nearestFreeOnWall(cell, wallSide, occupied, dims, exists);
700
715
  }
716
+ else if (authored && exists({ x: authored.x, y: authored.y, z: 0 }) && !occupied.has(key({ x: authored.x, y: authored.y, z: 0 }))) {
717
+ // A STAIR WHERE THE AUTHOR PUT IT (exit seed `cell`), on the
718
+ // ground level of the room, unless something already stands there.
719
+ cell = { x: authored.x, y: authored.y, z: 0 };
720
+ }
701
721
  else {
702
722
  // up/down (and anything exotic): no natural wall -- an open
703
723
  // z=0 cell near the centroid, same free-cell picker as unplaced
@@ -732,6 +752,28 @@ export function synthesizeGrid(input) {
732
752
  if (exists(approach))
733
753
  claim(approach);
734
754
  }
755
+ // --- AUTHORED SPOTS: exactly where the author put them. ---
756
+ //
757
+ // Player-built runs (2026-10-06: "real coordinates are a must"). A spot
758
+ // that carries its own cells takes them -- anchor first, the rest its
759
+ // footprint -- before anything is chosen for it. No rng is drawn here,
760
+ // so a room with no authored spot runs every pass below exactly as
761
+ // before, cell for cell. Doors and their approaches were claimed first
762
+ // and stay won: an authored cell on one is the audit's to report, not
763
+ // this pass's to move.
764
+ const fixed = new Set();
765
+ for (const spot of input.spots) {
766
+ if (spot.perch || !spot.cells || spot.cells.length === 0)
767
+ continue;
768
+ const cells = spot.cells.filter(c => exists(c));
769
+ if (cells.length === 0)
770
+ continue;
771
+ for (const c of cells)
772
+ claim(c);
773
+ grid.spotCells.set(spot.name, cells[0]);
774
+ grid.spotFootprints.set(spot.name, cells);
775
+ fixed.add(spot.name);
776
+ }
735
777
  // Seat each exit-serving spot, now that we know how many exits it
736
778
  // serves. ONE exit: the spot IS that doorway, so it takes the cell
737
779
  // (Mama Wu's "door" -> its east wall). SEVERAL: the spot is the hub
@@ -743,6 +785,8 @@ export function synthesizeGrid(input) {
743
785
  // directions also come off the stalls"). The doorways stay on their
744
786
  // own walls; the hub sits where all of them are plausibly reachable.
745
787
  for (const [spotName, served] of exitCellsBySpot.entries()) {
788
+ if (fixed.has(spotName))
789
+ continue; // the author already put it somewhere
746
790
  if (served.length === 1) {
747
791
  // FURNITURE BESIDE THE DOOR, NOT IN IT (XanDMPmRrbJQ8ta8e,
748
792
  // 2026-09-12: "the corner booth is right in front of the door
@@ -893,6 +937,18 @@ export function synthesizeGrid(input) {
893
937
  ...(input.keepClear ?? []).map(c => key({ x: c.x, y: c.y, z: 0 })),
894
938
  ]);
895
939
  for (const [name, anchor] of grid.spotCells.entries()) {
940
+ // A DOOR OR WINDOW HAS NO FURNITURE (wallFixtureKindOf): an empty
941
+ // footprint, as a camera's perch has. The anchor stays claimed, so
942
+ // nothing grows onto it; it is simply floor. No rng is skipped --
943
+ // growFurniture draws none for a one-cell name -- so nothing else in
944
+ // the room moves.
945
+ if (wallFixtureKindOf(name)) {
946
+ grid.spotFootprints.set(name, []);
947
+ continue;
948
+ }
949
+ // An authored spot already has the footprint its author drew.
950
+ if (fixed.has(name))
951
+ continue;
896
952
  if (name === OPEN_FLOOR_NAME || exitKeys.has(key(anchor))) {
897
953
  grid.spotFootprints.set(name, [anchor]);
898
954
  continue;
@@ -1019,7 +1075,12 @@ function placePerches(grid, input, base, levelCells, occupied, furnitureKeys) {
1019
1075
  const wanted = perch.place && SIDES.includes(perch.place) ? perch.place : undefined;
1020
1076
  const order = wanted ? [wanted, ...SIDES.filter(s => s !== wanted)] : SIDES;
1021
1077
  let cell;
1022
- for (const side of order) {
1078
+ // AN AUTHORED LENS hangs exactly where its author put it, before any
1079
+ // draw from this stream -- so authored perches move nothing else.
1080
+ const fixedAt = perch.cells?.[0];
1081
+ if (fixedAt && !taken.has(`${fixedAt.x},${fixedAt.y}`))
1082
+ cell = { x: fixedAt.x, y: fixedAt.y };
1083
+ for (const side of cell ? [] : order) {
1023
1084
  const free = wallCells(side).filter(c => usable(c.x, c.y));
1024
1085
  // Prefer clear of the claimed cells (an exit's approach, say) --
1025
1086
  // standing room under the lens -- but take what there is.
@@ -1213,6 +1274,8 @@ export function serializeRoomGrid(grid, roomName, plane) {
1213
1274
  vertical,
1214
1275
  terrain,
1215
1276
  levels,
1277
+ // The ground's missing cells: what other rooms own (GROUND_HOLES).
1278
+ ...(GROUND_HOLES.get(grid) ? { holes: [...GROUND_HOLES.get(grid)].map(k => { const [x, y] = k.split(',').map(Number); return { x, y }; }) } : {}),
1216
1279
  origin: plane?.origin,
1217
1280
  walls: plane?.walls,
1218
1281
  };
@@ -7,7 +7,7 @@ import { DRONE_COLOUR } from './drone-prose.js';
7
7
  import { PLAYER_GLYPH, OTHER_PLAYER_GLYPH, OTHER_PLAYER_COLOR, NEUTRAL_GLYPH, HOSTILE_GLYPH, CREW_GLYPH, DEAD_GLYPH, DOWNED_GLYPH, SPOT_GLYPH, RIGGED_GLYPH, UP_GLYPH, CAMERA_GLYPH, CAMERA_COLOR, CAMERA_DEAD_COLOR, BARRIER_GLYPH, BARRIER_COLOR, MANA_BARRIER_COLOR, exitGlyph, GLYPH_RANK, } from './map-glyphs.js';
8
8
  import { barriersWire } from './ways-out.js';
9
9
  import { xRulerLine, yRulerLabel, yGutterWidth } from './map-rulers.js';
10
- import { key, levelFootprint, dimsForSize, roomRasterRows, METERS_PER_LEVEL, METERS_PER_CELL, } from './room-grid.js';
10
+ import { key, levelFootprint, dimsForSize, roomRasterRows, METERS_PER_LEVEL, METERS_PER_CELL, wallFixtureKindOf, } from './room-grid.js';
11
11
  /**
12
12
  * WHO THIS PANEL DRAWS: everyone you can see, plus every ARO your AR is
13
13
  * showing you (BJDoj9xwy5GmnHAmj: "I should always be able to spot an
@@ -209,6 +209,11 @@ function buildRenderMaps(grid, room, viewer) {
209
209
  cellToSpot.set(key(c), name);
210
210
  }
211
211
  for (const [name, c] of grid.spotCells.entries()) {
212
+ // A door or window is not furniture (room-grid wallFixtureKindOf):
213
+ // its cell is plain floor, and a door that is an exit wears the
214
+ // exit's own arrow.
215
+ if (wallFixtureKindOf(name))
216
+ continue;
212
217
  if (!cellToSpot.has(key(c)))
213
218
  cellToSpot.set(key(c), name);
214
219
  }
@@ -541,6 +546,8 @@ function diamondSpots(room, grid) {
541
546
  return (room.spots ?? [])
542
547
  // A perch draws as its lens (CAMERA_GLYPH), with a legend row of its own.
543
548
  .filter(s => !isPerchSpot(s))
549
+ // ...and a door or window draws nothing, so it has no row to explain.
550
+ .filter(s => !wallFixtureKindOf(s.name))
544
551
  .filter(s => {
545
552
  const cell = grid.spotCells.get(s.name);
546
553
  return cell !== undefined && !onExit.has(key(cell));
@@ -0,0 +1,50 @@
1
+ import { NPC } from '../models/npc.js';
2
+ /**
3
+ * SPIDERS: an NPC that starts the run jacked into a host (player-built
4
+ * runs, 2026-10-06; SR5 p.267-268, Data Trails p.168-169 -- marks and
5
+ * ownership belong to the persona). Not ice: a persona with a body, so
6
+ * NPC.isConstruct() leaves them out and they talk, hear and act like the
7
+ * person they are. The body slumps where they sat (planes.ts
8
+ * shellsInRoom) and is attackable there; one solid hit slams them back
9
+ * into it with dumpshock (p.229), exactly as for a runner.
10
+ *
11
+ * Seated at scene build, without the jack-in's narration: the run opens
12
+ * with them already in. The GATES are the jack-in's own (commands/
13
+ * jack.ts) -- a rig to ride, VR needs a DNI (p.222-223), a living
14
+ * persona is hot-sim only (p.250) -- in one function the factory and the
15
+ * authored audit both read, so the editor's fault and the boot agree.
16
+ */
17
+ /** Why this NPC cannot be jacked in as asked, or undefined when they can. */
18
+ export function spiderRefusal(npc, mode) {
19
+ if (npc.plane !== 'meat')
20
+ return `${npc.name} is not in the meat to jack in from.`;
21
+ const rig = npc.chooseRig();
22
+ if (!rig) {
23
+ if (!npc.isTechnomancer())
24
+ return `${npc.name} carries no cyberdeck or commlink to jack in on (p.222).`;
25
+ if (mode === 'cold')
26
+ return `${npc.name} rides a living persona, which is hot-sim only (p.250).`;
27
+ return undefined;
28
+ }
29
+ if (!npc.hasDirectNeuralInterface())
30
+ return `${npc.name} has no datajack or trode net -- VR needs a direct neural interface (p.222-223).`;
31
+ return undefined;
32
+ }
33
+ /** Seats the persona inside the host, the body slumped in its room. Returns the refusal when they cannot be. */
34
+ export function seatSpider(scene, npc, host, mode) {
35
+ const refusal = spiderRefusal(npc, mode);
36
+ if (refusal)
37
+ return refusal;
38
+ npc.bodyRoom = npc.currentLocation;
39
+ npc.plane = 'matrix';
40
+ npc.simMode = mode;
41
+ npc.setActiveRig(npc.chooseRig());
42
+ npc.matrixPosition = { kind: 'host', host };
43
+ scene.addWorldEvent?.(`${npc.name} is jacked into ${host.name}.`, { plane: 'matrix' });
44
+ return undefined;
45
+ }
46
+ /** Every spider riding this host: NPC personas inside it with a body elsewhere. */
47
+ export function spidersIn(scene, host) {
48
+ return scene.allActors.filter((a) => a instanceof NPC && a.plane === 'matrix' && !!a.bodyRoom && a.icKind === undefined && a.hostInside === host);
49
+ }
50
+ //# sourceMappingURL=spider.js.map
@@ -6,7 +6,7 @@ import { wayOut } from './ways-out.js';
6
6
  import { significantWords, wordsAreCloseEnough } from './fuzzy-match.js';
7
7
  import { hint } from './hints.js';
8
8
  import { placeName } from './log-style.js';
9
- import { findPath, distanceMeters, coverBetween, blockingRegionFor, pathClearing, pathClimbSteps, levelFootprint, key, isCellBlocked, pathSqueezes, METERS_PER_CELL, doorwayKindOf, cellOnGrid, } from './room-grid.js';
9
+ import { findPath, distanceMeters, coverBetween, blockingRegionFor, pathClearing, pathClimbSteps, levelFootprint, key, isCellBlocked, pathSqueezes, METERS_PER_CELL, doorwayKindOf, wallFixtureKindOf, cellOnGrid, } from './room-grid.js';
10
10
  // The doorway vocabulary moved to room-grid.ts (the grid seats exit
11
11
  // spots by it); every caller of spots.ts keeps its import.
12
12
  export { doorwayKindOf };
@@ -908,7 +908,13 @@ export function stepFrom(room, mover, direction, count) {
908
908
  if (walledBy !== undefined)
909
909
  return { cell: cur, taken, blockedBy: 'barrier', blocker: walledBy };
910
910
  if (!standable.has(k)) {
911
- const inBounds = next.x >= 0 && next.y >= 0 && next.x < grid.dims.x && next.y < grid.dims.y;
911
+ const inRect = next.x >= 0 && next.y >= 0 && next.x < grid.dims.x && next.y < grid.dims.y;
912
+ // A HOLE IS OFF THE EDGE TOO (room-grid.ts GROUND_HOLES): a cell of
913
+ // this area's rectangle that a building or another street owns. The
914
+ // plane decides it exactly as it decides past the rectangle -- a
915
+ // street's ground walks on (move.ts crossOutdoorSeam re-homes you),
916
+ // a building's floor or wall stops you.
917
+ const inBounds = inRect && (next.z !== 0 || cellOnGrid(grid, next));
912
918
  // Off the edge of an OUTDOOR room is just more street, unless the
913
919
  // plane says something is built there.
914
920
  if (!inBounds && openOutside(next)) {
@@ -960,9 +966,13 @@ export function spotForCell(room, cell) {
960
966
  return OPEN_FLOOR;
961
967
  let best;
962
968
  let bestDist = Infinity;
963
- for (const [name, cells] of grid.spotFootprints.entries()) {
969
+ for (const [name, footprint] of grid.spotFootprints.entries()) {
964
970
  if (name === OPEN_FLOOR)
965
971
  continue;
972
+ // A door or window fills no cell (room-grid wallFixtureKindOf), so its
973
+ // anchor stands in for it: step up to the window and you are AT it.
974
+ const anchor = footprint.length === 0 && wallFixtureKindOf(name) ? grid.spotCells.get(name) : undefined;
975
+ const cells = anchor ? [anchor] : footprint;
966
976
  for (const c of cells) {
967
977
  if (c.z !== cell.z)
968
978
  continue;
@@ -2536,7 +2546,8 @@ export function describeSpotRoster(room, viewer) {
2536
2546
  * - AN ASTRAL BARRIER. "Astral barriers block astral travel, and you
2537
2547
  * might run into one before you realize that it's in your way" --
2538
2548
  * Room.warded, the same flag the aura rules read. Both ways: a ward
2539
- * keeps you out, and it keeps you in.
2549
+ * keeps you out, and it keeps you in -- unless `wardStops` says this
2550
+ * form is its creator, or has just pressed through it (utilities/wards.ts).
2540
2551
  *
2541
2552
  * Returns WORLD cells, not room-local ones, because an astral form is
2542
2553
  * not in a room in the sense a body is -- it is at a point on the
@@ -2546,7 +2557,10 @@ export function describeSpotRoster(room, viewer) {
2546
2557
  export function astralStepFrom(plane, from, direction, count, roomAt,
2547
2558
  /** Whose MANA BARRIER stands on a cell, if any (Scene.barrierAt --
2548
2559
  * the creator's own weave already excluded, p.315). */
2549
- manaBarrierAt) {
2560
+ manaBarrierAt,
2561
+ /** Does this warded room's ward stop THIS form (wards.ts wardHolds)?
2562
+ * Unset: every ward stops everyone. */
2563
+ wardStops) {
2550
2564
  /**
2551
2565
  * UP AND DOWN ARE DIRECTIONS OUT HERE (p.313-314: an astral form
2552
2566
  * "can move in any direction" and is "not limited by gravity").
@@ -2567,7 +2581,7 @@ manaBarrierAt) {
2567
2581
  if (owner === undefined)
2568
2582
  return undefined;
2569
2583
  const room = roomAt(owner);
2570
- return room?.warded === true ? room : undefined;
2584
+ return room?.warded === true && (wardStops?.(room) ?? true) ? room : undefined;
2571
2585
  };
2572
2586
  let cur = from;
2573
2587
  let taken = 0;
@@ -1,4 +1,5 @@
1
1
  import { spotOf } from './spots.js';
2
+ import { wallsBetween } from './district-plane.js';
2
3
  /**
3
4
  * SURVEILLANCE (player go 2026-08-24; canon-shaped, values engine-
4
5
  * adapted -- the RAG was down): facilities watch themselves. A room is
@@ -41,12 +42,70 @@ export function isWatched(room) {
41
42
  export function cameraMasterHost(rooms, room) {
42
43
  if (!isWatched(room))
43
44
  return undefined;
45
+ // An author who wired these lenses to a named host is obeyed.
46
+ if (room.camerasSlavedTo) {
47
+ const named = rooms[room.camerasSlavedTo] ?? Object.values(rooms).find(r => r.name === room.camerasSlavedTo);
48
+ if (named?.hasNode)
49
+ return named;
50
+ }
44
51
  if (room.hasNode)
45
52
  return room;
46
53
  // Deterministic pick, and the same set canSnoopFeeds treats as the
47
54
  // site network -- whichever host you own, you own these feeds.
48
55
  return Object.values(rooms).find(r => r.hasNode);
49
56
  }
57
+ /**
58
+ * THE HOST ONE LENS IS SLAVED TO (user 2026-10-06: "I can have a camera
59
+ * outside every shop, and they should slave to the shop host, not the
60
+ * outdoor strip"). A lens an author wired to a named host answers to
61
+ * that host; every other lens answers to its room's master
62
+ * (cameraMasterHost). One room's lenses can therefore feed several hosts.
63
+ */
64
+ export function lensMaster(rooms, room, cam) {
65
+ const named = (n) => (n ? (rooms[n] ?? Object.values(rooms).find(r => r.name === n)) : undefined);
66
+ const own = named(cam.masterHost);
67
+ if (own?.hasNode)
68
+ return own;
69
+ // BOLTED TO A BUILDING: that building's host.
70
+ const wall = named(cam.mountedOn);
71
+ const building = wall ? buildingHost(wall) : undefined;
72
+ if (building)
73
+ return building;
74
+ return cameraMasterHost(rooms, room);
75
+ }
76
+ /**
77
+ * THE HOST OF THE BUILDING A ROOM BELONGS TO (user 2026-10-06: "the 2nd
78
+ * story room can have a camera attached to the outside wall, as the host
79
+ * is on the first floor"): the room's own, else the nearest one through
80
+ * its inside doors and stairs -- never across open ground, which is a
81
+ * different building. Undefined when the whole building has none.
82
+ */
83
+ export function buildingHost(room) {
84
+ const seen = new Set([room]);
85
+ let frontier = [room];
86
+ while (frontier.length > 0) {
87
+ const hit = frontier.find(r => r.hasNode);
88
+ if (hit)
89
+ return hit;
90
+ const next = [];
91
+ for (const r of frontier) {
92
+ for (const exit of r.exits.values()) {
93
+ const other = typeof exit.getOtherSide === 'function'
94
+ ? exit.getOtherSide(r) : undefined;
95
+ if (!other || seen.has(other) || other.outdoors)
96
+ continue;
97
+ seen.add(other);
98
+ next.push(other);
99
+ }
100
+ }
101
+ frontier = next;
102
+ }
103
+ return undefined;
104
+ }
105
+ /** An author named this lens's master -- on the lens or for its room. */
106
+ function explicitMaster(rooms, room, cam) {
107
+ return cam.masterHost || cam.mountedOn || room.camerasSlavedTo ? lensMaster(rooms, room, cam) : undefined;
108
+ }
50
109
  /**
51
110
  * DO THESE LENSES BROADCAST AT ALL (reporter's ruling 2026-09-17,
52
111
  * eJANPpm7qCaCAJBJw)?
@@ -75,6 +134,12 @@ export function cameraMasterHost(rooms, room) {
75
134
  export function camerasBroadcast(rooms, room) {
76
135
  if (!isWatched(room))
77
136
  return false;
137
+ if (room.camerasWireless !== undefined && !onPole(room))
138
+ return room.camerasWireless;
139
+ // Any lens that broadcasts (lensBroadcasts) puts the cluster on the air.
140
+ const cams = room.ensureCameras();
141
+ if (cams.length > 0)
142
+ return cams.some(c => lensBroadcasts(rooms, room, c));
78
143
  if (room.camerasWireless !== undefined)
79
144
  return room.camerasWireless;
80
145
  return !cameraMasterHost(rooms, room);
@@ -175,7 +240,7 @@ export function describeCamera(rooms, room, cam, viewer) {
175
240
  const where = `up under the ceiling on the ${cam.wall} wall, three metres off the floor`;
176
241
  if (cam.destroyed)
177
242
  return `The ${cam.name}, ${where}: smashed -- a cracked housing hanging off its cable. (${cam.summary()})`;
178
- const knows = cam.knownOffBy.has(viewer.name) || canSnoopFeeds(rooms, room, viewer);
243
+ const knows = cam.knownOffBy.has(viewer.name) || canSnoopLens(rooms, room, cam, viewer);
179
244
  const state = knows && cam.bricked ? ' Bricked -- dead glass, though nothing shows it from down here.'
180
245
  : knows && cam.looped ? ' Looped -- it records an empty hallway for the house.'
181
246
  : cam.damage > 0 ? ' Chipped, still turning.' : '';
@@ -200,9 +265,26 @@ export function camerasLive(rooms, room) {
200
265
  // 2026-10-01): looped by Control Device, bricked by a Data Spike
201
266
  // (commands/disable.ts), or shot off the wall (commands/attack.ts).
202
267
  // One lens still recording is one lens too many.
268
+ //
269
+ // AND EACH LENS ANSWERS TO ITS OWN MASTER (lensMaster): cracking the
270
+ // shop's host blinds the lens slaved to it and not its neighbour.
203
271
  const cams = room.ensureCameras();
204
- if (cams.length > 0 && !cams.some(c => c.working))
272
+ if (cams.length > 0)
273
+ return cams.some(c => lensLive(rooms, room, c));
274
+ return roomFeedsLive(rooms, room);
275
+ }
276
+ /** Is this one lens still recording for the house? */
277
+ export function lensLive(rooms, room, cam) {
278
+ if (!cam.working)
205
279
  return false;
280
+ const master = explicitMaster(rooms, room, cam);
281
+ if (master)
282
+ return !master.hostCracked;
283
+ return roomFeedsLive(rooms, room);
284
+ }
285
+ /** The engine's default for lenses nobody wired: the room's own node, or
286
+ * -- a hostless watched room riding the site network -- any host. */
287
+ function roomFeedsLive(rooms, room) {
206
288
  if (room.hasNode)
207
289
  return !room.hostCracked;
208
290
  const hosts = Object.values(rooms).filter(r => r.hasNode);
@@ -230,12 +312,16 @@ export function liveFootage(rooms, camSeen) {
230
312
  const room = Object.values(rooms).find(r => r.name === name);
231
313
  if (!room || !camerasLive(rooms, room))
232
314
  continue;
233
- const master = cameraMasterHost(rooms, room);
234
- if (!master)
235
- continue;
236
- const list = byHost.get(master) ?? [];
237
- list.push(room);
238
- byHost.set(master, list);
315
+ // Each live lens's footage is on ITS master (lensMaster).
316
+ const cams = room.ensureCameras().filter(c => lensLive(rooms, room, c));
317
+ const masters = new Set(cams.length > 0 ? cams.map(c => lensMaster(rooms, room, c)) : [cameraMasterHost(rooms, room)]);
318
+ for (const master of masters) {
319
+ if (!master)
320
+ continue;
321
+ const list = byHost.get(master) ?? [];
322
+ list.push(room);
323
+ byHost.set(master, list);
324
+ }
239
325
  }
240
326
  let best;
241
327
  for (const [host, seen] of byHost) {
@@ -251,9 +337,108 @@ export function liveFootage(rooms, camSeen) {
251
337
  export function canSnoopFeeds(rooms, room, viewer) {
252
338
  if (!isWatched(room))
253
339
  return false;
340
+ const cams = room.ensureCameras();
341
+ if (cams.length > 0)
342
+ return cams.some(c => canSnoopLens(rooms, room, c, viewer));
343
+ return canSnoopRoom(rooms, room, viewer);
344
+ }
345
+ /** This lens answers to the viewer: its master cracked or marked by them. */
346
+ export function canSnoopLens(rooms, room, cam, viewer) {
347
+ if (!isWatched(room))
348
+ return false;
349
+ const master = explicitMaster(rooms, room, cam);
350
+ if (master)
351
+ return master.hostCracked || (master.hostMarksBy.get(viewer.name) ?? 0) > 0;
352
+ return canSnoopRoom(rooms, room, viewer);
353
+ }
354
+ /** The engine's default: the room's own node, or any of the site's hosts. */
355
+ function canSnoopRoom(rooms, room, viewer) {
254
356
  if (room.hasNode) {
255
357
  return room.hostCracked || (room.hostMarksBy.get(viewer.name) ?? 0) > 0;
256
358
  }
257
359
  return Object.values(rooms).some(r => r.hasNode && (r.hostCracked || (r.hostMarksBy.get(viewer.name) ?? 0) > 0));
258
360
  }
361
+ /** THE LOWEST HOST RATING THAT RUNS CABLE UNDER A STREET (user ruling
362
+ * 2026-10-06; SR5 p.247 puts "local police" at Host Rating 5-6). A
363
+ * pole camera -- one on an outdoor area, not on a building -- slaved to
364
+ * a host below it must broadcast: nobody else hardlines a lamp-post. */
365
+ export const POLE_HARDLINE_RATING = 5;
366
+ /** A lens on an outdoor area: on a pole, not on a building. */
367
+ export function onPole(room) {
368
+ return room.outdoors === true;
369
+ }
370
+ /**
371
+ * DOES THIS ONE LENS BROADCAST? A pole camera on a host below
372
+ * POLE_HARDLINE_RATING must (it cannot be hardlined); otherwise the
373
+ * room's own switch, else the default -- slaved means dark.
374
+ */
375
+ export function lensBroadcasts(rooms, room, cam) {
376
+ const master = lensMaster(rooms, room, cam);
377
+ // HARDLINED when it is bolted to its own host's building; otherwise an
378
+ // outdoor lens is cabled only by a police-grade host.
379
+ const wall = cam.mountedOn ? (rooms[cam.mountedOn] ?? Object.values(rooms).find(r => r.name === cam.mountedOn)) : undefined;
380
+ const hardlined = !!wall && !!master && buildingHost(wall) === master;
381
+ if (onPole(room) && master && !hardlined && master.hostRating < POLE_HARDLINE_RATING)
382
+ return true;
383
+ if (room.camerasWireless !== undefined)
384
+ return room.camerasWireless;
385
+ return !master;
386
+ }
387
+ /**
388
+ * WHICH WORKING CAMERA SEES THIS ACTOR, IF ANY (user ruling 2026-10-06:
389
+ * "LOS only, no radius ... same rule everywhere. No LOS, no view").
390
+ * Canon gives a camera no range at all -- the sensor table's Camera row
391
+ * reads "--" (SR5 p.446) -- so there is none here: a live lens sees what
392
+ * it has a clear line to.
393
+ *
394
+ * - Indoors, a lens sees everyone in its own room.
395
+ * - Outdoors, a lens sees anyone on any outdoor area of the storey --
396
+ * its own included -- with no wall between them on the plane (district-plane.ts wallsBetween) --
397
+ * the pole on the corner watches the strip and the ground alike. A
398
+ * building's walls hide its inside, and it never sees out.
399
+ *
400
+ * Plain Invisibility is a mana illusion and never fools a camera (SR5
401
+ * p.289-291); Improved Invisibility, which would, is not in the engine.
402
+ */
403
+ export function cameraSeeing(rooms, actor) {
404
+ const where = actor.bodyRoom ?? actor.currentLocation;
405
+ if (!where)
406
+ return undefined;
407
+ const ownRoom = () => {
408
+ for (const cam of isWatched(where) ? where.ensureCameras() : []) {
409
+ if (lensLive(rooms, where, cam))
410
+ return { room: where, cam };
411
+ }
412
+ return undefined;
413
+ };
414
+ if (!onPole(where))
415
+ return ownRoom();
416
+ // Nowhere on the plane to draw a line from (a body left behind, or a
417
+ // runner not yet stood on a cell): the area's own lenses, as before.
418
+ const plane = where.scene?.ensurePlane();
419
+ const mine = plane?.placements.get(where.name.trim().toLowerCase());
420
+ const at = actor.atCell;
421
+ if (!plane || !mine || !at || actor.bodyRoom)
422
+ return ownRoom();
423
+ const me = { x: mine.origin.x + at.x, y: mine.origin.y + at.y, z: mine.origin.z + at.z };
424
+ for (const room of Object.values(rooms)) {
425
+ if (!onPole(room) || !isWatched(room))
426
+ continue;
427
+ const there = plane.placements.get(room.name.trim().toLowerCase());
428
+ if (!there || there.origin.z !== me.z)
429
+ continue;
430
+ const grid = room.ensureGrid();
431
+ for (const cam of room.ensureCameras()) {
432
+ if (!lensLive(rooms, room, cam))
433
+ continue;
434
+ const perch = grid?.spotCells.get(cam.perch);
435
+ if (!perch)
436
+ continue;
437
+ const lens = { x: there.origin.x + perch.x, y: there.origin.y + perch.y, z: there.origin.z };
438
+ if (wallsBetween(plane, lens, me) === 0)
439
+ return { room, cam };
440
+ }
441
+ }
442
+ return undefined;
443
+ }
259
444
  //# sourceMappingURL=surveillance.js.map
@@ -4,7 +4,7 @@
4
4
  * `use` was inventory-only. With the puzzle mechanic removed and its
5
5
  * barriers becoming room-owned Devices, one verb now has to serve two
6
6
  * populations that were never in the same namespace: the gear in your
7
- * pack and the keypads, grates and wards bolted to the room.
7
+ * pack and the keypads and grates bolted to the room.
8
8
  *
9
9
  * That makes resolution a LADDER, and the order is the whole design.
10
10
  * Item lookup in this game is deliberately generous -- Inventory's
@@ -17,7 +17,7 @@ import { SPRITE_POWERS } from '../sprite-powers.js';
17
17
  * leave them stuck on a word that works.
18
18
  */
19
19
  export const HELP_CATEGORIES = [
20
- { title: 'moving', entries: [['look'], ['go'], ['move', 'walk', 'approach'], ['fly'], ['run'], ['sprint'], ['climb', 'mantle', 'scale', 'clamber'], ['descend'], ['sit', 'kneel'], ['lie', 'prone'], ['stand'], ['follow'], ['unfollow'], ['map', 'exits'], ['search'], ['sneak']] },
20
+ { title: 'moving', entries: [['look'], ['go'], ['move', 'walk', 'approach'], ['fly'], ['run'], ['sprint'], ['climb', 'mantle', 'scale', 'clamber'], ['descend'], ['leap'], ['sit', 'kneel'], ['lie', 'prone'], ['stand'], ['follow'], ['unfollow'], ['map', 'exits'], ['search'], ['sneak']] },
21
21
  { title: 'gear', entries: [['inv', 'inventory'], ['equipment', 'eq'], ['take'], ['loot'], ['drop'], ['give'], ['store'], ['dumpster'], ['open'], ['close'], ['put'], ['equip'], ['unequip'], ['fit'], ['unfit'], ['brandish', 'draw'], ['holster'], ['reload'], ['use'], ['read']] },
22
22
  { title: 'combat', entries: [['attack'], ['kill'], ['quickdraw'], ['subdue', 'stun', 'knock'], ['grapple', 'restrain', 'clinch'], ['struggle'], ['release'], ['cover'], ['aim'], ['defend'], ['delay'], ['end-turn', 'endturn', 'done', 'pass'], ['initiative', 'tracker', 'turn'], ['nudge'], ['surrender'], ['edge'], ['rest'], ['heal', 'firstaid', 'bandage', 'patch']] },
23
23
  // The party layer: everyone who walks (or flies, or manifests) at
@@ -30,7 +30,7 @@ export const HELP_CATEGORIES = [
30
30
  // entered like any other -- "mark jackpoint" takes its invitation
31
31
  // (utilities/invitations.ts), "enter jackpoint" walks in. The bare word is still
32
32
  // caught before the typo matcher (game.ts) so it cannot read as `jack`.
33
- { title: 'matrix', entries: [['jack'], ['deck'], ['load'], ['unload'], ['hack'], ['force', 'brute', 'smash'], ['mark', 'marks'], ['unmark'], ['disable'], ['brick'], ['download'], ['upload'], ['transfer'], ['enter'], ['exit-host'], ['hop'], ['tap', 'splice'], ['snoop'], ['overwatch', 'os'], ['hide'], ['edit'], ['erase', 'wipe'], ['pan'], ['wireless'], ['ar'], ['aros'], ['silent'], ['reboot'], ['agent'], ['drone'], ['jump', 'rig']] },
33
+ { title: 'matrix', entries: [['jack'], ['deck'], ['load'], ['unload'], ['hack'], ['force', 'brute', 'smash'], ['mark', 'marks'], ['unmark'], ['disable'], ['brick'], ['download'], ['upload'], ['transfer'], ['enter'], ['exit-host'], ['hop'], ['tap', 'splice'], ['snoop'], ['overwatch', 'os'], ['hide'], ['edit'], ['erase', 'wipe'], ['crack'], ['disarm'], ['pan'], ['wireless'], ['ar'], ['aros'], ['silent'], ['reboot'], ['agent'], ['drone'], ['jump', 'rig']] },
34
34
  // The Emerged get their own shelf (player request: "there MUST be
35
35
  // resonance" -- matrix is the place, Resonance is the talent).
36
36
  // `sustain` shelves here and not under magic: the command is