@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
@@ -0,0 +1,78 @@
1
+ import { Category, Size, Rating } from '../types/shared/item-enum.js';
2
+ import { catalogGear, catalogGearDef, catalogRowFor } from '../utilities/catalog.js';
3
+ import { NPC_EQUIP_CATEGORIES } from '../npc-kits.js';
4
+ /**
5
+ * A SEED ITEM BY CATALOG SLUG (player-built runs, 2026-10-06: "sourcing
6
+ * real catalog-backed loot"). `slug` names a gear row on the site's
7
+ * catalog; the item takes that row's NAME and its physical fields, so
8
+ * the catalog's stats -- a weapon's DV and AP, a jacket's armor, a
9
+ * deck's attributes, read by name through catalogRowFor -- reach it, and
10
+ * its price, availability and legality are the book's. What the seed
11
+ * still decides is where it is (room / heldBy / atSpot / owner) and how
12
+ * it is carried.
13
+ *
14
+ * EXACT, never fuzzy: catalogGearDef falls back to findCatalogItem, which
15
+ * is right for a player typing "predator" at a counter and wrong for an
16
+ * author's saved run -- a near miss would silently swap the gun.
17
+ *
18
+ * Throws on an unknown slug, so synthesis (and so the editor's audit)
19
+ * reports it rather than shipping an empty-handed guard.
20
+ */
21
+ export function resolveCatalogItemSeed(seed) {
22
+ if (!seed.slug)
23
+ return seed;
24
+ const row = catalogRowFor(seed.slug);
25
+ const def = row && row.slug === seed.slug ? catalogGearDef(row.slug) : undefined;
26
+ if (!def) {
27
+ throw new Error(`items: "${seed.slug}" is not a catalog gear slug -- pick the item from the catalog.`);
28
+ }
29
+ return {
30
+ ...seed,
31
+ name: def.name,
32
+ description: seed.description?.trim() ? seed.description : def.description,
33
+ category: enumKey(Category, def.category),
34
+ size: enumKey(Size, def.size),
35
+ rating: enumKey(Rating, def.rating),
36
+ shape: def.shape,
37
+ color: def.color,
38
+ texture: def.texture,
39
+ weight: def.weight,
40
+ ...(def.price !== undefined ? { price: def.price } : {}),
41
+ // A DECK WITH NO CONFIG IS A DECK WITH A ZEROED ARRAY (repro-scene's
42
+ // catalogSeedFor has the same rule): the identity order unless the
43
+ // author set one (p.227).
44
+ ...(def.category === Category.Cyberdeck && !seed.deckOrder ? { deckOrder: [0, 1, 2, 3] } : {}),
45
+ };
46
+ }
47
+ function enumKey(e, value) {
48
+ const entries = e;
49
+ return Object.keys(entries).find(k => entries[k] === value) ?? 'Unknown';
50
+ }
51
+ /**
52
+ * THE ITEM VOCABULARY, for an editor's dropdowns: every Category, Size
53
+ * and Rating key the engine accepts, and which categories the catalog
54
+ * actually stocks. A category with rows is one a hand-made item may not
55
+ * claim (the catalog's row is the real thing); one without -- a Key, a
56
+ * Document, a Badge -- is exactly what a hand-made item is for.
57
+ */
58
+ export function itemVocabulary() {
59
+ const stocked = new Map();
60
+ for (const row of catalogGear()) {
61
+ const key = String(row.category);
62
+ stocked.set(key, (stocked.get(key) ?? 0) + 1);
63
+ }
64
+ const list = (e) => Object.entries(e)
65
+ .filter(([k]) => Number.isNaN(Number(k)))
66
+ .map(([key, label]) => ({ key, label }));
67
+ return {
68
+ categories: list(Category).map(c => ({
69
+ ...c,
70
+ catalog: stocked.get(c.key) ?? 0,
71
+ equippable: NPC_EQUIP_CATEGORIES.has(c.label),
72
+ weapon: c.label === Category.Weapon || c.label === Category.MeleeWeapon || c.label === Category.RangedWeapon,
73
+ })),
74
+ sizes: list(Size),
75
+ ratings: list(Rating),
76
+ };
77
+ }
78
+ //# sourceMappingURL=catalog-item-seed.js.map
@@ -241,31 +241,27 @@ export function parseJobSize(said) {
241
241
  }
242
242
  // ============================ Requirements ============================
243
243
  /**
244
- * WHAT THIS SITE DEMANDS, read off its structure. Two things a job can
245
- * ask for that no findable clue or key supplies:
244
+ * WHAT THIS SITE DEMANDS, read off its structure -- what no findable
245
+ * clue or key supplies:
246
246
  * - 'decker': the objective is a file on a host. Only a persona
247
247
  * reaches it (a cyberdeck or a living one).
248
- * - 'mage': a ward stands on every route from the curb to the prize.
249
- * Nothing but the Art unweaves a ward.
250
248
  * Locks and panels never count: every one has a key or a clue on the
251
249
  * near side (the unconditional clue rule), so any runner gets through.
250
+ * Nor do wards, any more (user 2026-10-06): a ward is an astral barrier
251
+ * over a room, and bodies walk through it (p.294, p.315) -- there was a
252
+ * 'mage' requirement while a ward was a door only the Art opened.
252
253
  */
