@maka/maka-cli 5.63.1 → 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 (205) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/ar.js +29 -8
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/ar.js.map +1 -1
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/ask.d.ts +42 -0
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/ask.js +86 -3
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/ask.js.map +1 -1
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/backlog.d.ts +29 -2
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/backlog.js +151 -1
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/backlog.js.map +1 -1
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.d.ts +40 -13
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js +107 -25
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js.map +1 -1
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/breach.d.ts +11 -15
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/breach.js +13 -16
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/breach.js.map +1 -1
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/buy.d.ts +8 -0
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/buy.js +30 -2
  18. package/bundle/typescript/src/commands/game/sideQuest/commands/buy.js.map +1 -1
  19. package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.d.ts +40 -49
  20. package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.js +121 -160
  21. package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.js.map +1 -1
  22. package/bundle/typescript/src/commands/game/sideQuest/commands/call.js +29 -5
  23. package/bundle/typescript/src/commands/game/sideQuest/commands/call.js.map +1 -1
  24. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.d.ts +7 -8
  25. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +13 -13
  26. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js.map +1 -1
  27. package/bundle/typescript/src/commands/game/sideQuest/commands/contacts.js +22 -1
  28. package/bundle/typescript/src/commands/game/sideQuest/commands/contacts.js.map +1 -1
  29. package/bundle/typescript/src/commands/game/sideQuest/commands/cover.d.ts +30 -0
  30. package/bundle/typescript/src/commands/game/sideQuest/commands/cover.js +65 -0
  31. package/bundle/typescript/src/commands/game/sideQuest/commands/cover.js.map +1 -0
  32. package/bundle/typescript/src/commands/game/sideQuest/commands/dispel.d.ts +1 -1
  33. package/bundle/typescript/src/commands/game/sideQuest/commands/dispel.js +2 -2
  34. package/bundle/typescript/src/commands/game/sideQuest/commands/dispel.js.map +1 -1
  35. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +25 -3
  36. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js.map +1 -1
  37. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.d.ts +2 -3
  38. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +2 -2
  39. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js.map +1 -1
  40. package/bundle/typescript/src/commands/game/sideQuest/commands/jobs.js +18 -10
  41. package/bundle/typescript/src/commands/game/sideQuest/commands/jobs.js.map +1 -1
  42. package/bundle/typescript/src/commands/game/sideQuest/commands/journal.d.ts +44 -0
  43. package/bundle/typescript/src/commands/game/sideQuest/commands/journal.js +136 -0
  44. package/bundle/typescript/src/commands/game/sideQuest/commands/journal.js.map +1 -0
  45. package/bundle/typescript/src/commands/game/sideQuest/commands/look.d.ts +20 -0
  46. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +157 -5
  47. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js.map +1 -1
  48. package/bundle/typescript/src/commands/game/sideQuest/commands/map.js +18 -4
  49. package/bundle/typescript/src/commands/game/sideQuest/commands/map.js.map +1 -1
  50. package/bundle/typescript/src/commands/game/sideQuest/commands/move.js +1 -1
  51. package/bundle/typescript/src/commands/game/sideQuest/commands/pick.d.ts +2 -3
  52. package/bundle/typescript/src/commands/game/sideQuest/commands/pick.js +2 -2
  53. package/bundle/typescript/src/commands/game/sideQuest/commands/pick.js.map +1 -1
  54. package/bundle/typescript/src/commands/game/sideQuest/commands/players.js +8 -2
  55. package/bundle/typescript/src/commands/game/sideQuest/commands/players.js.map +1 -1
  56. package/bundle/typescript/src/commands/game/sideQuest/commands/posture.d.ts +45 -0
  57. package/bundle/typescript/src/commands/game/sideQuest/commands/posture.js +115 -0
  58. package/bundle/typescript/src/commands/game/sideQuest/commands/posture.js.map +1 -0
  59. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +46 -12
  60. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js.map +1 -1
  61. package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js +30 -1
  62. package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js.map +1 -1
  63. package/bundle/typescript/src/commands/game/sideQuest/commands/silent.js +11 -1
  64. package/bundle/typescript/src/commands/game/sideQuest/commands/silent.js.map +1 -1
  65. package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +12 -0
  66. package/bundle/typescript/src/commands/game/sideQuest/commands/take.js.map +1 -1
  67. package/bundle/typescript/src/commands/game/sideQuest/commands/talk.d.ts +47 -0
  68. package/bundle/typescript/src/commands/game/sideQuest/commands/talk.js +91 -5
  69. package/bundle/typescript/src/commands/game/sideQuest/commands/talk.js.map +1 -1
  70. package/bundle/typescript/src/commands/game/sideQuest/commands/time.d.ts +20 -0
  71. package/bundle/typescript/src/commands/game/sideQuest/commands/time.js +61 -0
  72. package/bundle/typescript/src/commands/game/sideQuest/commands/time.js.map +1 -0
  73. package/bundle/typescript/src/commands/game/sideQuest/commands/unlock.js +1 -1
  74. package/bundle/typescript/src/commands/game/sideQuest/commands/unlock.js.map +1 -1
  75. package/bundle/typescript/src/commands/game/sideQuest/commands/use.d.ts +17 -0
  76. package/bundle/typescript/src/commands/game/sideQuest/commands/use.js +136 -1
  77. package/bundle/typescript/src/commands/game/sideQuest/commands/use.js.map +1 -1
  78. package/bundle/typescript/src/commands/game/sideQuest/engine-version.d.ts +1 -1
  79. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +15 -1
  80. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js.map +1 -1
  81. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.d.ts +128 -44
  82. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +551 -254
  83. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js.map +1 -1
  84. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js +82 -84
  85. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js.map +1 -1
  86. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.d.ts +23 -4
  87. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js +95 -79
  88. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js.map +1 -1
  89. package/bundle/typescript/src/commands/game/sideQuest/factories/seed-migration.d.ts +30 -0
  90. package/bundle/typescript/src/commands/game/sideQuest/factories/seed-migration.js +176 -0
  91. package/bundle/typescript/src/commands/game/sideQuest/factories/seed-migration.js.map +1 -0
  92. package/bundle/typescript/src/commands/game/sideQuest/game.d.ts +104 -0
  93. package/bundle/typescript/src/commands/game/sideQuest/game.js +268 -16
  94. package/bundle/typescript/src/commands/game/sideQuest/game.js.map +1 -1
  95. package/bundle/typescript/src/commands/game/sideQuest/headless-harness.d.ts +78 -0
  96. package/bundle/typescript/src/commands/game/sideQuest/headless-harness.js +188 -0
  97. package/bundle/typescript/src/commands/game/sideQuest/headless-harness.js.map +1 -0
  98. package/bundle/typescript/src/commands/game/sideQuest/headless.d.ts +5 -0
  99. package/bundle/typescript/src/commands/game/sideQuest/headless.js +78 -0
  100. package/bundle/typescript/src/commands/game/sideQuest/headless.js.map +1 -1
  101. package/bundle/typescript/src/commands/game/sideQuest/models/_index.d.ts +0 -1
  102. package/bundle/typescript/src/commands/game/sideQuest/models/_index.js +0 -1
  103. package/bundle/typescript/src/commands/game/sideQuest/models/_index.js.map +1 -1
  104. package/bundle/typescript/src/commands/game/sideQuest/models/device.d.ts +121 -0
  105. package/bundle/typescript/src/commands/game/sideQuest/models/device.js +145 -0
  106. package/bundle/typescript/src/commands/game/sideQuest/models/device.js.map +1 -0
  107. package/bundle/typescript/src/commands/game/sideQuest/models/door.d.ts +27 -8
  108. package/bundle/typescript/src/commands/game/sideQuest/models/door.js +15 -10
  109. package/bundle/typescript/src/commands/game/sideQuest/models/door.js.map +1 -1
  110. package/bundle/typescript/src/commands/game/sideQuest/models/npc.d.ts +143 -0
  111. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +253 -30
  112. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js.map +1 -1
  113. package/bundle/typescript/src/commands/game/sideQuest/models/player.d.ts +68 -0
  114. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +102 -0
  115. package/bundle/typescript/src/commands/game/sideQuest/models/player.js.map +1 -1
  116. package/bundle/typescript/src/commands/game/sideQuest/models/room.d.ts +80 -34
  117. package/bundle/typescript/src/commands/game/sideQuest/models/room.js +126 -54
  118. package/bundle/typescript/src/commands/game/sideQuest/models/room.js.map +1 -1
  119. package/bundle/typescript/src/commands/game/sideQuest/models/scene.d.ts +1 -4
  120. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +0 -22
  121. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js.map +1 -1
  122. package/bundle/typescript/src/commands/game/sideQuest/scenes/scene1.json +78 -109
  123. package/bundle/typescript/src/commands/game/sideQuest/scenes/scene2.json +4 -101
  124. package/bundle/typescript/src/commands/game/sideQuest/types/save-file.d.ts +24 -0
  125. package/bundle/typescript/src/commands/game/sideQuest/types/seed/device-seed.d.ts +52 -0
  126. package/bundle/typescript/src/commands/game/sideQuest/types/seed/device-seed.js +2 -0
  127. package/bundle/typescript/src/commands/game/sideQuest/types/seed/device-seed.js.map +1 -0
  128. package/bundle/typescript/src/commands/game/sideQuest/types/seed/legacy-puzzle-seed.d.ts +63 -0
  129. package/bundle/typescript/src/commands/game/sideQuest/types/seed/legacy-puzzle-seed.js +2 -0
  130. package/bundle/typescript/src/commands/game/sideQuest/types/seed/legacy-puzzle-seed.js.map +1 -0
  131. package/bundle/typescript/src/commands/game/sideQuest/types/seed/player-seed.d.ts +6 -0
  132. package/bundle/typescript/src/commands/game/sideQuest/types/seed/room-seed.d.ts +4 -0
  133. package/bundle/typescript/src/commands/game/sideQuest/types/seed/scene-seed.d.ts +3 -2
  134. package/bundle/typescript/src/commands/game/sideQuest/types/shared/abstracts.d.ts +2 -28
  135. package/bundle/typescript/src/commands/game/sideQuest/types/shared/abstracts.js +1 -14
  136. package/bundle/typescript/src/commands/game/sideQuest/types/shared/abstracts.js.map +1 -1
  137. package/bundle/typescript/src/commands/game/sideQuest/ui.js +24 -0
  138. package/bundle/typescript/src/commands/game/sideQuest/ui.js.map +1 -1
  139. package/bundle/typescript/src/commands/game/sideQuest/utilities/affordances.d.ts +55 -0
  140. package/bundle/typescript/src/commands/game/sideQuest/utilities/affordances.js +36 -0
  141. package/bundle/typescript/src/commands/game/sideQuest/utilities/affordances.js.map +1 -0
  142. package/bundle/typescript/src/commands/game/sideQuest/utilities/ar.d.ts +22 -0
  143. package/bundle/typescript/src/commands/game/sideQuest/utilities/ar.js +26 -0
  144. package/bundle/typescript/src/commands/game/sideQuest/utilities/ar.js.map +1 -1
  145. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-dump.js +6 -0
  146. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-dump.js.map +1 -1
  147. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-review.d.ts +45 -0
  148. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-review.js +116 -0
  149. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-review.js.map +1 -0
  150. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog.d.ts +73 -1
  151. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog.js +56 -1
  152. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog.js.map +1 -1
  153. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +37 -4
  154. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js.map +1 -1
  155. package/bundle/typescript/src/commands/game/sideQuest/utilities/credentials.d.ts +26 -0
  156. package/bundle/typescript/src/commands/game/sideQuest/utilities/credentials.js +48 -0
  157. package/bundle/typescript/src/commands/game/sideQuest/utilities/credentials.js.map +1 -0
  158. package/bundle/typescript/src/commands/game/sideQuest/utilities/devices.d.ts +23 -0
  159. package/bundle/typescript/src/commands/game/sideQuest/utilities/devices.js +55 -0
  160. package/bundle/typescript/src/commands/game/sideQuest/utilities/devices.js.map +1 -0
  161. package/bundle/typescript/src/commands/game/sideQuest/utilities/fuzzy-match.d.ts +57 -2
  162. package/bundle/typescript/src/commands/game/sideQuest/utilities/fuzzy-match.js +46 -3
  163. package/bundle/typescript/src/commands/game/sideQuest/utilities/fuzzy-match.js.map +1 -1
  164. package/bundle/typescript/src/commands/game/sideQuest/utilities/log-style.d.ts +38 -0
  165. package/bundle/typescript/src/commands/game/sideQuest/utilities/log-style.js +42 -0
  166. package/bundle/typescript/src/commands/game/sideQuest/utilities/log-style.js.map +1 -1
  167. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +72 -17
  168. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js.map +1 -1
  169. package/bundle/typescript/src/commands/game/sideQuest/utilities/playtester.d.ts +50 -0
  170. package/bundle/typescript/src/commands/game/sideQuest/utilities/playtester.js +43 -0
  171. package/bundle/typescript/src/commands/game/sideQuest/utilities/playtester.js.map +1 -0
  172. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.d.ts +14 -3
  173. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.js +45 -31
  174. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.js.map +1 -1
  175. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-view.js +66 -8
  176. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-view.js.map +1 -1
  177. package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.d.ts +81 -15
  178. package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js +142 -33
  179. package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js.map +1 -1
  180. package/bundle/typescript/src/commands/game/sideQuest/utilities/use-target.d.ts +88 -0
  181. package/bundle/typescript/src/commands/game/sideQuest/utilities/use-target.js +113 -0
  182. package/bundle/typescript/src/commands/game/sideQuest/utilities/use-target.js.map +1 -0
  183. package/bundle/typescript/src/commands/game/sideQuest/utilities/world-clock.d.ts +83 -0
  184. package/bundle/typescript/src/commands/game/sideQuest/utilities/world-clock.js +106 -0
  185. package/bundle/typescript/src/commands/game/sideQuest/utilities/world-clock.js.map +1 -0
  186. package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js +54 -5
  187. package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js.map +1 -1
  188. package/bundle/typescript/src/maka.js +1 -1
  189. package/bundle/typescript/src/maka.js.map +1 -1
  190. package/bundle/typescript/src/tools/sdk-loader/relocate.d.ts +19 -2
  191. package/bundle/typescript/src/tools/sdk-loader/relocate.js +22 -2
  192. package/bundle/typescript/src/tools/sdk-loader/relocate.js.map +1 -1
  193. package/package.json +1 -1
  194. package/bundle/typescript/src/commands/game/sideQuest/commands/solve.d.ts +0 -6
  195. package/bundle/typescript/src/commands/game/sideQuest/commands/solve.js +0 -72
  196. package/bundle/typescript/src/commands/game/sideQuest/commands/solve.js.map +0 -1
  197. package/bundle/typescript/src/commands/game/sideQuest/factories/puzzle-factory.d.ts +0 -6
  198. package/bundle/typescript/src/commands/game/sideQuest/factories/puzzle-factory.js +0 -26
  199. package/bundle/typescript/src/commands/game/sideQuest/factories/puzzle-factory.js.map +0 -1
  200. package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.d.ts +0 -143
  201. package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.js +0 -366
  202. package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.js.map +0 -1
  203. package/bundle/typescript/src/commands/game/sideQuest/types/seed/puzzle-seed.d.ts +0 -28
  204. package/bundle/typescript/src/commands/game/sideQuest/types/seed/puzzle-seed.js +0 -2
  205. 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)}
