@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.
- package/bundle/typescript/package.json +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/ar.js +29 -8
- package/bundle/typescript/src/commands/game/sideQuest/commands/ar.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/ask.d.ts +42 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/ask.js +86 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/ask.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/backlog.d.ts +29 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/backlog.js +151 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/backlog.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.d.ts +40 -13
- package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js +107 -25
- package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/breach.d.ts +11 -15
- package/bundle/typescript/src/commands/game/sideQuest/commands/breach.js +13 -16
- package/bundle/typescript/src/commands/game/sideQuest/commands/breach.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/buy.d.ts +8 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/buy.js +30 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/buy.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.d.ts +40 -49
- package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.js +121 -160
- package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/call.js +29 -5
- package/bundle/typescript/src/commands/game/sideQuest/commands/call.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/cast.d.ts +7 -8
- package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +13 -13
- package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/contacts.js +22 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/contacts.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/cover.d.ts +30 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/cover.js +65 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/cover.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/dispel.d.ts +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/dispel.js +2 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/dispel.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +25 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/go.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/hack.d.ts +2 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +2 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/jobs.js +18 -10
- package/bundle/typescript/src/commands/game/sideQuest/commands/jobs.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/journal.d.ts +44 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/journal.js +136 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/journal.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/look.d.ts +20 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +157 -5
- package/bundle/typescript/src/commands/game/sideQuest/commands/look.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/map.js +18 -4
- package/bundle/typescript/src/commands/game/sideQuest/commands/map.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/move.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/pick.d.ts +2 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/pick.js +2 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/pick.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/players.js +8 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/players.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/posture.d.ts +45 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/posture.js +115 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/posture.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +46 -12
- package/bundle/typescript/src/commands/game/sideQuest/commands/search.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js +30 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/silent.js +11 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/silent.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +12 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/take.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/talk.d.ts +47 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/talk.js +91 -5
- package/bundle/typescript/src/commands/game/sideQuest/commands/talk.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/time.d.ts +20 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/time.js +61 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/time.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/unlock.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/unlock.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/use.d.ts +17 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/use.js +136 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/use.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/engine-version.d.ts +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +15 -1
- package/bundle/typescript/src/commands/game/sideQuest/engine-version.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.d.ts +128 -44
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +551 -254
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js +82 -84
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.d.ts +23 -4
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js +95 -79
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/seed-migration.d.ts +30 -0
- package/bundle/typescript/src/commands/game/sideQuest/factories/seed-migration.js +176 -0
- package/bundle/typescript/src/commands/game/sideQuest/factories/seed-migration.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/game.d.ts +104 -0
- package/bundle/typescript/src/commands/game/sideQuest/game.js +268 -16
- package/bundle/typescript/src/commands/game/sideQuest/game.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/headless-harness.d.ts +78 -0
- package/bundle/typescript/src/commands/game/sideQuest/headless-harness.js +188 -0
- package/bundle/typescript/src/commands/game/sideQuest/headless-harness.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/headless.d.ts +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/headless.js +78 -0
- package/bundle/typescript/src/commands/game/sideQuest/headless.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/_index.d.ts +0 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/_index.js +0 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/_index.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/device.d.ts +121 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/device.js +145 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/device.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/door.d.ts +27 -8
- package/bundle/typescript/src/commands/game/sideQuest/models/door.js +15 -10
- package/bundle/typescript/src/commands/game/sideQuest/models/door.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.d.ts +143 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +253 -30
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/player.d.ts +68 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/player.js +102 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/player.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/room.d.ts +80 -34
- package/bundle/typescript/src/commands/game/sideQuest/models/room.js +126 -54
- package/bundle/typescript/src/commands/game/sideQuest/models/room.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.d.ts +1 -4
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +0 -22
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/scenes/scene1.json +78 -109
- package/bundle/typescript/src/commands/game/sideQuest/scenes/scene2.json +4 -101
- package/bundle/typescript/src/commands/game/sideQuest/types/save-file.d.ts +24 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/seed/device-seed.d.ts +52 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/seed/device-seed.js +2 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/seed/device-seed.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/seed/legacy-puzzle-seed.d.ts +63 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/seed/legacy-puzzle-seed.js +2 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/seed/legacy-puzzle-seed.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/seed/player-seed.d.ts +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/seed/room-seed.d.ts +4 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/seed/scene-seed.d.ts +3 -2
- package/bundle/typescript/src/commands/game/sideQuest/types/shared/abstracts.d.ts +2 -28
- package/bundle/typescript/src/commands/game/sideQuest/types/shared/abstracts.js +1 -14
- package/bundle/typescript/src/commands/game/sideQuest/types/shared/abstracts.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/ui.js +24 -0
- package/bundle/typescript/src/commands/game/sideQuest/ui.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/affordances.d.ts +55 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/affordances.js +36 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/affordances.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/ar.d.ts +22 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/ar.js +26 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/ar.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-dump.js +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-dump.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-review.d.ts +45 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-review.js +116 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-review.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog.d.ts +73 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog.js +56 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +37 -4
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/credentials.d.ts +26 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/credentials.js +48 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/credentials.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/devices.d.ts +23 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/devices.js +55 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/devices.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/fuzzy-match.d.ts +57 -2
- package/bundle/typescript/src/commands/game/sideQuest/utilities/fuzzy-match.js +46 -3
- package/bundle/typescript/src/commands/game/sideQuest/utilities/fuzzy-match.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/log-style.d.ts +38 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/log-style.js +42 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/log-style.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +72 -17
- package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/playtester.d.ts +50 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/playtester.js +43 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/playtester.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.d.ts +14 -3
- package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.js +45 -31
- package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/room-view.js +66 -8
- package/bundle/typescript/src/commands/game/sideQuest/utilities/room-view.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.d.ts +81 -15
- package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js +142 -33
- package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/use-target.d.ts +88 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/use-target.js +113 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/use-target.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/world-clock.d.ts +83 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/world-clock.js +106 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/world-clock.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js +54 -5
- package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js.map +1 -1
- package/bundle/typescript/src/maka.js +1 -1
- package/bundle/typescript/src/maka.js.map +1 -1
- package/bundle/typescript/src/tools/sdk-loader/relocate.d.ts +19 -2
- package/bundle/typescript/src/tools/sdk-loader/relocate.js +22 -2
- package/bundle/typescript/src/tools/sdk-loader/relocate.js.map +1 -1
- package/package.json +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/solve.d.ts +0 -6
- package/bundle/typescript/src/commands/game/sideQuest/commands/solve.js +0 -72
- package/bundle/typescript/src/commands/game/sideQuest/commands/solve.js.map +0 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/puzzle-factory.d.ts +0 -6
- package/bundle/typescript/src/commands/game/sideQuest/factories/puzzle-factory.js +0 -26
- package/bundle/typescript/src/commands/game/sideQuest/factories/puzzle-factory.js.map +0 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.d.ts +0 -143
- package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.js +0 -366
- package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.js.map +0 -1
- package/bundle/typescript/src/commands/game/sideQuest/types/seed/puzzle-seed.d.ts +0 -28
- package/bundle/typescript/src/commands/game/sideQuest/types/seed/puzzle-seed.js +0 -2
- 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
|
-
*
|
|
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
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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
|
-
${
|
|
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
|
|
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
|
|
232
|
-
: `6 to 8 rooms, ${3 * heads} to ${4 * heads} NPCs besides the client, and 2 to 3
|
|
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
|
-
"
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
"
|
|
304
|
-
"name": string, "concept": string, // what
|
|
305
|
-
//
|
|
306
|
-
"room"
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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[].
|
|
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
|
-
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
|
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
|
|
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
|
|
506
|
-
if (skeleton.
|
|
520
|
+
export function buildDevicesPrompt(skeleton, previousError) {
|
|
521
|
+
if (skeleton.devices.length === 0)
|
|
507
522
|
return '';
|
|
508
523
|
return `
|
|
509
|
-
You are writing the
|
|
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
|
|
514
|
-
|
|
515
|
-
- "
|
|
516
|
-
never a cryptic riddle-poem. Describe
|
|
517
|
-
secured, and what
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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, "
|
|
536
|
-
"
|
|
537
|
-
One entry per skeleton
|
|
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,
|
|
565
|
+
export function buildItemsPrompt(skeleton, devices, previousError) {
|
|
541
566
|
const clueSpecs = skeleton.items
|
|
542
|
-
.filter(i => i.role === 'clue' && i.
|
|
567
|
+
.filter(i => i.role === 'clue' && i.forDevice)
|
|
543
568
|
.map(i => {
|
|
544
|
-
const
|
|
545
|
-
return `- "${i.name}" is the clue for
|
|
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
|
-
*
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
*
|
|
578
|
-
*
|
|
579
|
-
*
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
*
|
|
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
|
|
585
|
-
|
|
586
|
-
|
|
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
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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.
|
|
699
|
-
fail(`clue item "${item.name}" references unknown
|
|
754
|
+
if (item.forDevice && !deviceNames.has(item.forDevice))
|
|
755
|
+
fail(`clue item "${item.name}" references unknown device "${item.forDevice}".`);
|
|
700
756
|
}
|
|
701
|
-
// The
|
|
702
|
-
//
|
|
703
|
-
//
|
|
704
|
-
//
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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(`
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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,
|
|
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.
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
|
932
|
-
if (exit.
|
|
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
|
|
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
|
-
|
|
961
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
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
|
-
//
|
|
1189
|
-
//
|
|
1190
|
-
//
|
|
1191
|
-
//
|
|
1192
|
-
//
|
|
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
|
-
|
|
1197
|
-
hack: 'Service Log',
|
|
1198
|
-
spell: 'Ritual Notes',
|
|
1199
|
-
con: 'Staff Roster',
|
|
1276
|
+
panel: 'Service Log',
|
|
1200
1277
|
};
|
|
1201
|
-
for (const
|
|
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.
|
|
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.
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
1240
|
-
//
|
|
1241
|
-
|
|
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
|
|
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[
|
|
1257
|
-
let name = `${
|
|
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 = `${
|
|
1337
|
+
name = `${device.name} ${noun} ${i}`;
|
|
1260
1338
|
skeleton.items = items;
|
|
1261
|
-
skeleton.items.push({ name, role: 'clue', category: 'Book',
|
|
1339
|
+
skeleton.items.push({ name, role: 'clue', category: 'Book', forDevice: device.name, room: safe[0] });
|
|
1262
1340
|
synthesized++;
|
|
1263
|
-
notes.push(`normalized:
|
|
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
|
|
1280
|
-
if (!
|
|
1357
|
+
for (const device of skeleton.devices ?? []) {
|
|
1358
|
+
if (!device.grantsItem)
|
|
1281
1359
|
continue;
|
|
1282
|
-
const item = itemByName.get(
|
|
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:
|
|
1287
|
-
delete
|
|
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
|
|
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
|
|
1339
|
-
const phrase = (
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
1454
|
-
//
|
|
1455
|
-
//
|
|
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
|
|
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
|
-
|
|
1464
|
-
atSpot:
|
|
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
|
-
|
|
1512
|
-
|
|
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
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
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:
|
|
1564
|
-
room:
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
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
|
-
|
|
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 (/
|
|
1623
|
-
return '
|
|
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))
|