253
254
  export function requirementsFor(skeleton) {
254
255
  const needs = [];
255
256
  const win = skeleton.items.find(i => i.name === skeleton.winCondition?.item);
256
257
  if (win?.plane === 'matrix')
257
258
  needs.push('decker');
258
- const prizeRoom = objectiveRoomFor(skeleton);
259
- const start = skeleton.rooms.find(r => r.isStartRoom)?.name;
260
- if (prizeRoom && start && !reachableAvoidingWards(skeleton, start, prizeRoom)) {
261
- needs.push('mage');
262
- }
263
259
  return needs;
264
260
  }
265
261
  /**
266
262
  * WHERE THE OBJECTIVE STANDS: the prize item's room (or its holder's),
267
263
  * the mark's post, or the target device's room -- whichever the job
268
- * has. The one answer the lieutenant post, the ward walk and the pay's
264
+ * has. The one answer the lieutenant post and the pay's
269
265
  * "prize room" all read, so a kind added later changes it in one place.
270
266
  */
271
267
  export function objectiveRoomFor(skeleton) {
@@ -286,45 +282,12 @@ export function objectiveRoomFor(skeleton) {
286
282
  }
287
283
  return undefined;
288
284
  }
289
- /** Is `to` reachable from `from` without crossing an exit a ward holds?
290
- * Edges are two-way, as the engine builds them. A prize room that is
291
- * unreachable even WITH wards is the seed validator's problem, not a
292
- * mage's -- so it answers true rather than blaming the Art. */
293
- function reachableAvoidingWards(skeleton, from, to) {
294
- const wards = new Set((skeleton.devices ?? []).filter(d => d.kind === 'ward').map(d => d.name));
295
- const walk = (skipWards) => {
296
- const seen = new Set([from]);
297
- const queue = [from];
298
- while (queue.length) {
299
- const here = queue.shift();
300
- if (here === to)
301
- return true;
302
- for (const room of skeleton.rooms) {
303
- for (const exit of room.exits ?? []) {
304
- if (room.name !== here && exit.targetRoom !== here)
305
- continue;
306
- if (skipWards && exit.device && wards.has(exit.device))
307
- continue;
308
- const next = room.name === here ? exit.targetRoom : room.name;
309
- if (!seen.has(next)) {
310
- seen.add(next);
311
- queue.push(next);
312
- }
313
- }
314
- }
315
- }
316
- return false;
317
- };
318
- if (!walk(false))
319
- return true;
320
- return walk(true);
321
- }
322
285
  /** How Krow says it. Plainly (user ruling): the job's requirement, and
323
286
  * that a crew member covers it. */
324
287
  export function requirementLine(needs) {
325
288
  if (needs.length === 0)
326
289
  return '';
327
- const words = needs.map(n => n === 'decker' ? 'a decker' : 'a mage');
290
+ const words = needs.map(() => 'a decker');
328
291
  const list = words.length === 1 ? words[0] : `${words.slice(0, -1).join(', ')} and ${words[words.length - 1]}`;
329
292
  return `Straight talk: this one needs ${list}. If that's not you, put one on the crew before you ride.`;
330
293
  }
@@ -64,12 +64,10 @@ export const REPRO_FLOOR_SPOT = 'back wall';
64
64
  const DEFAULT_DEVICE_NAME = {
65
65
  lock: 'Maglock',
66
66
  panel: 'Keypad',
67
- ward: 'Chalk Ward',
68
67
  };
69
68
  const DEFAULT_DEVICE_DESCRIPTION = {
70
69
  lock: 'A heavy maglock, seated in the frame and holding the door shut.',
71
70
  panel: 'A keypad on a short steel stalk, its display waiting for a code.',
72
- ward: 'A ward drawn in chalk and old ash across the whole doorway, humming against your teeth.',
73
71
  };