@@ -409,6 +415,9 @@ ${skeletonContext(skeleton)}
409
415
  For EVERY room in the skeleton, write evocative 2-3 sentence prose expanding its "concept" --
410
416
  gritty cyberpunk noir, concrete sensory detail, no mechanics talk and no spoilers (never
411
417
  reveal passphrases, hidden items, or plot twists in a room description).
418
+ Never write a CODE-SHAPED string into room prose -- no door numbers, unit numbers, serials,
419
+ or stencilled digits on a wall. The barriers' answers are the only codes in this world, and
420
+ a number in scenery is a number a player will carry to the nearest lock and type.
412
421
 
413
422
  "At" SPOTS -- named places WITHIN a room the player can "move to":
414
423
  - Give 2-4 "spots" ONLY where positioning matters (bars, markets, offices, warehouses --
@@ -446,7 +455,13 @@ reveal passphrases, hidden items, or plot twists in a room description).
446
455
  ({ "<item name>": "<spot name>" }) -- a prize belongs at the guarded spot, not
447
456
  floating in the doorway; loose scatter can stay unanchored.
448
457
  - "exitSpots": which spot each exit leaves from ({ "<direction>": "<spot name>" }).
449
- Give one to EVERY exit of a spotted room. An exit blocked by a barrier deserves its
458
+ Give one to EVERY exit of a spotted room, and copy the spot name EXACTLY as you wrote
459
+ it in "spots" -- this is the only thing that tells the engine which way out a doorway
460
+ leads. Leave one off and the engine has to GUESS from the spot's name, which is how a
461
+ spot called "the back door" ended up bound to the stairs DOWN and stranded in the middle
462
+ of the floor. A vertical way out (stairs, a ladder, a floor hatch) needs a spot that
463
+ reads vertical, and a door needs one that reads like a door.
464
+ An exit blocked by a barrier deserves its
450
465
  OWN named spot -- a back gate is not the entrance; two gates are two spots -- so the
