@maka/maka-cli 5.391.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 (67) 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 +13 -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 +49 -3
  61. package/bundle/typescript/src/commands/game/sideQuest/utilities/spider.js +50 -0
  62. package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js +14 -4
  63. package/bundle/typescript/src/commands/game/sideQuest/utilities/surveillance.js +193 -8
  64. package/bundle/typescript/src/commands/game/sideQuest/utilities/use-target.js +1 -1
  65. package/bundle/typescript/src/commands/game/sideQuest/utilities/verb-index.js +2 -2
  66. package/bundle/typescript/src/commands/game/sideQuest/utilities/wards.js +116 -0
  67. package/package.json +1 -1
@@ -3,12 +3,15 @@ import path from 'path';
3
3
  import { AI } from '../../../../tools/ai/ai.class.js';
4
4
  import { Player } from '../models/player.js';
5
5
  import { Room } from '../models/room.js';
6
+ import { deviceMaster } from '../utilities/wan.js';
6
7
  import { Logger } from '../utilities/logger.js';
7
- import { clampDeviceKind } from '../utilities/affordances.js';
8
+ import { clampDeviceKind, isWardDevice } from '../utilities/affordances.js';
9
+ import { wardDevicesToRooms } from './seed-migration.js';
8
10
  import { GenerationCapture } from '../utilities/generation-capture.js';
9
11
  import { fetchCanonContext } from '../utilities/canon-lore.js';
10
12
  import { providerUnavailable } from '../utilities/draft-failure-note.js';
11
13
  import { SceneSynthesizer } from './scene-factory.js';
14
+ import { resolveCatalogItemSeed } from './catalog-item-seed.js';
12
15
  import { gridDefects } from '../game-master/diagnose.js';
13
16
  import { rollTwist, sizeForTier, pickJobKind, shapeFor } from './job-order.js';
14
17
  import { chapterJobKind } from './operation.js';
@@ -467,6 +470,76 @@ export class SceneSeedGenerator {
467
470
  this.validateRewardItemPlacement(candidate);
468
471
  this.validateDevicePlacement(candidate);
469
472
  }
473
+ /**
474
+ * EVERY FAULT, NOT THE FIRST (player-built runs, 2026-10-06). The same
475
+ * gate dryRunValidate runs, for a seed somebody is EDITING: an author
476
+ * fixes faults one pass at a time only if they can see them all, so
477
+ * each check here is caught and recorded instead of ending the audit.
478
+ *
479
+ * Works on a copy -- clampDeviceKinds repairs its input, and an
480
+ * editor's seed is the author's, not the validator's. Structure and
481
+ * synthesis still stop the audit: every check after them reads the
482
+ * built scene or assumes named rooms, and would only echo the first
483
+ * fault in another voice. The built Scene comes back for a caller
484
+ * that wants to draw it.
485
+ */
486
+ static async auditSeed(seed, player) {
487
+ const candidate = structuredClone(seed);
488
+ const message = (err) => (err instanceof Error ? err.message : String(err));
489
+ // CATALOG SLUGS FIRST: a slug-only item has no name until its row is
490
+ // read, and the structure check below would call it nameless. Each
491
+ // bad slug is its own fault, and the item is left out of the build so
492
+ // the rest of the run is still checked.
493
+ const slugFaults = [];
494
+ candidate.items = (candidate.items ?? []).flatMap(item => {
495
+ try {
496
+ return [resolveCatalogItemSeed(item)];
497
+ }
498
+ catch (err) {
499
+ slugFaults.push({ check: 'items', message: message(err) });
500
+ return [];
501
+ }
502
+ });
503
+ try {
504
+ this.validateBasicStructure(candidate);
505
+ }
506
+ catch (err) {
507
+ return { faults: [...slugFaults, { check: 'structure', message: message(err) }] };
508
+ }
509
+ const nexus = new Room({ name: 'Nexus', description: 'A featureless room where lost souls drift.', roomType: 'void' });
510
+ const throwawayPlayer = new Player({ playerName: candidate?.player?.name || 'Maka', startLocation: nexus });
511
+ let built;
512
+ try {
513
+ built = await SceneSynthesizer.synthesizeFromJson(candidate, throwawayPlayer, false);
514
+ }
515
+ catch (err) {
516
+ return { faults: [...slugFaults, { check: 'synthesis', message: message(err) }] };
517
+ }
518
+ const faults = [
519
+ ...slugFaults,
520
+ ...built.planeFaults().map(f => ({ check: 'plane', message: f })),
521
+ ];
522
+ const checks = [
523
+ ['clues', () => this.validateClueItems(candidate, built)],
524
+ ['winCondition', () => this.validateWinCondition(candidate, player)],
525
+ ['client', () => this.validateClient(candidate)],
526
+ ['matrix', () => this.validateMatrixDefense(candidate)],
527
+ ['startingItems', () => this.validateStartingItems(candidate, player)],
528
+ ['devices', () => this.clampDeviceKinds(candidate)],
529
+ ['doorKeys', () => this.validateDoorKeys(candidate)],
530
+ ['rewardItem', () => this.validateRewardItemPlacement(candidate)],
531
+ ['devicePlacement', () => this.validateDevicePlacement(candidate)],
532
+ ];
533
+ for (const [check, run] of checks) {
534
+ try {
535
+ run();
536
+ }
537
+ catch (err) {
538
+ faults.push({ check, message: message(err) });
539
+ }
540
+ }
541
+ return { faults, built };
542
+ }
470
543
  /**
471
544
  * The one structural guarantee every other check assumes: every room,
472
545
  * NPC, and item actually has a real, non-blank "name". Nothing about the
@@ -632,6 +705,25 @@ export class SceneSeedGenerator {
632
705
  edges.push({ a: room.name, b: exit.targetRoom, keyRequired: exit.keyRequired, puzzle: exit.puzzle });
633
706
  }
634
707
  }
708
+ // OUTDOOR SEAMS ARE WALKED, NOT DOORS (user rulings 2026-10-06: a space
709
+ // drawn on the ground joins it seamlessly, and spaces that touch join
710
+ // each other): two outdoor areas on one storey whose footprints
711
+ // overlap or meet edge to edge are open ground between them
712
+ // (move.ts crossOutdoorSeam), with no exit declared and none needed.
713
+ const outdoor = rooms.filter(r => r.outdoors && r.footprint);
714
+ for (let i = 0; i < outdoor.length; i++) {
715
+ for (let j = i + 1; j < outdoor.length; j++) {
716
+ const a = outdoor[i];
717
+ const b = outdoor[j];
718
+ if ((a.storey ?? 0) !== (b.storey ?? 0))
719
+ continue;
720
+ const fa = a.footprint;
721
+ const fb = b.footprint;
722
+ if (fa.x <= fb.x + fb.w && fb.x <= fa.x + fa.w && fa.y <= fb.y + fb.h && fb.y <= fa.y + fa.h) {
723
+ edges.push({ a: a.name, b: b.name });
724
+ }
725
+ }
726
+ }
635
727
  reachable.add(startRoom);
636
728
  let grew = true;
637
729
  while (grew) {
@@ -865,6 +957,18 @@ export class SceneSeedGenerator {
865
957
  * SYSTEM", which is backwards for a physical mechanism.
866
958
  */
