@maka/maka-cli 5.64.0 → 5.65.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 (161) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/backlog.d.ts +29 -2
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/backlog.js +146 -1
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/backlog.js.map +1 -1
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.d.ts +40 -13
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js +107 -25
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js.map +1 -1
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/breach.d.ts +11 -15
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/breach.js +13 -16
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/breach.js.map +1 -1
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/buy.d.ts +8 -0
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/buy.js +30 -2
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/buy.js.map +1 -1
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.d.ts +40 -49
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.js +121 -160
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.js.map +1 -1
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.d.ts +7 -8
  18. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +13 -13
  19. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js.map +1 -1
  20. package/bundle/typescript/src/commands/game/sideQuest/commands/cover.d.ts +30 -0
  21. package/bundle/typescript/src/commands/game/sideQuest/commands/cover.js +65 -0
  22. package/bundle/typescript/src/commands/game/sideQuest/commands/cover.js.map +1 -0
  23. package/bundle/typescript/src/commands/game/sideQuest/commands/dispel.d.ts +1 -1
  24. package/bundle/typescript/src/commands/game/sideQuest/commands/dispel.js +2 -2
  25. package/bundle/typescript/src/commands/game/sideQuest/commands/dispel.js.map +1 -1
  26. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +25 -3
  27. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js.map +1 -1
  28. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.d.ts +2 -3
  29. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +2 -2
  30. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js.map +1 -1
  31. package/bundle/typescript/src/commands/game/sideQuest/commands/jobs.js +2 -1
  32. package/bundle/typescript/src/commands/game/sideQuest/commands/jobs.js.map +1 -1
  33. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +58 -2
  34. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js.map +1 -1
  35. package/bundle/typescript/src/commands/game/sideQuest/commands/map.js +18 -4
  36. package/bundle/typescript/src/commands/game/sideQuest/commands/map.js.map +1 -1
  37. package/bundle/typescript/src/commands/game/sideQuest/commands/move.js +1 -1
  38. package/bundle/typescript/src/commands/game/sideQuest/commands/pick.d.ts +2 -3
  39. package/bundle/typescript/src/commands/game/sideQuest/commands/pick.js +2 -2
  40. package/bundle/typescript/src/commands/game/sideQuest/commands/pick.js.map +1 -1
  41. package/bundle/typescript/src/commands/game/sideQuest/commands/posture.d.ts +45 -0
  42. package/bundle/typescript/src/commands/game/sideQuest/commands/posture.js +115 -0
  43. package/bundle/typescript/src/commands/game/sideQuest/commands/posture.js.map +1 -0
  44. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +35 -24
  45. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js.map +1 -1
  46. package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +12 -0
  47. package/bundle/typescript/src/commands/game/sideQuest/commands/take.js.map +1 -1
  48. package/bundle/typescript/src/commands/game/sideQuest/commands/unlock.js +1 -1
  49. package/bundle/typescript/src/commands/game/sideQuest/commands/unlock.js.map +1 -1
  50. package/bundle/typescript/src/commands/game/sideQuest/commands/use.d.ts +17 -0
  51. package/bundle/typescript/src/commands/game/sideQuest/commands/use.js +136 -1
  52. package/bundle/typescript/src/commands/game/sideQuest/commands/use.js.map +1 -1
  53. package/bundle/typescript/src/commands/game/sideQuest/engine-version.d.ts +1 -1
  54. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +15 -1
  55. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js.map +1 -1
  56. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.d.ts +82 -43
  57. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +419 -263
  58. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js.map +1 -1
  59. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js +82 -84
  60. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js.map +1 -1
  61. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.d.ts +23 -4
  62. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js +95 -79
  63. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js.map +1 -1
  64. package/bundle/typescript/src/commands/game/sideQuest/factories/seed-migration.d.ts +30 -0
  65. package/bundle/typescript/src/commands/game/sideQuest/factories/seed-migration.js +176 -0
  66. package/bundle/typescript/src/commands/game/sideQuest/factories/seed-migration.js.map +1 -0
  67. package/bundle/typescript/src/commands/game/sideQuest/game.js +11 -5
  68. package/bundle/typescript/src/commands/game/sideQuest/game.js.map +1 -1
  69. package/bundle/typescript/src/commands/game/sideQuest/headless.js +24 -0
  70. package/bundle/typescript/src/commands/game/sideQuest/headless.js.map +1 -1
  71. package/bundle/typescript/src/commands/game/sideQuest/models/_index.d.ts +0 -1
  72. package/bundle/typescript/src/commands/game/sideQuest/models/_index.js +0 -1
  73. package/bundle/typescript/src/commands/game/sideQuest/models/_index.js.map +1 -1
  74. package/bundle/typescript/src/commands/game/sideQuest/models/device.d.ts +121 -0
  75. package/bundle/typescript/src/commands/game/sideQuest/models/device.js +145 -0
  76. package/bundle/typescript/src/commands/game/sideQuest/models/device.js.map +1 -0
  77. package/bundle/typescript/src/commands/game/sideQuest/models/door.d.ts +27 -8
  78. package/bundle/typescript/src/commands/game/sideQuest/models/door.js +15 -10
  79. package/bundle/typescript/src/commands/game/sideQuest/models/door.js.map +1 -1
  80. package/bundle/typescript/src/commands/game/sideQuest/models/npc.d.ts +81 -0
  81. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +132 -21
  82. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js.map +1 -1
  83. package/bundle/typescript/src/commands/game/sideQuest/models/player.d.ts +68 -0
  84. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +102 -0
  85. package/bundle/typescript/src/commands/game/sideQuest/models/player.js.map +1 -1
  86. package/bundle/typescript/src/commands/game/sideQuest/models/room.d.ts +32 -46
  87. package/bundle/typescript/src/commands/game/sideQuest/models/room.js +38 -63
  88. package/bundle/typescript/src/commands/game/sideQuest/models/room.js.map +1 -1
  89. package/bundle/typescript/src/commands/game/sideQuest/models/scene.d.ts +1 -4
  90. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +0 -22
  91. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js.map +1 -1
  92. package/bundle/typescript/src/commands/game/sideQuest/scenes/scene1.json +78 -109
  93. package/bundle/typescript/src/commands/game/sideQuest/scenes/scene2.json +4 -101
  94. package/bundle/typescript/src/commands/game/sideQuest/types/save-file.d.ts +24 -0
  95. package/bundle/typescript/src/commands/game/sideQuest/types/seed/device-seed.d.ts +52 -0
  96. package/bundle/typescript/src/commands/game/sideQuest/types/seed/device-seed.js +2 -0
  97. package/bundle/typescript/src/commands/game/sideQuest/types/seed/device-seed.js.map +1 -0
  98. package/bundle/typescript/src/commands/game/sideQuest/types/seed/legacy-puzzle-seed.d.ts +63 -0
  99. package/bundle/typescript/src/commands/game/sideQuest/types/seed/legacy-puzzle-seed.js +2 -0
  100. package/bundle/typescript/src/commands/game/sideQuest/types/seed/legacy-puzzle-seed.js.map +1 -0
  101. package/bundle/typescript/src/commands/game/sideQuest/types/seed/player-seed.d.ts +6 -0
  102. package/bundle/typescript/src/commands/game/sideQuest/types/seed/room-seed.d.ts +4 -0
  103. package/bundle/typescript/src/commands/game/sideQuest/types/seed/scene-seed.d.ts +3 -2
  104. package/bundle/typescript/src/commands/game/sideQuest/types/shared/abstracts.d.ts +2 -28
  105. package/bundle/typescript/src/commands/game/sideQuest/types/shared/abstracts.js +1 -14
  106. package/bundle/typescript/src/commands/game/sideQuest/types/shared/abstracts.js.map +1 -1
  107. package/bundle/typescript/src/commands/game/sideQuest/ui.js +24 -0
  108. package/bundle/typescript/src/commands/game/sideQuest/ui.js.map +1 -1
  109. package/bundle/typescript/src/commands/game/sideQuest/utilities/affordances.d.ts +55 -0
  110. package/bundle/typescript/src/commands/game/sideQuest/utilities/affordances.js +36 -0
  111. package/bundle/typescript/src/commands/game/sideQuest/utilities/affordances.js.map +1 -0
  112. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-review.d.ts +45 -0
  113. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-review.js +116 -0
  114. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-review.js.map +1 -0
  115. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog.d.ts +67 -1
  116. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog.js +56 -1
  117. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog.js.map +1 -1
  118. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +37 -4
  119. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js.map +1 -1
  120. package/bundle/typescript/src/commands/game/sideQuest/utilities/credentials.d.ts +26 -0
  121. package/bundle/typescript/src/commands/game/sideQuest/utilities/credentials.js +48 -0
  122. package/bundle/typescript/src/commands/game/sideQuest/utilities/credentials.js.map +1 -0
  123. package/bundle/typescript/src/commands/game/sideQuest/utilities/devices.d.ts +23 -0
  124. package/bundle/typescript/src/commands/game/sideQuest/utilities/devices.js +55 -0
  125. package/bundle/typescript/src/commands/game/sideQuest/utilities/devices.js.map +1 -0
  126. package/bundle/typescript/src/commands/game/sideQuest/utilities/fuzzy-match.d.ts +57 -2
  127. package/bundle/typescript/src/commands/game/sideQuest/utilities/fuzzy-match.js +46 -3
  128. package/bundle/typescript/src/commands/game/sideQuest/utilities/fuzzy-match.js.map +1 -1
  129. package/bundle/typescript/src/commands/game/sideQuest/utilities/log-style.d.ts +16 -0
  130. package/bundle/typescript/src/commands/game/sideQuest/utilities/log-style.js +18 -0
  131. package/bundle/typescript/src/commands/game/sideQuest/utilities/log-style.js.map +1 -1
  132. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +68 -38
  133. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js.map +1 -1
  134. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.d.ts +3 -3
  135. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.js +3 -3
  136. package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.d.ts +36 -12
  137. package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js +101 -27
  138. package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js.map +1 -1
  139. package/bundle/typescript/src/commands/game/sideQuest/utilities/use-target.d.ts +88 -0
  140. package/bundle/typescript/src/commands/game/sideQuest/utilities/use-target.js +113 -0
  141. package/bundle/typescript/src/commands/game/sideQuest/utilities/use-target.js.map +1 -0
  142. package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js +54 -5
  143. package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js.map +1 -1
  144. package/bundle/typescript/src/maka.js +1 -1
  145. package/bundle/typescript/src/maka.js.map +1 -1
  146. package/bundle/typescript/src/tools/sdk-loader/relocate.d.ts +19 -2
  147. package/bundle/typescript/src/tools/sdk-loader/relocate.js +22 -2
  148. package/bundle/typescript/src/tools/sdk-loader/relocate.js.map +1 -1
  149. package/package.json +1 -1
  150. package/bundle/typescript/src/commands/game/sideQuest/commands/solve.d.ts +0 -6
  151. package/bundle/typescript/src/commands/game/sideQuest/commands/solve.js +0 -72
  152. package/bundle/typescript/src/commands/game/sideQuest/commands/solve.js.map +0 -1
  153. package/bundle/typescript/src/commands/game/sideQuest/factories/puzzle-factory.d.ts +0 -6
  154. package/bundle/typescript/src/commands/game/sideQuest/factories/puzzle-factory.js +0 -26
  155. package/bundle/typescript/src/commands/game/sideQuest/factories/puzzle-factory.js.map +0 -1
  156. package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.d.ts +0 -152
  157. package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.js +0 -393
  158. package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.js.map +0 -1
  159. package/bundle/typescript/src/commands/game/sideQuest/types/seed/puzzle-seed.d.ts +0 -28
  160. package/bundle/typescript/src/commands/game/sideQuest/types/seed/puzzle-seed.js +0 -2
  161. package/bundle/typescript/src/commands/game/sideQuest/types/seed/puzzle-seed.js.map +0 -1