451
466
  player always knows which gate is which.
452
467
  - ONE STORY: the prose and the spot data must agree. When the prose mentions a
@@ -502,47 +517,57 @@ ${JSON_ONLY}
502
517
  One entry per skeleton npc, "name" copied exactly.${repairNote(previousError)}
503
518
  `.trim();
504
519
  }
505
- export function buildPuzzlesPrompt(skeleton, previousError) {
506
- if (skeleton.puzzles.length === 0)
520
+ export function buildDevicesPrompt(skeleton, previousError) {
521
+ if (skeleton.devices.length === 0)
507
522
  return '';
508
523
  return `
509
- 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.
510
526
 
511
527
  ${skeletonContext(skeleton)}
512
528
 
513
- For EVERY puzzle in the skeleton, expand its "concept" (what the barrier physically is --
514
- that's fixed, as is its bypassType):
515
- - "riddle": what the player sees on approaching it, shown verbatim -- PLAIN and concrete,
516
- never a cryptic riddle-poem. Describe the barrier as a THING: what it is, how it's
517
- secured, and what kind of credential it visibly expects (a keypad implies a code, an
518
- intercom awaits a spoken phrase, a scrawled sign says "password"). Write NO people into
519
- it -- no guards, no dialogue, no "they demand": anyone standing watch does their own
520
- demanding in play, in their own voice. Never state the credential itself.
521
- - "requiredPhrase": the exact credential -- ALL LOWERCASE, 2-5 plain words, no punctuation.
522
- A concrete literal thing (a code, a passphrase, a name), never wordplay.
523
- - "successMessage" / "failureMessage": one sentence each, in-fiction.
524
- - "lockedMessage": only for puzzles with "requiresPuzzle" -- what the player sees trying it
525
- too early, hinting at the prerequisite without naming mechanics.
526
- - "guardsExit": ONLY for a puzzle with a "room" (never a door-guarding puzzle) whose
527
- concept is physically TERRAIN blocking a passage -- rubble, a collapsed rack, a
528
- security shutter across a walkway -- name the exact "direction" of one of that
529
- room's own exits (from the skeleton) that it seals. A barrier on a container, safe,
530
- or item pickup guards nothing physical -- leave this unset.
531
- - "footprint": "small" | "medium" | "large" -- how much of the room the barrier's
532
- wreckage/blockage occupies. Only meaningful alongside "guardsExit".
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.
545
+ * Otherwise (an intercom, a voice-gate, a spoken passphrase, a name, a sign reading
546
+ "password") -- ALL LOWERCASE, 2-5 plain words, no punctuation.
547
+ Either way a concrete literal thing, never wordplay.
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".
533
558
 
534
559
  ${JSON_ONLY}
535
- [ { "name": string, "riddle": string, "requiredPhrase": string, "successMessage": string,
536
- "failureMessage": string, "lockedMessage"?: string, "guardsExit"?: string, "footprint"?: string } ]
537
- 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)}
538
563
  `.trim();
539
564
  }
540
- export function buildItemsPrompt(skeleton, puzzles, previousError) {
565
+ export function buildItemsPrompt(skeleton, devices, previousError) {
541
566
  const clueSpecs = skeleton.items
542
- .filter(i => i.role === 'clue' && i.forPuzzle)
567
+ .filter(i => i.role === 'clue' && i.forDevice)
543
568
  .map(i => {
544
- const phrase = puzzles.find(p => p.name === i.forPuzzle)?.requiredPhrase;
545
- 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).`;
546
571
  })
