@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
@@ -11,12 +11,26 @@ import * as fuzzyMatch from '../utilities/fuzzy-match.js';
11
11
  import { sameHostSide } from './player.js';
12
12
  import { AI } from '../../../../tools/ai/ai.class.js';
13
13
  import { AbstractRoom } from '../types/shared/abstracts.js';
14
- import { doorstepsOnto } from '../utilities/district-plane.js';
14
+ import { doorstepsOnto, holesOf } from '../utilities/district-plane.js';
15
15
  import { dwellingDims } from '../utilities/dwellings.js';
16
16
  import { spotsActive, isPerchSpot, actorDistanceMeters, voiceBandFor, voiceTag, doorwayKindOf, exitDoorwayKind } from '../utilities/spots.js';
17
17
  import { canHearSpeech, canHearShout } from '../utilities/earshot.js';
18
18
  import { freshCrowd, clampHeadcount, deriveOccupancy } from '../utilities/crowd.js';
19
19
  import { synthesizeGrid, attachExitCell, detachExitCell, hashFnv1a, mulberry32, setCellBlocker, } from '../utilities/room-grid.js';
20
+ /** An authored spot's cells, anchor first: `size` cells east and south
21
+ * of `cell`, on the spot's level. */
22
+ export function authoredSpotCells(s) {
23
+ if (!s.cell)
24
+ return undefined;
25
+ const w = Math.max(1, Math.round(s.size?.w ?? 1));
26
+ const h = Math.max(1, Math.round(s.size?.h ?? 1));
27
+ const z = s.level ?? 0;
28
+ const cells = [];
29
+ for (let dy = 0; dy < h; dy++)
30
+ for (let dx = 0; dx < w; dx++)
31
+ cells.push({ x: s.cell.x + dx, y: s.cell.y + dy, z });
32
+ return cells;
33
+ }
20
34
  /**
21
35
  * A CAMERA'S DEVICE RATING when the scene does not say (p.356, which
22
36
  * lists "Camera" at 1 -- the same table that gives the door lock the 2
@@ -24,6 +38,13 @@ import { synthesizeGrid, attachExitCell, detachExitCell, hashFnv1a, mulberry32,
24
38
  * ordinary security devices at 2 and mil-spec at 4.
25
39
  */
26
40
  export const DEFAULT_CAMERA_RATING = 1;