867
959
  static clampDeviceKinds(candidate) {
960
+ // A WARD IS A ROOM'S (user 2026-10-06): anything written or read as
961
+ // one is moved onto the room it protects first, so the clamp below
962
+ // never turns it into a lock or a panel.
963
+ for (const room of candidate.rooms ?? []) {
964
+ for (const device of room.devices ?? []) {
965
+ if (isWardDevice(device.kind, device.name, device.description))
966
+ device.kind = 'ward';
967
+ }
968
+ }
969
+ const moved = wardDevicesToRooms(candidate);
970
+ candidate.rooms = moved.rooms;
971
+ candidate.items = moved.items;
868
972
  for (const room of candidate.rooms ?? []) {
869
973
  for (const device of room.devices ?? []) {
870
974
  const clamped = clampDeviceKind(device.kind, device.name, device.description);
@@ -950,8 +1054,24 @@ export class SceneSeedGenerator {
950
1054
  * documentation -- finding it and seeing its name on screen already
951
1055
  * reveals the answer. A Book clue is still required for anything else.
952
1056
  */
953
- static validateClueItems(candidate) {
1057
+ static validateClueItems(candidate, built) {
954
1058
  const items = candidate.items ?? [];
1059
+ // THE HOST'S OWNER KNOWS IT (user ruling 2026-10-06: "circumvented if
1060
+ // the keypad is connected to a host -> room -> room owner"). A panel
1061
+ // slaved to a host whose room has an owner in the cast has its code
1062
+ // in somebody's head (NPC hostCodeNote) -- social or violent, the way
1063
+ // to it exists. Asked of the BUILT scene, so the master is the
1064
+ // engine's own wiring (wan.ts deviceMaster, nearest-host default
1065
+ // included); without one -- generation -- the rule is unchanged.
1066
+ const knownToOwner = (roomName, deviceName) => {
1067
+ if (!built)
1068
+ return false;
1069
+ const rooms = built.getRooms();
1070
+ const room = Object.values(rooms).find(r => r.name === roomName);
1071
+ const device = room?.allDevices().find(d => d.name === deviceName);
1072
+ const master = room && device ? deviceMaster(rooms, room, device) : undefined;
1073
+ return !!master?.owner && !!built.getActor(master.owner);
1074
+ };
955
1075
  for (const room of candidate.rooms ?? []) {
956
1076
  for (const device of room.devices ?? []) {
957
1077
  // ONLY A PANEL HAS A CODE TO WRITE DOWN. A lock's findable way
@@ -979,7 +1099,7 @@ export class SceneSeedGenerator {
979
1099
  // playing, and so look like random flakiness. A code is
980
1100
  // findable for anyone; the decker who did not need it loses
981
1101
  // nothing by its existing.
982
- if (!hasBookClue && !hasNamedItemClue) {
1102
+ if (!hasBookClue && !hasNamedItemClue && !knownToOwner(room.name, device.name)) {
983
1103
  throw new Error(`Panel "${device.name}" has no discoverable clue: no Book-category item in a different room whose "details" contains the code "${device.code}", and no item elsewhere literally named "${device.code}". Every panel's code must be written down somewhere findable.`);
984
1104
  }
985
1105
  }
@@ -1,4 +1,5 @@
1
1
  import { Logger } from '../utilities/logger.js';
2
+ import { WARD_DEFAULT_FORCE } from '../models/room.js';
2
3
  /**
3
4
  * KIND, from the old bypassType (rule 4).
4
5
  *
@@ -11,7 +12,9 @@ import { Logger } from '../utilities/logger.js';
11
12
  function kindFor(bypassType) {
12
13
  switch (bypassType) {
13
14
  case 'lockpick': return 'lock';
14
- case 'spell': return 'ward';
15
+ // A spell barrier was a ward. It is written as the legacy ward
16
+ // device here and moved onto its room by wardDevicesToRooms below.
17
+ case 'spell': return LEGACY_WARD;
15
18
  case 'hack': return 'panel';
16
19
  // Absent, 'con', or anything unrecognised.
17
20
  default: return 'panel';
@@ -39,6 +42,47 @@ function synthesiseCode(name) {
39
42
  export function migrateLegacySeed(json) {
40
43
  return migrateLegacySeedWithReport(json).seed;
41
44
  }
45
+ /** The retired device kind, as old seeds and old models still write it. */
46
+ const LEGACY_WARD = 'ward';
47
+ /**
48
+ * A WARD IS A ROOM'S, NOT A DOOR'S (user 2026-10-06; SR5 p.294, p.297-298,
49
+ * p.315). The ward DEVICE stood on a door and locked it to bodies until a
50
+ * mage dispelled it -- but a ward is an astral barrier over a place, and
51
+ * it "does not restrict living beings or physical objects". So every
52
+ * legacy ward device becomes a warded ROOM:
53
+ * - on an exit, the room behind that door -- what it was protecting;
54
+ * - on no exit (a warded strongbox), the room it stood in.
55
+ * Its Force is the device's rating where it had one, else the default.
56
+ * The door it held is simply a door now. Whatever it granted is put on
57
+ * the floor of its room, so a prize behind a ward is not stranded.
58
+ * Pure and idempotent: a seed with no ward device comes back as itself.
59
+ */
60
+ export function wardDevicesToRooms(json) {
61
+ const isWard = (d) => d.kind === LEGACY_WARD;
62
+ if (!(json.rooms ?? []).some(r => (r.devices ?? []).some(isWard)))
63
+ return json;
64
+ const log = Logger.getInstance();
65
+ const rooms = (json.rooms ?? []).map(r => ({ ...r, devices: [...(r.devices ?? [])] }));
66
+ const byName = new Map(rooms.map(r => [r.name, r]));
67
+ let items = json.items;
68
+ for (const room of rooms) {
69
+ const wards = room.devices.filter(isWard);
70
+ if (wards.length === 0)
71
+ continue;
72
+ room.devices = room.devices.filter(d => !isWard(d));
73
+ for (const d of wards) {
74
+ const exit = d.opensExit ? (room.exits ?? []).find(e => e.direction === d.opensExit) : undefined;
75
+ const warded = (exit ? byName.get(exit.targetRoom) : undefined) ?? room;
76
+ warded.warded = true;
77
+ warded.wardForce = Math.max(warded.wardForce ?? 0, d.rating !== undefined && d.rating > 0 ? Math.floor(d.rating) : WARD_DEFAULT_FORCE);
78
+ if (d.grantsItem) {
79
+ items = (items ?? []).map(i => i.name === d.grantsItem && !i.room && !i.heldBy ? { ...i, room: room.name } : i);
80
+ }
81
+ log.write(`seed-migration: ward device "${d.name}" -> ${warded.name} is WARDED (Force ${warded.wardForce}); a ward stops astral forms, not bodies.`);
82
+ }
83
+ }
84
+ return { ...json, rooms, items };
85
+ }
42
86
  /**
43
87
  * THE HUB CARRIES NO HOSTS (ruling 2026-09-12 on N4erz3f63MiCZLkDE).
44
88
  *
@@ -113,7 +157,7 @@ export function migrateLegacySeedWithReport(json) {
113
157
  // Hub hosts first, so the puzzle pass below sees the rooms as they
114
158
  // will be played. Both passes are pure; order only matters for the
115
159
  // spread that follows.
116
- json = stripHubHosts(json);
160
+ json = wardDevicesToRooms(stripHubHosts(json));
117
161
  const puzzles = json.puzzles ?? [];
118
162
  // IDEMPOTENCE: a seed already migrated has no puzzles left to move,
119
163
  // so the whole pass is a copy. Checked first so a re-migrated seed
@@ -207,7 +251,9 @@ export function migrateLegacySeedWithReport(json) {
207
251
  // MANDATORY (see device-seed.ts): an absent riddle used to render
208
252
  // the literal string "undefined" into an NPC's prompt.
209
253
  description: p.riddle || `${p.name} -- sealed.`,
210
- kind,
254
+ // A legacy ward is written as one and moved onto its room at the
255
+ // end of this pass (wardDevicesToRooms).
256
+ kind: kind,
211
257
  code,
212
258
  keyItem: p.requiredItem,
213
259
  grantsItem: p.rewardItem,
@@ -244,6 +290,8 @@ export function migrateLegacySeedWithReport(json) {
244
290
  // dropping the content on the floor silently.
245
291
  log.error(`seed-migration: "${p.name}" is placed in no room and guards ${guardedExits.length} exits -- cannot site it. Dropped.`);
246
292
  }
247
- return { seed: { ...json, rooms, npcs, puzzles: [] }, fates };
293
+ // The puzzle pass writes spell barriers as legacy ward devices; they
294
+ // land on their rooms here, the same as any other seed's.
295
+ return { seed: wardDevicesToRooms({ ...json, rooms, npcs, puzzles: [] }), fates };
248
296
  }
249
297
  //# sourceMappingURL=seed-migration.js.map
@@ -20,6 +20,9 @@ import { ledSeatsNow, hostCannotAnswer, reasonOf, CREW_JOB_RIDE_LINE, CREW_JOB_A
20
20
  import { isHostProcess } from './utilities/host-process.js';
21
21
  import { buildCleanupSeed } from './factories/cleanup-seed.js';
22
22
  import { EditFileCommand, EraseCommand } from './commands/edit-file.js';
23
+ import { LeapCommand } from './commands/leap.js';
24
+ import { CrackCommand } from './commands/crack.js';
25
+ import { DisarmCommand } from './commands/disarm.js';
23
26
  import { EraseMarkCommand } from './commands/erase-mark.js';
24
27
  import { SPRITE_POWER_COMMANDS } from './commands/sprite-power.js';
25
28
  import { cleanupOutcome } from './utilities/cleanup-errand.js';
@@ -192,7 +195,7 @@ import { HintsCommand } from './commands/hints.js';
192
195
  import { hint, setHints, terse } from './utilities/hints.js';
193
196
  import { businessWitness } from './factions.js';
194
197
  import { wouldNotice } from './utilities/spots.js';
195
- import { camerasLive, liveFootage } from './utilities/surveillance.js';
198
+ import { cameraSeeing, liveFootage } from './utilities/surveillance.js';
196
199
  import { InstallCommand } from './commands/install.js';
197
200
  import { BuyCommand, BrowseCommand } from './commands/buy.js';
198
201
  import { SellCommand } from './commands/sell.js';
@@ -666,8 +669,11 @@ export default class Game {
666
669
  this.scene.crimeLogged = true;
667
670
  let billed = amount;
668
671
  const who = actor ?? this.heatActor();
669
- const room = who.bodyRoom ?? who.currentLocation;
670
- if (room && camerasLive(this.scene.getRooms(), room)) {
672
+ // A LENS THAT CAN SEE THEM (surveillance.ts cameraSeeing: line of
673
+ // sight, no range) -- the room whose camera it is keeps the footage.
674
+ const seen = cameraSeeing(this.scene.getRooms(), who);
675
+ const room = seen?.room;
676
+ if (room) {
671
677
  billed *= 2;
672
678
  if (!this._camSeenRooms.has(room.name)) {
673
679
  this._camSeenRooms.add(room.name);
@@ -5284,6 +5290,8 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
5284
5290
  // Vertical travel by name (commands/climb.ts) -- shortcuts over
5285
5291
  // move, which already crosses levels.
5286
5292
  CommandFactory.registerCommand('climb', ClimbCommand);
5293
+ // Over a ledge on purpose (utilities/ledge-jump.ts).
5294
+ CommandFactory.registerCommand('leap', LeapCommand);
5287
5295
  // The word a player actually typed (vkDz3cquad25GACpm). mantle is
5288
5296
  // six edits from climb, so no amount of typo tolerance was ever
5289
5297
  // going to find it -- a synonym has to be said out loud.
@@ -5365,6 +5373,9 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
5365
5373
  CommandFactory.registerCommand('edit', EditFileCommand);
5366
5374
  CommandFactory.registerCommand('erase', EraseCommand);
5367
5375
  CommandFactory.registerCommand('wipe', EraseCommand);
5376
+ // A file's own defences (p.238-242, utilities/file-guard.ts).
5377
+ CommandFactory.registerCommand('crack', CrackCommand);
5378
+ CommandFactory.registerCommand('disarm', DisarmCommand);
5368
5379
  // MAtrix Recognition Keys (canon p.235-236).
5369
5380
  CommandFactory.registerCommand('mark', MarkCommand);
5370
5381
  CommandFactory.registerCommand('marks', MarkCommand);
@@ -10236,7 +10247,9 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
10236
10247
  // guessVerb, which owns the ordering and the reason.
10237
10248
  const guess = guessVerb(verb, knownVerbs);
10238
10249
  if (guess && guess !== verb) {
10239
- if (guess === 'quit' || guess === 'exit') {
10250
+ // A LEAP IS NEVER GUESSED (user 2026-10-06: "don't attempt to auto
10251
+ // read this one"): a fall is one deliberate command, not a typo's.
10252
+ if (guess === 'quit' || guess === 'exit' || guess === 'leap') {
10240
10253
  return out(`I don't understand "${verb}" -- did you mean "${guess}"?`);
10241
10254
  }
10242
10255
  const corrected = await this.runInputAs(actorName, [guess, ...args].join(' '));
@@ -49,8 +49,16 @@ export class Camera {
49
49
  * dark without owning the host (surveillance.ts cameraStateNote).
50
50
  * A smashed lens needs no knowing: anyone can see it. */
51
51
  knownOffBy = new Set();
52
+ /** THE HOST THIS LENS IS SLAVED TO, when the author named one: a room
53
+ * with a node (surveillance.ts lensMaster). Unset = the room's master. */
54
+ masterHost;
55
+ /** The building an outdoor lens is bolted to (seed `mountedOn`): not a
56
+ * pole, and hardlined to that building's host. */
57
+ mountedOn;
52
58
  constructor(cfg) {
53
59
  this.name = cfg.name;
60
+ this.masterHost = cfg.masterHost;
61
+ this.mountedOn = cfg.mountedOn;
54
62
  this.perch = cfg.name;
55
63
  this.wall = cfg.wall;
56
64
  this.rating = cfg.rating;
@@ -6,9 +6,8 @@ import { hint } from '../utilities/hints.js';
6
6
  * which prices a door lock at 2, and p.234, where a device's Data
7
7
  * Processing and Firewall are both simply its Device Rating).
8
8
  *
9
- * A 'ward' is deliberately absent: it is the Art, not electronics, and
10
- * giving it a rating would be inventing a device where canon has none.
11
- * broadcastsAro() answers false for it on the same grounds -- see
9
+ * A WARD IS NOT A DEVICE (user 2026-10-06): it is a room's astral
10
+ * barrier (utilities/wards.ts), with a Force and no Device Rating -- see
12
11
  * kbWdrysBQSahg67cA, where the ask was "anything that can be HACKED must
13
12
  * be a device", not "everything in the room is".
14
13
  */
@@ -21,8 +20,7 @@ export const DEFAULT_DEVICE_RATING = {
21
20
  * hidden from view, such as locks and other security devices"; user
22
21
  * ruling 2026-09-16 on what that covers -- "cameras, locks, sensors
23
22
  * etc"). In this engine a 'lock' and a 'panel' ARE the security
24
- * furniture: the maglock, the keypad, the safe. A 'ward' is the Art and
25
- * has no wireless mode to hide in.
23
+ * furniture: the maglock, the keypad, the safe.
26
24
  */
27
25
  export const SILENT_BY_DEFAULT = new Set(['lock', 'panel']);
28
26
  /**
@@ -57,8 +55,7 @@ export const WIRELESS_OFF_BY_DEFAULT = new Set(['lock', 'panel']);
57
55
  * Structure / Armor. Fragile is standard glass; Reinforced is a security
58
56
  * door, armored glass, Kevlar wallboard; Heavy is concrete or a metal
59
57
  * beam. A device seed that says neither gets Reinforced -- the ordinary
60
- * locked door -- or Heavy when its footprint is large. A ward is the Art
61
- * and has no Structure to shoot.
58
+ * locked door -- or Heavy when its footprint is large.
62
59
  */
63
60
  export const BARRIER_TIERS = {
64
61
  fragile: { structure: 1, armor: 2 },
@@ -66,8 +63,6 @@ export const BARRIER_TIERS = {
66
63
  heavy: { structure: 12, armor: 20 },
67
64
  };
68
65
  export function barrierDefaultsFor(kind, footprint) {
69
- if (kind === 'ward')
70
- return { structure: 0, armor: 0 };
71
66
  return footprint === 'large' ? BARRIER_TIERS.heavy : BARRIER_TIERS.reinforced;
72
67
  }
73
68
  /** A seed's rating, kept to the range the book uses (1-12, the same
@@ -108,7 +103,7 @@ export function clampDeviceRating(rating) {
108
103
  * A CODE ONLY BELONGS ON A PANEL. User ruling, verbatim: "if it's a
109
104
  * _mechanical_ lock, it has a key or can be picked. You can't talk at a
110
105
  * _mechanical_ lock." A 'lock' therefore opens by key or by force, and
111
- * a 'ward' by the Art -- neither has anything to hear a phrase with.
106
+ * has nothing to hear a phrase with.
112
107
  * The migration enforces this by dropping the phrase off anything that
113
108
  * isn't a panel.
114
109
  */
@@ -262,7 +257,7 @@ export class Device {
262
257
  * ceases functioning" -- a camera records nothing, a smartgun sparks
263
258
  * and dies -- but "a lock stays locked" (p.228): bricking is never a
264
259
  * way through a maglock. Repaired on the bench (rest.ts), never by a
265
- * reboot. A ward is not electronics and has no monitor at all.
260
+ * reboot.
266
261
  */
267
262
  _matrixDamage = 0;
268
263
  /**
@@ -289,19 +284,17 @@ export class Device {
289
284
  this.refuseMessage = config.refuseMessage || `Nothing happens.`;
290
285
  this.rating = config.rating !== undefined ? clampDeviceRating(config.rating) : DEFAULT_DEVICE_RATING[config.kind] ?? 0;
291
286
  const tier = barrierDefaultsFor(config.kind, config.footprint);
292
- this.structure = config.kind === 'ward' ? 0 : Math.max(0, Math.floor(config.structure ?? tier.structure));
293
- this.armor = config.kind === 'ward' ? 0 : Math.max(0, Math.floor(config.armor ?? tier.armor));
294
- // A ward has no wireless mode to be in. Everything else defaults ON,
295
- // because a seeded keypad nobody thought about is a keypad on the
287
+ this.structure = Math.max(0, Math.floor(config.structure ?? tier.structure));
288
+ this.armor = Math.max(0, Math.floor(config.armor ?? tier.armor));
289
+ // Wireless defaults ON, because a seeded keypad nobody thought about is a keypad on the
296
290
  // grid -- which is the state the report says the game should model.
297
- this.wireless = config.kind === 'ward' ? false : (config.wireless ?? true);
291
+ this.wireless = config.wireless ?? true;
298
292
  this.masterHost = config.masterHost;
299
293
  // SECURITY IS SILENT BY DEFAULT (p.217-218: "locks and other
300
294
  // security devices"). In this engine a 'lock' and a 'panel' ARE the
301
295
  // security furniture -- the maglock, the keypad, the safe -- so both
302
- // are born dark unless a seed says otherwise. A 'ward' has no
303
- // wireless mode to hide in the first place.
304
- this.silent = config.kind === 'ward' ? false : (config.silent ?? SILENT_BY_DEFAULT.has(config.kind));
296
+ // are born dark unless a seed says otherwise.
297
+ this.silent = config.silent ?? SILENT_BY_DEFAULT.has(config.kind);
305
298
  }
306
299
  isOpen() {
307
300
  return this.open;
@@ -335,7 +328,7 @@ export class Device {
335
328
  * of those three sites.
336
329
  */
337
330
  onHoled;
338
- /** Boxes actually taken; a ward has nothing to fill. */
331
+ /** Boxes actually taken. */
339
332
  takeBarrierDamage(boxes) {
340
333
  if (this.structure <= 0)
341
334
  return 0;
@@ -372,7 +365,7 @@ export class Device {
372
365
  */
373
366
  // ---- The Matrix condition monitor (commands/disable.ts) ----
374
367
  /** p.228: 8 + (Device Rating / 2), rounded up as the deck's is. 0 for
375
- * a thing with no rating (a ward): nothing there to brick. */
368
+ * a thing with no rating: nothing there to brick. */
376
369
  get matrixMonitor() {
377
370
  return this.rating > 0 ? 8 + Math.ceil(this.rating / 2) : 0;
378
371
  }
@@ -165,6 +165,14 @@ export class Host {
165
165
  * to say for itself is the report.
166
166
  */
167
167
  purpose;
168
+ /** The author's IC launch order (room seed icOrder), names as written. */
169
+ icOrder;
170
+ /** Reboot a bricked program first, or continue down the line first. */
171
+ icBricked;
172
+ /** What the author called it (room seed hostName), when they did. The
173
+ * host's IDENTITY stays `name` (its room's); this is what it is CALLED
174
+ * -- grid-names.ts prints and accepts it. */
175
+ displayName;
168
176
  sculpt;
169
177
  constructor(over, opts = {}) {
170
178
  this.over = over;
@@ -39,6 +39,10 @@ export class Item extends AbstractItem {
39
39
  price;
40
40
  // See IItemConstructorConfig.atSpot; take.ts gates reach on it.
41
41
  atSpot;
42
+ /** WHERE IT LIES, room-local cells, when an author put it there
43
+ * (player-built runs, 2026-10-06; seed items[].cell). Reach still goes
44
+ * through atSpot, which is set from this cell; maps draw it here. */
45
+ atCell;
42
46
  // PAN warfare (see commands/hack.ts): a cracked smart-device locks up.
43
47
  // A jammed firearm can't fire (fists until cleared); "reboot" clears
44
48
  // every jammed device you carry, in the lull.
@@ -112,6 +116,16 @@ export class Item extends AbstractItem {
112
116
  * holds (commands/edit-file.ts). */
113
117
  hashedBy;
114
118
  hashTurns = 0;
119
+ /** PROTECTION (SR5 p.239): the rating Edit File's "protect" left on a
120
+ * file. It cannot be read, changed, deleted or copied until Crack File
121
+ * (p.238) breaks it -- utilities/file-guard.ts. 0 = unprotected. */
122
+ protection = 0;
123
+ /** A DATA BOMB (p.242): goes off on any read/edit/copy/delete without
124
+ * the passcode (utilities/file-guard.ts). One per file. */
125
+ dataBomb;
126
+ /** Personas whose Matrix Perception has turned up the bomb (p.242) --
127
+ * what Disarm Data Bomb (commands/disarm.ts) needs before it is tried. */
128
+ bombSeenBy = new Set();
115
129
  /** WATERMARK: an invisible tag only Resonance-driven things read -- a
116
130
  * technomancer or another sprite. A new one overwrites the old. */
117
131
  watermark;
@@ -12,6 +12,7 @@ import { Door } from './door.js';
12
12
  import { AI } from '../../../../tools/ai/ai.class.js';
13
13
  import { CALL_ICON, CALL_COLOR } from '../utilities/comm-style.js';
14
14
  import { hostLabel } from '../utilities/grid-names.js';
15
+ import { wanDevices } from '../utilities/wan.js';
15
16
  import { ephemeralDenials } from '../utilities/ephemeral.js';
16
17
  import { rollPool, formatRoll } from '../utilities/dice.js';
17
18
  import { spotsActive, spotOf, describeSpotRoster, spotDistanceMeters, spotCoverBetween, wouldNotice } from '../utilities/spots.js';
@@ -152,6 +153,24 @@ export function hostileStanceNote(hostile, intruderName) {
152
153
  + ` told it or they tell you -- act on what you can see, not on a file you never read.`
153
154
  + ` If they talk their way round you, or beat you, that settles it.`;
154
155
  }
156
+ /**
157
+ * THE OWNER KNOWS THEIR HOST'S CODES (user rulings 2026-10-06: "tie the
158
+ * data file all the way back to the owner of the host"; "keypad codes
159
+ * also bubble up to the host owner"). Whoever set a data bomb chose its
160
+ * passcode (SR5 p.242), and the codes of the locks slaved to a host are
161
+ * its owner's to know. Like a barrier's pass, each is a SECRET they guard:
162
+ * the way to it is social or violent -- persuaded, frightened, bribed or
163
+ * beaten out of them -- and telling it IS how they comply.
164
+ *
165
+ * PURE and exported, like barrierPostNote: it fails silently -- an owner
166
+ * who forgets they hold the code just shrugs, and nothing throws.
167
+ */
168
+ export function hostCodeNote(codes) {
169
+ const guard = 'is a SECRET you guard with your standing: give it up only under real pressure -- beaten, genuinely frightened, well bribed, or masterfully played. Under that pressure, telling them IS how you comply. When you do, say it exactly.';
170
+ return codes.map(c => c.kind === 'file'
171
+ ? `\nA FILE YOU OWN: "${c.file}" in your host, ${c.host}, carries a data bomb you set -- its passcode is "${c.code}". Anyone who touches that file without it gets the bomb in the face. The passcode ${guard}`
172
+ : `\nA LOCK YOUR HOST KEEPS: ${c.device} in ${c.room}${c.direction ? ` (the ${c.direction} door)` : ''} is slaved to your host, ${c.host}, and you know its code: "${c.code}". The code ${guard}`).join('');
173
+ }
155
174
  export function barrierPostNote(barriers) {
156
175
  return barriers.map(b => {
157
176
  const dirNote = b.sealingDirection ? ` sealing the way ${b.sealingDirection}` : '';
@@ -257,9 +276,15 @@ export function castPin(names) {
257
276
  * real. There is no assertion anywhere else in the engine that would
258
277
  * notice.
259
278
  */
260
- export function credentialHonestyNote(passesHeld) {
261
- const known = passesHeld > 0
262
- ? `the ${passesHeld === 1 ? 'pass' : `${passesHeld} passes`} for the barrier${passesHeld === 1 ? '' : 's'} at your own post, named above, and NO others`
279
+ export function credentialHonestyNote(passesHeld, hostCodesHeld = 0) {
280
+ const held = [
281
+ ...(passesHeld > 0 ? [`the ${passesHeld === 1 ? 'pass' : `${passesHeld} passes`} for the barrier${passesHeld === 1 ? '' : 's'} at your own post`] : []),
282
+ // The codes your own host keeps (hostCodeNote): bomb passcodes on
283
+ // its files, keypad codes on the devices slaved to it.
284
+ ...(hostCodesHeld > 0 ? [`the ${hostCodesHeld === 1 ? 'code' : `${hostCodesHeld} codes`} your own host keeps`] : []),
285
+ ];
286
+ const known = held.length > 0
287
+ ? `${held.join(' and ')}, named above, and NO others`
263
288
  : `NO code, pass, or combination to anything -- you hold none`;
264
289
  return `
265
290
  CREDENTIALS YOU DO NOT HOLD: you know ${known}. You cannot deduce, guess, or
@@ -1087,9 +1112,6 @@ Available in-game commands (use with [COMMAND] prefix):
1087
1112
  on yourself or an ally; its bearer gets +soak, and YOU carry -2 dice to all else
1088
1113
  while you hold it; cast at them again to release). EVERY cast costs drain -- stun damage to YOU the caster; fill
1089
1114
  your stun track and you're out cold. Never cast if you're mundane with no magic gear.
1090
- - cast — bare "cast" attempts to magically bypass the ward/barrier in this room
1091
- without knowing the answer (requires the same talent or gear; failing burns the
1092
- route for good and the backlash hurts you badly)
1093
1115
  - search — search the room for hidden things
1094
1116
  - look — look around the current room
1095
1117
  - look at <target> - looks at an item, player, or direction
@@ -1231,7 +1253,9 @@ description text.`;
1231
1253
  * stimulus to wake.
1232
1254
  */
1233
1255
  isConstruct() {
1234
- return this.icKind !== undefined || this.plane === 'matrix';
1256
+ // A PERSONA WITH A BODY IS A PERSON (utilities/spider.ts): a spider
1257
+ // jacked in from a chair is on the matrix plane and is not ice.
1258
+ return this.icKind !== undefined || (this.plane === 'matrix' && !this.bodyRoom);
1235
1259
  }
1236
1260
  /** Every AI entry point asks this: a construct never runs the model,
1237
1261
  * and anyone in a Combat Turn yields to the turn loop. */
@@ -2469,7 +2493,36 @@ An ORDER is the only way these goods change hands -- never a dialogue sale, neve
2469
2493
  // Applies to EVERYONE, companions and the client included: a bound
2470
2494
  // spirit inventing its master a code is the same bug wearing a
2471
2495
  // leash.
2472
- const credentialHonesty = credentialHonestyNote(passesHeldBy(barrierPosts));
2496
+ // THE CODES THEIR OWN HOST KEEPS (hostCodeNote; user ruling
2497
+ // 2026-10-06: "keypad codes also bubble up to the host owner, same
2498
+ // as data bomb codes"). Read live off every host this NPC owns,
2499
+ // wherever they stand: the bomb passcodes on its files, and the code
2500
+ // of every still-shut device slaved to it (wan.ts wanDevices -- the
2501
+ // same wiring a hack through the host reaches). A spent bomb or an
2502
+ // opened lock drops out on its own; a lock already briefed as the
2503
+ // barrier at their own post is not said twice. Not for an ally shell
2504
+ // (the same caller decision as the barrier posts above).
2505
+ const hostCodes = [];
2506
+ if (!this.allyOf) {
2507
+ const rooms = this._scene?.getRooms?.() ?? {};
2508
+ const atPost = new Set(barrierPosts.map(b => b.name));
2509
+ for (const r of Object.values(rooms)) {
2510
+ if (r.owner !== this.name || !r.host)
2511
+ continue;
2512
+ const host = hostLabel(r);
2513
+ for (const f of r.host.files()) {
2514
+ if (f.dataBomb?.passcode)
2515
+ hostCodes.push({ kind: 'file', file: f.name, host, code: f.dataBomb.passcode });
2516
+ }
2517
+ for (const { device, room: at } of wanDevices(rooms, r)) {
2518
+ if (!device.code || device.open || atPost.has(device.name))
2519
+ continue;
2520
+ hostCodes.push({ kind: 'device', device: device.name, room: at.name, direction: device.opensExit ? String(device.opensExit) : undefined, host, code: device.code });
2521
+ }
2522
+ }
2523
+ }
2524
+ const hostCodeBlock = hostCodeNote(hostCodes);
2525
+ const credentialHonesty = credentialHonestyNote(passesHeldBy(barrierPosts), hostCodes.length);
2473
2526
  const localKnowledge = localKnowledgeNote(room?.name);
2474
2527
  // AN EPHEMERAL IS A PASSING FACE (7ALf6u7PvTg6TZSm7, see
2475
2528
  // utilities/ephemeral.ts). The brief is trimmed hard everywhere
@@ -2664,7 +2717,7 @@ Your objective is to drive the story line.`}
2664
2717
  ${formattedExitsStr}
2665
2718
  ${roomInventoryDesc}
2666
2719
  ${eph ? '' : waresDesc}
2667
- ${eph ? '' : swagNote}${eph ? '' : clientNote}${hostileBlock}${picketBlock}${barrierBlock}${credentialHonesty}${localKnowledge}${ephDenials}
2720
+ ${eph ? '' : swagNote}${eph ? '' : clientNote}${hostileBlock}${picketBlock}${barrierBlock}${hostCodeBlock}${credentialHonesty}${localKnowledge}${ephDenials}
2668
2721
  ${eph ? '' : familiarity}
2669
2722
  ${eph ? '' : ordersNote}
2670
2723
  ${droneNote}
@@ -298,6 +298,14 @@ export class Player extends AbstractPlayer {
298
298
  * persuaded him while standing in the same doorway.
299
299
  */
300
300
  guarding = false;
301
+ /**
302
+ * THE DOOR THIS ACTOR WATCHES -- a direction out of the room they stand
303
+ * in (utilities/door-watch.ts). Not a barrier: they let you by, until
304
+ * they notice you going for it -- then they turn hostile and the fight
305
+ * opens there (user ruling 2026-10-06, "guarded by"). A guarding actor
306
+ * in a doorway watches it too, without this being set.
307
+ */
308
+ watching;
301
309
  /**
302
310
  * THIS ACTOR IS OPPOSITION (JjQdy4A4tzcQdPHno: "none of the NPCs ever
303
311
  * seem to get hostile").
@@ -5381,6 +5389,9 @@ export class Player extends AbstractPlayer {
5381
5389
  // Sneaking-vs-Perception rolls per witness (NPC.see). Broken by any
5382
5390
  // loud act -- attacking, casting, speaking, brandishing, calling.
5383
5391
  sneaking = false;
5392
+ /** The ward this astral form has PRESSED THROUGH and not yet crossed
5393
+ * (utilities/wards.ts pressThroughWard): one crossing, then spent. */
5394
+ wardPass;
5384
5395
  // ==================== Jumped-in rigging (commands/jump.ts) ====================
5385
5396
  // The drone currently being ridden while plane === 'drone': the item
5386
5397
  // whose hull track (Item.droneDamage) eats what would hit the rider,