547
572
  .join('\n');
548
573
  return `
@@ -562,6 +587,14 @@ For EVERY item in the skeleton, expand it into a full item ("name" and "category
562
587
  Clue requirements (mechanically validated -- the exact phrase must appear in "details"):
563
588
  ${clueSpecs || '- (no clue items in this scene)'}
564
589
 
590
+ THE ONLY CODES IN THIS WORLD are the clue phrases listed just above. No other item may
591
+ carry a code-shaped string -- no serial numbers, no model numbers, no unit or locker or
592
+ apartment numbers, no "access code" on a scrap of paper that isn't a listed clue. A player
593
+ who reads a number writes it down and takes it to the nearest lock; a second plausible
594
+ number is indistinguishable from the real one and it costs them the run. Quantities and
595
+ prices are fine (a clip "holds 10 rounds", a stick "carries 2000 nuyen") -- those read as
596
+ amounts, not credentials.
597
+
565
598
  ${JSON_ONLY}
566
599
  [ { "name": string, "description": string, "size": string, "category": string,
567
600
  "shape": string, "color": string, "texture": string, "rating": string, "weight": number,
@@ -571,32 +604,33 @@ One entry per skeleton item, "name" and "category" copied exactly.${repairNote(p
571
604
  }
572
605
  // ========================= Parse / validate / assemble ====================
573
606
  /**
574
- * Whether THIS runner can reliably work a barrier of the given bypassType
575
- * with their own hands -- no written-down credential required. This is
576
- * what loosens the "every puzzle needs a discoverable clue" rule when the
577
- * scene is generated against a dossier: a lockpick maglock in a scene
578
- * built for a master locksmith doesn't need its code lying in a book --
579
- * the pick IS their route. Deliberately conservative: EXPLICIT skill
580
- * ratings only (the combatSkill fallback would let any bruiser "qualify"
581
- * as a hacker), threshold 3 (trained professional), and with no dossier
582
- * 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.
583
630
  */
584
- export function canBypassUnaided(player, bypassType) {
585
- if (!player || !bypassType)
586
- return false;
587
- // THROUGH skillRating, NOT the raw sheet (bug found 2026-08-26): the
588
- // raw read sailed past the "incompetent-<skill>" quality, which
589
- // skillRating pins to 0 (player.ts) -- an Incompetent-locksmith runner
590
- // was qualifying to have the lockpick clue waived on the strength of a
591
- // number the rest of the game refuses to honour.
592
- const rated = (skill) => player.skillRating(skill);
593
- switch (bypassType) {
594
- case 'lockpick': return rated('locksmith') >= 3;
595
- case 'hack': return rated('hacking') >= 3 || player.isTechnomancer() || !!player.getAnyCyberdeck();
596
- case 'spell': return player.isAwakened() && !player.adept;
597
- case 'con': return rated('con') >= 3 || player.charisma >= 5;
598
- default: return false;
599
- }
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;
600
634
  }
601
635
  /** Strips accidental markdown fences and parses; throws with a useful message. */
602
636
  export function parseJsonReply(reply) {
@@ -668,7 +702,7 @@ export function validateSkeleton(skeleton, player, crew) {
668
702
  const roomNames = new Set(skeleton.rooms.map(r => r.name));
669
703
  const npcNames = new Set(skeleton.npcs.map(n => n.name));
670
704
  const itemNames = new Set(skeleton.items.map(i => i.name));
671
- const puzzleNames = new Set((skeleton.puzzles ?? []).map(p => p.name));
705
+ const deviceNames = new Set((skeleton.devices ?? []).map(d => d.name));
672
706
  const startRooms = skeleton.rooms.filter(r => r.isStartRoom);
673
707
  if (startRooms.length !== 1)
674
708
  fail(`exactly one room must have "isStartRoom": true (found ${startRooms.length}).`);
@@ -677,11 +711,33 @@ export function validateSkeleton(skeleton, player, crew) {
677
711
  for (const exit of room.exits ?? []) {
678
712
  if (!roomNames.has(exit.targetRoom))
679
713
  fail(`exit from "${room.name}" targets unknown room "${exit.targetRoom}".`);
680
- if (exit.puzzle) {
681
- if (!puzzleNames.has(exit.puzzle))
682
- fail(`exit from "${room.name}" references unknown puzzle "${exit.puzzle}".`);
683
- 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.`);
684
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);
738
+ }
739
+ if (exit.guard && !npcNames.has(exit.guard))
740
+ fail(`exit from "${room.name}" is guarded by unknown npc "${exit.guard}".`);
685
741
  if (exit.keyRequired && !itemNames.has(exit.keyRequired))
686
742
  fail(`exit from "${room.name}" requires unknown key item "${exit.keyRequired}".`);
687
743
  }
@@ -695,40 +751,72 @@ export function validateSkeleton(skeleton, player, crew) {
695
751
  fail(`item "${item.name}" placed in unknown room "${item.room}".`);
696
752
  if (item.heldBy && !npcNames.has(item.heldBy))
697
753
  fail(`item "${item.name}" heldBy unknown npc "${item.heldBy}".`);
698
- if (item.forPuzzle && !puzzleNames.has(item.forPuzzle))
699
- 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}".`);
700
756
  }