41
+ /**
42
+ * A WARD'S FORCE when a seed sets `warded` without one. Canon prices a
43
+ * ward by its Force and caps it nowhere in the ritual rules (p.296-298),
44
+ * so this is a house default for legacy seeds only -- the generator and
45
+ * the editor always write a Force (user ruling 2026-10-06).
46
+ */
47
+ export const WARD_DEFAULT_FORCE = 4;
27
48
  export class Room extends AbstractRoom {
28
49
  logger = Logger.getInstance();
29
50
  actors = new Set();
@@ -38,6 +59,10 @@ export class Room extends AbstractRoom {
38
59
  * utilities/outdoors.ts (the taxi curb and the generator's
39
60
  * start-room guarantee both go through it). */
40
61
  outdoors;
62
+ /** The run's ground: the outdoor area under every other (seed `ground`). */
63
+ ground;
64
+ /** Storeys this room spans, from `storey` up (seed `storeys`); 1 unless tall. */
65
+ storeysSpanned = 1;
41
66
  isStartRoom;
42
67
  inventory = new Inventory();
43
68
  supportedLifestyles;
@@ -100,6 +125,12 @@ export class Room extends AbstractRoom {
100
125
  * to force either way per site.
101
126
  */
102
127
  camerasWireless;
128
+ /** THE LENSES AN AUTHOR HUNG (IRoomSeedJson.cameras): when set,
129
+ * ensureCameras builds exactly these instead of deriving them. */
130
+ authoredCameras;
131
+ /** The room whose host these cameras are slaved to (surveillance.ts
132
+ * cameraMasterHost). Unset = this room's own host, else the site's. */
133
+ camerasSlavedTo;
103
134
  /** Has this viewer pinned the cluster's icon? Inside the host these
104
135
  * lenses are slaved to, there is nothing to pin: p.233 makes a
105
136
  * persona in the WAN directly connected to everything on it, which is
@@ -292,6 +323,9 @@ export class Room extends AbstractRoom {
292
323
  * authored plan, and by ensureGrid below for the room own extent. */
293
324
  reachable;
294
325
  footprint;
326
+ /** DOORS AND STAIRS WHERE THE AUTHOR PUT THEM (exit seed `cell`),
327
+ * room-local, by the direction this room declared them. */
328
+ authoredDoorCells;
295
329
  storey;
296
330
  planeOverride;
297
331
  /**
@@ -468,6 +502,44 @@ export class Room extends AbstractRoom {
468
502
  }
469
503
  return bound;
470
504
  }
505
+ /**
506
+ * WHERE A STAIR STANDS IN AN AUTHORED ROOM (user 2026-10-06: "like doors
507
+ * n/s/e/w, doors u/d need to match their counterpart"). The two ends of a
508
+ * stair are over each other: this room's own word if it gave one, else
509
+ * the other end's spot carried across the storeys, else the middle of
510
+ * where the two floors overlap -- the same answer from both ends, so up
511
+ * here lands on down there. Room-local, kept inside the room.
512
+ */
513
+ stairCell(dir) {
514
+ const own = this.authoredDoorCells?.[dir];
515
+ if (own)
516
+ return own;
517
+ const fp = this.footprint;
518
+ const door = this.exits.get(dir);
519
+ const other = door instanceof Door ? door.getOtherSide(this) : undefined;
520
+ const ofp = other?.footprint;
521
+ if (!fp || !ofp)
522
+ return undefined;
523
+ const back = dir === Direction.UP ? Direction.DOWN : Direction.UP;
524
+ const theirs = other.authoredDoorCells?.[back];
525
+ let wx;
526
+ let wy;
527
+ if (theirs) {
528
+ wx = ofp.x + theirs.x;
529
+ wy = ofp.y + theirs.y;
530
+ }
531
+ else {
532
+ const x0 = Math.max(fp.x, ofp.x);
533
+ const x1 = Math.min(fp.x + fp.w, ofp.x + ofp.w);
534
+ const y0 = Math.max(fp.y, ofp.y);
535
+ const y1 = Math.min(fp.y + fp.h, ofp.y + ofp.h);
536
+ if (x1 <= x0 || y1 <= y0)
537
+ return undefined;
538
+ wx = Math.floor((x0 + x1 - 1) / 2);
539
+ wy = Math.floor((y0 + y1 - 1) / 2);
540
+ }
541
+ return { x: Math.max(0, Math.min(fp.w - 1, wx - fp.x)), y: Math.max(0, Math.min(fp.h - 1, wy - fp.y)) };
542
+ }
471
543
  ensureGrid() {
472
544
  // EVERY ROOM IS MATRIXED (player ruling 2026-08-25: "all rooms
473
545
  // should be a matrixed grid"). This used to return undefined for a
@@ -496,8 +568,14 @@ export class Room extends AbstractRoom {
496
568
  // A PACKED room that is one room of a building takes its doors
497
569
  // from the building layout the same way (Scene.partyDoorsFor), so
498
570
  // the gap in a shared wall is where both rooms stand their exit.
571
+ // ...and its STAIRS where the author put them (exit seed `cell`):
572
+ // up and down have no wall, so the room's own word is the cell.
499
573
  doorCells: this.footprint
500
- ? this.scene?.ensurePlane().placements.get(this.name.trim().toLowerCase())?.doors
574
+ ? {
575
+ ...(this.scene?.ensurePlane().placements.get(this.name.trim().toLowerCase())?.doors ?? {}),
576
+ ...(this.stairCell(Direction.UP) ? { [Direction.UP]: this.stairCell(Direction.UP) } : {}),
577
+ ...(this.stairCell(Direction.DOWN) ? { [Direction.DOWN]: this.stairCell(Direction.DOWN) } : {}),
578
+ }
501
579
  : this.outdoors || this.roomType === 'host' ? undefined : this.scene?.partyDoorsFor?.(this.name),
502
580
  // NOTHING STANDS ON A DOORSTEP. A street holds no exit toward the
503
581
  // buildings that front it -- their doors are places, not
@@ -506,6 +584,12 @@ export class Room extends AbstractRoom {
506
584
  keepClear: this.outdoors && this.scene
507
585
  ? doorstepsOnto(this.scene.ensurePlane(), this.name)
508
586
  : undefined,
587
+ // ROOMS ALWAYS WIN (user 2026-10-06): an authored outdoor area loses
588
+ // the cells a building -- or, for the ground, a street -- drawn over
589
+ // it owns (district-plane.ts holesOf). A packed street has none.
590
+ holes: this.outdoors && this.footprint && this.scene
591
+ ? holesOf(this.scene.ensurePlane(), this.name)
592
+ : undefined,
509
593
  // AN OUTDOOR ROOM HAS NO EXTENT OF ITS OWN. It is a region of the
510
594
  // district plane, so it takes the cells the packer allotted it
511
595
  // and its size class is only what decided how wide the street was
@@ -540,6 +624,9 @@ export class Room extends AbstractRoom {
540
624
  name: s.name, place: s.place, level: s.level, tags: s.tags,
541
625
  preferPerimeter: doorwayKindOf(s.name) === 'lateral',
542
626
  perch: isPerchSpot(s) || undefined,
627
+ // A perch's authored cell is the FLOOR cell under the lens (z 0);
628
+ // placePerches hangs it a storey up from there.
629
+ cells: isPerchSpot(s) && s.cell ? [{ x: s.cell.x, y: s.cell.y, z: 0 }] : authoredSpotCells(s),
543
630
  })),
544
631
  // DECLARED bindings only (seed `exits[].atSpot`), plus the
545
632
  // name-matched doorways, resolved below. The resolved exitSpot()
@@ -738,6 +825,13 @@ export class Room extends AbstractRoom {
738
825
  * saved.
739
826
  */
740
827
  warded = false;
828
+ /** The ward's Force (utilities/wards.ts); meaningful only while warded. */
829
+ wardForce = 0;
830
+ /** Who raised the ward -- passes it freely (p.315). */
831
+ wardCreator;
832
+ /** Structure boxes an astral attack has taken off the ward this
833
+ * Combat Turn; refilled at the end of every turn (p.316). */
834
+ wardDamage = 0;
741
835
  // See IRoomConstructorConfig.watched (utilities/surveillance.ts).
742
836
  watched = false;
743
837
  /**
@@ -756,6 +850,23 @@ export class Room extends AbstractRoom {
756
850
  ensureCameras() {
757
851
  if (this._cameras.length > 0)
758
852
  return this._cameras;
853
+ // EXACTLY THE LENSES THE AUTHOR HUNG, where they hung them.
854
+ if (this.authoredCameras && this.roomType !== 'host') {
855
+ const taken = new Set((this.spots ?? []).map(s => s.name.toLowerCase()));
856
+ for (const cam of this.authoredCameras) {
857
+ let name = (cam.name?.trim() || `${cam.wall} camera`).toLowerCase();
858
+ for (let i = 2; taken.has(name); i++)
859
+ name = `${cam.wall} camera ${i}`;
860
+ taken.add(name);
861
+ this._cameras.push(new Camera({ name, wall: cam.wall, rating: cam.rating ?? this.cameraRating, ...(cam.slavedTo ? { masterHost: cam.slavedTo } : {}), ...(cam.mountedOn ? { mountedOn: cam.mountedOn } : {}) }));
862
+ this.spots = [...(this.spots ?? []), {
863
+ name, place: cam.wall, level: 1, tags: ['perch'],
864
+ ...(cam.cell ? { cell: { x: cam.cell.x, y: cam.cell.y } } : {}),
865
+ }];
866
+ }
867
+ this._grid = undefined;
868
+ return this._cameras;
869
+ }
759
870
  // A host's own sculpted space is the Matrix, not a floor with walls.
760
871
  if (!(this.watched || this.hasNode) || this.roomType === 'host')
761
872
  return this._cameras;
@@ -942,6 +1053,8 @@ export class Room extends AbstractRoom {
942
1053
  this.description = config.description;
943
1054
  this.roomType = config.roomType;
944
1055
  this.outdoors = config.outdoors;
1056
+ this.ground = config.ground === true ? true : undefined;
1057
+ this.storeysSpanned = Math.max(1, Math.min(4, Math.floor(config.storeys ?? 1)));
945
1058
  this.hasNode = config.hasNode ?? false;
946
1059
  // THE MIGRATION, AT THE ONE DOOR EVERY ROOM COMES THROUGH.
947
1060
  //
@@ -966,6 +1079,19 @@ export class Room extends AbstractRoom {
966
1079
  if (this.host) {
967
1080
  // Blank from a seed reads as "none declared", never as an empty line.
968
1081
  this.host.purpose = Host.phrase(config.hostPurpose);
1082
+ // A NAMED HOST: its own label, and its Matrix-side room wears it.
1083
+ // THE LINEUP, as the author ordered it (ic.ts resolveICOrder drops
1084
+ // names the engine does not model; the authored audit faults them).
1085
+ if (config.icOrder && config.icOrder.length > 0)
1086
+ this.host.icOrder = [...config.icOrder];
1087
+ if (config.icBricked)
1088
+ this.host.icBricked = config.icBricked;
1089
+ const named = Host.phrase(config.hostName);
1090
+ if (named) {
1091
+ this.host.displayName = named;
1092
+ if (this.host.room && this.host.room !== this)
1093
+ this.host.room.name = named;
1094
+ }
969
1095
  this.host.sculpt = Host.phrase(config.hostSculpt);
970
1096
  // A seeded quality implies the invitation; a bare `hostInvite`
971
1097
  // is open to everyone who can see the host.
@@ -974,7 +1100,15 @@ export class Room extends AbstractRoom {
974
1100
  }
975
1101
  }
976
1102
  this.warded = config.warded ?? false;
1103
+ this.wardForce = this.warded ? Math.max(1, Math.floor(config.wardForce ?? WARD_DEFAULT_FORCE)) : 0;
1104
+ this.wardCreator = this.warded ? config.wardCreator || undefined : undefined;
977
1105
  this.watched = config.watched ?? false;
1106
+ this.authoredCameras = config.cameras && config.cameras.length > 0 ? config.cameras : undefined;
1107
+ if (this.authoredCameras)
1108
+ this.watched = true;
1109
+ if (config.camerasWireless !== undefined)
1110
+ this.camerasWireless = config.camerasWireless;
1111
+ this.camerasSlavedTo = config.camerasSlavedTo;
978
1112
  this.exits = config.exits || new Map();
979
1113
  this.inventory = config.inventory || new Inventory();
980
1114
  this.isStartRoom = config.isStartRoom || false;
@@ -1292,7 +1426,11 @@ Keep it concise and dramatic.`;
1292
1426
  * runtime-attached hideout, keep the symmetric wiring they have
1293
1427
  * always had.
1294
1428
  */
1295
- const streetDoor = this.footprint !== undefined && isOutdoorsPlace(targetRoom) && !isOutdoorsPlace(this);
1429
+ // A SIDE DOOR only: a stair up onto a roof or down into a yard is one
1430
+ // flight with two ends, filed in both rooms like any other (user
1431
+ // 2026-10-06: "doors u/d need to match their counterpart").
1432
+ const lateral = direction !== Direction.UP && direction !== Direction.DOWN;
1433
+ const streetDoor = lateral && this.footprint !== undefined && isOutdoorsPlace(targetRoom) && !isOutdoorsPlace(this);
1296
1434
  if (streetDoor) {
1297
1435
  // No dedupe either: a building may open onto the same street
1298
1436
  // twice (a front door and a side door), and each is its own Door.
@@ -1942,12 +1942,15 @@ export class Scene extends AbstractScene {
1942
1942
  name: r.name,
1943
1943
  size: r.size,
1944
1944
  outdoors: r.outdoors,
1945
+ ground: r.ground,
1946
+ storeys: r.storeysSpanned > 1 ? r.storeysSpanned : undefined,
1945
1947
  exits: [...r.exits.entries()].map(([direction, exit]) => ({
1946
1948
  direction,
1947
1949
  targetRoom: exit instanceof Door ? exit.getOtherSide(r).name : String(exit),
1948
1950
  })),
1949
1951
  footprint: r.footprint,
1950
1952
  storey: r.storey,
1953
+ doorAt: r.footprint ? r.authoredDoorCells : undefined,
1951
1954
  // A DWELLING BRINGS ITS OWN FLOOR (utilities/dwellings.ts), so the
1952
1955
  // square the plane parks is the square the room grid builds.
1953
1956
  dims: r.dwelling !== undefined ? dwellingDims(r.dwelling) : undefined,
@@ -13,13 +13,6 @@ export const AFFORDANCE_FLAVOR = {
13
13
  failureMessage: `Your intrusion trips a countermeasure -- the system logs the attempt and pings whoever's watching.`,
14
14
  hint: `This looks hackable -- try "hack" to skip finding the code (Logic + hacking). Fail, and the system pings whoever's watching.`,
15
15
  },
16
- ward: {
17
- verb: 'cast',
18
- verbs: ['cast', 'dispel'],
19
- resists: `Your hands find nothing to work and your tools find nothing to bite -- the barrier isn't matter. It answers to the Art: "cast" at it, or "dispel" it.`,
20
- failureMessage: `The weave slips through your fingers -- backlash crackles outward, and something out there felt it.`,
21
- hint: `This could be unwoven magically -- try "dispel" (Magic + counterspelling) or "cast" (the casting pool). Fail, and the backlash BITES.`,
22
- },
23
16
  person: {
24
17
  verb: 'bluff',
25
18
  verbs: ['bluff', 'persuade', 'subdue'],
@@ -35,7 +28,9 @@ export function verbsFor(kind) {
35
28
  }
36
29
  /** WARD-SOUNDING NAMES, checked first: a magical barrier is rare and
37
30
  * specific enough here that missing one costs less than a false
38
- * positive stealing a genuine lock from `lock`. */
31
+ * positive stealing a genuine lock from `lock`. A ward is not a device
32
+ * any more -- it is a room's astral barrier (utilities/wards.ts) -- so a
33
+ * device that reads as one is moved onto its room (isWardDevice). */
39
34
  const WARD_HINTS = ['ward', 'seal', 'rune', 'glyph', 'weave', 'astral', 'spell', 'enchant', 'mystic'];
40
35
  /** PHYSICAL-MECHANISM NAMES, checked second -- the actual fix
41
36
  * (backlog 2uNPnc4L6jHaYEtnN / tuku6Z5XTtmNemkzd, 2026-09-08). A
@@ -64,13 +59,25 @@ const LOCK_HINTS = [
64
59
  * description first.
65
60
  */
66
61
  export function clampDeviceKind(rawKind, name, description) {
67
- if (rawKind === 'lock' || rawKind === 'panel' || rawKind === 'ward')
62
+ if (rawKind === 'lock' || rawKind === 'panel')
68
63
  return rawKind;
69
64
  const text = `${name} ${description ?? ''}`.toLowerCase();
70
- if (WARD_HINTS.some(hint => text.includes(hint)))
71
- return 'ward';
72
65
  if (LOCK_HINTS.some(hint => text.includes(hint)))
73
66
  return 'lock';
74
67
  return 'panel';
75
68
  }
69
+ /**
70
+ * IS THIS "DEVICE" REALLY A WARD (user 2026-10-06)? The retired kind, or
71
+ * an unknown kind whose name or description reads as one. Callers move
72
+ * it onto its room as an astral barrier (seed-migration.ts
73
+ * wardDevicesToRooms) instead of clamping it into a lock or a panel.
74
+ */
75
+ export function isWardDevice(rawKind, name, description) {
76
+ if (rawKind === 'ward')
77
+ return true;
78
+ if (rawKind === 'lock' || rawKind === 'panel')
79
+ return false;
80
+ const text = `${name} ${description ?? ''}`.toLowerCase();
81
+ return WARD_HINTS.some(hint => text.includes(hint));
82
+ }
76
83
  //# sourceMappingURL=affordances.js.map
@@ -62,6 +62,9 @@ export function alarmedStaff(scene, player) {
62
62
  // Ice is not staff: a program answers to the host's dice
63
63
  // (utilities/ic.ts), never to an alarm beat (NPC.isConstruct).
64
64
  .filter(npc => !npc.isConstruct())
65
+ // Nor a spider's slumped body: the person is in the host
66
+ // (utilities/spider.ts), and the host's fight is how they answer.
67
+ .filter(npc => npc.plane === 'meat')
65
68
  .filter(npc => !npc.isIncapacitated());
66
69
  }
67
70
  /**
@@ -589,8 +589,10 @@ export function astralTags(scene, actor, room) {
589
589
  // A ward is opaque astrally (SG p.107) -- the one thing that
590
590
  // stops an aura. Cast over a PLACE, so it is the rooms that
591
591
  // carry it, never the cells between.
592
- const wards = (other.warded ? 1 : 0)
593
- + (room.warded && other !== room ? 1 : 0);
592
+ // A ward is a SURFACE: it hides what is on its far side, never
593
+ // what shares the room with you (a body in your own warded room
594
+ // was going dark because its room was warded).
595
+ const wards = other === room ? 0 : (other.warded ? 1 : 0) + (room.warded ? 1 : 0);
594
596
  if (!auraVisible(d, wards))
595
597
  continue;
596
598
  if (!castsAura(body))
@@ -4,6 +4,7 @@ import { isOutdoorsPlace } from './outdoors.js';
4
4
  import { astralStepFrom, spotForCell } from './spots.js';
5
5
  import { isOpenGround, isOpenAir } from './district-plane.js';
6
6
  import { hint } from './hints.js';
7
+ import { spendWardPass, wardHolds } from './wards.js';
7
8
  import { METERS_PER_CELL } from './room-grid.js';
8
9
  /**
9
10
  * THE OUTDOOR ROOM A PATCH OF UNOWNED GROUND BELONGS TO.
@@ -75,7 +76,7 @@ export function driftAstral(scene, actor, rooms, direction, count, untilOut = fa
75
76
  // the room it is trying to leave is large.
76
77
  const limit = untilOut ? 50 : count;
77
78
  while (taken < limit) {
78
- const step = astralStepFrom(plane, cell, direction, 1, byKey, c => scene.barrierAt?.(c, 'mana', actor.name)?.barrier.caster);
79
+ const step = astralStepFrom(plane, cell, direction, 1, byKey, c => scene.barrierAt?.(c, 'mana', actor.name)?.barrier.caster, r => wardHolds(r, actor));
79
80
  if (step.taken === 0) {
80
81
  blockedBy = step.blockedBy;
81
82
  ward = step.ward;
@@ -178,6 +179,10 @@ export function driftAstral(scene, actor, rooms, direction, count, untilOut = fa
178
179
  }
179
180
  scene.updateStatus?.();
180
181
  scene.updateExits?.(actor.currentLocation);
182
+ // A PRESSED-THROUGH WARD IS ONE CROSSING (wards.ts): the drift that
183
+ // made it spends the pass, so the way back out is pressed again.
184
+ if (taken > 0)
185
+ spendWardPass(actor);
181
186
  return { cell, taken, arrived, blockedBy, ward, metres: taken * METERS_PER_CELL };
182
187
  }
183
188
  /** What stopped you, said the way the astral would say it. */
@@ -197,7 +202,7 @@ export function driftRefusal(d, direction) {
197
202
  return `A mana barrier hangs across the way ${direction}${d.ward ? ` -- ${d.ward}'s working` : ''}: a hazy, solid sheet of woven mana. The astral does not pass (p.315-316).`;
198
203
  }
199
204
  if (d.blockedBy === 'ward') {
200
- return `A lattice of woven mana stands across the way ${direction}${d.ward ? ` -- ${d.ward}'s` : ''}. The astral does not pass.`;
205
+ return `A ward stands across the way ${direction}${d.ward ? ` -- the one over ${d.ward}` : ''}: a hazy, solid lattice of woven mana. The astral does not pass${hint(` -- "go ${direction}" presses through it (Magic + Charisma v. its Force x 2, p.316), "attack ward" tears at it`)}.`;
201
206
  }
202
207
  // WHICH NOTHING IT IS depends on which way you tried. Downward the
203
208
  // refusal is canon and worth saying out loud -- solid earth is the
@@ -7,7 +7,7 @@ export function resolveBarrierStrike(scene, logger, attacker, device, room, opts
7
7
  const meta = [];
8
8
  const done = (opened = false) => ({ world, meta, opened });
9
9
  if (device.structure <= 0) {
10
- world.push(`${device.name} isn't matter -- there is nothing for a round to hit. It answers to the Art.`);
10
+ world.push(`${device.name} has no Structure -- there is nothing for a round to break.`);
11
11
  return done();
12
12
  }
13
13
  if (device.isOpen() || device.isHoled) {
@@ -1,3 +1,4 @@
1
+ import { mendWards } from './wards.js';
1
2
  import { regenerate } from './barrier-geometry.js';
2
3
  import { rollInitiativeScore, formatInitiative, INITIATIVE_PASS_DROP, rollPool, formatRoll } from './dice.js';
3
4
  import { ActionBudget } from './action-budget.js';
@@ -823,6 +824,14 @@ export class CombatEncounter {
823
824
  mended = true;
824
825
  this.logger.log(`{cyan-fg}${barrier.caster}'s ${kind === 'mana' ? 'mana barrier' : 'barrier'} knits itself whole again -- Structure ${barrier.structure} (${kind === 'mana' ? 'p.316' : 'p.294'}).{/cyan-fg}`, { room: barrier.roomName });
825
826
  }
827
+ // WARDS TOO (p.316): a ward is a mana barrier, whole again at the end
828
+ // of every Combat Turn -- tearing one down is a single turn's work.
829
+ if (kind === 'mana') {
830
+ for (const ward of mendWards(Object.values(this.scene.getRooms?.() ?? {}))) {
831
+ mended = true;
832
+ this.logger.log(`{cyan-fg}The ward over ${ward.name} knits itself whole again -- Structure ${ward.wardForce} (p.316).{/cyan-fg}`, { room: ward.name });
833
+ }
834
+ }
826
835
  if (mended) {
827
836
  this.scene.updateStatus?.();
828
837
  this.scene.updateExits?.(this.room);
@@ -626,7 +626,8 @@ export function planeFaults(plane, rooms) {
626
626
  for (let j = i + 1; j < floors.length; j++) {
627
627
  const [an, a] = floors[i];
628
628
  const [bn, b] = floors[j];
629
- if (a.origin.z !== b.origin.z)
629
+ // Spans that share a storey (a tall room reaches up through them).
630
+ if (a.origin.z + (a.height ?? 1) <= b.origin.z || b.origin.z + (b.height ?? 1) <= a.origin.z)
630
631
  continue;
631
632
  if (overlaps(a, b.origin.x - WALL_CELLS, b.origin.y - WALL_CELLS, b.dims.x + 2 * WALL_CELLS, b.dims.y + 2 * WALL_CELLS)) {
632
633
  out.push(`${label(an)} and ${label(bn)} are drawn touching, with no wall between them.`);
@@ -991,9 +992,11 @@ function interiorMap(placements) {
991
992
  for (const [name, at] of placements) {
992
993
  if (at.outdoors)
993
994
  continue;
994
- for (let x = at.origin.x; x < at.origin.x + at.dims.x; x++) {
995
- for (let y = at.origin.y; y < at.origin.y + at.dims.y; y++) {
996
- interiorAt.set(`${x},${y},${at.origin.z}`, name);
995
+ for (let z = at.origin.z; z < at.origin.z + (at.height ?? 1); z++) {
996
+ for (let x = at.origin.x; x < at.origin.x + at.dims.x; x++) {
997
+ for (let y = at.origin.y; y < at.origin.y + at.dims.y; y++) {
998
+ interiorAt.set(`${x},${y},${z}`, name);
999
+ }
997
1000
  }
998
1001
  }
999
1002
  }
@@ -1046,20 +1049,21 @@ function ringsAndDoorways(placements, graph) {
1046
1049
  for (const [, at] of placements) {
1047
1050
  if (at.outdoors)
1048
1051
  continue;
1049
- const z = at.origin.z;
1050
- for (let x = at.origin.x - WALL_CELLS; x < at.origin.x + at.dims.x + WALL_CELLS; x++) {
1051
- for (let y = at.origin.y - WALL_CELLS; y < at.origin.y + at.dims.y + WALL_CELLS; y++) {
1052
- const inside = x >= at.origin.x && x < at.origin.x + at.dims.x
1053
- && y >= at.origin.y && y < at.origin.y + at.dims.y;
1054
- if (inside)
1055
- continue;
1056
- // Never wall over someone else's floor: two buildings sharing a
1057
- // gutter get one wall between them, not two overlapping rings.
1058
- if (interiorAt.has(`${x},${y},${z}`))
1059
- continue;
1060
- walls.add(`${x},${y},${z}`);
1052
+ // A TALL ROOM IS WALLED ON EVERY STOREY IT SPANS (seed `storeys`).
1053
+ for (let z = at.origin.z; z < at.origin.z + (at.height ?? 1); z++)
1054
+ for (let x = at.origin.x - WALL_CELLS; x < at.origin.x + at.dims.x + WALL_CELLS; x++) {
1055
+ for (let y = at.origin.y - WALL_CELLS; y < at.origin.y + at.dims.y + WALL_CELLS; y++) {
1056
+ const inside = x >= at.origin.x && x < at.origin.x + at.dims.x
1057
+ && y >= at.origin.y && y < at.origin.y + at.dims.y;
1058
+ if (inside)
1059
+ continue;
1060
+ // Never wall over someone else's floor: two buildings sharing a
1061
+ // gutter get one wall between them, not two overlapping rings.
1062
+ if (interiorAt.has(`${x},${y},${z}`))
1063
+ continue;
1064
+ walls.add(`${x},${y},${z}`);
1065
+ }
1061
1066
  }
1062
- }
1063
1067
  }
1064
1068
  // DOORWAYS: a gap in the wall between two rooms the exit graph joins
1065
1069
  // laterally. Punched on the facing edge, nearest the neighbour, so
@@ -1124,7 +1128,27 @@ function overlaps(a, bx, by, bw, bh) {
1124
1128
  * Room.ensureGrid takes its exit cell FROM the plane (the reverse of
1125
1129
  * the packed path). One rule, no jitter, nothing to reconcile.
1126
1130
  */
1127
- function authoredDoors(placements, graph) {
1131
+ function authoredDoors(placements, graph, rooms = []) {
1132
+ // WHERE AN AUTHOR MOVED A DOOR (user 2026-10-06: "move ... the door
1133
+ // placement, within reason based on wall area between the rooms"):
1134
+ // one plane coordinate along the shared wall per pair of rooms, from
1135
+ // whichever side declared it, so both halves of the door agree and it
1136
+ // stays one gap in one wall (planeFaults). Clamped below to the
1137
+ // overlap -- a door cannot open onto masonry.
1138
+ const pairKey = (a, b) => [a, b].sort().join('|');
1139
+ const along = new Map();
1140
+ for (const room of rooms) {
1141
+ const at = placements.get(norm(room.name));
1142
+ if (!at || !room.doorAt)
1143
+ continue;
1144
+ for (const link of graph.get(norm(room.name)) ?? []) {
1145
+ const lat = LATERAL[link.direction];
1146
+ const cell = room.doorAt[link.direction];
1147
+ if (!lat || !cell)
1148
+ continue;
1149
+ along.set(pairKey(norm(room.name), link.to), lat[0] !== 0 ? at.origin.y + cell.y : at.origin.x + cell.x);
1150
+ }
1151
+ }
1128
1152
  for (const [name, at] of placements) {
1129
1153
  if (at.outdoors)
1130
1154
  continue;
@@ -1138,13 +1162,19 @@ function authoredDoors(placements, graph) {
1138
1162
  // East/west wall: the door slides along Y.
1139
1163
  const lo = Math.max(at.origin.y, other.origin.y);
1140
1164
  const hi = Math.min(at.origin.y + at.dims.y, other.origin.y + other.dims.y);
1141
- const midY = hi > lo ? Math.floor((lo + hi - 1) / 2) : at.origin.y + Math.floor(at.dims.y / 2);
1165
+ const authored = along.get(pairKey(name, link.to));
1166
+ const midY = hi > lo
1167
+ ? (authored !== undefined ? clamp(authored, lo, hi - 1) : Math.floor((lo + hi - 1) / 2))
1168
+ : at.origin.y + Math.floor(at.dims.y / 2);
1142
1169
  doors[link.direction] = { x: 0, y: clamp(midY - at.origin.y, 0, at.dims.y - 1) };
1143
1170
  }
1144
1171
  else {
1145
1172
  const lo = Math.max(at.origin.x, other.origin.x);
1146
1173
  const hi = Math.min(at.origin.x + at.dims.x, other.origin.x + other.dims.x);
1147
- const midX = hi > lo ? Math.floor((lo + hi - 1) / 2) : at.origin.x + Math.floor(at.dims.x / 2);
1174
+ const authored = along.get(pairKey(name, link.to));
1175
+ const midX = hi > lo
1176
+ ? (authored !== undefined ? clamp(authored, lo, hi - 1) : Math.floor((lo + hi - 1) / 2))
1177
+ : at.origin.x + Math.floor(at.dims.x / 2);
1148
1178
  doors[link.direction] = { x: clamp(midX - at.origin.x, 0, at.dims.x - 1), y: 0 };
1149
1179
  }
1150
1180
  }
@@ -1168,6 +1198,10 @@ function parkUnplaced(rooms, placements, graph, byName, notes) {
1168
1198
  for (const at of placements.values()) {
1169
1199
  if (at.origin.z !== z)
1170
1200
  continue;
1201
+ // THE GROUND IS WHERE THINGS PARK, not in their way: a room set
1202
+ // down on it cuts its own hole (roomAtCell).
1203
+ if (at.ground)
1204
+ continue;
1171
1205
  // One clear cell all round, so two rings never fuse.
1172
1206
  if (overlaps(at, x - 1, y - 1, w + 2, h + 2))
1173
1207
  return false;
@@ -1258,11 +1292,13 @@ export function buildAuthoredPlane(rooms) {
1258
1292
  origin: { x: fp.x, y: fp.y, z: room.storey ?? 0 },
1259
1293
  dims: { x: Math.max(1, fp.w), y: Math.max(1, fp.h) },
1260
1294
  outdoors: room.outdoors === true,
1295
+ ...(room.ground ? { ground: true } : {}),
1296
+ ...(room.storeys && room.storeys > 1 ? { height: room.storeys } : {}),
1261
1297
  });
1262
1298
  }
1263
1299
  parkUnplaced(rooms, placements, graph, byName, notes);
1264
- authoredDoors(placements, graph);
1265
- const storeys = [...new Set([0, ...[...placements.values()].map(p => p.origin.z)])]
1300
+ authoredDoors(placements, graph, rooms);
1301
+ const storeys = [...new Set([0, ...[...placements.values()].flatMap(p => Array.from({ length: p.height ?? 1 }, (_, i) => p.origin.z + i))])]
1266
1302
  .sort((a, b) => a - b);
1267
1303
  // The canvas is the extent worth DRAWING, not a fence -- the edges
1268
1304
  // are open, so this only has to cover what exists.
@@ -1305,16 +1341,34 @@ export function worldCell(plane, room, local) {
1305
1341
  return undefined;
1306
1342
  return { x: at.origin.x + local.x, y: at.origin.y + local.y, z: at.origin.z + local.z };
1307
1343
  }
1308
- /** Which room owns a plane cell, if any. */
1344
+ /**
1345
+ * Which room owns a plane cell, if any.
1346
+ *
1347
+ * ROOMS ALWAYS WIN (user rulings 2026-10-06): where rectangles overlap,
1348
+ * an indoor room owns the cell; else an outdoor space; the ground --
1349
+ * the area under everything -- only what nothing else covers. So a
1350
+ * building drawn on a street cuts its floor out of the street, and a
1351
+ * street drawn on the ground cuts its own out of the ground, whatever
1352
+ * order they were drawn or named in. A packed district never overlaps,
1353
+ * so it answers exactly as the first-match scan always did.
1354
+ */
1309
1355
  export function roomAtCell(plane, c) {
1356
+ let space;
1357
+ let ground;
1310
1358
  for (const [name, at] of plane.placements) {
1311
- if (at.origin.z !== c.z)
1359
+ if (c.z < at.origin.z || c.z >= at.origin.z + (at.height ?? 1))
1360
+ continue;
1361
+ if (c.x < at.origin.x || c.x >= at.origin.x + at.dims.x
1362
+ || c.y < at.origin.y || c.y >= at.origin.y + at.dims.y)
1312
1363
  continue;
1313
- if (c.x >= at.origin.x && c.x < at.origin.x + at.dims.x
1314
- && c.y >= at.origin.y && c.y < at.origin.y + at.dims.y)
1364
+ if (!at.outdoors)
1315
1365
  return name;
1366
+ if (at.ground)
1367
+ ground ??= name;
1368
+ else
1369
+ space ??= name;
1316
1370
  }
1317
- return undefined;
1371
+ return space ?? ground;
1318
1372
  }
1319
1373
  /**
1320
1374
  * Straight-line distance between two plane cells, in metres. The whole
@@ -1650,6 +1704,30 @@ export const planeSeed = hashFnv1a;
1650
1704
  * side is the ground outside it. No side arithmetic and no exit graph:
1651
1705
  * ask which of a doorway's four neighbours this street covers.
1652
1706
  */
1707
+ /**
1708
+ * THE CELLS OF AN OUTDOOR AREA'S RECTANGLE IT DOES NOT OWN, room-local
1709
+ * "x,y" (user rulings 2026-10-06: "rooms always win"; spaces are drawn
1710
+ * over the ground): another room's floor or a wall. Empty for anything
1711
+ * indoor, and for an area nothing is drawn over -- which is every packed
1712
+ * district, so their grids are untouched.
1713
+ */
1714
+ export function holesOf(plane, room) {
1715
+ const holes = new Set();
1716
+ const me = norm(room);
1717
+ const at = plane.placements.get(me);
1718
+ if (!at?.outdoors)
1719
+ return holes;
1720
+ const z = at.origin.z;
1721
+ for (let lx = 0; lx < at.dims.x; lx++) {
1722
+ for (let ly = 0; ly < at.dims.y; ly++) {
1723
+ const x = at.origin.x + lx;
1724
+ const y = at.origin.y + ly;
1725
+ if (plane.walls.has(`${x},${y},${z}`) || roomAtCell(plane, { x, y, z }) !== me)
1726
+ holes.add(`${lx},${ly}`);
1727
+ }
1728
+ }
1729
+ return holes;
1730
+ }
1653
1731
  export function doorstepsOnto(plane, room) {
1654
1732
  const at = plane.placements.get(norm(room));
1655
1733
  if (at === undefined || !at.outdoors)
@@ -1667,6 +1745,10 @@ export function doorstepsOnto(plane, room) {
1667
1745
  continue;
1668
1746
  if (y < at.origin.y || y >= at.origin.y + at.dims.y)
1669
1747
  continue;
1748
+ // Only the street's OWN ground: a building drawn inside it owns
1749
+ // the cells on its side of the doorway (roomAtCell).
1750
+ if (roomAtCell(plane, { x, y, z: at.origin.z }) !== norm(room))
1751
+ continue;
1670
1752
  const local = `${x - at.origin.x},${y - at.origin.y}`;
1671
1753
  if (seen.has(local))
1672
1754
  continue;