74
72
  const OPPOSITE = {
75
73
  [Direction.NORTH]: Direction.SOUTH,
@@ -79,9 +77,6 @@ const OPPOSITE = {
79
77
  [Direction.UP]: Direction.DOWN,
80
78
  [Direction.DOWN]: Direction.UP,
81
79
  };
82
- /** Archetypes that can work a ward. `dispel` needs the Art; nothing
83
- * else in the game does. */
84
- const AWAKENED = new Set(['Adept', 'Combat Mage', 'Street Shaman']);
85
80
  /** Bounds. A repro is a bench: more than this and it is a scene, and a
86
81
  * scene is a place a reproduction gets lost in. */
87
82
  const MAX_NPCS = 4;
@@ -258,21 +253,24 @@ export function buildReproScene(fixture) {
258
253
  }
259
254
  if (barrier && !plainDoor) {
260
255
  // INVARIANT 1: a code belongs to a panel and nowhere else. The
261
- // Device constructor DROPS it on a lock or a ward, silently and
256
+ // Device constructor DROPS it on a lock, silently and
262
257
  // correctly -- that is the runtime honouring "you can't talk at a
263
258
  // mechanical lock". Inheriting that drop here would produce a bench
264
259
  // whose stated credential does nothing, and the reviewer would read
265
260
  // the refusal as the bug still being present.
266
261
  if (barrier.code && barrier.kind !== 'panel') {
267
- throw new ReproFixtureError(`A ${barrier.kind} has no code -- only a panel does. Give it a keyItem, or let it be picked or dispelled.`);
262
+ throw new ReproFixtureError(`A ${barrier.kind} has no code -- only a panel does. Give it a keyItem, or let it be picked.`);
263
+ }
264
+ // A WARD IS NOT A DOOR (user 2026-10-06): it is a room's astral
265
+ // barrier and stops no body (utilities/wards.ts). A spec still asking
266
+ // for one on a doorway is describing a game that no longer exists.
267
+ if (barrier.kind === 'ward') {
268
+ throw new ReproFixtureError('A ward is not a door barrier any more -- it is an astral barrier over a room, and bodies walk through it. Use a "lock" or a "panel" here.');
268
269
  }
269
270
  // A panel with no code cannot be opened the way a panel opens.
270
271
  if (barrier.kind === 'panel' && !barrier.code && !barrier.keyItem && !barrier.open) {
271
272
  warnings.push('This panel has neither a code nor a key, so the only way through it is force.');
272
273
  }
273
- if (barrier.kind === 'ward' && !AWAKENED.has(archetypeName)) {
274
- warnings.push(`A ward answers to the Art, and ${archetypeName} is mundane -- fine if the refusal is the point, otherwise use Combat Mage.`);
275
- }
276
274
  device = {
277
275
  name: barrier.name ?? DEFAULT_DEVICE_NAME[barrier.kind],
278
276
  description: DEFAULT_DEVICE_DESCRIPTION[barrier.kind],
@@ -16,6 +16,8 @@ export class RoomFactory {
16
16
  isStartRoom: roomJson.isStartRoom,
17
17
  roomType: roomJson.roomType,
18
18
  outdoors: roomJson.outdoors,
19
+ ground: roomJson.ground,
20
+ storeys: roomJson.storeys,
19
21
  tags: roomJson.tags ?? [],
20
22
  hasNode: roomJson.hasNode,
21
23
  nodeAccess: roomJson.nodeAccess,
@@ -25,6 +27,9 @@ export class RoomFactory {
25
27
  hostRating: roomJson.hostRating,
26
28
  hostGrid: roomJson.hostGrid,
27
29
  hostPurpose: roomJson.hostPurpose,
30
+ hostName: roomJson.hostName,
31
+ icOrder: roomJson.icOrder,
32
+ icBricked: roomJson.icBricked,
28
33
  hostSculpt: roomJson.hostSculpt,
29
34
  // THE INVITATION (p.240; Host.invite). Eaten by this whitelist on
30
35
  // 5.243.0's first live walk: the served hub carried hostInvite,
@@ -35,7 +40,14 @@ export class RoomFactory {
35
40
  hostInvite: roomJson.hostInvite,
36
41
  hostInviteQuality: roomJson.hostInviteQuality,
37
42
  warded: roomJson.warded,
43
+ wardForce: roomJson.wardForce,
44
+ wardCreator: roomJson.wardCreator,
38
45
  watched: roomJson.watched,
46
+ // Authored lenses (player-built runs) -- named here or this
47
+ // whitelist eats them, as it ate `owner` and `hostInvite`.
48
+ cameras: roomJson.cameras,
49
+ camerasWireless: roomJson.camerasWireless,
50
+ camerasSlavedTo: roomJson.camerasSlavedTo,
39
51
  // The crowd (utilities/crowd.ts) -- named here or this whitelist eats it.
40
52
  occupancy: roomJson.occupancy,
41
53
  // "At" spots ride through -- this whitelist SILENTLY DROPS any
@@ -4,7 +4,8 @@ import { Logger } from '../utilities/logger.js';
4
4
  import { catalogAugmentations } from '../utilities/catalog.js';
5
5
  import { isOutdoorsPlace } from '../utilities/outdoors.js';
6
6
  import { exitContradictions } from '../utilities/district-plane.js';
7
- import { clampDeviceKind } from '../utilities/affordances.js';
7
+ import { clampDeviceKind, isWardDevice } from '../utilities/affordances.js';
8
+ import { wardDevicesToRooms } from './seed-migration.js';
8
9
  import { kitSlugForTier } from '../npc-kits.js';
9
10
  import { SIZE_SECURITY_BANDS, SIZE_HOST_BANDS, scopeFor, postsLieutenant, sizeForTier, highestOpposingPool, payFor, rollTwist, requirementsFor, DATA_RUN_SHARE, objectiveRoomFor, isDataCourier, courierMustBeUnseen, } from './job-order.js';
10
11
  export { DATA_RUN_SHARE } from './job-order.js';
@@ -464,8 +465,9 @@ routes, and the site should carry a third where it fits -- pick from:
464
465
  bribed, or put down;
465
466
  * the BACK WAY: a lock or panel on a side door with its key or clue findable elsewhere;
466
467
  * the MATRIX WAY: a hasNode host whose maglocks and cameras a persona can take over;
467
- * the ASTRAL WAY: a ward on one door only (never on every door), and a spirit or a
468
- warded room that a mage reads differently;
468
+ * the ASTRAL WAY: a warded room ("warded" + "wardForce") -- an astral barrier over the
469
+ whole room that keeps projecting mages and spirits out but stops NO body -- and the
470
+ rooms around it a projecting mage can scout freely;
469
471
  * the QUIET WAY: a route past sleeping or distracted staff, a service corridor, a roof.
470
472
  Never gate the prize behind ONE chokepoint. Never build a route ONLY a decker or ONLY a
471
473
  mage could take unless another route exists for everyone else -- but DO build those
@@ -577,7 +579,12 @@ ${JSON_ONLY}
577
579
  // corporate, low-level government. 9-10 regional corporate, secure
578
580
  // sites. 11-12 megacorporate HQ, military command. Rate it for the
579
581
  // place it actually is; omit it and the host is treated as a 3.
580
- "warded"?: boolean, // astral barrier: projecting mages can't enter. Use sparingly.
582
+ "warded"?: boolean, // a WARD over this whole room: an astral barrier that stops
583
+ // projecting mages and astral spirits going in OR out. It stops
584
+ // NO body and no physical object -- runners walk through it.
585
+ // Corporate sites buy them as a cheap contracted service.
586
+ "wardForce"?: number, // REQUIRED with warded: the ward's Force. 2-3 a small business,
587
+ // 4-6 a corporate site, 7+ a megacorporate vault.
581
588
  "watched"?: boolean, // security cameras cover this room (feeds ride the site's hosts;
582
589
  // hasNode rooms are always watched implicitly). Use for lobbies,
583
590
  // corridors, and secure floors in corp/guarded locations -- loud
@@ -632,13 +639,14 @@ ${JSON_ONLY}
632
639
  }],
633
640
  "devices": [{
634
641
  "name": string, "concept": string, // what it physically IS (a maglock, a keypad, a
635
- // scanner, a ward) -- a THING, never a person
642
+ // scanner) -- a THING, never a person, never a ward
643
+ // (a ward is a ROOM's "warded", not a device)
636
644
  "room": string, // REQUIRED. A device always stands in ONE room -- the one you must be
637
645
  // in to reach it. If it holds a door, declare that exit FROM THIS
638
646
  // SAME ROOM with "device": "<this name>".
639
- "kind": "lock"|"panel"|"ward", // lock = a mechanism: picked, breached, or opened by its
640
- // key. panel = an interface: a code, or hacked. ward = a working:
641
- // cast at or dispelled. EXACTLY these lowercase strings.
647
+ "kind": "lock"|"panel", // lock = a mechanism: picked, breached, or opened by its
648
+ // key. panel = an interface: a code, or hacked. EXACTLY these
649
+ // lowercase strings.
642
650
  // A device is always a THING. A doorman or a checkpoint guard is
643
651
  // NOT a device -- write them into npcs[] and name them in that
644
652
  // exit's "guard"; people are got past socially, not picked.
@@ -987,7 +995,7 @@ export function buildDevicesPrompt(skeleton, previousError) {
987
995
  return '';
988
996
  return `
989
997
  You are writing the DEVICES for a Shadowrun text adventure. A device is a THING that is
990
- shut and can be opened -- a lock, a panel, a ward. Never a person.
998
+ shut and can be opened -- a lock or a panel. Never a person.
991
999
 
992
1000
  ${skeletonContext(skeleton)}
993
1001
 
@@ -1010,9 +1018,8 @@ fixed, as is its "kind"):
1010
1018
  * Otherwise (an intercom, a voice-gate, a spoken passphrase, a name, a sign reading
1011
1019
  "password") -- ALL LOWERCASE, 2-5 plain words, no punctuation.
1012
1020
  Either way a concrete literal thing, never wordplay.
1013
- OMIT "code" for a "lock" or a "ward". You cannot talk at a mechanical lock, and a ward
1014
- answers to the Art -- neither has anything to hear a phrase with, so a code on one is a
1015
- promise the game cannot keep.
1021
+ OMIT "code" for a "lock". You cannot talk at a mechanical lock -- it has nothing to hear
1022
+ a phrase with, so a code on one is a promise the game cannot keep.
1016
1023
  - "openMessage" / "refuseMessage": one sentence each, in-fiction.
1017
1024
  - "guardsExit": ONLY when the concept is physically TERRAIN blocking a passage -- rubble, a
1018
1025
  collapsed rack, a security shutter across a walkway -- name the exact "direction" of one
@@ -1173,35 +1180,6 @@ export function unwrapChunk(parsed, label) {
1173
1180
  throw new Error(`The "${label}" chunk came back as neither an array nor a { "${CHUNK_ENVELOPE_KEY}": [...] } object.`);
1174
1181
  }
1175
1182
  // ========================= Parse / validate / assemble ====================
1176
- /**
1177
- * Can anyone at this table unweave a WARD?
1178
- *
1179
- * The last surviving branch of what used to be canBypassUnaided, which
1180
- * asked the same question of all three barrier kinds. The other two are
1181
- * gone because their answers stopped mattering (ruling 2026-08-27):
1182
- *
1183
- * A PANEL takes a code, and a code is FINDABLE. Write it down and any
1184
- * runner can type it; a decker who did not need it loses nothing.
1185
- * A LOCK takes a key, and a key is FINDABLE. Place it and any runner
1186
- * can carry it to the door; picking becomes the SHORTCUT for someone
1187
- * with locksmith, not the requirement.
1188
- *
1189
- * So for those two the clue/key rule is UNCONDITIONAL now, and the
1190
- * character-dependent waiver is gone with the condition it rested on.
1191
- *
1192
- * A WARD is irreducible. There is no findable answer to a ward; it takes
1193
- * Magic. A mundane team cannot dispel one however much is written down,
1194
- * so a ward generated for a party with nobody awakened is an unopenable
1195
- * barrier no clue can rescue. That is where the character dependence is
1196
- * REAL rather than incidental, and it is the only place this survives.
1197
- *
1198
- * Named for what it now is: a one-case function still called
1199
- * canBypassUnaided would attract the other two cases back.
1200
- */
1201
- export function partyCanDispel(player) {
1202
- // Adepts are awakened but cannot cast, so they cannot unweave a ward.
1203
- return !!player && player.isAwakened() && !player.adept;
1204
- }
1205
1183
  /**
1206
1184
  * CAN THIS RUNNER WORK THE GRID AT ALL (ruoedrMTA6kCRNmEN, user ruling:
1207
1185
  * data runs are offered to deck-capable runners only)?
@@ -1487,8 +1465,6 @@ export function validateSkeleton(skeleton, _player, _crew, order) {
1487
1465
  fail(`device "${device.name}" is the demolition target and also holds a door -- the target is a thing to wreck, not a way through. Give the door its own device.`);
1488
1466
  if (device.grantsItem)
1489
1467
  fail(`device "${device.name}" is the demolition target and "grantsItem" -- the target opens onto nothing; drop grantsItem.`);
1490
- if ((device.kind ?? 'panel') === 'ward')
1491
- fail(`device "${device.name}" is the demolition target and a ward -- a ward is not matter and cannot be wrecked. Make it a "panel" or a "lock".`);
1492
1468
  continue;
1493
1469
  }
1494
1470
  // EVERY WAY THROUGH IS FINDABLE, and it no longer depends on who is
@@ -1549,10 +1525,6 @@ export function validateSkeleton(skeleton, _player, _crew, order) {
1549
1525
  fail(`lock "${device.name}" ${holdsDoor ? 'holds a door' : 'holds a prize'} and no "key"-role item opens it -- add one with "forDevice": "${device.name}", so a runner without locksmith has a way through.`);
1550
1526
  }
1551
1527
  }
1552
- // A WARD has no findable answer, and that is now allowed (ruling
1553
- // 2026-09-24): the prompt asks for a second route past any ward,
1554
- // and job-order.ts requirementsFor tells Krow to say "you'll need a
1555
- // mage" when a ward stands on every route to the prize.
1556
1528
  }
1557
1529
  if (skeleton.winCondition.item !== undefined && !itemNames.has(skeleton.winCondition.item))
1558
1530
  fail(`winCondition.item "${skeleton.winCondition.item}" is not in items.`);
@@ -2235,8 +2207,7 @@ export function normalizeSkeleton(skeleton) {
2235
2207
  // protected from being converted into an unplaced reward.
2236
2208
  //
2237
2209
  // PANELS ONLY. A lock's findable way through is its key, not a
2238
- // written code, and a ward has no findable answer at all (it is
2239
- // refused outright at validation when nobody can dispel). Synthesis
2210
+ // written code. Synthesis
2240
2211
  // is unconditional for panels now -- there is no waiver left to
2241
2212
  // second-guess, which is what made the old version's log lines need
2242
2213
  // to explain which kind of failure they were.
@@ -2880,6 +2851,9 @@ export function assembleScene(skeleton, details, playerName) {
2880
2851
  hostPurpose: room.hasNode && typeof room.hostPurpose === 'string' ? room.hostPurpose : undefined,
2881
2852
  hostSculpt: room.hasNode && typeof detail?.hostSculpt === 'string' ? detail.hostSculpt : undefined,
2882
2853
  warded: room.warded,
2854
+ // A ward always has a Force; a bad or missing one takes the default
2855
+ // at load (Room WARD_DEFAULT_FORCE) rather than failing the draft.
2856
+ wardForce: room.warded && typeof room.wardForce === 'number' && Number.isFinite(room.wardForce) && room.wardForce >= 1 ? Math.floor(room.wardForce) : undefined,
2883
2857
  watched: room.watched,
2884
2858
  // A headcount or nothing: a bad number falls back to the derived one.
2885
2859
  occupancy: typeof room.occupancy === 'number' && Number.isFinite(room.occupancy) && room.occupancy >= 0 ? Math.round(room.occupancy) : undefined,
@@ -3169,7 +3143,11 @@ export function assembleScene(skeleton, details, playerName) {
3169
3143
  // why, and for the incident (a "tunnel grate" clamped to `panel`
3170
3144
  // and rendered hack-only) that put a name/description-aware
3171
3145
  // fallback here instead of a blind default to `panel`.
3172
- const kind = clampDeviceKind(device.kind, device.name, detail?.description ?? device.concept);
3146
+ // A WARD WRITTEN AS A DEVICE stays one here, only to be moved onto
3147
+ // its room by wardDevicesToRooms at the end of assembly.
3148
+ const kind = isWardDevice(device.kind, device.name, detail?.description ?? device.concept)
3149
+ ? 'ward'
3150
+ : clampDeviceKind(device.kind, device.name, detail?.description ?? device.concept);
3173
3151
  // A CODE ONLY BELONGS ON A PANEL -- enforced here as well as in the
3174
3152
  // prompt, because the prompt is advice and this is not. You cannot
3175
3153
  // talk at a mechanical lock.
@@ -3190,7 +3168,7 @@ export function assembleScene(skeleton, details, playerName) {
3190
3168
  };
3191
3169
  });
3192
3170
  const startRoom = skeleton.rooms.find(r => r.isStartRoom)?.name ?? skeleton.rooms[0]?.name ?? '';
3193
- return {
3171
+ return wardDevicesToRooms({
3194
3172
  name: skeleton.name,
3195
3173
  story: skeleton.story,
3196
3174
  currency: { type: 'nuyen', authorityRoomTags: ['Bank'] },
@@ -3254,7 +3232,7 @@ export function assembleScene(skeleton, details, playerName) {
3254
3232
  ...(skeleton.securityTier !== undefined ? { securityTier: skeleton.securityTier } : {}),
3255
3233
  ...(skeleton.neighborhood !== undefined ? { neighborhood: skeleton.neighborhood } : {}),
3256
3234
  ...(skeleton.lawContract ? { lawContract: skeleton.lawContract } : {}),
3257
- };
3235
+ });
3258
3236
  }
3259
3237
  /**
3260
3238
  * Maps a validation failure to the chunk that owns the broken content, so
@@ -5,6 +5,7 @@ import { Door } from '../models/door.js';
5
5
  import { RoomFactory } from './room-factory.js';
6
6
  import { NPCFactory } from './npc-factory.js';
7
7
  import { ItemFactory } from './item-factory.js';
8
+ import { resolveCatalogItemSeed } from './catalog-item-seed.js';
8
9
  /** The heaviest a job's prize may be: a Strength 1 runner carries 30 kg
9
10
  * (player.ts maxCarryingWeight), and they still need room for gear. */
10
11
  export const OBJECTIVE_MAX_WEIGHT_KG = 15;
@@ -17,6 +18,9 @@ import { fuzzyPickName } from '../utilities/fuzzy-match.js';
17
18
  import { adoptSeededIce } from '../utilities/ic-actors.js';
18
19
  import { NPC_EQUIP_CATEGORIES, outfitNpcBySlug } from '../npc-kits.js';
19
20
  import { seatHostOwner } from '../utilities/matrix-roster.js';
21
+ import { seatSpider } from '../utilities/spider.js';
22
+ import { cellOnGrid } from '../utilities/room-grid.js';
23
+ import { spotForCell } from '../utilities/spots.js';
20
24
  /**
21
25
  * How many AI enrichment calls may be in flight at once.
22
26
  *
@@ -123,6 +127,31 @@ export class SceneSynthesizer {
123
127
  // that has already been migrated (and saved, and reloaded) passes
124
128
  // through untouched.
125
129
  json = migrateLegacySeed(json);
130
+ // A CATALOG SLUG IS THE ROW ITSELF (catalog-item-seed.ts): resolved
131
+ // once, here, so every later step that matches items by name -- heldBy,
132
+ // ownership, the win condition, keys -- sees the catalog's name. A
133
+ // seed with no slugs passes through untouched.
134
+ if ((json.items ?? []).some(i => i.slug)) {
135
+ json = { ...json, items: json.items.map(resolveCatalogItemSeed) };
136
+ }
137
+ // A DEVICE WITH A CELL STANDS THERE (device-seed.ts cell): it gets a
138
+ // one-cell spot of its own name, and is `at` it. A seed with no
139
+ // placed device passes through untouched.
140
+ if ((json.rooms ?? []).some(r => (r.devices ?? []).some(d => d.cell && !d.atSpot))) {
141
+ json = {
142
+ ...json,
143
+ rooms: json.rooms.map(r => {
144
+ const placed = (r.devices ?? []).filter(d => d.cell && !d.atSpot);
145
+ if (placed.length === 0)
146
+ return r;
147
+ return {
148
+ ...r,
149
+ spots: [...(r.spots ?? []), ...placed.map(d => ({ name: d.name, cell: d.cell, size: { w: 1, h: 1 } }))],
150
+ devices: (r.devices ?? []).map(d => (d.cell && !d.atSpot ? { ...d, atSpot: d.name } : d)),
151
+ };
152
+ }),
153
+ };
154
+ }
126
155
  // Step 1: Create the Scene using the JSON’s room configuration
127
156
  logger.write(`Initilizing Scene`);
128
157
  const scene = new Scene({
@@ -187,6 +216,12 @@ Key Locations: ${json.rooms.map(r => r.name).join(', ')}
187
216
  if (exit.atSpot) {
188
217
  scene.getRoom(roomJson.name)?.exitSpots.set(exit.direction.toLowerCase(), exit.atSpot);
189
218
  }
219
+ // An authored door or stair position (exit seed `cell`).
220
+ if (exit.cell) {
221
+ const declaring = scene.getRoom(roomJson.name);
222
+ if (declaring)
223
+ declaring.authoredDoorCells = { ...(declaring.authoredDoorCells ?? {}), [exit.direction.toLowerCase()]: { x: exit.cell.x, y: exit.cell.y } };
224
+ }
190
225
  }
191
226
  }
192
227
  beat('exits');
@@ -217,6 +252,11 @@ Key Locations: ${json.rooms.map(r => r.name).join(', ')}
217
252
  if (npcJson.player.guarding) {
218
253
  npc.guarding = true;
219
254
  }
255
+ // ...and a WATCHED one is guarded without being blocked (utilities/
256
+ // door-watch.ts): they turn on anyone caught going for it.
257
+ if (npcJson.player.watches) {
258
+ npc.watching = String(npcJson.player.watches).toLowerCase();
259
+ }
220
260
  // A MERCHANT'S TRADE (IMerchantSeed): what they sell is authored,
221
261
  // not read off whatever happens to lie in their room.
222
262
  if (npcJson.merchant) {
@@ -290,8 +330,18 @@ Key Locations: ${json.rooms.map(r => r.name).join(', ')}
290
330
  // Step 4: Add Items
291
331
  const itemFactory = new ItemFactory();
292
332
  const itemMap = new Map();
333
+ /** Weapons and armor an author asked to keep holstered / packed
334
+ * (IItemSeedJson.equipped === false) -- read at Step 4.05. */
335
+ const carryAs = new Map();
293
336
  for (const itemJson of json.items ?? []) {
294
337
  const item = await itemFactory.createItemFromJson(itemJson);
338
+ if (typeof itemJson.equipped === 'boolean')
339
+ carryAs.set(item, itemJson.equipped);
340
+ // A FILE'S OWN DEFENCES (utilities/file-guard.ts), authored only.
341
+ if (itemJson.protection && itemJson.protection > 0)
342
+ item.protection = itemJson.protection;
343
+ if (itemJson.dataBomb && itemJson.dataBomb.rating > 0)
344
+ item.dataBomb = { ...itemJson.dataBomb };
295
345
  // THE PRIZE HAS TO BE CARRIABLE (KSfzQzuHTx7kHjohi, 2026-09-12:
296
346
  // "I shouldn't have an item I need to return, that I can't carry
297
347
  // -- this case is too heavy"). The seed's items are written by a
@@ -556,6 +606,48 @@ Key Locations: ${json.rooms.map(r => r.name).join(', ')}
556
606
  logger.error(`Scene "${json.name}": the door ${String(direction)} of ${room.name} was locked by "${wanted ?? '(nothing)'}", which no device holds and no item matches -- it ships unlocked rather than as a wall.`);
557
607
  }
558
608
  }
609
+ // Step 4.96: AUTHORED START CELLS (player-built runs, 2026-10-06). An
610
+ // NPC with `startCell` stands exactly there. HERE, not at Step 3 where
611
+ // they were placed: spotForCell builds the room's grid, and a grid
612
+ // built before the device step above (4.9) would be frozen without
613
+ // the rubble its terrain hints add. The spot comes from `startSpot`
614
+ // when the author gave one, otherwise from whatever the cell touches.
615
+ for (const npcJson of json.npcs ?? []) {
616
+ const at = npcJson.player.startCell;
617
+ if (!at)
618
+ continue;
619
+ const npc = scene.getActor(npcJson.player.name);
620
+ const room = npc?.currentLocation;
621
+ if (!npc || !room?.ensureGrid())
622
+ continue;
623
+ const cell = { x: Math.round(at.x), y: Math.round(at.y), z: Math.round(at.z ?? 0) };
624
+ if (!cellOnGrid(room.ensureGrid(), cell)) {
625
+ logger.error(`Scene "${json.name}": ${npc.name}'s startCell ${cell.x},${cell.y} is off ${room.name}'s grid -- left to their spot.`);
626
+ continue;
627
+ }
628
+ npc.atSpot = npcJson.player.startSpot ?? spotForCell(room, cell);
629
+ npc.atCell = cell;
630
+ }
631
+ // ...and items lying where the author put them (items[].cell). An
632
+ // item may sit ON furniture -- the file on the desk -- so the cell is
633
+ // not checked against footprints; reach goes through the spot it
634
+ // touches, exactly as for a body standing there.
635
+ for (const itemJson of json.items ?? []) {
636
+ const at = itemJson.cell;
637
+ if (!at || !itemJson.room)
638
+ continue;
639
+ const item = itemMap.get(itemJson.name);
640
+ const room = scene.getRoom(itemJson.room);
641
+ if (!item || !room?.ensureGrid())
642
+ continue;
643
+ const cell = { x: Math.round(at.x), y: Math.round(at.y), z: Math.round(at.z ?? 0) };
644
+ if (!cellOnGrid(room.ensureGrid(), cell)) {
645
+ logger.error(`Scene "${json.name}": "${item.name}" cell ${cell.x},${cell.y} is off ${room.name}'s grid -- left unplaced.`);
646
+ continue;
647
+ }
648
+ item.atCell = cell;
649
+ item.atSpot = itemJson.atSpot ?? spotForCell(room, cell);
650
+ }
559
651
  // Step 4.04: NPCs get their kit (npc-kits.ts, SR5 p.379-385, catalog
560
652
  // v21). AFTER "heldBy" has landed (Step 4.5 above -- the numbering is
561
653
  // historical, the source order is what runs) and BEFORE the equip
@@ -600,8 +692,12 @@ Key Locations: ${json.rooms.map(r => r.name).join(', ')}
600
692
  if (!npc)
601
693
  continue;
602
694
  for (const item of [...npc.inventory.getAllItems()]) {
695
+ // Only weapons and armor are ever equipped -- a key is carried,
696
+ // never wielded -- and an author may keep one of those holstered.
603
697
  if (!NPC_EQUIP_CATEGORIES.has(item.category))
604
698
  continue;
699
+ if (carryAs.get(item) === false)
700
+ continue;
605
701
  if (weaponCategories.has(item.category) && npc.getCarriedWeapon())
606
702
  continue;
607
703
  try {
@@ -728,6 +824,22 @@ Key Locations: ${json.rooms.map(r => r.name).join(', ')}
728
824
  if (seated)
729
825
  logger.write(`"${seated.name}" works the host over "${room.name}" -- his icon is inside it.`);
730
826
  }
827
+ // Step 7c: SPIDERS (utilities/spider.ts). After 7b, so a host owner
828
+ // who is also its spider ends up in VR rather than AR; after 4.96 and
829
+ // the gear steps, so the body slumps at its authored cell with its
830
+ // deck in hand. A refusal leaves them in the meat -- the authored
831
+ // audit reports the same refusal as a fault.
832
+ for (const npcJson of json.npcs ?? []) {
833
+ const into = npcJson.player.jackedInto;
834
+ if (!into)
835
+ continue;
836
+ const npc = scene.getActor(npcJson.player.name);
837
+ const host = scene.getRoom(into)?.host;
838
+ if (!npc || !host)
839
+ continue;
840
+ const refused = seatSpider(scene, npc, host, npcJson.player.sim ?? 'cold');
841
+ logger.write(refused ? `Spider "${npc.name}" stays in the meat: ${refused}` : `"${npc.name}" is jacked into ${host.name} (${npc.simMode}-sim).`);
842
+ }
731
843
  // JACKPOINT, IN EVERY SCENE (qgyKnJLSeoxMf23j8). A standing host has
732
844
  // to be reachable from wherever the runner is, and Scene.getRooms()
733
845
  // is the whole reachable universe -- so the haven is added to each