@maka/maka-cli 5.64.0 → 5.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundle/typescript/package.json +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 +146 -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/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/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 +2 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/jobs.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +58 -2
- 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/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 +35 -24
- package/bundle/typescript/src/commands/game/sideQuest/commands/search.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/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 +82 -43
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +419 -263
- 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.js +11 -5
- package/bundle/typescript/src/commands/game/sideQuest/game.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/headless.js +24 -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 +81 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +132 -21
- 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 +32 -46
- package/bundle/typescript/src/commands/game/sideQuest/models/room.js +38 -63
- 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/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 +67 -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 +16 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/log-style.js +18 -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 +68 -38
- package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.d.ts +3 -3
- package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.js +3 -3
- package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.d.ts +36 -12
- package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js +101 -27
- 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.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 -152
- package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.js +0 -393
- 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)}
|
|
@@ -511,58 +517,57 @@ ${JSON_ONLY}
|
|
|
511
517
|
One entry per skeleton npc, "name" copied exactly.${repairNote(previousError)}
|
|
512
518
|
`.trim();
|
|
513
519
|
}
|
|
514
|
-
export function
|
|
515
|
-
if (skeleton.
|
|
520
|
+
export function buildDevicesPrompt(skeleton, previousError) {
|
|
521
|
+
if (skeleton.devices.length === 0)
|
|
516
522
|
return '';
|
|
517
523
|
return `
|
|
518
|
-
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.
|
|
519
526
|
|
|
520
527
|
${skeletonContext(skeleton)}
|
|
521
528
|
|
|
522
|
-
For EVERY
|
|
523
|
-
|
|
524
|
-
- "
|
|
525
|
-
never a cryptic riddle-poem. Describe
|
|
526
|
-
secured, and what
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
spaces ("470912"). Then the barrier really does take a number.
|
|
529
|
+
For EVERY device in the skeleton, expand its "concept" (what it physically is -- that's
|
|
530
|
+
fixed, as is its "kind"):
|
|
531
|
+
- "description": what the player sees on approaching it, shown verbatim -- PLAIN and
|
|
532
|
+
concrete, never a cryptic riddle-poem. Describe it as a THING: what it is, how it's
|
|
533
|
+
secured, and what it visibly expects. Write NO people into it -- no guards, no dialogue,
|
|
534
|
+
no "they demand": anyone standing watch does their own demanding in play, in their own
|
|
535
|
+
voice. Never state the credential itself, and never write ANY OTHER number into it -- no
|
|
536
|
+
unit number, no model number, no serial, no floor number. Every code-shaped string a
|
|
537
|
+
player reads is one they will go and type.
|
|
538
|
+
- "code": ONLY for kind "panel", and REQUIRED for one. The exact credential it accepts, and
|
|
539
|
+
it MUST BE THE SAME KIND OF THING your "description" says the panel wants. This is the
|
|
540
|
+
single most confusing thing in the game when it drifts: a player who reads "a keypad with
|
|
541
|
+
worn digits" and then finds "double or nothing" written in a notebook does not believe
|
|
542
|
+
they have the code, and goes looking for a number that does not exist.
|
|
543
|
+
* If the description shows a NUMERIC pad, dial, or counter -- digits only, 3-6 of them,
|
|
544
|
+
no spaces ("470912"). Then it really does take a number.
|
|
539
545
|
* Otherwise (an intercom, a voice-gate, a spoken passphrase, a name, a sign reading
|
|
540
546
|
"password") -- ALL LOWERCASE, 2-5 plain words, no punctuation.
|
|
541
547
|
Either way a concrete literal thing, never wordplay.
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
- "guardsExit": ONLY
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
wreckage/blockage occupies. Only meaningful alongside "guardsExit".
|
|
548
|
+
OMIT "code" for a "lock" or a "ward". You cannot talk at a mechanical lock, and a ward
|
|
549
|
+
answers to the Art -- neither has anything to hear a phrase with, so a code on one is a
|
|
550
|
+
promise the game cannot keep.
|
|
551
|
+
- "openMessage" / "refuseMessage": one sentence each, in-fiction.
|
|
552
|
+
- "guardsExit": ONLY when the concept is physically TERRAIN blocking a passage -- rubble, a
|
|
553
|
+
collapsed rack, a security shutter across a walkway -- name the exact "direction" of one
|
|
554
|
+
of its own room's exits that it seals. A device on a container, safe, or item pickup
|
|
555
|
+
guards nothing physical: leave it unset.
|
|
556
|
+
- "footprint": "small" | "medium" | "large" -- how much of the room the blockage occupies.
|
|
557
|
+
Only meaningful alongside "guardsExit".
|
|
553
558
|
|
|
554
559
|
${JSON_ONLY}
|
|
555
|
-
[ { "name": string, "
|
|
556
|
-
"
|
|
557
|
-
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)}
|
|
558
563
|
`.trim();
|
|
559
564
|
}
|
|
560
|
-
export function buildItemsPrompt(skeleton,
|
|
565
|
+
export function buildItemsPrompt(skeleton, devices, previousError) {
|
|
561
566
|
const clueSpecs = skeleton.items
|
|
562
|
-
.filter(i => i.role === 'clue' && i.
|
|
567
|
+
.filter(i => i.role === 'clue' && i.forDevice)
|
|
563
568
|
.map(i => {
|
|
564
|
-
const
|
|
565
|
-
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).`;
|
|
566
571
|
})
|
|
567
572
|
.join('\n');
|
|
568
573
|
return `
|
|
@@ -599,32 +604,33 @@ One entry per skeleton item, "name" and "category" copied exactly.${repairNote(p
|
|
|
599
604
|
}
|
|
600
605
|
// ========================= Parse / validate / assemble ====================
|
|
601
606
|
/**
|
|
602
|
-
*
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
610
|
-
*
|
|
607
|
+
* Can anyone at this table unweave a WARD?
|
|
608
|
+
*
|
|
609
|
+
* The last surviving branch of what used to be canBypassUnaided, which
|
|
610
|
+
* asked the same question of all three barrier kinds. The other two are
|
|
611
|
+
* gone because their answers stopped mattering (ruling 2026-08-27):
|
|
612
|
+
*
|
|
613
|
+
* A PANEL takes a code, and a code is FINDABLE. Write it down and any
|
|
614
|
+
* runner can type it; a decker who did not need it loses nothing.
|
|
615
|
+
* A LOCK takes a key, and a key is FINDABLE. Place it and any runner
|
|
616
|
+
* can carry it to the door; picking becomes the SHORTCUT for someone
|
|
617
|
+
* with locksmith, not the requirement.
|
|
618
|
+
*
|
|
619
|
+
* So for those two the clue/key rule is UNCONDITIONAL now, and the
|
|
620
|
+
* character-dependent waiver is gone with the condition it rested on.
|
|
621
|
+
*
|
|
622
|
+
* A WARD is irreducible. There is no findable answer to a ward; it takes
|
|
623
|
+
* Magic. A mundane team cannot dispel one however much is written down,
|
|
624
|
+
* so a ward generated for a party with nobody awakened is an unopenable
|
|
625
|
+
* barrier no clue can rescue. That is where the character dependence is
|
|
626
|
+
* REAL rather than incidental, and it is the only place this survives.
|
|
627
|
+
*
|
|
628
|
+
* Named for what it now is: a one-case function still called
|
|
629
|
+
* canBypassUnaided would attract the other two cases back.
|
|
611
630
|
*/
|
|
612
|
-
export function
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
// THROUGH skillRating, NOT the raw sheet (bug found 2026-08-26): the
|
|
616
|
-
// raw read sailed past the "incompetent-<skill>" quality, which
|
|
617
|
-
// skillRating pins to 0 (player.ts) -- an Incompetent-locksmith runner
|
|
618
|
-
// was qualifying to have the lockpick clue waived on the strength of a
|
|
619
|
-
// number the rest of the game refuses to honour.
|
|
620
|
-
const rated = (skill) => player.skillRating(skill);
|
|
621
|
-
switch (bypassType) {
|
|
622
|
-
case 'lockpick': return rated('locksmith') >= 3;
|
|
623
|
-
case 'hack': return rated('hacking') >= 3 || player.isTechnomancer() || !!player.getAnyCyberdeck();
|
|
624
|
-
case 'spell': return player.isAwakened() && !player.adept;
|
|
625
|
-
case 'con': return rated('con') >= 3 || player.charisma >= 5;
|
|
626
|
-
default: return false;
|
|
627
|
-
}
|
|
631
|
+
export function partyCanDispel(player) {
|
|
632
|
+
// Adepts are awakened but cannot cast, so they cannot unweave a ward.
|
|
633
|
+
return !!player && player.isAwakened() && !player.adept;
|
|
628
634
|
}
|
|
629
635
|
/** Strips accidental markdown fences and parses; throws with a useful message. */
|
|
630
636
|
export function parseJsonReply(reply) {
|
|
@@ -696,7 +702,7 @@ export function validateSkeleton(skeleton, player, crew) {
|
|
|
696
702
|
const roomNames = new Set(skeleton.rooms.map(r => r.name));
|
|
697
703
|
const npcNames = new Set(skeleton.npcs.map(n => n.name));
|
|
698
704
|
const itemNames = new Set(skeleton.items.map(i => i.name));
|
|
699
|
-
const
|
|
705
|
+
const deviceNames = new Set((skeleton.devices ?? []).map(d => d.name));
|
|
700
706
|
const startRooms = skeleton.rooms.filter(r => r.isStartRoom);
|
|
701
707
|
if (startRooms.length !== 1)
|
|
702
708
|
fail(`exactly one room must have "isStartRoom": true (found ${startRooms.length}).`);
|
|
@@ -705,11 +711,33 @@ export function validateSkeleton(skeleton, player, crew) {
|
|
|
705
711
|
for (const exit of room.exits ?? []) {
|
|
706
712
|
if (!roomNames.has(exit.targetRoom))
|
|
707
713
|
fail(`exit from "${room.name}" targets unknown room "${exit.targetRoom}".`);
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
714
|
+
// A DOOR IS GATED BY A PERSON OR A MECHANISM, NEVER BOTH.
|
|
715
|
+
// Stated as a validator and not only in the prompt, because a
|
|
716
|
+
// prompt that can express a contradiction eventually will: two
|
|
717
|
+
// gates on one door means beating the guard leaves the door still
|
|
718
|
+
// locked, or picking the lock leaves the body still in it, and
|
|
719
|
+
// the player cannot tell which half they failed to solve.
|
|
720
|
+
if (exit.device && exit.guard) {
|
|
721
|
+
fail(`exit from "${room.name}" has BOTH a device ("${exit.device}") and a guard ("${exit.guard}") -- a door is gated by a thing or a person, never both. Drop one.`);
|
|
722
|
+
}
|
|
723
|
+
if (exit.device) {
|
|
724
|
+
if (!deviceNames.has(exit.device))
|
|
725
|
+
fail(`exit from "${room.name}" references unknown device "${exit.device}".`);
|
|
726
|
+
// THE ROOM-OWNED RULE MADE ENFORCEABLE rather than merely true.
|
|
727
|
+
// The old model made "placed in a room AND on a door" possible
|
|
728
|
+
// and validated it away; room ownership makes that
|
|
729
|
+
// unrepresentable, but makes a NEW incoherence expressible --
|
|
730
|
+
// an exit naming a device that lives somewhere else. It fails
|
|
731
|
+
// exactly like the shared-door case: the door locks, and the
|
|
732
|
+
// mechanism is in a room the player has no reason to look in.
|
|
733
|
+
const owner = (skeleton.devices ?? []).find(d => d.name === exit.device);
|
|
734
|
+
if (owner && owner.room !== room.name) {
|
|
735
|
+
fail(`exit from "${room.name}" is held by device "${exit.device}", which stands in "${owner.room}" -- a device can only hold a door of its OWN room. Move the device, or declare this exit from "${owner.room}".`);
|
|
736
|
+
}
|
|
737
|
+
doorRefs.set(exit.device, (doorRefs.get(exit.device) ?? 0) + 1);
|
|
712
738
|
}
|
|
739
|
+
if (exit.guard && !npcNames.has(exit.guard))
|
|
740
|
+
fail(`exit from "${room.name}" is guarded by unknown npc "${exit.guard}".`);
|
|
713
741
|
if (exit.keyRequired && !itemNames.has(exit.keyRequired))
|
|
714
742
|
fail(`exit from "${room.name}" requires unknown key item "${exit.keyRequired}".`);
|
|
715
743
|
}
|
|
@@ -723,40 +751,72 @@ export function validateSkeleton(skeleton, player, crew) {
|
|
|
723
751
|
fail(`item "${item.name}" placed in unknown room "${item.room}".`);
|
|
724
752
|
if (item.heldBy && !npcNames.has(item.heldBy))
|
|
725
753
|
fail(`item "${item.name}" heldBy unknown npc "${item.heldBy}".`);
|
|
726
|
-
if (item.
|
|
727
|
-
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}".`);
|
|
728
756
|
}
|
|
729
|
-
// The
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
757
|
+
// The structural faults real generations actually shipped -- a device
|
|
758
|
+
// with no way through at all (unfixable by ANY items pass) and
|
|
759
|
+
// unreachable rooms -- must die HERE, before four detail passes build
|
|
760
|
+
// on bad bones.
|
|
761
|
+
//
|
|
762
|
+
// WHAT LEFT WITH THE PUZZLE MECHANIC: the unplaced-or-double-placed
|
|
763
|
+
// check. A device names its own room and always has one, so "placed
|
|
764
|
+
// both in a room and on a door" is now unrepresentable rather than
|
|
765
|
+
// merely invalid -- the room-owned rule doing the work two validators
|
|
766
|
+
// in two files used to do in near-identical prose.
|
|
767
|
+
for (const device of skeleton.devices ?? []) {
|
|
768
|
+
if (!device.room)
|
|
769
|
+
fail(`device "${device.name}" has no "room" -- every device stands in one.`);
|
|
770
|
+
else if (!roomNames.has(device.room))
|
|
771
|
+
fail(`device "${device.name}" placed in unknown room "${device.room}".`);
|
|
772
|
+
if (device.grantsItem && !itemNames.has(device.grantsItem))
|
|
773
|
+
fail(`device "${device.name}" grants unknown item "${device.grantsItem}".`);
|
|
774
|
+
const refs = doorRefs.get(device.name) ?? 0;
|
|
743
775
|
if (refs > 1)
|
|
744
|
-
fail(`
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
776
|
+
fail(`device "${device.name}" is referenced by ${refs} doors -- a device can only hold one.`);
|
|
777
|
+
// EVERY WAY THROUGH IS FINDABLE, and it no longer depends on who is
|
|
778
|
+
// playing. A code is findable and a key is placeable, so both are
|
|
779
|
+
// openable by ANY runner; the skilled route (pick, hack) is the
|
|
780
|
+
// SHORTCUT, not the requirement. That kills the character-dependent
|
|
781
|
+
// waiver which was the single most common generation failure on
|
|
782
|
+
// record -- and which looked like random flakiness precisely
|
|
783
|
+
// because it varied with the character.
|
|
784
|
+
const kind = device.kind ?? 'panel';
|
|
785
|
+
if (kind === 'panel') {
|
|
786
|
+
const clue = skeleton.items.find(i => i.role === 'clue' && i.forDevice === device.name);
|
|
787
|
+
if (!clue) {
|
|
788
|
+
fail(`panel "${device.name}" has no "clue"-role item with "forDevice": "${device.name}" -- every panel's code must be written down somewhere findable, so a runner who cannot hack can still open it.`);
|
|
789
|
+
}
|
|
790
|
+
else if (!clue.room) {
|
|
791
|
+
fail(`clue item "${clue.name}" needs a "room".`);
|
|
792
|
+
}
|
|
793
|
+
else if (refs === 0 && clue.room === device.room) {
|
|
794
|
+
// THE RULE IS REACHABILITY, NOT DISTANCE -- and the distinction
|
|
795
|
+
// only appeared once devices became room-owned. A door device
|
|
796
|
+
// stands in the room you approach the door FROM, so its own
|
|
797
|
+
// room is on the safe side and is a perfectly good place for
|
|
798
|
+
// the clue; excluding it would leave a device on the start
|
|
799
|
+
// room's only exit with nowhere legal at all. For a device
|
|
800
|
+
// that holds no door (a safe), the same room is merely
|
|
801
|
+
// uninteresting, so keep the old separation there.
|
|
802
|
+
fail(`clue item "${clue.name}" sits in the SAME room as its device "${device.name}", which guards no door -- place the clue in a different room.`);
|
|
753
803
|
}
|
|
754
804
|
}
|
|
755
|
-
else {
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
805
|
+
else if (kind === 'lock') {
|
|
806
|
+
// A lock's findable way through is its KEY. Either the door it
|
|
807
|
+
// holds names one, or a key-role item exists for it.
|
|
808
|
+
const holdsDoor = refs > 0;
|
|
809
|
+
const keyed = skeleton.rooms.some(r => (r.exits ?? []).some(e => e.device === device.name && e.keyRequired));
|
|
810
|
+
const looseKey = skeleton.items.some(i => i.role === 'key');
|
|
811
|
+
if (holdsDoor && !keyed && !looseKey) {
|
|
812
|
+
fail(`lock "${device.name}" holds a door with no "keyRequired" and the scene has no "key"-role item -- a runner without locksmith has no way through it. Add a key.`);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
// A WARD is the exception no clue can rescue: there is no findable
|
|
816
|
+
// answer to one, so it is gated on the party rather than on an
|
|
817
|
+
// item. See partyCanDispel.
|
|
818
|
+
if (kind === 'ward' && !partyCanDispel(player)) {
|
|
819
|
+
fail(`device "${device.name}" is a ward, and nobody at this table can dispel -- there is no findable answer to a ward, so it would be unopenable. Make it a "lock" or a "panel".`);
|
|
760
820
|
}
|
|
761
821
|
}
|
|
762
822
|
if (!itemNames.has(skeleton.winCondition.item))
|
|
@@ -783,7 +843,7 @@ export function validateSkeleton(skeleton, player, crew) {
|
|
|
783
843
|
const edges = [];
|
|
784
844
|
for (const room of skeleton.rooms) {
|
|
785
845
|
for (const exit of room.exits ?? []) {
|
|
786
|
-
edges.push({ a: room.name, b: exit.targetRoom, keyRequired: exit.keyRequired,
|
|
846
|
+
edges.push({ a: room.name, b: exit.targetRoom, keyRequired: exit.keyRequired, device: exit.device });
|
|
787
847
|
}
|
|
788
848
|
}
|
|
789
849
|
const reachable = new Set([startRooms[0].name]);
|
|
@@ -796,7 +856,7 @@ export function validateSkeleton(skeleton, player, crew) {
|
|
|
796
856
|
if (!from || reachable.has(to))
|
|
797
857
|
continue;
|
|
798
858
|
const keyLoc = edge.keyRequired ? itemRoom.get(edge.keyRequired) : undefined;
|
|
799
|
-
const passable = !!edge.
|
|
859
|
+
const passable = !!edge.device || !edge.keyRequired || (keyLoc !== undefined && reachable.has(keyLoc));
|
|
800
860
|
if (passable) {
|
|
801
861
|
reachable.add(to);
|
|
802
862
|
grew = true;
|
|
@@ -857,7 +917,7 @@ function freeStrandedRooms(skeleton) {
|
|
|
857
917
|
const edges = [];
|
|
858
918
|
for (const room of skeleton.rooms ?? []) {
|
|
859
919
|
for (const exit of room.exits ?? []) {
|
|
860
|
-
edges.push({ a: room.name, b: exit.targetRoom, keyRequired: exit.keyRequired,
|
|
920
|
+
edges.push({ a: room.name, b: exit.targetRoom, keyRequired: exit.keyRequired, device: exit.device });
|
|
861
921
|
}
|
|
862
922
|
}
|
|
863
923
|
// Same walk the validator runs, so a repair here is judged by exactly
|
|
@@ -875,7 +935,7 @@ function freeStrandedRooms(skeleton) {
|
|
|
875
935
|
if (reachable.has(to))
|
|
876
936
|
continue;
|
|
877
937
|
const keyLoc = edge.keyRequired ? locs.get(edge.keyRequired) : undefined;
|
|
878
|
-
if (!!edge.
|
|
938
|
+
if (!!edge.device || !edge.keyRequired || (keyLoc !== undefined && reachable.has(keyLoc))) {
|
|
879
939
|
reachable.add(to);
|
|
880
940
|
grew = true;
|
|
881
941
|
}
|
|
@@ -895,7 +955,7 @@ function freeStrandedRooms(skeleton) {
|
|
|
895
955
|
// ground, held shut by a key nobody can get to.
|
|
896
956
|
const gate = edges.find(e => {
|
|
897
957
|
const touchesBoth = (reachable.has(e.a) && strandedNames.has(e.b)) || (reachable.has(e.b) && strandedNames.has(e.a));
|
|
898
|
-
if (!touchesBoth || e.
|
|
958
|
+
if (!touchesBoth || e.device || !e.keyRequired)
|
|
899
959
|
return false;
|
|
900
960
|
const keyLoc = itemRoom().get(e.keyRequired);
|
|
901
961
|
return keyLoc === undefined || !reachable.has(keyLoc);
|
|
@@ -916,7 +976,7 @@ function freeStrandedRooms(skeleton) {
|
|
|
916
976
|
const STAFFED_ROOM_TYPES = /^(market|shop|store|bar|pub|tavern|restaurant|cafe|clinic|pawn|stall|vendor|kiosk|dispensary)/i;
|
|
917
977
|
/**
|
|
918
978
|
* WHERE A CLUE MAY LIVE. The validator's reachability walk treats every
|
|
919
|
-
* puzzle door as PASSABLE (`!!edge.
|
|
979
|
+
* puzzle door as PASSABLE (`!!edge.device ||` in validateSkeleton), which
|
|
920
980
|
* is right for "can the runner eventually get there" and dead wrong for
|
|
921
981
|
* "can the runner read this BEFORE facing the barrier". So a clue placed
|
|
922
982
|
* behind the very door its puzzle guards validates clean and softlocks in
|
|
@@ -933,7 +993,7 @@ const STAFFED_ROOM_TYPES = /^(market|shop|store|bar|pub|tavern|restaurant|cafe|c
|
|
|
933
993
|
*
|
|
934
994
|
* Returns [] when nowhere is safe -- callers MUST bail, never guess.
|
|
935
995
|
*/
|
|
936
|
-
function safeRoomsFor(skeleton,
|
|
996
|
+
function safeRoomsFor(skeleton, device) {
|
|
937
997
|
const start = (skeleton.rooms ?? []).find(r => r.isStartRoom);
|
|
938
998
|
if (!start)
|
|
939
999
|
return [];
|
|
@@ -956,8 +1016,8 @@ function safeRoomsFor(skeleton, puzzle) {
|
|
|
956
1016
|
grew = false;
|
|
957
1017
|
for (const room of skeleton.rooms ?? []) {
|
|
958
1018
|
for (const exit of room.exits ?? []) {
|
|
959
|
-
// A barrier is a wall until it is
|
|
960
|
-
if (exit.
|
|
1019
|
+
// A barrier is a wall until it is opened. That is the whole point.
|
|
1020
|
+
if (exit.device)
|
|
961
1021
|
continue;
|
|
962
1022
|
const a = room.name, b = exit.targetRoom;
|
|
963
1023
|
if (isolated.has(a) || isolated.has(b))
|
|
@@ -982,14 +1042,21 @@ function safeRoomsFor(skeleton, puzzle) {
|
|
|
982
1042
|
// its room: the puzzles pass can later emit `guardsExit` sealing one of
|
|
983
1043
|
// that room's doors, which is unknowable here, so be conservative. Fall
|
|
984
1044
|
// back to merely excluding the room itself when that leaves nothing.
|
|
985
|
-
// A door
|
|
1045
|
+
// A door device needs no extra exclusion -- cutting its edge already
|
|
986
1046
|
// leaves exactly the near-side component, and the near-side room is a
|
|
987
1047
|
// fine place to find the clue.
|
|
988
|
-
|
|
989
|
-
|
|
1048
|
+
const holdsDoor = (skeleton.rooms ?? []).some(r => (r.exits ?? []).some(e => e.device === device.name));
|
|
1049
|
+
let reached = walk(device.room);
|
|
1050
|
+
if (device.room && reached.size <= 1)
|
|
990
1051
|
reached = walk();
|
|
991
1052
|
const candidates = (skeleton.rooms ?? [])
|
|
992
|
-
|
|
1053
|
+
// A DOOR device's own room is on the SAFE side of the door it
|
|
1054
|
+
// holds, so it is a legal home for the clue -- excluding it would
|
|
1055
|
+
// leave a device on the start room's only exit with nowhere at all.
|
|
1056
|
+
// A device guarding nothing keeps the old separation: its own room
|
|
1057
|
+
// is reachable but uninteresting.
|
|
1058
|
+
.filter(r => reached.has(r.name) && !isolated.has(r.name)
|
|
1059
|
+
&& (holdsDoor || r.name !== device.room));
|
|
993
1060
|
if (candidates.length === 0)
|
|
994
1061
|
return [];
|
|
995
1062
|
// Rank: earned over free (not the curb), unowned over owned, deeper
|
|
@@ -1003,7 +1070,7 @@ function safeRoomsFor(skeleton, puzzle) {
|
|
|
1003
1070
|
const cur = bfs.shift();
|
|
1004
1071
|
for (const room of skeleton.rooms ?? []) {
|
|
1005
1072
|
for (const exit of room.exits ?? []) {
|
|
1006
|
-
if (exit.
|
|
1073
|
+
if (exit.device)
|
|
1007
1074
|
continue;
|
|
1008
1075
|
const pair = [room.name, exit.targetRoom];
|
|
1009
1076
|
if (!pair.includes(cur))
|
|
@@ -1098,7 +1165,14 @@ function resolveDirectionCollisions(skeleton) {
|
|
|
1098
1165
|
}
|
|
1099
1166
|
return notes;
|
|
1100
1167
|
}
|
|
1101
|
-
|
|
1168
|
+
/**
|
|
1169
|
+
* `player` is GONE from this signature, not underscored. Its only
|
|
1170
|
+
* consumer was the character-dependent clue waiver, and with the
|
|
1171
|
+
* clue/key rule unconditional there is nothing here that varies with
|
|
1172
|
+
* who is playing. A parameter kept "just in case" is an invitation to
|
|
1173
|
+
* make normalization character-dependent again.
|
|
1174
|
+
*/
|
|
1175
|
+
export function normalizeSkeleton(skeleton) {
|
|
1102
1176
|
const notes = [];
|
|
1103
1177
|
// Topology before anything that reasons about topology: freeStranded-
|
|
1104
1178
|
// Rooms, clue placement and the curb all walk this graph, and they
|
|
@@ -1182,80 +1256,62 @@ export function normalizeSkeleton(skeleton, player) {
|
|
|
1182
1256
|
notes.push(`normalized: start room "${start.name}" is indoors but every direction on it is taken -- could not inject a curb.`);
|
|
1183
1257
|
}
|
|
1184
1258
|
}
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
doorRefs.set(exit.puzzle, (doorRefs.get(exit.puzzle) ?? 0) + 1);
|
|
1190
|
-
}
|
|
1191
|
-
}
|
|
1192
|
-
for (const puzzle of skeleton.puzzles ?? []) {
|
|
1193
|
-
if (puzzle.room && (doorRefs.get(puzzle.name) ?? 0) === 1) {
|
|
1194
|
-
notes.push(`normalized: puzzle "${puzzle.name}" was double-placed (room + door); kept the door placement.`);
|
|
1195
|
-
delete puzzle.room;
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1198
|
-
// THE CLUE ITEM -- half of every generation failure on record.
|
|
1199
|
-
//
|
|
1200
|
-
// Measured 2026-08-26 across 20 sessions: 19 job attempts, 3 valid
|
|
1201
|
-
// scenes, 12 outright give-ups. The dominant fault, at both the
|
|
1202
|
-
// skeleton and the assembled stage, is a puzzle whose clue item is
|
|
1203
|
-
// missing, roomless, or sitting in its own puzzle's room. It is
|
|
1204
|
-
// character-dependent, which is why it looked like flakiness: a Face
|
|
1205
|
-
// (con 5, no locksmith/hacking, magic 0) satisfies canBypassUnaided
|
|
1206
|
-
// for exactly ONE bypassType, so every lockpick/hack/spell barrier
|
|
1207
|
-
// demands a clue -- and the model keeps forgetting to write one.
|
|
1208
|
-
//
|
|
1209
|
-
// Repaired here rather than by retrying, because retries reproduce it
|
|
1210
|
-
// verbatim: the skeleton prompt feeds back the ERROR but not the map
|
|
1211
|
-
// the model just drew, so attempt 2 redraws from scratch and forgets
|
|
1259
|
+
// NO DOUBLE-PLACEMENT REPAIR ANY MORE. A device always names its own
|
|
1260
|
+
// room, so "placed in a room AND on a door" is unrepresentable rather
|
|
1261
|
+
// than something to reconcile -- the room-owned rule retiring a
|
|
1262
|
+
// repair as well as a validator.
|
|
1212
1263
|
// again. Runs BEFORE reward reconciliation on purpose -- that step
|
|
1213
1264
|
// treats role "clue" as load-bearing, so anything promoted here is
|
|
1214
1265
|
// protected from being converted into an unplaced reward.
|
|
1215
1266
|
//
|
|
1216
|
-
//
|
|
1217
|
-
//
|
|
1218
|
-
//
|
|
1219
|
-
//
|
|
1220
|
-
//
|
|
1267
|
+
// PANELS ONLY. A lock's findable way through is its key, not a
|
|
1268
|
+
// written code, and a ward has no findable answer at all (it is
|
|
1269
|
+
// refused outright at validation when nobody can dispel). Synthesis
|
|
1270
|
+
// is unconditional for panels now -- there is no waiver left to
|
|
1271
|
+
// second-guess, which is what made the old version's log lines need
|
|
1272
|
+
// to explain which kind of failure they were.
|
|
1221
1273
|
const SYNTHESIZED_CLUE_CAP = 2;
|
|
1222
1274
|
let synthesized = 0;
|
|
1223
1275
|
const CLUE_NOUN = {
|
|
1224
|
-
|
|
1225
|
-
hack: 'Service Log',
|
|
1226
|
-
spell: 'Ritual Notes',
|
|
1227
|
-
con: 'Staff Roster',
|
|
1276
|
+
panel: 'Service Log',
|
|
1228
1277
|
};
|
|
1229
|
-
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;
|
|
1230
1282
|
const items = skeleton.items ?? [];
|
|
1231
|
-
let clue = items.find(i => i.role === 'clue' && i.
|
|
1283
|
+
let clue = items.find(i => i.role === 'clue' && i.forDevice === device.name);
|
|
1232
1284
|
// Reclassify before inventing: the model often authored the object
|
|
1233
1285
|
// and only mislabelled it. Cheaper than synthesis and it keeps the
|
|
1234
1286
|
// model's own prose intent.
|
|
1235
1287
|
if (!clue) {
|
|
1236
|
-
const mislabelled = items.find(i => i.
|
|
1288
|
+
const mislabelled = items.find(i => i.forDevice === device.name && i.role !== 'clue');
|
|
1237
1289
|
if (mislabelled) {
|
|
1238
|
-
notes.push(`normalized: item "${mislabelled.name}" was a ${mislabelled.role} pointing at
|
|
1290
|
+
notes.push(`normalized: item "${mislabelled.name}" was a ${mislabelled.role} pointing at device "${device.name}"; promoted it to that device's clue.`);
|
|
1239
1291
|
mislabelled.role = 'clue';
|
|
1240
1292
|
mislabelled.category = 'Book';
|
|
1241
1293
|
clue = mislabelled;
|
|
1242
1294
|
}
|
|
1243
1295
|
}
|
|
1244
|
-
const safe = safeRoomsFor(skeleton,
|
|
1296
|
+
const safe = safeRoomsFor(skeleton, device);
|
|
1245
1297
|
if (clue) {
|
|
1246
1298
|
// A clue in its own puzzle's room, or with no room at all, is the
|
|
1247
1299
|
// same repair: put it somewhere legal and safe.
|
|
1248
|
-
|
|
1300
|
+
// Same rule as the validator: a DOOR device's own room is on the
|
|
1301
|
+
// safe side, so a clue there is fine. Only a device guarding
|
|
1302
|
+
// nothing wants its clue moved out of its own room.
|
|
1303
|
+
const holdsDoor = (skeleton.rooms ?? []).some(r => (r.exits ?? []).some(e => e.device === device.name));
|
|
1304
|
+
const needsRoom = !clue.room || (!holdsDoor && clue.room === device.room);
|
|
1249
1305
|
if (needsRoom) {
|
|
1250
1306
|
// A holder standing in a safe room is the better answer than a
|
|
1251
1307
|
// book on the floor -- but only if the validators can see it.
|
|
1252
1308
|
const holderRoom = clue.heldBy ? (skeleton.npcs ?? []).find(n => n.name === clue.heldBy)?.startLocation : undefined;
|
|
1253
1309
|
const target = (holderRoom && safe.includes(holderRoom)) ? holderRoom : safe[0];
|
|
1254
1310
|
if (!target) {
|
|
1255
|
-
notes.push(`could not relocate clue "${clue.name}": no room is reachable without passing
|
|
1311
|
+
notes.push(`could not relocate clue "${clue.name}": no room is reachable without passing device "${device.name}".`);
|
|
1256
1312
|
}
|
|
1257
1313
|
else {
|
|
1258
|
-
const was = clue.room ? `sat in its own
|
|
1314
|
+
const was = clue.room ? `sat in its own device's room "${clue.room}"` : 'had no room';
|
|
1259
1315
|
clue.room = target;
|
|
1260
1316
|
delete clue.heldBy;
|
|
1261
1317
|
notes.push(`normalized: clue "${clue.name}" ${was}; moved it to "${target}" (reachable without passing the barrier).`);
|
|
@@ -1264,31 +1320,25 @@ export function normalizeSkeleton(skeleton, player) {
|
|
|
1264
1320
|
continue;
|
|
1265
1321
|
}
|
|
1266
1322
|
if (safe.length === 0) {
|
|
1267
|
-
//
|
|
1268
|
-
//
|
|
1269
|
-
|
|
1270
|
-
// answer, which is the gamble-only case getBypassHint over-promises
|
|
1271
|
-
// on. If they can't force it, the draft is dead and the validator
|
|
1272
|
-
// will say so. Two very different log lines; say which one it is.
|
|
1273
|
-
notes.push(canBypassUnaided(player, puzzle.bypassType)
|
|
1274
|
-
? `could not synthesize a clue for puzzle "${puzzle.name}": nowhere is reachable without passing it -- it ships on the runner's own ${puzzle.bypassType}, with no written answer anywhere.`
|
|
1275
|
-
: `could not synthesize a clue for puzzle "${puzzle.name}": nowhere is reachable without passing it.`);
|
|
1323
|
+
// One log line now, not two: with the waiver gone the draft is
|
|
1324
|
+
// simply dead here, and validateSkeleton will say so.
|
|
1325
|
+
notes.push(`could not synthesize a clue for panel "${device.name}": nowhere is reachable without passing it.`);
|
|
1276
1326
|
continue;
|
|
1277
1327
|
}
|
|
1278
1328
|
if (synthesized >= SYNTHESIZED_CLUE_CAP) {
|
|
1279
|
-
notes.push(`declined to synthesize a clue for
|
|
1329
|
+
notes.push(`declined to synthesize a clue for panel "${device.name}": already invented ${SYNTHESIZED_CLUE_CAP} this skeleton -- a fresh draft is the better bet.`);
|
|
1280
1330
|
continue;
|
|
1281
1331
|
}
|
|
1282
1332
|
// The NAME is the entire brief the items prose pass receives (an
|
|
1283
1333
|
// ISkeletonItem has no concept field), so it has to imply the object.
|
|
1284
|
-
const noun = CLUE_NOUN[
|
|
1285
|
-
let name = `${
|
|
1334
|
+
const noun = CLUE_NOUN[device.kind ?? 'panel'] ?? 'Notes';
|
|
1335
|
+
let name = `${device.name} ${noun}`;
|
|
1286
1336
|
for (let i = 2; items.some(it => it.name === name); i++)
|
|
1287
|
-
name = `${
|
|
1337
|
+
name = `${device.name} ${noun} ${i}`;
|
|
1288
1338
|
skeleton.items = items;
|
|
1289
|
-
skeleton.items.push({ name, role: 'clue', category: 'Book',
|
|
1339
|
+
skeleton.items.push({ name, role: 'clue', category: 'Book', forDevice: device.name, room: safe[0] });
|
|
1290
1340
|
synthesized++;
|
|
1291
|
-
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).`);
|
|
1292
1342
|
}
|
|
1293
1343
|
// REWARD-REFERENCE reconciliation. A rewardItem (a puzzle's, or the
|
|
1294
1344
|
// winCondition's) must point at an UNPLACED role-"reward" item, but
|
|
@@ -1304,18 +1354,18 @@ export function normalizeSkeleton(skeleton, player) {
|
|
|
1304
1354
|
// reward reference is dropped instead (door puzzles don't need one).
|
|
1305
1355
|
const itemByName = new Map(skeleton.items.map(i => [i.name, i]));
|
|
1306
1356
|
const loadBearing = new Set(['clue', 'key', 'winCondition', 'starting']);
|
|
1307
|
-
for (const
|
|
1308
|
-
if (!
|
|
1357
|
+
for (const device of skeleton.devices ?? []) {
|
|
1358
|
+
if (!device.grantsItem)
|
|
1309
1359
|
continue;
|
|
1310
|
-
const item = itemByName.get(
|
|
1360
|
+
const item = itemByName.get(device.grantsItem);
|
|
1311
1361
|
if (!item || item.role === 'reward')
|
|
1312
1362
|
continue;
|
|
1313
1363
|
if (loadBearing.has(item.role) || item.name === skeleton.winCondition?.item) {
|
|
1314
|
-
notes.push(`normalized:
|
|
1315
|
-
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;
|
|
1316
1366
|
}
|
|
1317
1367
|
else {
|
|
1318
|
-
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.`);
|
|
1319
1369
|
item.role = 'reward';
|
|
1320
1370
|
delete item.room;
|
|
1321
1371
|
delete item.heldBy;
|
|
@@ -1363,11 +1413,11 @@ export function normalizeSkeleton(skeleton, player) {
|
|
|
1363
1413
|
export function ensureClueDetails(skeleton, details) {
|
|
1364
1414
|
const notes = [];
|
|
1365
1415
|
const byName = new Map((details.items ?? []).map(i => [i.name, i]));
|
|
1366
|
-
for (const
|
|
1367
|
-
const phrase = (
|
|
1416
|
+
for (const deviceDetail of details.devices ?? []) {
|
|
1417
|
+
const phrase = (deviceDetail.code ?? '').trim();
|
|
1368
1418
|
if (!phrase)
|
|
1369
1419
|
continue;
|
|
1370
|
-
const clues = (skeleton.items ?? []).filter(i => i.role === 'clue' && i.
|
|
1420
|
+
const clues = (skeleton.items ?? []).filter(i => i.role === 'clue' && i.forDevice === deviceDetail.name);
|
|
1371
1421
|
if (clues.length === 0)
|
|
1372
1422
|
continue;
|
|
1373
1423
|
// An item literally NAMED the phrase satisfies the validator too, and
|
|
@@ -1407,7 +1457,7 @@ export function ensureClueDetails(skeleton, details) {
|
|
|
1407
1457
|
}
|
|
1408
1458
|
const body = (detail.details ?? '').trim();
|
|
1409
1459
|
detail.details = `${body}${body ? ' ' : ''}Scrawled in the margin, in a different hand: "${phrase}".`;
|
|
1410
|
-
notes.push(`normalized: clue "${target.name}" didn't carry
|
|
1460
|
+
notes.push(`normalized: clue "${target.name}" didn't carry device "${deviceDetail.name}"'s code; wrote it into the margin.`);
|
|
1411
1461
|
}
|
|
1412
1462
|
return notes;
|
|
1413
1463
|
}
|
|
@@ -1486,9 +1536,9 @@ const SPOKEN_DEVICE = /\b(intercom|voice[- ]gate|voice panel|passphrase|password
|
|
|
1486
1536
|
*/
|
|
1487
1537
|
export function ensureCredentialKindAgrees(details) {
|
|
1488
1538
|
const notes = [];
|
|
1489
|
-
for (const
|
|
1490
|
-
const phrase = (
|
|
1491
|
-
const riddle =
|
|
1539
|
+
for (const device of details.devices ?? []) {
|
|
1540
|
+
const phrase = (device.code ?? '').trim();
|
|
1541
|
+
const riddle = device.description ?? '';
|
|
1492
1542
|
if (phrase.length === 0 || riddle.length === 0)
|
|
1493
1543
|
continue;
|
|
1494
1544
|
const answerIsNumeric = NUMERIC_ANSWER.test(phrase);
|
|
@@ -1497,13 +1547,13 @@ export function ensureCredentialKindAgrees(details) {
|
|
|
1497
1547
|
if (!answerIsNumeric && promisesNumber && !promisesWords) {
|
|
1498
1548
|
// The expensive direction: the player hunts for digits that were
|
|
1499
1549
|
// never generated, while the words sit unrecognised in their pack.
|
|
1500
|
-
|
|
1501
|
-
notes.push(`
|
|
1550
|
+
device.description = `${riddle.trimEnd()} It takes a spoken passphrase, not a number.`;
|
|
1551
|
+
notes.push(`device "${device.name}": description promised a number, code is a phrase -- said so plainly.`);
|
|
1502
1552
|
continue;
|
|
1503
1553
|
}
|
|
1504
1554
|
if (answerIsNumeric && promisesWords && !promisesNumber) {
|
|
1505
|
-
|
|
1506
|
-
notes.push(`
|
|
1555
|
+
device.description = `${riddle.trimEnd()} It takes a numeric code, not a spoken phrase.`;
|
|
1556
|
+
notes.push(`device "${device.name}": description promised words, code is a number -- said so plainly.`);
|
|
1507
1557
|
}
|
|
1508
1558
|
}
|
|
1509
1559
|
return notes;
|
|
@@ -1514,7 +1564,7 @@ export function assembleScene(skeleton, details, playerName) {
|
|
|
1514
1564
|
ensureCredentialKindAgrees(details);
|
|
1515
1565
|
const roomDetail = new Map(details.rooms.map(r => [r.name, r]));
|
|
1516
1566
|
const npcDetail = new Map(details.npcs.map(n => [n.name, n]));
|
|
1517
|
-
const
|
|
1567
|
+
const deviceDetail = new Map(details.devices.map(d => [d.name, d]));
|
|
1518
1568
|
const itemDetail = new Map(details.items.map(i => [i.name, i]));
|
|
1519
1569
|
// "At" spots from the rooms pass, normalized before they touch the
|
|
1520
1570
|
// seed: valid cover tags only, at most 5 spots, and a spotted room
|
|
@@ -1601,7 +1651,7 @@ export function assembleScene(skeleton, details, playerName) {
|
|
|
1601
1651
|
direction: e.direction,
|
|
1602
1652
|
targetRoom: e.targetRoom,
|
|
1603
1653
|
keyRequired: e.keyRequired,
|
|
1604
|
-
|
|
1654
|
+
device: e.device,
|
|
1605
1655
|
atSpot: raw ? resolveSpotName(raw, spots.map(s => s.name)) : undefined,
|
|
1606
1656
|
};
|
|
1607
1657
|
}),
|
|
@@ -1636,6 +1686,77 @@ export function assembleScene(skeleton, details, playerName) {
|
|
|
1636
1686
|
}
|
|
1637
1687
|
}
|
|
1638
1688
|
}
|
|
1689
|
+
// A GUARDED DOORWAY ALWAYS GETS A SPOT -- the one exception to the
|
|
1690
|
+
// "unbound stays unbound" rule above, and it earns it.
|
|
1691
|
+
//
|
|
1692
|
+
// That rule is right in general: the runtime pairs a doorway to an
|
|
1693
|
+
// exit with better information than generation has. But a GUARD is
|
|
1694
|
+
// not paired by the runtime at all -- spots.ts holdsGround finds a
|
|
1695
|
+
// blocker by reading the exit's own cell and its approach, so a guard
|
|
1696
|
+
// whose exit has no spot is `guarding: true` with NO EFFECT. The
|
|
1697
|
+
// player walks straight past a person the fiction says stops them.
|
|
1698
|
+
//
|
|
1699
|
+
// Warn-and-continue was the first instinct and it is not enough: it
|
|
1700
|
+
// leaves a silent, player-visible wrong. Failing the draft is the
|
|
1701
|
+
// other extreme, and it is the expensive-retry path ensureClueDetails
|
|
1702
|
+
// exists to escape -- three regenerations of a 6,000-token chunk to
|
|
1703
|
+
// not write one word down. So REPAIR it, deterministically, the way
|
|
1704
|
+
// ensureClueDetails writes a missing phrase into its clue.
|
|
1705
|
+
//
|
|
1706
|
+
// Reuse before invention: a spotted room already carries a doorway
|
|
1707
|
+
// spot (normalizeSpots injects one), so nearly always this just binds
|
|
1708
|
+
// the exit to the door that is already there.
|
|
1709
|
+
for (const room of rooms) {
|
|
1710
|
+
const skel = skeleton.rooms.find(r => r.name === room.name);
|
|
1711
|
+
for (const e of (room.exits ?? [])) {
|
|
1712
|
+
const guarded = (skel?.exits ?? []).find(se => se.direction === e.direction)?.guard;
|
|
1713
|
+
if (!guarded || e.atSpot)
|
|
1714
|
+
continue;
|
|
1715
|
+
const spots = (room.spots ?? []);
|
|
1716
|
+
const doorish = spots.find(sp => /door|entrance|entry|threshold|gate/i.test(sp.name));
|
|
1717
|
+
if (doorish) {
|
|
1718
|
+
e.atSpot = doorish.name;
|
|
1719
|
+
}
|
|
1720
|
+
else {
|
|
1721
|
+
const invented = `the ${e.direction} doorway`;
|
|
1722
|
+
room.spots = [...spots, { name: invented }];
|
|
1723
|
+
e.atSpot = invented;
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
// A GUARD'S POST IS DERIVED FROM WHAT THEY GUARD.
|
|
1728
|
+
//
|
|
1729
|
+
// exits[].guard names the person; the exit already knows its atSpot.
|
|
1730
|
+
// Taking the spot from the exit rather than asking the model to also
|
|
1731
|
+
// station them correctly means the fiction and the geometry cannot
|
|
1732
|
+
// disagree about where the guard is standing -- and standing IN the
|
|
1733
|
+
// doorway is exactly what makes them unpassable (spots.ts
|
|
1734
|
+
// holdsGround reads the exit cell and its approach).
|
|
1735
|
+
const guardPost = new Map();
|
|
1736
|
+
for (const room of rooms) {
|
|
1737
|
+
for (const e of (room.exits ?? [])) {
|
|
1738
|
+
const named = (skeleton.rooms.find(r => r.name === room.name)?.exits ?? [])
|
|
1739
|
+
.find(se => se.direction === e.direction)?.guard;
|
|
1740
|
+
if (!named)
|
|
1741
|
+
continue;
|
|
1742
|
+
// Should be unreachable: the repair above gives every guarded
|
|
1743
|
+
// exit a spot. Kept as a tripwire rather than deleted -- if it
|
|
1744
|
+
// ever fires, a guard is standing somewhere that cannot hold a
|
|
1745
|
+
// door, and that is worth a line in the log rather than silence.
|
|
1746
|
+
if (!e.atSpot) {
|
|
1747
|
+
// GUARDED, because assembleScene must not start REQUIRING a
|
|
1748
|
+
// session just to warn about something. Logger.getInstance()
|
|
1749
|
+
// throws when none has been initialised, and this function was
|
|
1750
|
+
// callable without one before -- a diagnostic that can break
|
|
1751
|
+
// its caller is worse than no diagnostic.
|
|
1752
|
+
try {
|
|
1753
|
+
Logger.getInstance().write(`assembleScene: "${named}" guards ${room.name}'s ${e.direction} exit, but that exit has no atSpot -- they will not physically hold the doorway.`);
|
|
1754
|
+
}
|
|
1755
|
+
catch { /* no session: the note is a nicety, not a contract */ }
|
|
1756
|
+
}
|
|
1757
|
+
guardPost.set(named, { room: room.name, spot: e.atSpot });
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1639
1760
|
const npcs = skeleton.npcs.map(npc => {
|
|
1640
1761
|
const detail = npcDetail.get(npc.name);
|
|
1641
1762
|
const station = npcStation.get(npc.name);
|
|
@@ -1649,8 +1770,11 @@ export function assembleScene(skeleton, details, playerName) {
|
|
|
1649
1770
|
dialogue: detail?.dialogue ?? [],
|
|
1650
1771
|
player: {
|
|
1651
1772
|
name: npc.name,
|
|
1652
|
-
|
|
1653
|
-
|
|
1773
|
+
// A posted guard starts where their door is, whatever the
|
|
1774
|
+
// skeleton said -- the post wins over the roster.
|
|
1775
|
+
startLocation: guardPost.get(npc.name)?.room ?? npc.startLocation,
|
|
1776
|
+
startSpot: guardPost.get(npc.name)?.spot ?? startSpot,
|
|
1777
|
+
guarding: guardPost.has(npc.name) || undefined,
|
|
1654
1778
|
lifestyleChoices: [],
|
|
1655
1779
|
combat: detail?.combat,
|
|
1656
1780
|
},
|
|
@@ -1694,23 +1818,46 @@ export function assembleScene(skeleton, details, playerName) {
|
|
|
1694
1818
|
// school, same as every other spot/exit validation in this file).
|
|
1695
1819
|
const VALID_FOOTPRINTS = new Set(['small', 'medium', 'large']);
|
|
1696
1820
|
const roomExitDirsByName = new Map(rooms.map(r => [r.name, new Set((r.exits ?? []).map(e => e.direction.toLowerCase()))]));
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1821
|
+
// WHICH EXIT EACH DEVICE HOLDS. Derived from the exit that names it,
|
|
1822
|
+
// never asked of the model twice -- the skeleton says it once, on the
|
|
1823
|
+
// exit, and this is the only place it becomes the device's own field.
|
|
1824
|
+
//
|
|
1825
|
+
// WITHOUT THIS THE DOOR NEVER LOCKS. scene-factory locks a door from
|
|
1826
|
+
// its device's `opensExit`; a device assembled without one is a
|
|
1827
|
+
// keypad beside a door that stands open, with no crash, no error and
|
|
1828
|
+
// no failing test. Caught by an end-to-end probe rather than by the
|
|
1829
|
+
// suite, which is exactly the failure mode this field has.
|
|
1830
|
+
const heldExit = new Map();
|
|
1831
|
+
for (const room of skeleton.rooms ?? []) {
|
|
1832
|
+
for (const e of room.exits ?? []) {
|
|
1833
|
+
if (e.device)
|
|
1834
|
+
heldExit.set(e.device, e.direction);
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
const devices = (skeleton.devices ?? []).map(device => {
|
|
1838
|
+
const detail = deviceDetail.get(device.name);
|
|
1839
|
+
const guardsExit = device.room && detail?.guardsExit && roomExitDirsByName.get(device.room)?.has(detail.guardsExit.toLowerCase())
|
|
1700
1840
|
? detail.guardsExit.toLowerCase()
|
|
1701
1841
|
: undefined;
|
|
1702
1842
|
const footprint = guardsExit && detail?.footprint && VALID_FOOTPRINTS.has(detail.footprint) ? detail.footprint : undefined;
|
|
1843
|
+
// KIND CLAMPS, never throws. A device whose kind we cannot read is
|
|
1844
|
+
// still a device, and rejecting a whole draft over one word is a
|
|
1845
|
+
// worse outcome than a panel where the model meant something else.
|
|
1846
|
+
const kind = device.kind === 'lock' || device.kind === 'ward' ? device.kind : 'panel';
|
|
1847
|
+
// A CODE ONLY BELONGS ON A PANEL -- enforced here as well as in the
|
|
1848
|
+
// prompt, because the prompt is advice and this is not. You cannot
|
|
1849
|
+
// talk at a mechanical lock.
|
|
1850
|
+
const code = kind === 'panel' ? (detail?.code || undefined) : undefined;
|
|
1703
1851
|
return {
|
|
1704
|
-
name:
|
|
1705
|
-
room:
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
bypassType: puzzle.bypassType,
|
|
1852
|
+
name: device.name,
|
|
1853
|
+
room: device.room,
|
|
1854
|
+
kind,
|
|
1855
|
+
opensExit: heldExit.get(device.name),
|
|
1856
|
+
description: detail?.description ?? device.concept,
|
|
1857
|
+
code,
|
|
1858
|
+
grantsItem: device.grantsItem,
|
|
1859
|
+
openMessage: detail?.openMessage ?? 'It opens.',
|
|
1860
|
+
refuseMessage: detail?.refuseMessage ?? 'Nothing happens.',
|
|
1714
1861
|
guardsExit,
|
|
1715
1862
|
footprint,
|
|
1716
1863
|
};
|
|
@@ -1721,10 +1868,19 @@ export function assembleScene(skeleton, details, playerName) {
|
|
|
1721
1868
|
story: skeleton.story,
|
|
1722
1869
|
currency: { type: 'nuyen', authorityRoomTags: ['Bank'] },
|
|
1723
1870
|
residenceTiers: ['Street', 'Squatter', 'Low', 'Middle', 'High', 'Luxury'],
|
|
1724
|
-
rooms
|
|
1871
|
+
rooms: rooms.map(r => ({
|
|
1872
|
+
...r,
|
|
1873
|
+
// A DEVICE RIDES ITS OWN ROOM -- there is no scene-level devices[]
|
|
1874
|
+
// to reconcile against room membership, because room ownership IS
|
|
1875
|
+
// the placement.
|
|
1876
|
+
devices: devices.filter(d => d.room === r.name).map(({ room: _room, ...d }) => d),
|
|
1877
|
+
})),
|
|
1725
1878
|
npcs,
|
|
1726
1879
|
items,
|
|
1727
|
-
|
|
1880
|
+
// The tombstone (see types/save-file.ts): always empty, never read
|
|
1881
|
+
// by this build. A generated seed is written fresh, so this is only
|
|
1882
|
+
// shape-compatibility for an older reader.
|
|
1883
|
+
puzzles: [],
|
|
1728
1884
|
player: {
|
|
1729
1885
|
name: playerName,
|
|
1730
1886
|
startLocation: startRoom,
|
|
@@ -1760,8 +1916,8 @@ export function classifyRepair(message) {
|
|
|
1760
1916
|
return 'items';
|
|
1761
1917
|
if (/startingItems|Win condition (item|rewardItem)|items\[/i.test(message))
|
|
1762
1918
|
return 'items';
|
|
1763
|
-
if (/
|
|
1764
|
-
return '
|
|
1919
|
+
if (/code|description|openMessage/i.test(message))
|
|
1920
|
+
return 'devices';
|
|
1765
1921
|
if (/npcs\[|dialogue/i.test(message))
|
|
1766
1922
|
return 'npcs';
|
|
1767
1923
|
if (/rooms\[\d+\][^]*name/i.test(message))
|