701
- // The three structural faults real generations actually shipped -- an
702
- // unplaced-or-double-placed puzzle, a puzzle with no clue item at all
703
- // (unfixable by ANY items pass), and unreachable rooms -- must die HERE,
704
- // before four detail passes build on bad bones.
705
- for (const puzzle of skeleton.puzzles ?? []) {
706
- if (puzzle.room && !roomNames.has(puzzle.room))
707
- fail(`puzzle "${puzzle.name}" placed in unknown room "${puzzle.room}".`);
708
- if (puzzle.rewardItem && !itemNames.has(puzzle.rewardItem))
709
- fail(`puzzle "${puzzle.name}" rewards unknown item "${puzzle.rewardItem}".`);
710
- const refs = doorRefs.get(puzzle.name) ?? 0;
711
- if (puzzle.room && refs > 0)
712
- fail(`puzzle "${puzzle.name}" is placed both in room "${puzzle.room}" AND on a door (exits[].puzzle) -- exactly one or the other.`);
713
- if (!puzzle.room && refs === 0)
714
- 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;
715
775
  if (refs > 1)
716
- fail(`puzzle "${puzzle.name}" is referenced by ${refs} doors -- a puzzle can only guard one.`);
717
- const clue = skeleton.items.find(i => i.role === 'clue' && i.forPuzzle === puzzle.name);
718
- if (!clue) {
719
- // Loosened when the dossier says the runner can work this barrier
720
- // unaided -- their bypass skill IS the way past (player note from a
721
- // real failed generation: "it just needs a way past, and if the
722
- // player has the means, it should be allowed").
723
- if (!canBypassUnaided(player, puzzle.bypassType)) {
724
- 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.`);
725
803
  }
726
804
  }
727
- else {
728
- if (!clue.room)
729
- fail(`clue item "${clue.name}" needs a "room" (a different one than its puzzle's).`);
730
- if (puzzle.room && clue.room === puzzle.room)
731
- 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".`);
732
820
  }
733
821
  }
734
822
  if (!itemNames.has(skeleton.winCondition.item))
@@ -755,7 +843,7 @@ export function validateSkeleton(skeleton, player, crew) {
755
843
  const edges = [];
756
844
  for (const room of skeleton.rooms) {
757
845
  for (const exit of room.exits ?? []) {
758
- 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 });
759
847
  }
760
848
  }
761
849
  const reachable = new Set([startRooms[0].name]);
@@ -768,7 +856,7 @@ export function validateSkeleton(skeleton, player, crew) {
768
856
  if (!from || reachable.has(to))
769
857
  continue;
770
858
  const keyLoc = edge.keyRequired ? itemRoom.get(edge.keyRequired) : undefined;
771
- const passable = !!edge.puzzle || !edge.keyRequired || (keyLoc !== undefined && reachable.has(keyLoc));
859
+ const passable = !!edge.device || !edge.keyRequired || (keyLoc !== undefined && reachable.has(keyLoc));
772
860
  if (passable) {
773
861
  reachable.add(to);
774
862
  grew = true;
@@ -829,7 +917,7 @@ function freeStrandedRooms(skeleton) {
829
917
  const edges = [];
830
918
  for (const room of skeleton.rooms ?? []) {
831
919
  for (const exit of room.exits ?? []) {
832
- 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 });
833
921
  }
834
922
  }
835
923
  // Same walk the validator runs, so a repair here is judged by exactly
@@ -847,7 +935,7 @@ function freeStrandedRooms(skeleton) {
847
935
  if (reachable.has(to))
848
936
  continue;
849
937
  const keyLoc = edge.keyRequired ? locs.get(edge.keyRequired) : undefined;
850
- if (!!edge.puzzle || !edge.keyRequired || (keyLoc !== undefined && reachable.has(keyLoc))) {
938
+ if (!!edge.device || !edge.keyRequired || (keyLoc !== undefined && reachable.has(keyLoc))) {
851
939
  reachable.add(to);
852
940
  grew = true;
853
941
  }
@@ -867,7 +955,7 @@ function freeStrandedRooms(skeleton) {
867
955
  // ground, held shut by a key nobody can get to.
868
956
  const gate = edges.find(e => {
869
957
  const touchesBoth = (reachable.has(e.a) && strandedNames.has(e.b)) || (reachable.has(e.b) && strandedNames.has(e.a));
870
- if (!touchesBoth || e.puzzle || !e.keyRequired)
958
+ if (!touchesBoth || e.device || !e.keyRequired)
871
959
  return false;
872
960
  const keyLoc = itemRoom().get(e.keyRequired);
873
961
  return keyLoc === undefined || !reachable.has(keyLoc);
@@ -888,7 +976,7 @@ function freeStrandedRooms(skeleton) {
888
976
  const STAFFED_ROOM_TYPES = /^(market|shop|store|bar|pub|tavern|restaurant|cafe|clinic|pawn|stall|vendor|kiosk|dispensary)/i;
889
977
  /**
890
978
  * WHERE A CLUE MAY LIVE. The validator's reachability walk treats every
891
- * puzzle door as PASSABLE (`!!edge.puzzle ||` in validateSkeleton), which
979
+ * puzzle door as PASSABLE (`!!edge.device ||` in validateSkeleton), which
892
980
  * is right for "can the runner eventually get there" and dead wrong for
893
981
  * "can the runner read this BEFORE facing the barrier". So a clue placed
894
982
  * behind the very door its puzzle guards validates clean and softlocks in
@@ -905,7 +993,7 @@ const STAFFED_ROOM_TYPES = /^(market|shop|store|bar|pub|tavern|restaurant|cafe|c
905
993
  *
906
994
  * Returns [] when nowhere is safe -- callers MUST bail, never guess.
907
995
  */
908
- function safeRoomsFor(skeleton, puzzle) {
996
+ function safeRoomsFor(skeleton, device) {
909
997
  const start = (skeleton.rooms ?? []).find(r => r.isStartRoom);
910
998
  if (!start)
911
999
  return [];
@@ -928,8 +1016,8 @@ function safeRoomsFor(skeleton, puzzle) {
928
1016
  grew = false;
929
1017
  for (const room of skeleton.rooms ?? []) {
930
1018
  for (const exit of room.exits ?? []) {
931
- // A barrier is a wall until it is solved. That is the whole point.
932
- if (exit.puzzle)
1019
+ // A barrier is a wall until it is opened. That is the whole point.
1020
+ if (exit.device)
933
1021
  continue;
934
1022
  const a = room.name, b = exit.targetRoom;
935
1023
  if (isolated.has(a) || isolated.has(b))
@@ -954,14 +1042,21 @@ function safeRoomsFor(skeleton, puzzle) {
954
1042
  // its room: the puzzles pass can later emit `guardsExit` sealing one of
955
1043
  // that room's doors, which is unknowable here, so be conservative. Fall
956
1044
  // back to merely excluding the room itself when that leaves nothing.
957
- // A door puzzle needs no extra exclusion -- cutting its edge already
1045
+ // A door device needs no extra exclusion -- cutting its edge already
958
1046
  // leaves exactly the near-side component, and the near-side room is a
959
1047
  // fine place to find the clue.
960
- let reached = walk(puzzle.room);
961
- 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)
962
1051
  reached = walk();
963
1052
  const candidates = (skeleton.rooms ?? [])
964
- .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));
965
1060
  if (candidates.length === 0)
966
1061
  return [];
967
1062
  // Rank: earned over free (not the curb), unowned over owned, deeper
@@ -975,7 +1070,7 @@ function safeRoomsFor(skeleton, puzzle) {
975
1070
  const cur = bfs.shift();
976
1071
  for (const room of skeleton.rooms ?? []) {
977
1072
  for (const exit of room.exits ?? []) {
978
- if (exit.puzzle)
1073
+ if (exit.device)
979
1074
  continue;
980
1075
  const pair = [room.name, exit.targetRoom];
981
1076
  if (!pair.includes(cur))
@@ -1070,7 +1165,14 @@ function resolveDirectionCollisions(skeleton) {
1070
1165
  }
1071
1166
  return notes;
1072
1167
  }
1073
- 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) {
1074
1176
  const notes = [];
1075
1177
  // Topology before anything that reasons about topology: freeStranded-
1076
1178
  // Rooms, clue placement and the curb all walk this graph, and they
@@ -1154,80 +1256,62 @@ export function normalizeSkeleton(skeleton, player) {
1154
1256
  notes.push(`normalized: start room "${start.name}" is indoors but every direction on it is taken -- could not inject a curb.`);
1155
1257
  }
1156
1258
  }
1157
- const doorRefs = new Map();
1158
- for (const room of skeleton.rooms ?? []) {
1159
- for (const exit of room.exits ?? []) {
1160
- if (exit.puzzle)
1161
- doorRefs.set(exit.puzzle, (doorRefs.get(exit.puzzle) ?? 0) + 1);
1162
- }
1163
- }
1164
- for (const puzzle of skeleton.puzzles ?? []) {
1165
- if (puzzle.room && (doorRefs.get(puzzle.name) ?? 0) === 1) {
1166
- notes.push(`normalized: puzzle "${puzzle.name}" was double-placed (room + door); kept the door placement.`);
1167
- delete puzzle.room;
1168
- }
1169
- }
1170
- // THE CLUE ITEM -- half of every generation failure on record.
1171
- //
1172
- // Measured 2026-08-26 across 20 sessions: 19 job attempts, 3 valid
1173
- // scenes, 12 outright give-ups. The dominant fault, at both the
1174
- // skeleton and the assembled stage, is a puzzle whose clue item is
1175
- // missing, roomless, or sitting in its own puzzle's room. It is
1176
- // character-dependent, which is why it looked like flakiness: a Face
1177
- // (con 5, no locksmith/hacking, magic 0) satisfies canBypassUnaided
1178
- // for exactly ONE bypassType, so every lockpick/hack/spell barrier
1179
- // demands a clue -- and the model keeps forgetting to write one.
1180
- //
1181
- // Repaired here rather than by retrying, because retries reproduce it
1182
- // verbatim: the skeleton prompt feeds back the ERROR but not the map
1183
- // 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.
1184
1263
  // again. Runs BEFORE reward reconciliation on purpose -- that step
1185
1264
  // treats role "clue" as load-bearing, so anything promoted here is
1186
1265
  // protected from being converted into an unplaced reward.
1187
1266
  //
1188
- // Synthesis happens EVEN WHEN canBypassUnaided waives the clue. The
1189
- // waiver is a latent softlock: getBypassHint promises the player "the
1190
- // answer is recorded somewhere out in the world", bypass is a coin
1191
- // flip, and a failed flip burns the route with "Only the real code
1192
- // 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.
1193
1273
  const SYNTHESIZED_CLUE_CAP = 2;
1194
1274
  let synthesized = 0;
1195
1275
  const CLUE_NOUN = {
1196
- lockpick: 'Maintenance Log',
1197
- hack: 'Service Log',
1198
- spell: 'Ritual Notes',
1199
- con: 'Staff Roster',
1276
+ panel: 'Service Log',
1200
1277
  };
1201
- 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;
1202
1282
  const items = skeleton.items ?? [];
1203
- 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);
1204
1284
  // Reclassify before inventing: the model often authored the object
1205
1285
  // and only mislabelled it. Cheaper than synthesis and it keeps the
1206
1286
  // model's own prose intent.
1207
1287
  if (!clue) {
1208
- 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');
1209
1289
  if (mislabelled) {
1210
- 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.`);
1211
1291
  mislabelled.role = 'clue';
1212
1292
  mislabelled.category = 'Book';
1213
1293
  clue = mislabelled;
1214
1294
  }
1215
1295
  }
1216
- const safe = safeRoomsFor(skeleton, puzzle);
1296
+ const safe = safeRoomsFor(skeleton, device);
1217
1297
  if (clue) {
1218
1298
  // A clue in its own puzzle's room, or with no room at all, is the
1219
1299
  // same repair: put it somewhere legal and safe.
1220
- 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);
1221
1305
  if (needsRoom) {
1222
1306
  // A holder standing in a safe room is the better answer than a
1223
1307
  // book on the floor -- but only if the validators can see it.
1224
1308
  const holderRoom = clue.heldBy ? (skeleton.npcs ?? []).find(n => n.name === clue.heldBy)?.startLocation : undefined;
1225
1309
  const target = (holderRoom && safe.includes(holderRoom)) ? holderRoom : safe[0];
1226
1310
  if (!target) {
1227
- 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}".`);
1228
1312
  }
1229
1313
  else {
1230
- 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';
1231
1315
  clue.room = target;
1232
1316
  delete clue.heldBy;
1233
1317
  notes.push(`normalized: clue "${clue.name}" ${was}; moved it to "${target}" (reachable without passing the barrier).`);
@@ -1236,31 +1320,25 @@ export function normalizeSkeleton(skeleton, player) {
1236
1320
  continue;
1237
1321
  }
1238
1322
  if (safe.length === 0) {
1239
- // How bad this is depends on the runner. If the barrier happens to
1240
- // be one they can force unaided, validateSkeleton's waiver still
1241
- // lets the draft through -- it just ships a barrier with no written
1242
- // answer, which is the gamble-only case getBypassHint over-promises
1243
- // on. If they can't force it, the draft is dead and the validator
1244
- // will say so. Two very different log lines; say which one it is.
1245
- notes.push(canBypassUnaided(player, puzzle.bypassType)
1246
- ? `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.`
1247
- : `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.`);
1248
1326
  continue;
1249
1327
  }
1250
1328
  if (synthesized >= SYNTHESIZED_CLUE_CAP) {
1251
- 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.`);
1252
1330
  continue;
1253
1331
  }
1254
1332
  // The NAME is the entire brief the items prose pass receives (an
1255
1333
  // ISkeletonItem has no concept field), so it has to imply the object.
1256
- const noun = CLUE_NOUN[puzzle.bypassType ?? ''] ?? 'Notes';
1257
- let name = `${puzzle.name} ${noun}`;
1334
+ const noun = CLUE_NOUN[device.kind ?? 'panel'] ?? 'Notes';
1335
+ let name = `${device.name} ${noun}`;
1258
1336
  for (let i = 2; items.some(it => it.name === name); i++)
1259
- name = `${puzzle.name} ${noun} ${i}`;
1337
+ name = `${device.name} ${noun} ${i}`;
1260
1338
  skeleton.items = items;
1261
- 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] });
1262
1340
  synthesized++;
1263
- 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).`);
1264
1342
  }
1265
1343
  // REWARD-REFERENCE reconciliation. A rewardItem (a puzzle's, or the
1266
1344
  // winCondition's) must point at an UNPLACED role-"reward" item, but
@@ -1276,18 +1354,18 @@ export function normalizeSkeleton(skeleton, player) {
1276
1354
  // reward reference is dropped instead (door puzzles don't need one).
1277
1355
  const itemByName = new Map(skeleton.items.map(i => [i.name, i]));
1278
1356
  const loadBearing = new Set(['clue', 'key', 'winCondition', 'starting']);
1279
- for (const puzzle of skeleton.puzzles ?? []) {
1280
- if (!puzzle.rewardItem)
1357
+ for (const device of skeleton.devices ?? []) {
1358
+ if (!device.grantsItem)
1281
1359
  continue;
1282
- const item = itemByName.get(puzzle.rewardItem);
1360
+ const item = itemByName.get(device.grantsItem);
1283
1361
  if (!item || item.role === 'reward')
1284
1362
  continue;
1285
1363
  if (loadBearing.has(item.role) || item.name === skeleton.winCondition?.item) {
1286
- 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).`);
1287
- 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;
1288
1366
  }
1289
1367
  else {
1290
- 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.`);
1291
1369
  item.role = 'reward';
1292
1370
  delete item.room;
1293
1371
  delete item.heldBy;
@@ -1335,11 +1413,11 @@ export function normalizeSkeleton(skeleton, player) {
1335
1413
  export function ensureClueDetails(skeleton, details) {
1336
1414
  const notes = [];
1337
1415
  const byName = new Map((details.items ?? []).map(i => [i.name, i]));
1338
- for (const puzzleDetail of details.puzzles ?? []) {
1339
- const phrase = (puzzleDetail.requiredPhrase ?? '').trim();
1416
+ for (const deviceDetail of details.devices ?? []) {
1417
+ const phrase = (deviceDetail.code ?? '').trim();
1340
1418
  if (!phrase)
1341
1419
  continue;
1342
- 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);
1343
1421
  if (clues.length === 0)
1344
1422
  continue;
1345
1423
  // An item literally NAMED the phrase satisfies the validator too, and
@@ -1379,17 +1457,114 @@ export function ensureClueDetails(skeleton, details) {
1379
1457
  }
1380
1458
  const body = (detail.details ?? '').trim();
1381
1459
  detail.details = `${body}${body ? ' ' : ''}Scrawled in the margin, in a different hand: "${phrase}".`;
1382
- 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.`);
1383
1461
  }
1384
1462
  return notes;
1385
1463
  }
1386
1464
  /** Merges the skeleton's structure with the detail passes into a full seed. */
1465
+ /**
1466
+ * Resolve a spot name the model WROTE to a spot that actually EXISTS.
1467
+ *
1468
+ * Generated cross-references are prose, not identifiers: the rooms pass
1469
+ * writes spot names into `exitSpots` by hand, and it varies the surface
1470
+ * form constantly -- an article ("the loading dock"), a capital ("Back
1471
+ * Door"), a plural, a hyphen. An exact-match test threw every one of
1472
+ * those away silently and fell back to guessing the binding from the
1473
+ * spot's name, which is the failure this whole seam exists to remove.
1474
+ *
1475
+ * Conservative on purpose, in this order: exact, then punctuation- and
1476
+ * article-insensitive, then a UNIQUE containment match. Ambiguity
1477
+ * resolves to undefined -- binding an exit to the wrong spot is worse
1478
+ * than leaving it unbound, because unbound still gets a considered
1479
+ * answer from the runtime while wrong is simply wrong.
1480
+ */
1481
+ export function resolveSpotName(declared, spotNames) {
1482
+ const want = declared.trim().toLowerCase();
1483
+ if (want.length === 0 || spotNames.length === 0)
1484
+ return undefined;
1485
+ if (spotNames.includes(want))
1486
+ return want;
1487
+ const loosen = (s) => s.toLowerCase()
1488
+ .replace(/[^a-z0-9\s]/g, ' ')
1489
+ .replace(/\b(the|a|an)\b/g, ' ')
1490
+ .replace(/\s+/g, ' ')
1491
+ .trim();
1492
+ const loose = loosen(want);
1493
+ if (loose.length === 0)
1494
+ return undefined;
1495
+ const exactLoose = spotNames.filter(n => loosen(n) === loose);
1496
+ if (exactLoose.length === 1)
1497
+ return exactLoose[0];
1498
+ const contained = spotNames.filter(n => {
1499
+ const ln = loosen(n);
1500
+ return ln.length > 0 && (ln.includes(loose) || loose.includes(ln));
1501
+ });
1502
+ return contained.length === 1 ? contained[0] : undefined;
1503
+ }
1504
+ /** A credential that is nothing but digits -- what a keypad actually takes. */
1505
+ const NUMERIC_ANSWER = /^\d+$/;
1506
+ /** Prose that promises the player a NUMBER: a pad, a dial, digits. */
1507
+ const NUMERIC_DEVICE = /\b(keypad|key pad|number pad|numeric pad|pin pad|numeric keypad|combination dial|number dial|digits?|numeric code|numeric)\b/i;
1508
+ /** Prose that promises the player WORDS: something spoken or written. */
1509
+ const SPOKEN_DEVICE = /\b(intercom|voice[- ]gate|voice panel|passphrase|password|speak|spoken|say the|watchword|countersign)\b/i;
1510
+ /**
1511
+ * THE BARRIER MUST WANT WHAT THE ANSWER IS.
1512
+ *
1513
+ * Playtest 2026-08-27, 12:15: "there are conflicting numbers here for the
1514
+ * code... let's make it less confusing", then "all these codes are wrong,
1515
+ * it seems like the AI is just making them up".
1516
+ *
1517
+ * The second half was NPCs inventing credentials, fixed in the wave
1518
+ * before this one. The first half is older and entirely separate, and it
1519
+ * was written into the generator on purpose, two lines apart in
1520
+ * buildPuzzlesPrompt: the riddle was invited to describe a keypad ("a
1521
+ * keypad implies a code") while requiredPhrase was required to be "2-5
1522
+ * plain words". So a generated keypad could never take a number, and the
1523
+ * player who read "worn digits" and then found "double or nothing"
1524
+ * scrawled in a notebook did not believe they had the code. They went
1525
+ * looking for a number instead -- and every stray number in the scene
1526
+ * became a candidate.
1527
+ *
1528
+ * The prompt now demands the two agree. This is the deterministic floor
1529
+ * under that instruction, in the same spirit as ensureClueDetails: the
1530
+ * ANSWER is authoritative (clue items quote it verbatim and solve()
1531
+ * matches it exactly), so the presentation is what gets corrected, and
1532
+ * it is corrected by ADDING a plain sentence rather than rewriting
1533
+ * generated prose -- surgery on a model's sentence is how you get
1534
+ * nonsense, and a player only needs to be told which kind of thing to
1535
+ * type.
1536
+ */
1537
+ export function ensureCredentialKindAgrees(details) {
1538
+ const notes = [];
1539
+ for (const device of details.devices ?? []) {
1540
+ const phrase = (device.code ?? '').trim();
1541
+ const riddle = device.description ?? '';
1542
+ if (phrase.length === 0 || riddle.length === 0)
1543
+ continue;
1544
+ const answerIsNumeric = NUMERIC_ANSWER.test(phrase);
1545
+ const promisesNumber = NUMERIC_DEVICE.test(riddle);
1546
+ const promisesWords = SPOKEN_DEVICE.test(riddle);
1547
+ if (!answerIsNumeric && promisesNumber && !promisesWords) {
1548
+ // The expensive direction: the player hunts for digits that were
1549
+ // never generated, while the words sit unrecognised in their pack.
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.`);
1552
+ continue;
1553
+ }
1554
+ if (answerIsNumeric && promisesWords && !promisesNumber) {
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.`);
1557
+ }
1558
+ }
1559
+ return notes;
1560
+ }
1387
1561
  export function assembleScene(skeleton, details, playerName) {
1388
1562
  // The answer has to be legible before anything is assembled from it.
1389
1563
  ensureClueDetails(skeleton, details);
1564
+ ensureCredentialKindAgrees(details);
1390
1565
  const roomDetail = new Map(details.rooms.map(r => [r.name, r]));
1391
1566
  const npcDetail = new Map(details.npcs.map(n => [n.name, n]));
1392
- const puzzleDetail = new Map(details.puzzles.map(p => [p.name, p]));
1567
+ const deviceDetail = new Map(details.devices.map(d => [d.name, d]));
1393
1568
  const itemDetail = new Map(details.items.map(i => [i.name, i]));
1394
1569
  // "At" spots from the rooms pass, normalized before they touch the
1395
1570
  // seed: valid cover tags only, at most 5 spots, and a spotted room
@@ -1450,18 +1625,34 @@ export function assembleScene(skeleton, details, playerName) {
1450
1625
  size: detail?.size && VALID_SIZES.has(detail.size) ? detail.size : undefined,
1451
1626
  spots: spots.length > 0 ? spots : undefined,
1452
1627
  levels: levels.length > 0 ? levels : undefined,
1453
- // Per-exit spots, honored only when the named spot survived
1454
- // normalization -- an invalid one falls back to the doorway-name
1455
- // heuristic (utilities/spots.ts exitSpot), never a phantom place.
1628
+ // Per-exit spots. An explicit direction -> spot binding is the
1629
+ // ONLY thing that tells the grid which way out a doorway spot
1630
+ // actually serves; without one the runtime falls back to matching
1631
+ // the spot's NAME (models/room.ts namedDoorwayFor), which is how
1632
+ // "the back door" ended up bound to a DOWN exit and seated in the
1633
+ // middle of the floor (playtest 2026-08-27, 12:17).
1634
+ //
1635
+ // So a near-miss is REPAIRED rather than dropped. The model
1636
+ // routinely names the right spot with the wrong surface form --
1637
+ // "the loading dock" for a spot called "loading dock", "Back Door"
1638
+ // for "back door" -- and the old exact-match test threw all of
1639
+ // those away, silently, straight into the name heuristic.
1640
+ //
1641
+ // What this deliberately does NOT do is invent a binding for an
1642
+ // exit the model left unbound. Pairing an unbound doorway to a
1643
+ // plausible exit is the runtime's job and is being centralised
1644
+ // there; a second implementation here is exactly the divergence
1645
+ // that caused this bug. Unbound stays unbound, and the runtime
1646
+ // decides -- with better information than generation has.
1456
1647
  exits: (room.exits ?? []).map(e => {
1457
1648
  const declared = detail?.exitSpots?.[e.direction];
1458
- const spot = typeof declared === 'string' ? declared.trim().toLowerCase() : undefined;
1649
+ const raw = typeof declared === 'string' ? declared.trim().toLowerCase() : undefined;
1459
1650
  return {
1460
1651
  direction: e.direction,
1461
1652
  targetRoom: e.targetRoom,
1462
1653
  keyRequired: e.keyRequired,
1463
- puzzle: e.puzzle,
1464
- atSpot: spot && spots.some(s => s.name === spot) ? spot : undefined,
1654
+ device: e.device,
1655
+ atSpot: raw ? resolveSpotName(raw, spots.map(s => s.name)) : undefined,
1465
1656
  };
1466
1657
  }),
1467
1658
  };
@@ -1495,6 +1686,77 @@ export function assembleScene(skeleton, details, playerName) {
1495
1686
  }
1496
1687
  }
1497
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
+ }
1498
1760
  const npcs = skeleton.npcs.map(npc => {
1499
1761
  const detail = npcDetail.get(npc.name);
1500
1762
  const station = npcStation.get(npc.name);
@@ -1508,8 +1770,11 @@ export function assembleScene(skeleton, details, playerName) {
1508
1770
  dialogue: detail?.dialogue ?? [],
1509
1771
  player: {
1510
1772
  name: npc.name,
1511
- startLocation: npc.startLocation,
1512
- 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,
1513
1778
  lifestyleChoices: [],
1514
1779
  combat: detail?.combat,
1515
1780
  },
@@ -1553,23 +1818,46 @@ export function assembleScene(skeleton, details, playerName) {
1553
1818
  // school, same as every other spot/exit validation in this file).
1554
1819
  const VALID_FOOTPRINTS = new Set(['small', 'medium', 'large']);
1555
1820
  const roomExitDirsByName = new Map(rooms.map(r => [r.name, new Set((r.exits ?? []).map(e => e.direction.toLowerCase()))]));
1556
- const puzzles = (skeleton.puzzles ?? []).map(puzzle => {
1557
- const detail = puzzleDetail.get(puzzle.name);
1558
- 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())
1559
1840
  ? detail.guardsExit.toLowerCase()
1560
1841
  : undefined;
1561
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;
1562
1851
  return {
1563
- name: puzzle.name,
1564
- room: puzzle.room,
1565
- riddle: detail?.riddle ?? puzzle.concept,
1566
- requiredPhrase: detail?.requiredPhrase ?? '',
1567
- successMessage: detail?.successMessage ?? 'It opens.',
1568
- failureMessage: detail?.failureMessage ?? 'Nothing happens.',
1569
- lockedMessage: detail?.lockedMessage,
1570
- requiresPuzzle: puzzle.requiresPuzzle,
1571
- rewardItem: puzzle.rewardItem,
1572
- 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.',
1573
1861
  guardsExit,
1574
1862
  footprint,
1575
1863
  };
@@ -1580,10 +1868,19 @@ export function assembleScene(skeleton, details, playerName) {
1580
1868
  story: skeleton.story,
1581
1869
  currency: { type: 'nuyen', authorityRoomTags: ['Bank'] },
1582
1870
  residenceTiers: ['Street', 'Squatter', 'Low', 'Middle', 'High', 'Luxury'],
1583
- 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
+ })),
1584
1878
  npcs,
1585
1879
  items,
1586
- 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: [],
1587
1884
  player: {
1588
1885
  name: playerName,
1589
1886
  startLocation: startRoom,
@@ -1619,8 +1916,8 @@ export function classifyRepair(message) {
1619
1916
  return 'items';
1620
1917
  if (/startingItems|Win condition (item|rewardItem)|items\[/i.test(message))
1621
1918
  return 'items';
1622
- if (/requiredPhrase|riddle|lockedMessage/i.test(message))
1623
- return 'puzzles';
1919
+ if (/code|description|openMessage/i.test(message))
1920
+ return 'devices';
1624
1921
  if (/npcs\[|dialogue/i.test(message))
1625
1922
  return 'npcs';
1626
1923
  if (/rooms\[\d+\][^]*name/i.test(message))