@@ -1,4 +1,5 @@
1
1
  import { SKILL_NAMES } from '../models/player.js';
2
+ import { Logger } from '../utilities/logger.js';
2
3
  import { AUGMENTATIONS } from '../augmentations.js';
3
4
  import { isOutdoorsPlace } from '../utilities/outdoors.js';
4
5
  /**
@@ -44,32 +45,26 @@ export function isFixerName(name) {
44
45
  * Returns '' when no player is available.
45
46
  */
46
47
  /**
47
- * SPELL OUT THE CLUE RULE, IN THIS RUNNER'S TERMS. The dossier already
48
- * hints at it in prose ("con"-bypass barriers for a talker), and the
49
- * model still missed it: the single most common generation failure on
50
- * record is a barrier this runner can't open with no clue written for
51
- * it. It's character-dependent, which is what made it look like random
52
- * flakiness -- a Face satisfies exactly ONE of the four bypass types, so
53
- * three quarters of the barriers the model naturally reaches for demand
54
- * a clue item, and it keeps forgetting.
48
+ * THE ONE BARRIER RULE THAT STILL DEPENDS ON WHO IS PLAYING.
55
49
  *
56
- * So state it mechanically instead of hinting: name the types they can
57
- * and can't do, and name the consequence. normalizeSkeleton repairs this
58
- * anyway -- this is the half that stops the fault being authored at all.
50
+ * This used to name all three barrier types the runner could and could
51
+ * not force, and demand a clue for each one they could not -- the
52
+ * single most common generation failure on record, and one that looked
53
+ * like random flakiness precisely because it varied with the character.
54
+ *
55
+ * It shrank to this because the clue/key rule became UNCONDITIONAL for
56
+ * panels and locks (see partyCanDispel): a code is findable and a key
57
+ * is placeable, so both are openable by ANY runner and there is nothing
58
+ * character-dependent left to state. A ward is the exception that
59
+ * cannot be written away -- no clue rescues it for a mundane team -- so
60
+ * it is the only thing this still says.
59
61
  */
60
- export function bypassBrief(player) {
61
- const ALL = ['lockpick', 'hack', 'spell', 'con'];
62
- const can = ALL.filter(t => canBypassUnaided(player, t));
63
- const cannot = ALL.filter(t => !can.includes(t));
64
- const canText = can.length > 0
65
- ? `Unaided, this runner can only get past a barrier by: ${can.join(', ')}.`
66
- : `Unaided, this runner cannot force ANY barrier type on their own.`;
67
- const cannotText = cannot.length > 0 ? ` They CANNOT ${cannot.join(', ')}.` : '';
68
- return `- BARRIER RULE, MECHANICAL (most common reason a draft is rejected): ${canText}${cannotText}
69
- Every puzzle whose bypassType is one they cannot do MUST have its own "clue"-role item --
70
- category "Book", placed in a DIFFERENT room from the barrier, and in a room reachable
71
- WITHOUT passing through that barrier. Prefer barriers they can actually engage with; when
72
- you write one they can't, write its clue in the same breath.`;
62
+ export function wardBrief(player) {
63
+ return partyCanDispel(player)
64
+ ? `- WARDS: this runner can dispel, so a "ward" device is fair game.`
65
+ : `- WARDS: NOBODY at this table can dispel. Do NOT write a "ward" device -- there is no
66
+ findable answer to a ward, so a mundane team simply cannot pass one. Use a "lock" or a
67
+ "panel"; both have a way through that anyone can find.`;
73
68
  }
74
69
  export function buildPlayerDossier(player) {
75
70
  if (!player)
@@ -146,7 +141,7 @@ ${caps.map(c => `- ${c}`).join('\n')}
146
141
  Tailoring rules:
147
142
  - Include at least one route, subplot, or payoff that rewards THIS runner's toolkit (matrix
148
143
  content and hasNode rooms for a decker/technomancer; astral angles or Awakened opposition
149
- for a mage; melee-shaped trouble for an adept; social leverage and "con"-bypass barriers
144
+ for a mage; melee-shaped trouble for an adept; social leverage and people worth talking round
150
145
  for a talker; recon value for a drone owner). Pick what fits this character and this
151
146
  story -- don't bolt on every plane at once.
152
147
  - Never gate FINISHING the run behind a capability they lack: no matrix-plane winCondition
@@ -162,7 +157,7 @@ Tailoring rules:
162
157
  dice below the runner's pools, and a centerpiece enforcer can match or slightly exceed
163
158
  them. Don't send soft civilians at a combat monster, or elite security at a runner built
164
159
  for talking their way through.
165
- ${bypassBrief(player)}
160
+ ${wardBrief(player)}
166
161
  `.trim();
167
162
  }
168
163
  // ============================ Prompt builders ============================
@@ -226,10 +221,10 @@ export function buildSkeletonPrompt(tier, player, previousError, rumor, crew, ca
226
221
  // for more guns. Scaling the map too would just add walking.
227
222
  const heads = partyHeadcount(crew);
228
223
  const scope = tier <= 1
229
- ? `3 to 4 rooms, exactly ${heads} NPC${heads === 1 ? '' : 's'} besides the client, and exactly 1 puzzle`
224
+ ? `3 to 4 rooms, exactly ${heads} NPC${heads === 1 ? '' : 's'} besides the client, and exactly 1 device`
230
225
  : tier === 2
231
- ? `4 to 6 rooms, ${2 * heads} NPCs besides the client, and 1 to 2 puzzles`
232
- : `6 to 8 rooms, ${3 * heads} to ${4 * heads} NPCs besides the client, and 2 to 3 puzzles`;
226
+ ? `4 to 6 rooms, ${2 * heads} NPCs besides the client, and 1 to 2 devices`
227
+ : `6 to 8 rooms, ${3 * heads} to ${4 * heads} NPCs besides the client, and 2 to 3 devices`;
233
228
  const dossier = buildPlayerDossier(player) + buildTeamBlock(crew);
234
229
  // The hub's active street rumor becomes the job's backdrop -- gossip
235
230
  // the runner heard at the noodle stand pays off as foreshadowing when
@@ -282,10 +277,14 @@ ${JSON_ONLY}
282
277
  // acts on camera cost runners dearly, and deckers loop the feeds.
283
278
  "exits": [{ "direction": "north"|"east"|"south"|"west"|"up"|"down", "targetRoom": string,
284
279
  "keyRequired"?: string, // an items[].name with role "key"
285
- "puzzle"?: string }] // a puzzles[].name guarding this door. A door puzzle
286
- // lives ON THE DOOR ONLY: that puzzle must have NO
287
- // "room" field of its own (setting both is the most
288
- // common structural mistake -- don't).
280
+ "device"?: string, // a devices[].name that holds this door shut. That
281
+ // device's own "room" MUST be the room declaring this
282
+ // exit -- you have to stand where the mechanism is.
283
+ "guard"?: string }] // an npcs[].name who STANDS in this doorway and will
284
+ // not move. A person, not a mechanism: the way past
285
+ // is talking them round or putting them down.
286
+ // NEVER set "device" and "guard" on one exit -- a
287
+ // door is gated by a thing OR a person, never both.
289
288
  // Doors are TWO-WAY automatically: declare each connection from ONE
290
289
  // side only (with its keyRequired/puzzle on that one declaration) --
291
290
  // never declare the reverse direction too.
@@ -300,18 +299,21 @@ ${JSON_ONLY}
300
299
  "plane"?: "meat"|"matrix"|"astral" // "matrix" = ICE guarding a hasNode room;
301
300
  // "astral" = a spirit. Default meat.
302
301
  }],
303
- "puzzles": [{
304
- "name": string, "concept": string, // what the barrier physically IS (a maglock, a
305
- // keypad, a scanner, a doorman, a ward)
306
- "room"?: string, // room-anchored placement -- OMIT when an exits[].puzzle references it
307
- "bypassType"?: "lockpick"|"hack"|"spell"|"con", // the matching risky route: literal
308
- // lock -> "lockpick"; terminal/scanner -> "hack"; ward -> "spell";
309
- // a PERSON/voice-gate that could be talked past -> "con". Omit if
310
- // no such route. EXACTLY these lowercase strings.
311
- "requiresPuzzle"?: string, // another puzzle solved FIRST (no cycles)
312
- "rewardItem"?: string // an items[].name with role "reward" -- REQUIRED for room-anchored
313
- // puzzles (solving it is how the player gets that item); omit for
314
- // door puzzles (the open door is the reward)
302
+ "devices": [{
303
+ "name": string, "concept": string, // what it physically IS (a maglock, a keypad, a
304
+ // scanner, a ward) -- a THING, never a person
305
+ "room": string, // REQUIRED. A device always stands in ONE room -- the one you must be
306
+ // in to reach it. If it holds a door, declare that exit FROM THIS
307
+ // SAME ROOM with "device": "<this name>".
308
+ "kind": "lock"|"panel"|"ward", // lock = a mechanism: picked, breached, or opened by its
309
+ // key. panel = an interface: a code, or hacked. ward = a working:
310
+ // cast at or dispelled. EXACTLY these lowercase strings.
311
+ // A device is always a THING. A doorman or a checkpoint guard is
312
+ // NOT a device -- write them into npcs[] and name them in that
313
+ // exit's "guard"; people are got past socially, not picked.
314
+ "grantsItem"?: string // an items[].name with role "reward" -- what opening it hands
315
+ // over. Omit for a device that holds a door: the open way IS
316
+ // the reward.
315
317
  }],
316
318
  "items": [{
317
319
  "name": string,
@@ -367,7 +369,7 @@ commlink of their own too.
367
369
 
368
370
  Hard structural rules (validated mechanically -- a violation is rejected):
369
371
  - Every exits[].targetRoom, npcs[].startLocation, items[].room name a real rooms[].name;
370
- every items[].heldBy names a real npcs[].name; every exits[].puzzle names a real
372
+ every items[].heldBy names a real npcs[].name; every exits[].device names a real
371
373
  puzzles[].name; every keyRequired names a real items[].name with role "key".
372
374
  - "client" names a real npcs[].name, stationed in a REACHABLE room, holding a
373
375
  Commlink-category item.
@@ -378,18 +380,22 @@ Hard structural rules (validated mechanically -- a violation is rejected):
378
380
  placed beyond them: every key must be reachable WITHOUT passing through the door it opens.
379
381
  - A "reachable": false room never connects to a reachable one, and nothing the player must
380
382
  retrieve (winCondition item, keys, clues) is ever placed in one.
381
- - Each puzzle is placed EXACTLY one way: its own "room", or exactly one exits[].puzzle
382
- reference. Each puzzle has exactly one "clue"-role Book item (forPuzzle set) in a
383
- DIFFERENT room -- EXCEPT when the puzzle's bypassType matches a skill the runner's
384
- dossier shows real strength in (a lockpick barrier for a master locksmith, a con
385
- barrier for a silver-tongued face): their skill IS the way past, and the clue may be
386
- omitted. When in doubt, include the clue. requiresPuzzle chains never cycle.
383
+ - EVERY device names its own "room". If it holds a door, that door's exit is declared
384
+ from THAT SAME room with "device": "<its name>".
385
+ - EVERY WAY THROUGH IS FINDABLE -- no exceptions, and no dependence on who is playing:
386
+ * every "panel" gets a code AND exactly one "clue"-role Book item (forDevice set) in a
387
+ DIFFERENT room, reachable without passing the panel itself;
388
+ * every "lock" gets a "key"-role item placed in the world, reachable without passing
389
+ the lock itself.
390
+ Write the clue or the key in the same breath as the device. A runner with the matching
391
+ skill can still take the shortcut -- pick the lock, hack the panel -- but the found way
392
+ must exist for a runner who has neither.
387
393
  - The winCondition item must not be heldBy winCondition.toNpc. If a matrix-plane item is the
388
394
  winCondition item, its room has hasNode true AND a "matrix"-plane NPC stationed there.
389
395
  - ${isContinuation
390
396
  ? 'ZERO "starting"-role items -- the runner arrives with everything they own. 4-6'
391
397
  : '3-5 "starting"-role items (basics the runner doesn\'t already own -- see the dossier), 4-6'}
392
- "loot"-role world items, one "clue" per puzzle.
398
+ "loot"-role world items, one "clue" per panel and one "key" per lock.
393
399
 
394
400
  Scope: ${scope}. Gritty noir Shadowrun tone: a client, a job, a debt, a betrayal -- and a
395
401
  fresh premise, not a generic "corp blackmail vault" rerun.${repairNote(previousError)}
@@ -511,58 +517,57 @@ ${JSON_ONLY}
511
517
  One entry per skeleton npc, "name" copied exactly.${repairNote(previousError)}
512
518
  `.trim();
513
519
  }
514
- export function buildPuzzlesPrompt(skeleton, previousError) {
515
- if (skeleton.puzzles.length === 0)
520
+ export function buildDevicesPrompt(skeleton, previousError) {
521
+ if (skeleton.devices.length === 0)
516
522
  return '';
517
523
  return `
518
- You are writing the barriers (puzzles) for a Shadowrun text adventure.
524
+ You are writing the DEVICES for a Shadowrun text adventure. A device is a THING that is
525
+ shut and can be opened -- a lock, a panel, a ward. Never a person.
519
526
 
520
527
  ${skeletonContext(skeleton)}
521
528
 
522
- For EVERY puzzle in the skeleton, expand its "concept" (what the barrier physically is --
523
- that's fixed, as is its bypassType):
524
- - "riddle": what the player sees on approaching it, shown verbatim -- PLAIN and concrete,
525
- never a cryptic riddle-poem. Describe the barrier as a THING: what it is, how it's
526
- secured, and what kind of credential it visibly expects (a keypad implies a code, an
527
- intercom awaits a spoken phrase, a scrawled sign says "password"). Write NO people into
528
- it -- no guards, no dialogue, no "they demand": anyone standing watch does their own
529
- demanding in play, in their own voice. Never state the credential itself, and never write
530
- ANY OTHER number into it -- no unit number, no model number, no serial, no floor number.
531
- Every code-shaped string a player reads is one they will go and type.
532
- - "requiredPhrase": the exact credential the barrier accepts, and it MUST BE THE SAME KIND
533
- OF THING your "riddle" says the barrier wants. This is the single most confusing thing
534
- in the game when it drifts: a player who reads "a keypad with worn digits" and then finds
535
- "double or nothing" written in a notebook does not believe they have the code, and goes
536
- looking for a number that does not exist.
537
- * If the riddle shows a NUMERIC pad, dial, or counter -- digits only, 3-6 of them, no
538
- spaces ("470912"). Then the barrier really does take a number.
529
+ For EVERY device in the skeleton, expand its "concept" (what it physically is -- that's
530
+ fixed, as is its "kind"):
531
+ - "description": what the player sees on approaching it, shown verbatim -- PLAIN and
532
+ concrete, never a cryptic riddle-poem. Describe it as a THING: what it is, how it's
533
+ secured, and what it visibly expects. Write NO people into it -- no guards, no dialogue,
534
+ no "they demand": anyone standing watch does their own demanding in play, in their own
535
+ voice. Never state the credential itself, and never write ANY OTHER number into it -- no
536
+ unit number, no model number, no serial, no floor number. Every code-shaped string a
537
+ player reads is one they will go and type.
538
+ - "code": ONLY for kind "panel", and REQUIRED for one. The exact credential it accepts, and
539
+ it MUST BE THE SAME KIND OF THING your "description" says the panel wants. This is the
540
+ single most confusing thing in the game when it drifts: a player who reads "a keypad with
541
+ worn digits" and then finds "double or nothing" written in a notebook does not believe
542
+ they have the code, and goes looking for a number that does not exist.
543
+ * If the description shows a NUMERIC pad, dial, or counter -- digits only, 3-6 of them,
544
+ no spaces ("470912"). Then it really does take a number.
539
545
  * Otherwise (an intercom, a voice-gate, a spoken passphrase, a name, a sign reading
540
546
  "password") -- ALL LOWERCASE, 2-5 plain words, no punctuation.
541
547
  Either way a concrete literal thing, never wordplay.
542
- Do NOT put a number in the riddle and words in the requiredPhrase, or the reverse.
543
- - "successMessage" / "failureMessage": one sentence each, in-fiction.
544
- - "lockedMessage": only for puzzles with "requiresPuzzle" -- what the player sees trying it
545
- too early, hinting at the prerequisite without naming mechanics.
546
- - "guardsExit": ONLY for a puzzle with a "room" (never a door-guarding puzzle) whose
547
- concept is physically TERRAIN blocking a passage -- rubble, a collapsed rack, a
548
- security shutter across a walkway -- name the exact "direction" of one of that
549
- room's own exits (from the skeleton) that it seals. A barrier on a container, safe,
550
- or item pickup guards nothing physical -- leave this unset.
551
- - "footprint": "small" | "medium" | "large" -- how much of the room the barrier's
552
- wreckage/blockage occupies. Only meaningful alongside "guardsExit".
548
+ OMIT "code" for a "lock" or a "ward". You cannot talk at a mechanical lock, and a ward
549
+ answers to the Art -- neither has anything to hear a phrase with, so a code on one is a
550
+ promise the game cannot keep.
551
+ - "openMessage" / "refuseMessage": one sentence each, in-fiction.
552
+ - "guardsExit": ONLY when the concept is physically TERRAIN blocking a passage -- rubble, a
553
+ collapsed rack, a security shutter across a walkway -- name the exact "direction" of one
554
+ of its own room's exits that it seals. A device on a container, safe, or item pickup
555
+ guards nothing physical: leave it unset.
556
+ - "footprint": "small" | "medium" | "large" -- how much of the room the blockage occupies.
557
+ Only meaningful alongside "guardsExit".
553
558
 
554
559
  ${JSON_ONLY}
555
- [ { "name": string, "riddle": string, "requiredPhrase": string, "successMessage": string,
556
- "failureMessage": string, "lockedMessage"?: string, "guardsExit"?: string, "footprint"?: string } ]
557
- One entry per skeleton puzzle, "name" copied exactly.${repairNote(previousError)}
560
+ [ { "name": string, "description": string, "code"?: string, "openMessage": string,
561
+ "refuseMessage": string, "guardsExit"?: string, "footprint"?: string } ]
562
+ One entry per skeleton device, "name" copied exactly.${repairNote(previousError)}
558
563
  `.trim();
559
564
  }
560
- export function buildItemsPrompt(skeleton, puzzles, previousError) {
565
+ export function buildItemsPrompt(skeleton, devices, previousError) {
561
566
  const clueSpecs = skeleton.items
562
- .filter(i => i.role === 'clue' && i.forPuzzle)
567
+ .filter(i => i.role === 'clue' && i.forDevice)
563
568
  .map(i => {
564
- const phrase = puzzles.find(p => p.name === i.forPuzzle)?.requiredPhrase;
565
- return `- "${i.name}" is the clue for puzzle "${i.forPuzzle}": its "details" text MUST contain the exact phrase "${phrase ?? 'UNKNOWN'}" verbatim (a diary entry, scrawled note, or manual that gives it away once read).`;
569
+ const code = devices.find(d => d.name === i.forDevice)?.code;
570
+ return `- "${i.name}" is the clue for device "${i.forDevice}": its "details" text MUST contain the exact code "${code ?? 'UNKNOWN'}" verbatim (a diary entry, scrawled note, or manual that gives it away once read).`;
566
571
  })
567
572
  .join('\n');
568
573
  return `
@@ -599,32 +604,33 @@ One entry per skeleton item, "name" and "category" copied exactly.${repairNote(p
599
604
  }
600
605
  // ========================= Parse / validate / assemble ====================
601
606
  /**
602
- * Whether THIS runner can reliably work a barrier of the given bypassType
603
- * with their own hands -- no written-down credential required. This is
604
- * what loosens the "every puzzle needs a discoverable clue" rule when the
605
- * scene is generated against a dossier: a lockpick maglock in a scene
606
- * built for a master locksmith doesn't need its code lying in a book --
607
- * the pick IS their route. Deliberately conservative: EXPLICIT skill
608
- * ratings only (the combatSkill fallback would let any bruiser "qualify"
609
- * as a hacker), threshold 3 (trained professional), and with no dossier
610
- * at all the strict clue rule stays.
607
+ * Can anyone at this table unweave a WARD?
608
+ *
609
+ * The last surviving branch of what used to be canBypassUnaided, which
610
+ * asked the same question of all three barrier kinds. The other two are
611
+ * gone because their answers stopped mattering (ruling 2026-08-27):
612
+ *
613
+ * A PANEL takes a code, and a code is FINDABLE. Write it down and any
614
+ * runner can type it; a decker who did not need it loses nothing.
615
+ * A LOCK takes a key, and a key is FINDABLE. Place it and any runner
616
+ * can carry it to the door; picking becomes the SHORTCUT for someone
617
+ * with locksmith, not the requirement.
618
+ *
619
+ * So for those two the clue/key rule is UNCONDITIONAL now, and the
620
+ * character-dependent waiver is gone with the condition it rested on.
621
+ *
622
+ * A WARD is irreducible. There is no findable answer to a ward; it takes
623
+ * Magic. A mundane team cannot dispel one however much is written down,
624
+ * so a ward generated for a party with nobody awakened is an unopenable
625
+ * barrier no clue can rescue. That is where the character dependence is
626
+ * REAL rather than incidental, and it is the only place this survives.
627
+ *
628
+ * Named for what it now is: a one-case function still called
629
+ * canBypassUnaided would attract the other two cases back.
611
630
  */
612
- export function canBypassUnaided(player, bypassType) {
613
- if (!player || !bypassType)
614
- return false;
615
- // THROUGH skillRating, NOT the raw sheet (bug found 2026-08-26): the
616
- // raw read sailed past the "incompetent-<skill>" quality, which
617
- // skillRating pins to 0 (player.ts) -- an Incompetent-locksmith runner
618
- // was qualifying to have the lockpick clue waived on the strength of a
619
- // number the rest of the game refuses to honour.
620
- const rated = (skill) => player.skillRating(skill);
621
- switch (bypassType) {
622
- case 'lockpick': return rated('locksmith') >= 3;
623
- case 'hack': return rated('hacking') >= 3 || player.isTechnomancer() || !!player.getAnyCyberdeck();
624
- case 'spell': return player.isAwakened() && !player.adept;
625
- case 'con': return rated('con') >= 3 || player.charisma >= 5;
626
- default: return false;
627
- }
631
+ export function partyCanDispel(player) {
632
+ // Adepts are awakened but cannot cast, so they cannot unweave a ward.
633
+ return !!player && player.isAwakened() && !player.adept;
628
634
  }
629
635
  /** Strips accidental markdown fences and parses; throws with a useful message. */
630
636
  export function parseJsonReply(reply) {
@@ -696,7 +702,7 @@ export function validateSkeleton(skeleton, player, crew) {
696
702
  const roomNames = new Set(skeleton.rooms.map(r => r.name));
697
703
  const npcNames = new Set(skeleton.npcs.map(n => n.name));
698
704
  const itemNames = new Set(skeleton.items.map(i => i.name));
699
- const puzzleNames = new Set((skeleton.puzzles ?? []).map(p => p.name));
705
+ const deviceNames = new Set((skeleton.devices ?? []).map(d => d.name));
700
706
  const startRooms = skeleton.rooms.filter(r => r.isStartRoom);
701
707
  if (startRooms.length !== 1)
702
708
  fail(`exactly one room must have "isStartRoom": true (found ${startRooms.length}).`);
@@ -705,11 +711,33 @@ export function validateSkeleton(skeleton, player, crew) {
705
711
  for (const exit of room.exits ?? []) {
706
712
  if (!roomNames.has(exit.targetRoom))
707
713
  fail(`exit from "${room.name}" targets unknown room "${exit.targetRoom}".`);
708
- if (exit.puzzle) {
709
- if (!puzzleNames.has(exit.puzzle))
710
- fail(`exit from "${room.name}" references unknown puzzle "${exit.puzzle}".`);
711
- doorRefs.set(exit.puzzle, (doorRefs.get(exit.puzzle) ?? 0) + 1);
714
+ // A DOOR IS GATED BY A PERSON OR A MECHANISM, NEVER BOTH.
715
+ // Stated as a validator and not only in the prompt, because a
716
+ // prompt that can express a contradiction eventually will: two
717
+ // gates on one door means beating the guard leaves the door still
718
+ // locked, or picking the lock leaves the body still in it, and
719
+ // the player cannot tell which half they failed to solve.
720
+ if (exit.device && exit.guard) {
721
+ fail(`exit from "${room.name}" has BOTH a device ("${exit.device}") and a guard ("${exit.guard}") -- a door is gated by a thing or a person, never both. Drop one.`);
722
+ }
723
+ if (exit.device) {
724
+ if (!deviceNames.has(exit.device))
725
+ fail(`exit from "${room.name}" references unknown device "${exit.device}".`);
726
+ // THE ROOM-OWNED RULE MADE ENFORCEABLE rather than merely true.
727
+ // The old model made "placed in a room AND on a door" possible
728
+ // and validated it away; room ownership makes that
729
+ // unrepresentable, but makes a NEW incoherence expressible --
730
+ // an exit naming a device that lives somewhere else. It fails
731
+ // exactly like the shared-door case: the door locks, and the
732
+ // mechanism is in a room the player has no reason to look in.
733
+ const owner = (skeleton.devices ?? []).find(d => d.name === exit.device);
734
+ if (owner && owner.room !== room.name) {
735
+ fail(`exit from "${room.name}" is held by device "${exit.device}", which stands in "${owner.room}" -- a device can only hold a door of its OWN room. Move the device, or declare this exit from "${owner.room}".`);
736
+ }
737
+ doorRefs.set(exit.device, (doorRefs.get(exit.device) ?? 0) + 1);
712
738
  }
739
+ if (exit.guard && !npcNames.has(exit.guard))
740
+ fail(`exit from "${room.name}" is guarded by unknown npc "${exit.guard}".`);
713
741
  if (exit.keyRequired && !itemNames.has(exit.keyRequired))
714
742
  fail(`exit from "${room.name}" requires unknown key item "${exit.keyRequired}".`);
715
743
  }
@@ -723,40 +751,72 @@ export function validateSkeleton(skeleton, player, crew) {
723
751
  fail(`item "${item.name}" placed in unknown room "${item.room}".`);
724
752
  if (item.heldBy && !npcNames.has(item.heldBy))
725
753
  fail(`item "${item.name}" heldBy unknown npc "${item.heldBy}".`);
726
- if (item.forPuzzle && !puzzleNames.has(item.forPuzzle))
727
- fail(`clue item "${item.name}" references unknown puzzle "${item.forPuzzle}".`);
754
+ if (item.forDevice && !deviceNames.has(item.forDevice))
755
+ fail(`clue item "${item.name}" references unknown device "${item.forDevice}".`);
728
756
  }
729
- // The three structural faults real generations actually shipped -- an
730
- // unplaced-or-double-placed puzzle, a puzzle with no clue item at all
731
- // (unfixable by ANY items pass), and unreachable rooms -- must die HERE,
732
- // before four detail passes build on bad bones.
733
- for (const puzzle of skeleton.puzzles ?? []) {
734
- if (puzzle.room && !roomNames.has(puzzle.room))
735
- fail(`puzzle "${puzzle.name}" placed in unknown room "${puzzle.room}".`);
736
- if (puzzle.rewardItem && !itemNames.has(puzzle.rewardItem))
737
- fail(`puzzle "${puzzle.name}" rewards unknown item "${puzzle.rewardItem}".`);
738
- const refs = doorRefs.get(puzzle.name) ?? 0;
739
- if (puzzle.room && refs > 0)
740
- fail(`puzzle "${puzzle.name}" is placed both in room "${puzzle.room}" AND on a door (exits[].puzzle) -- exactly one or the other.`);
741
- if (!puzzle.room && refs === 0)
742
- fail(`puzzle "${puzzle.name}" isn't placed anywhere -- set its "room", or reference it from one exits[].puzzle.`);
757
+ // The structural faults real generations actually shipped -- a device
758
+ // with no way through at all (unfixable by ANY items pass) and
759
+ // unreachable rooms -- must die HERE, before four detail passes build
760
+ // on bad bones.
761
+ //
762
+ // WHAT LEFT WITH THE PUZZLE MECHANIC: the unplaced-or-double-placed
763
+ // check. A device names its own room and always has one, so "placed
764
+ // both in a room and on a door" is now unrepresentable rather than
765
+ // merely invalid -- the room-owned rule doing the work two validators
766
+ // in two files used to do in near-identical prose.
767
+ for (const device of skeleton.devices ?? []) {
768
+ if (!device.room)
769
+ fail(`device "${device.name}" has no "room" -- every device stands in one.`);
770
+ else if (!roomNames.has(device.room))
771
+ fail(`device "${device.name}" placed in unknown room "${device.room}".`);
772
+ if (device.grantsItem && !itemNames.has(device.grantsItem))
773
+ fail(`device "${device.name}" grants unknown item "${device.grantsItem}".`);
774
+ const refs = doorRefs.get(device.name) ?? 0;
743
775
  if (refs > 1)
744
- fail(`puzzle "${puzzle.name}" is referenced by ${refs} doors -- a puzzle can only guard one.`);
745
- const clue = skeleton.items.find(i => i.role === 'clue' && i.forPuzzle === puzzle.name);
746
- if (!clue) {
747
- // Loosened when the dossier says the runner can work this barrier
748
- // unaided -- their bypass skill IS the way past (player note from a
749
- // real failed generation: "it just needs a way past, and if the
750
- // player has the means, it should be allowed").
751
- if (!canBypassUnaided(player, puzzle.bypassType)) {
752
- fail(`puzzle "${puzzle.name}" has no "clue"-role item with "forPuzzle": "${puzzle.name}" and its bypassType (${puzzle.bypassType ?? 'none'}) is not something this runner reliably handles -- add a "clue"-role Book item in a different room, or give the barrier a bypassType matching the runner's dossier strengths.`);
776
+ fail(`device "${device.name}" is referenced by ${refs} doors -- a device can only hold one.`);
777
+ // EVERY WAY THROUGH IS FINDABLE, and it no longer depends on who is
778
+ // playing. A code is findable and a key is placeable, so both are
779
+ // openable by ANY runner; the skilled route (pick, hack) is the
780
+ // SHORTCUT, not the requirement. That kills the character-dependent
781
+ // waiver which was the single most common generation failure on
782
+ // record -- and which looked like random flakiness precisely
783
+ // because it varied with the character.
784
+ const kind = device.kind ?? 'panel';
785
+ if (kind === 'panel') {
786
+ const clue = skeleton.items.find(i => i.role === 'clue' && i.forDevice === device.name);
787
+ if (!clue) {
788
+ fail(`panel "${device.name}" has no "clue"-role item with "forDevice": "${device.name}" -- every panel's code must be written down somewhere findable, so a runner who cannot hack can still open it.`);
789
+ }
790
+ else if (!clue.room) {
791
+ fail(`clue item "${clue.name}" needs a "room".`);
792
+ }
793
+ else if (refs === 0 && clue.room === device.room) {
794
+ // THE RULE IS REACHABILITY, NOT DISTANCE -- and the distinction
795
+ // only appeared once devices became room-owned. A door device
796
+ // stands in the room you approach the door FROM, so its own
797
+ // room is on the safe side and is a perfectly good place for
798
+ // the clue; excluding it would leave a device on the start
799
+ // room's only exit with nowhere legal at all. For a device
800
+ // that holds no door (a safe), the same room is merely
801
+ // uninteresting, so keep the old separation there.
802
+ fail(`clue item "${clue.name}" sits in the SAME room as its device "${device.name}", which guards no door -- place the clue in a different room.`);
753
803
  }
754
804
  }
755
- else {
756
- if (!clue.room)
757
- fail(`clue item "${clue.name}" needs a "room" (a different one than its puzzle's).`);
758
- if (puzzle.room && clue.room === puzzle.room)
759
- fail(`clue item "${clue.name}" sits in the SAME room as its puzzle "${puzzle.name}" -- place the clue in a different room.`);
805
+ else if (kind === 'lock') {
806
+ // A lock's findable way through is its KEY. Either the door it
807
+ // holds names one, or a key-role item exists for it.
808
+ const holdsDoor = refs > 0;
809
+ const keyed = skeleton.rooms.some(r => (r.exits ?? []).some(e => e.device === device.name && e.keyRequired));
810
+ const looseKey = skeleton.items.some(i => i.role === 'key');
811
+ if (holdsDoor && !keyed && !looseKey) {
812
+ fail(`lock "${device.name}" holds a door with no "keyRequired" and the scene has no "key"-role item -- a runner without locksmith has no way through it. Add a key.`);
813
+ }
814
+ }
815
+ // A WARD is the exception no clue can rescue: there is no findable
816
+ // answer to one, so it is gated on the party rather than on an
817
+ // item. See partyCanDispel.
818
+ if (kind === 'ward' && !partyCanDispel(player)) {
819
+ fail(`device "${device.name}" is a ward, and nobody at this table can dispel -- there is no findable answer to a ward, so it would be unopenable. Make it a "lock" or a "panel".`);
760
820
  }
761
821
  }
762
822
  if (!itemNames.has(skeleton.winCondition.item))
@@ -783,7 +843,7 @@ export function validateSkeleton(skeleton, player, crew) {
783
843
  const edges = [];
784
844
  for (const room of skeleton.rooms) {
785
845
  for (const exit of room.exits ?? []) {
786
- edges.push({ a: room.name, b: exit.targetRoom, keyRequired: exit.keyRequired, puzzle: exit.puzzle });
846
+ edges.push({ a: room.name, b: exit.targetRoom, keyRequired: exit.keyRequired, device: exit.device });
787
847
  }
788
848
  }
789
849
  const reachable = new Set([startRooms[0].name]);
@@ -796,7 +856,7 @@ export function validateSkeleton(skeleton, player, crew) {
796
856
  if (!from || reachable.has(to))
797
857
  continue;
798
858
  const keyLoc = edge.keyRequired ? itemRoom.get(edge.keyRequired) : undefined;
799
- const passable = !!edge.puzzle || !edge.keyRequired || (keyLoc !== undefined && reachable.has(keyLoc));
859
+ const passable = !!edge.device || !edge.keyRequired || (keyLoc !== undefined && reachable.has(keyLoc));
800
860
  if (passable) {
801
861
  reachable.add(to);
802
862
  grew = true;
@@ -857,7 +917,7 @@ function freeStrandedRooms(skeleton) {
857
917
  const edges = [];
858
918
  for (const room of skeleton.rooms ?? []) {
859
919
  for (const exit of room.exits ?? []) {
860
- edges.push({ a: room.name, b: exit.targetRoom, keyRequired: exit.keyRequired, puzzle: exit.puzzle });
920
+ edges.push({ a: room.name, b: exit.targetRoom, keyRequired: exit.keyRequired, device: exit.device });
861
921
  }
862
922
  }
863
923
  // Same walk the validator runs, so a repair here is judged by exactly
@@ -875,7 +935,7 @@ function freeStrandedRooms(skeleton) {
875
935
  if (reachable.has(to))
876
936
  continue;
877
937
  const keyLoc = edge.keyRequired ? locs.get(edge.keyRequired) : undefined;
878
- if (!!edge.puzzle || !edge.keyRequired || (keyLoc !== undefined && reachable.has(keyLoc))) {
938
+ if (!!edge.device || !edge.keyRequired || (keyLoc !== undefined && reachable.has(keyLoc))) {
879
939
  reachable.add(to);
880
940
  grew = true;
881
941
  }
@@ -895,7 +955,7 @@ function freeStrandedRooms(skeleton) {
895
955
  // ground, held shut by a key nobody can get to.
896
956
  const gate = edges.find(e => {
897
957
  const touchesBoth = (reachable.has(e.a) && strandedNames.has(e.b)) || (reachable.has(e.b) && strandedNames.has(e.a));
898
- if (!touchesBoth || e.puzzle || !e.keyRequired)
958
+ if (!touchesBoth || e.device || !e.keyRequired)
899
959
  return false;
900
960
  const keyLoc = itemRoom().get(e.keyRequired);
901
961
  return keyLoc === undefined || !reachable.has(keyLoc);
@@ -916,7 +976,7 @@ function freeStrandedRooms(skeleton) {
916
976
  const STAFFED_ROOM_TYPES = /^(market|shop|store|bar|pub|tavern|restaurant|cafe|clinic|pawn|stall|vendor|kiosk|dispensary)/i;
917
977
  /**
918
978
  * WHERE A CLUE MAY LIVE. The validator's reachability walk treats every
919
- * puzzle door as PASSABLE (`!!edge.puzzle ||` in validateSkeleton), which
979
+ * puzzle door as PASSABLE (`!!edge.device ||` in validateSkeleton), which
920
980
  * is right for "can the runner eventually get there" and dead wrong for
921
981
  * "can the runner read this BEFORE facing the barrier". So a clue placed
922
982
  * behind the very door its puzzle guards validates clean and softlocks in
@@ -933,7 +993,7 @@ const STAFFED_ROOM_TYPES = /^(market|shop|store|bar|pub|tavern|restaurant|cafe|c
933
993
  *
934
994
  * Returns [] when nowhere is safe -- callers MUST bail, never guess.
935
995
  */
936
- function safeRoomsFor(skeleton, puzzle) {
996
+ function safeRoomsFor(skeleton, device) {
937
997
  const start = (skeleton.rooms ?? []).find(r => r.isStartRoom);
938
998
  if (!start)
939
999
  return [];
@@ -956,8 +1016,8 @@ function safeRoomsFor(skeleton, puzzle) {
956
1016
  grew = false;
957
1017
  for (const room of skeleton.rooms ?? []) {
958
1018
  for (const exit of room.exits ?? []) {
959
- // A barrier is a wall until it is solved. That is the whole point.
960
- if (exit.puzzle)
1019
+ // A barrier is a wall until it is opened. That is the whole point.
1020
+ if (exit.device)
961
1021
  continue;
962
1022
  const a = room.name, b = exit.targetRoom;
963
1023
  if (isolated.has(a) || isolated.has(b))
@@ -982,14 +1042,21 @@ function safeRoomsFor(skeleton, puzzle) {
982
1042
  // its room: the puzzles pass can later emit `guardsExit` sealing one of
983
1043
  // that room's doors, which is unknowable here, so be conservative. Fall
984
1044
  // back to merely excluding the room itself when that leaves nothing.
985
- // A door puzzle needs no extra exclusion -- cutting its edge already
1045
+ // A door device needs no extra exclusion -- cutting its edge already
986
1046
  // leaves exactly the near-side component, and the near-side room is a
987
1047
  // fine place to find the clue.
988
- let reached = walk(puzzle.room);
989
- if (puzzle.room && reached.size <= 1)
1048
+ const holdsDoor = (skeleton.rooms ?? []).some(r => (r.exits ?? []).some(e => e.device === device.name));
1049
+ let reached = walk(device.room);
1050
+ if (device.room && reached.size <= 1)
990
1051
  reached = walk();
991
1052
  const candidates = (skeleton.rooms ?? [])
992
- .filter(r => reached.has(r.name) && r.name !== puzzle.room && !isolated.has(r.name));
1053
+ // A DOOR device's own room is on the SAFE side of the door it
1054
+ // holds, so it is a legal home for the clue -- excluding it would
1055
+ // leave a device on the start room's only exit with nowhere at all.
1056
+ // A device guarding nothing keeps the old separation: its own room
1057
+ // is reachable but uninteresting.
1058
+ .filter(r => reached.has(r.name) && !isolated.has(r.name)
1059
+ && (holdsDoor || r.name !== device.room));
993
1060
  if (candidates.length === 0)
994
1061
  return [];
995
1062
  // Rank: earned over free (not the curb), unowned over owned, deeper
@@ -1003,7 +1070,7 @@ function safeRoomsFor(skeleton, puzzle) {
1003
1070
  const cur = bfs.shift();
1004
1071
  for (const room of skeleton.rooms ?? []) {
1005
1072
  for (const exit of room.exits ?? []) {
1006
- if (exit.puzzle)
1073
+ if (exit.device)
1007
1074
  continue;
1008
1075
  const pair = [room.name, exit.targetRoom];
1009
1076
  if (!pair.includes(cur))
@@ -1098,7 +1165,14 @@ function resolveDirectionCollisions(skeleton) {
1098
1165
  }
1099
1166
  return notes;
1100
1167
  }
1101
- export function normalizeSkeleton(skeleton, player) {
1168
+ /**
1169
+ * `player` is GONE from this signature, not underscored. Its only
1170
+ * consumer was the character-dependent clue waiver, and with the
1171
+ * clue/key rule unconditional there is nothing here that varies with
1172
+ * who is playing. A parameter kept "just in case" is an invitation to
1173
+ * make normalization character-dependent again.
1174
+ */
1175
+ export function normalizeSkeleton(skeleton) {
1102
1176
  const notes = [];
1103
1177
  // Topology before anything that reasons about topology: freeStranded-
1104
1178
  // Rooms, clue placement and the curb all walk this graph, and they
@@ -1182,80 +1256,62 @@ export function normalizeSkeleton(skeleton, player) {
1182
1256
  notes.push(`normalized: start room "${start.name}" is indoors but every direction on it is taken -- could not inject a curb.`);
1183
1257
  }
1184
1258
  }
1185
- const doorRefs = new Map();
1186
- for (const room of skeleton.rooms ?? []) {
1187
- for (const exit of room.exits ?? []) {
1188
- if (exit.puzzle)
1189
- doorRefs.set(exit.puzzle, (doorRefs.get(exit.puzzle) ?? 0) + 1);
1190
- }
1191
- }
1192
- for (const puzzle of skeleton.puzzles ?? []) {
1193
- if (puzzle.room && (doorRefs.get(puzzle.name) ?? 0) === 1) {
1194
- notes.push(`normalized: puzzle "${puzzle.name}" was double-placed (room + door); kept the door placement.`);
1195
- delete puzzle.room;
1196
- }
1197
- }
1198
- // THE CLUE ITEM -- half of every generation failure on record.
1199
- //
1200
- // Measured 2026-08-26 across 20 sessions: 19 job attempts, 3 valid
1201
- // scenes, 12 outright give-ups. The dominant fault, at both the
1202
- // skeleton and the assembled stage, is a puzzle whose clue item is
1203
- // missing, roomless, or sitting in its own puzzle's room. It is
1204
- // character-dependent, which is why it looked like flakiness: a Face
1205
- // (con 5, no locksmith/hacking, magic 0) satisfies canBypassUnaided
1206
- // for exactly ONE bypassType, so every lockpick/hack/spell barrier
1207
- // demands a clue -- and the model keeps forgetting to write one.
1208
- //
1209
- // Repaired here rather than by retrying, because retries reproduce it
1210
- // verbatim: the skeleton prompt feeds back the ERROR but not the map
1211
- // the model just drew, so attempt 2 redraws from scratch and forgets
1259
+ // NO DOUBLE-PLACEMENT REPAIR ANY MORE. A device always names its own
1260
+ // room, so "placed in a room AND on a door" is unrepresentable rather
1261
+ // than something to reconcile -- the room-owned rule retiring a
1262
+ // repair as well as a validator.
1212
1263
  // again. Runs BEFORE reward reconciliation on purpose -- that step
1213
1264
  // treats role "clue" as load-bearing, so anything promoted here is
1214
1265
  // protected from being converted into an unplaced reward.
1215
1266
  //
1216
- // Synthesis happens EVEN WHEN canBypassUnaided waives the clue. The
1217
- // waiver is a latent softlock: getBypassHint promises the player "the
1218
- // answer is recorded somewhere out in the world", bypass is a coin
1219
- // flip, and a failed flip burns the route with "Only the real code
1220
- // will open it" -- for a code that was never written down.
1267
+ // PANELS ONLY. A lock's findable way through is its key, not a
1268
+ // written code, and a ward has no findable answer at all (it is
1269
+ // refused outright at validation when nobody can dispel). Synthesis
1270
+ // is unconditional for panels now -- there is no waiver left to
1271
+ // second-guess, which is what made the old version's log lines need
1272
+ // to explain which kind of failure they were.
1221
1273
  const SYNTHESIZED_CLUE_CAP = 2;
1222
1274
  let synthesized = 0;
1223
1275
  const CLUE_NOUN = {
1224
- lockpick: 'Maintenance Log',
1225
- hack: 'Service Log',
1226
- spell: 'Ritual Notes',
1227
- con: 'Staff Roster',
1276
+ panel: 'Service Log',
1228
1277
  };
1229
- for (const puzzle of skeleton.puzzles ?? []) {
1278
+ for (const device of skeleton.devices ?? []) {
1279
+ // Only a panel has a code to write down.
1280
+ if ((device.kind ?? 'panel') !== 'panel')
1281
+ continue;
1230
1282
  const items = skeleton.items ?? [];
1231
- let clue = items.find(i => i.role === 'clue' && i.forPuzzle === puzzle.name);
1283
+ let clue = items.find(i => i.role === 'clue' && i.forDevice === device.name);
1232
1284
  // Reclassify before inventing: the model often authored the object
1233
1285
  // and only mislabelled it. Cheaper than synthesis and it keeps the
1234
1286
  // model's own prose intent.
1235
1287
  if (!clue) {
1236
- const mislabelled = items.find(i => i.forPuzzle === puzzle.name && i.role !== 'clue');
1288
+ const mislabelled = items.find(i => i.forDevice === device.name && i.role !== 'clue');
1237
1289
  if (mislabelled) {
1238
- notes.push(`normalized: item "${mislabelled.name}" was a ${mislabelled.role} pointing at puzzle "${puzzle.name}"; promoted it to that puzzle's clue.`);
1290
+ notes.push(`normalized: item "${mislabelled.name}" was a ${mislabelled.role} pointing at device "${device.name}"; promoted it to that device's clue.`);
1239
1291
  mislabelled.role = 'clue';
1240
1292
  mislabelled.category = 'Book';
1241
1293
  clue = mislabelled;
1242
1294
  }
1243
1295
  }
1244
- const safe = safeRoomsFor(skeleton, puzzle);
1296
+ const safe = safeRoomsFor(skeleton, device);
1245
1297
  if (clue) {
1246
1298
  // A clue in its own puzzle's room, or with no room at all, is the
1247
1299
  // same repair: put it somewhere legal and safe.
1248
- const needsRoom = !clue.room || clue.room === puzzle.room;
1300
+ // Same rule as the validator: a DOOR device's own room is on the
1301
+ // safe side, so a clue there is fine. Only a device guarding
1302
+ // nothing wants its clue moved out of its own room.
1303
+ const holdsDoor = (skeleton.rooms ?? []).some(r => (r.exits ?? []).some(e => e.device === device.name));
1304
+ const needsRoom = !clue.room || (!holdsDoor && clue.room === device.room);
1249
1305
  if (needsRoom) {
1250
1306
  // A holder standing in a safe room is the better answer than a
1251
1307
  // book on the floor -- but only if the validators can see it.
1252
1308
  const holderRoom = clue.heldBy ? (skeleton.npcs ?? []).find(n => n.name === clue.heldBy)?.startLocation : undefined;
1253
1309
  const target = (holderRoom && safe.includes(holderRoom)) ? holderRoom : safe[0];
1254
1310
  if (!target) {
1255
- notes.push(`could not relocate clue "${clue.name}": no room is reachable without passing puzzle "${puzzle.name}".`);
1311
+ notes.push(`could not relocate clue "${clue.name}": no room is reachable without passing device "${device.name}".`);
1256
1312
  }
1257
1313
  else {
1258
- const was = clue.room ? `sat in its own puzzle's room "${clue.room}"` : 'had no room';
1314
+ const was = clue.room ? `sat in its own device's room "${clue.room}"` : 'had no room';
1259
1315
  clue.room = target;
1260
1316
  delete clue.heldBy;
1261
1317
  notes.push(`normalized: clue "${clue.name}" ${was}; moved it to "${target}" (reachable without passing the barrier).`);
@@ -1264,31 +1320,25 @@ export function normalizeSkeleton(skeleton, player) {
1264
1320
  continue;
1265
1321
  }
1266
1322
  if (safe.length === 0) {
1267
- // How bad this is depends on the runner. If the barrier happens to
1268
- // be one they can force unaided, validateSkeleton's waiver still
1269
- // lets the draft through -- it just ships a barrier with no written
1270
- // answer, which is the gamble-only case getBypassHint over-promises
1271
- // on. If they can't force it, the draft is dead and the validator
1272
- // will say so. Two very different log lines; say which one it is.
1273
- notes.push(canBypassUnaided(player, puzzle.bypassType)
1274
- ? `could not synthesize a clue for puzzle "${puzzle.name}": nowhere is reachable without passing it -- it ships on the runner's own ${puzzle.bypassType}, with no written answer anywhere.`
1275
- : `could not synthesize a clue for puzzle "${puzzle.name}": nowhere is reachable without passing it.`);
1323
+ // One log line now, not two: with the waiver gone the draft is
1324
+ // simply dead here, and validateSkeleton will say so.
1325
+ notes.push(`could not synthesize a clue for panel "${device.name}": nowhere is reachable without passing it.`);
1276
1326
  continue;
1277
1327
  }
1278
1328
  if (synthesized >= SYNTHESIZED_CLUE_CAP) {
1279
- notes.push(`declined to synthesize a clue for puzzle "${puzzle.name}": already invented ${SYNTHESIZED_CLUE_CAP} this skeleton -- a fresh draft is the better bet.`);
1329
+ notes.push(`declined to synthesize a clue for panel "${device.name}": already invented ${SYNTHESIZED_CLUE_CAP} this skeleton -- a fresh draft is the better bet.`);
1280
1330
  continue;
1281
1331
  }
1282
1332
  // The NAME is the entire brief the items prose pass receives (an
1283
1333
  // ISkeletonItem has no concept field), so it has to imply the object.
1284
- const noun = CLUE_NOUN[puzzle.bypassType ?? ''] ?? 'Notes';
1285
- let name = `${puzzle.name} ${noun}`;
1334
+ const noun = CLUE_NOUN[device.kind ?? 'panel'] ?? 'Notes';
1335
+ let name = `${device.name} ${noun}`;
1286
1336
  for (let i = 2; items.some(it => it.name === name); i++)
1287
- name = `${puzzle.name} ${noun} ${i}`;
1337
+ name = `${device.name} ${noun} ${i}`;
1288
1338
  skeleton.items = items;
1289
- skeleton.items.push({ name, role: 'clue', category: 'Book', forPuzzle: puzzle.name, room: safe[0] });
1339
+ skeleton.items.push({ name, role: 'clue', category: 'Book', forDevice: device.name, room: safe[0] });
1290
1340
  synthesized++;
1291
- notes.push(`normalized: puzzle "${puzzle.name}" had no clue item; synthesized Book clue "${name}" in "${safe[0]}" (reachable without passing the barrier).`);
1341
+ notes.push(`normalized: panel "${device.name}" had no clue item; synthesized Book clue "${name}" in "${safe[0]}" (reachable without passing the barrier).`);
1292
1342
  }
1293
1343
  // REWARD-REFERENCE reconciliation. A rewardItem (a puzzle's, or the
1294
1344
  // winCondition's) must point at an UNPLACED role-"reward" item, but
@@ -1304,18 +1354,18 @@ export function normalizeSkeleton(skeleton, player) {
1304
1354
  // reward reference is dropped instead (door puzzles don't need one).
1305
1355
  const itemByName = new Map(skeleton.items.map(i => [i.name, i]));
1306
1356
  const loadBearing = new Set(['clue', 'key', 'winCondition', 'starting']);
1307
- for (const puzzle of skeleton.puzzles ?? []) {
1308
- if (!puzzle.rewardItem)
1357
+ for (const device of skeleton.devices ?? []) {
1358
+ if (!device.grantsItem)
1309
1359
  continue;
1310
- const item = itemByName.get(puzzle.rewardItem);
1360
+ const item = itemByName.get(device.grantsItem);
1311
1361
  if (!item || item.role === 'reward')
1312
1362
  continue;
1313
1363
  if (loadBearing.has(item.role) || item.name === skeleton.winCondition?.item) {
1314
- notes.push(`normalized: puzzle "${puzzle.name}" claimed the ${item.role} item "${item.name}" as its reward; dropped the reward reference (the ${item.role} role is load-bearing).`);
1315
- delete puzzle.rewardItem;
1364
+ notes.push(`normalized: device "${device.name}" claimed the ${item.role} item "${item.name}" as what it grants; dropped the reference (the ${item.role} role is load-bearing).`);
1365
+ delete device.grantsItem;
1316
1366
  }
1317
1367
  else {
1318
- notes.push(`normalized: item "${item.name}" was a placed ${item.role} but is puzzle "${puzzle.name}"'s reward; converted to an unplaced reward.`);
1368
+ notes.push(`normalized: item "${item.name}" was a placed ${item.role} but is device "${device.name}"'s grant; converted to an unplaced reward.`);
1319
1369
  item.role = 'reward';
1320
1370
  delete item.room;
1321
1371
  delete item.heldBy;
@@ -1363,11 +1413,11 @@ export function normalizeSkeleton(skeleton, player) {
1363
1413
  export function ensureClueDetails(skeleton, details) {
1364
1414
  const notes = [];
1365
1415
  const byName = new Map((details.items ?? []).map(i => [i.name, i]));
1366
- for (const puzzleDetail of details.puzzles ?? []) {
1367
- const phrase = (puzzleDetail.requiredPhrase ?? '').trim();
1416
+ for (const deviceDetail of details.devices ?? []) {
1417
+ const phrase = (deviceDetail.code ?? '').trim();
1368
1418
  if (!phrase)
1369
1419
  continue;
1370
- const clues = (skeleton.items ?? []).filter(i => i.role === 'clue' && i.forPuzzle === puzzleDetail.name);
1420
+ const clues = (skeleton.items ?? []).filter(i => i.role === 'clue' && i.forDevice === deviceDetail.name);
1371
1421
  if (clues.length === 0)
1372
1422
  continue;
1373
1423
  // An item literally NAMED the phrase satisfies the validator too, and
@@ -1407,7 +1457,7 @@ export function ensureClueDetails(skeleton, details) {
1407
1457
  }
1408
1458
  const body = (detail.details ?? '').trim();
1409
1459
  detail.details = `${body}${body ? ' ' : ''}Scrawled in the margin, in a different hand: "${phrase}".`;
1410
- notes.push(`normalized: clue "${target.name}" didn't carry puzzle "${puzzleDetail.name}"'s answer; wrote it into the margin.`);
1460
+ notes.push(`normalized: clue "${target.name}" didn't carry device "${deviceDetail.name}"'s code; wrote it into the margin.`);
1411
1461
  }
1412
1462
  return notes;
1413
1463
  }
@@ -1486,9 +1536,9 @@ const SPOKEN_DEVICE = /\b(intercom|voice[- ]gate|voice panel|passphrase|password
1486
1536
  */
1487
1537
  export function ensureCredentialKindAgrees(details) {
1488
1538
  const notes = [];
1489
- for (const puzzle of details.puzzles ?? []) {
1490
- const phrase = (puzzle.requiredPhrase ?? '').trim();
1491
- const riddle = puzzle.riddle ?? '';
1539
+ for (const device of details.devices ?? []) {
1540
+ const phrase = (device.code ?? '').trim();
1541
+ const riddle = device.description ?? '';
1492
1542
  if (phrase.length === 0 || riddle.length === 0)
1493
1543
  continue;
1494
1544
  const answerIsNumeric = NUMERIC_ANSWER.test(phrase);
@@ -1497,13 +1547,13 @@ export function ensureCredentialKindAgrees(details) {
1497
1547
  if (!answerIsNumeric && promisesNumber && !promisesWords) {
1498
1548
  // The expensive direction: the player hunts for digits that were
1499
1549
  // never generated, while the words sit unrecognised in their pack.
1500
- puzzle.riddle = `${riddle.trimEnd()} It takes a spoken passphrase, not a number.`;
1501
- notes.push(`puzzle "${puzzle.name}": riddle promised a number, answer is a phrase -- said so plainly.`);
1550
+ device.description = `${riddle.trimEnd()} It takes a spoken passphrase, not a number.`;
1551
+ notes.push(`device "${device.name}": description promised a number, code is a phrase -- said so plainly.`);
1502
1552
  continue;
1503
1553
  }
1504
1554
  if (answerIsNumeric && promisesWords && !promisesNumber) {
1505
- puzzle.riddle = `${riddle.trimEnd()} It takes a numeric code, not a spoken phrase.`;
1506
- notes.push(`puzzle "${puzzle.name}": riddle promised words, answer is a number -- said so plainly.`);
1555
+ device.description = `${riddle.trimEnd()} It takes a numeric code, not a spoken phrase.`;
1556
+ notes.push(`device "${device.name}": description promised words, code is a number -- said so plainly.`);
1507
1557
  }
1508
1558
  }
1509
1559
  return notes;
@@ -1514,7 +1564,7 @@ export function assembleScene(skeleton, details, playerName) {
1514
1564
  ensureCredentialKindAgrees(details);
1515
1565
  const roomDetail = new Map(details.rooms.map(r => [r.name, r]));
1516
1566
  const npcDetail = new Map(details.npcs.map(n => [n.name, n]));
1517
- const puzzleDetail = new Map(details.puzzles.map(p => [p.name, p]));
1567
+ const deviceDetail = new Map(details.devices.map(d => [d.name, d]));
1518
1568
  const itemDetail = new Map(details.items.map(i => [i.name, i]));
1519
1569
  // "At" spots from the rooms pass, normalized before they touch the
1520
1570
  // seed: valid cover tags only, at most 5 spots, and a spotted room
@@ -1601,7 +1651,7 @@ export function assembleScene(skeleton, details, playerName) {
1601
1651
  direction: e.direction,
1602
1652
  targetRoom: e.targetRoom,
1603
1653
  keyRequired: e.keyRequired,
1604
- puzzle: e.puzzle,
1654
+ device: e.device,
1605
1655
  atSpot: raw ? resolveSpotName(raw, spots.map(s => s.name)) : undefined,
1606
1656
  };
1607
1657
  }),
@@ -1636,6 +1686,77 @@ export function assembleScene(skeleton, details, playerName) {
1636
1686
  }
1637
1687
  }
1638
1688
  }
1689
+ // A GUARDED DOORWAY ALWAYS GETS A SPOT -- the one exception to the
1690
+ // "unbound stays unbound" rule above, and it earns it.
1691
+ //
1692
+ // That rule is right in general: the runtime pairs a doorway to an
1693
+ // exit with better information than generation has. But a GUARD is
1694
+ // not paired by the runtime at all -- spots.ts holdsGround finds a
1695
+ // blocker by reading the exit's own cell and its approach, so a guard
1696
+ // whose exit has no spot is `guarding: true` with NO EFFECT. The
1697
+ // player walks straight past a person the fiction says stops them.
1698
+ //
1699
+ // Warn-and-continue was the first instinct and it is not enough: it
1700
+ // leaves a silent, player-visible wrong. Failing the draft is the
1701
+ // other extreme, and it is the expensive-retry path ensureClueDetails
1702
+ // exists to escape -- three regenerations of a 6,000-token chunk to
1703
+ // not write one word down. So REPAIR it, deterministically, the way
1704
+ // ensureClueDetails writes a missing phrase into its clue.
1705
+ //
1706
+ // Reuse before invention: a spotted room already carries a doorway
1707
+ // spot (normalizeSpots injects one), so nearly always this just binds
1708
+ // the exit to the door that is already there.
1709
+ for (const room of rooms) {
1710
+ const skel = skeleton.rooms.find(r => r.name === room.name);
1711
+ for (const e of (room.exits ?? [])) {
1712
+ const guarded = (skel?.exits ?? []).find(se => se.direction === e.direction)?.guard;
1713
+ if (!guarded || e.atSpot)
1714
+ continue;
1715
+ const spots = (room.spots ?? []);
1716
+ const doorish = spots.find(sp => /door|entrance|entry|threshold|gate/i.test(sp.name));
1717
+ if (doorish) {
1718
+ e.atSpot = doorish.name;
1719
+ }
1720
+ else {
1721
+ const invented = `the ${e.direction} doorway`;
1722
+ room.spots = [...spots, { name: invented }];
1723
+ e.atSpot = invented;
1724
+ }
1725
+ }
1726
+ }
1727
+ // A GUARD'S POST IS DERIVED FROM WHAT THEY GUARD.
1728
+ //
1729
+ // exits[].guard names the person; the exit already knows its atSpot.
1730
+ // Taking the spot from the exit rather than asking the model to also
1731
+ // station them correctly means the fiction and the geometry cannot
1732
+ // disagree about where the guard is standing -- and standing IN the
1733
+ // doorway is exactly what makes them unpassable (spots.ts
1734
+ // holdsGround reads the exit cell and its approach).
1735
+ const guardPost = new Map();
1736
+ for (const room of rooms) {
1737
+ for (const e of (room.exits ?? [])) {
1738
+ const named = (skeleton.rooms.find(r => r.name === room.name)?.exits ?? [])
1739
+ .find(se => se.direction === e.direction)?.guard;
1740
+ if (!named)
1741
+ continue;
1742
+ // Should be unreachable: the repair above gives every guarded
1743
+ // exit a spot. Kept as a tripwire rather than deleted -- if it
1744
+ // ever fires, a guard is standing somewhere that cannot hold a
1745
+ // door, and that is worth a line in the log rather than silence.
1746
+ if (!e.atSpot) {
1747
+ // GUARDED, because assembleScene must not start REQUIRING a
1748
+ // session just to warn about something. Logger.getInstance()
1749
+ // throws when none has been initialised, and this function was
1750
+ // callable without one before -- a diagnostic that can break
1751
+ // its caller is worse than no diagnostic.
1752
+ try {
1753
+ Logger.getInstance().write(`assembleScene: "${named}" guards ${room.name}'s ${e.direction} exit, but that exit has no atSpot -- they will not physically hold the doorway.`);
1754
+ }
1755
+ catch { /* no session: the note is a nicety, not a contract */ }
1756
+ }
1757
+ guardPost.set(named, { room: room.name, spot: e.atSpot });
1758
+ }
1759
+ }
1639
1760
  const npcs = skeleton.npcs.map(npc => {
1640
1761
  const detail = npcDetail.get(npc.name);
1641
1762
  const station = npcStation.get(npc.name);
@@ -1649,8 +1770,11 @@ export function assembleScene(skeleton, details, playerName) {
1649
1770
  dialogue: detail?.dialogue ?? [],
1650
1771
  player: {
1651
1772
  name: npc.name,
1652
- startLocation: npc.startLocation,
1653
- startSpot,
1773
+ // A posted guard starts where their door is, whatever the
1774
+ // skeleton said -- the post wins over the roster.
1775
+ startLocation: guardPost.get(npc.name)?.room ?? npc.startLocation,
1776
+ startSpot: guardPost.get(npc.name)?.spot ?? startSpot,
1777
+ guarding: guardPost.has(npc.name) || undefined,
1654
1778
  lifestyleChoices: [],
1655
1779
  combat: detail?.combat,
1656
1780
  },
@@ -1694,23 +1818,46 @@ export function assembleScene(skeleton, details, playerName) {
1694
1818
  // school, same as every other spot/exit validation in this file).
1695
1819
  const VALID_FOOTPRINTS = new Set(['small', 'medium', 'large']);
1696
1820
  const roomExitDirsByName = new Map(rooms.map(r => [r.name, new Set((r.exits ?? []).map(e => e.direction.toLowerCase()))]));
1697
- const puzzles = (skeleton.puzzles ?? []).map(puzzle => {
1698
- const detail = puzzleDetail.get(puzzle.name);
1699
- const guardsExit = puzzle.room && detail?.guardsExit && roomExitDirsByName.get(puzzle.room)?.has(detail.guardsExit.toLowerCase())
1821
+ // WHICH EXIT EACH DEVICE HOLDS. Derived from the exit that names it,
1822
+ // never asked of the model twice -- the skeleton says it once, on the
1823
+ // exit, and this is the only place it becomes the device's own field.
1824
+ //
1825
+ // WITHOUT THIS THE DOOR NEVER LOCKS. scene-factory locks a door from
1826
+ // its device's `opensExit`; a device assembled without one is a
1827
+ // keypad beside a door that stands open, with no crash, no error and
1828
+ // no failing test. Caught by an end-to-end probe rather than by the
1829
+ // suite, which is exactly the failure mode this field has.
1830
+ const heldExit = new Map();
1831
+ for (const room of skeleton.rooms ?? []) {
1832
+ for (const e of room.exits ?? []) {
1833
+ if (e.device)
1834
+ heldExit.set(e.device, e.direction);
1835
+ }
1836
+ }
1837
+ const devices = (skeleton.devices ?? []).map(device => {
1838
+ const detail = deviceDetail.get(device.name);
1839
+ const guardsExit = device.room && detail?.guardsExit && roomExitDirsByName.get(device.room)?.has(detail.guardsExit.toLowerCase())
1700
1840
  ? detail.guardsExit.toLowerCase()
1701
1841
  : undefined;
1702
1842
  const footprint = guardsExit && detail?.footprint && VALID_FOOTPRINTS.has(detail.footprint) ? detail.footprint : undefined;
1843
+ // KIND CLAMPS, never throws. A device whose kind we cannot read is
1844
+ // still a device, and rejecting a whole draft over one word is a
1845
+ // worse outcome than a panel where the model meant something else.
1846
+ const kind = device.kind === 'lock' || device.kind === 'ward' ? device.kind : 'panel';
1847
+ // A CODE ONLY BELONGS ON A PANEL -- enforced here as well as in the
1848
+ // prompt, because the prompt is advice and this is not. You cannot
1849
+ // talk at a mechanical lock.
1850
+ const code = kind === 'panel' ? (detail?.code || undefined) : undefined;
1703
1851
  return {
1704
- name: puzzle.name,
1705
- room: puzzle.room,
1706
- riddle: detail?.riddle ?? puzzle.concept,
1707
- requiredPhrase: detail?.requiredPhrase ?? '',
1708
- successMessage: detail?.successMessage ?? 'It opens.',
1709
- failureMessage: detail?.failureMessage ?? 'Nothing happens.',
1710
- lockedMessage: detail?.lockedMessage,
1711
- requiresPuzzle: puzzle.requiresPuzzle,
1712
- rewardItem: puzzle.rewardItem,
1713
- bypassType: puzzle.bypassType,
1852
+ name: device.name,
1853
+ room: device.room,
1854
+ kind,
1855
+ opensExit: heldExit.get(device.name),
1856
+ description: detail?.description ?? device.concept,
1857
+ code,
1858
+ grantsItem: device.grantsItem,
1859
+ openMessage: detail?.openMessage ?? 'It opens.',
1860
+ refuseMessage: detail?.refuseMessage ?? 'Nothing happens.',
1714
1861
  guardsExit,
1715
1862
  footprint,
1716
1863
  };
@@ -1721,10 +1868,19 @@ export function assembleScene(skeleton, details, playerName) {
1721
1868
  story: skeleton.story,
1722
1869
  currency: { type: 'nuyen', authorityRoomTags: ['Bank'] },
1723
1870
  residenceTiers: ['Street', 'Squatter', 'Low', 'Middle', 'High', 'Luxury'],
1724
- rooms,
1871
+ rooms: rooms.map(r => ({
1872
+ ...r,
1873
+ // A DEVICE RIDES ITS OWN ROOM -- there is no scene-level devices[]
1874
+ // to reconcile against room membership, because room ownership IS
1875
+ // the placement.
1876
+ devices: devices.filter(d => d.room === r.name).map(({ room: _room, ...d }) => d),
1877
+ })),
1725
1878
  npcs,
1726
1879
  items,
1727
- puzzles,
1880
+ // The tombstone (see types/save-file.ts): always empty, never read
1881
+ // by this build. A generated seed is written fresh, so this is only
1882
+ // shape-compatibility for an older reader.
1883
+ puzzles: [],
1728
1884
  player: {
1729
1885
  name: playerName,
1730
1886
  startLocation: startRoom,
@@ -1760,8 +1916,8 @@ export function classifyRepair(message) {
1760
1916
  return 'items';
1761
1917
  if (/startingItems|Win condition (item|rewardItem)|items\[/i.test(message))
1762
1918
  return 'items';
1763
- if (/requiredPhrase|riddle|lockedMessage/i.test(message))
1764
- return 'puzzles';
1919
+ if (/code|description|openMessage/i.test(message))
1920
+ return 'devices';
1765
1921
  if (/npcs\[|dialogue/i.test(message))
1766
1922
  return 'npcs';
1767
1923
  if (/rooms\[\d+\][^]*name/i.test(message))