@maka/maka-cli 5.3.16 → 5.4.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/create.js +1 -1
- package/bundle/typescript/src/commands/create.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/archetypes.d.ts +43 -0
- package/bundle/typescript/src/commands/game/sideQuest/archetypes.js +131 -0
- package/bundle/typescript/src/commands/game/sideQuest/archetypes.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/assense.d.ts +14 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/assense.js +79 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/assense.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/attack.d.ts +42 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +224 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.d.ts +28 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.js +101 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/call.d.ts +19 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/call.js +149 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/call.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/cast.d.ts +58 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +378 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/command-registry.d.ts +11 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/command-registry.js +8 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/command-registry.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/command.d.ts +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/command.js +4 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/command.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/drone.d.ts +15 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/drone.js +60 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/drone.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/edge.d.ts +13 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/edge.js +31 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/edge.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/end-call.d.ts +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/end-call.js +27 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/end-call.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/equip.d.ts +4 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/equip.js +40 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/equip.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/equipment.d.ts +13 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/equipment.js +43 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/equipment.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/give.js +7 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/give.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/go.d.ts +11 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +115 -4
- package/bundle/typescript/src/commands/game/sideQuest/commands/go.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/hack.d.ts +25 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +176 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/inv.d.ts +12 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/inv.js +39 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/inv.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/jack.d.ts +17 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/jack.js +64 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/jack.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/kill.d.ts +10 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/kill.js +11 -33
- package/bundle/typescript/src/commands/game/sideQuest/commands/kill.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +35 -4
- package/bundle/typescript/src/commands/game/sideQuest/commands/look.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/map.d.ts +12 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/map.js +46 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/map.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/pan.d.ts +12 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/pan.js +55 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/pan.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/pick.d.ts +9 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/pick.js +18 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/pick.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/project.d.ts +19 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/project.js +54 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/project.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/read.js +6 -5
- package/bundle/typescript/src/commands/game/sideQuest/commands/read.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/reboot.d.ts +12 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/reboot.js +52 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/reboot.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/reload.d.ts +14 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/reload.js +40 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/reload.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/rest.d.ts +15 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/rest.js +59 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/rest.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/say.js +23 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/say.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +20 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/search.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.d.ts +12 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js +49 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/silent.d.ts +13 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/silent.js +50 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/silent.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/solve.js +32 -11
- package/bundle/typescript/src/commands/game/sideQuest/commands/solve.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/spells.d.ts +14 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/spells.js +65 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/spells.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/stance.d.ts +12 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/stance.js +41 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/stance.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/subdue.d.ts +21 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/subdue.js +43 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/subdue.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/surrender.d.ts +20 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/surrender.js +45 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/surrender.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/take.d.ts +8 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +80 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/take.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/unlock.js +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/unlock.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/use.d.ts +24 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/use.js +101 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/use.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/item-factory.js +2 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/item-factory.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/npc-factory.js +7 -2
- package/bundle/typescript/src/commands/game/sideQuest/factories/npc-factory.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/puzzle-factory.js +2 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/puzzle-factory.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/room-factory.js +2 -0
- package/bundle/typescript/src/commands/game/sideQuest/factories/room-factory.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.d.ts +8 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js +138 -21
- 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 +119 -0
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js +816 -0
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/game.d.ts +70 -9
- package/bundle/typescript/src/commands/game/sideQuest/game.js +403 -164
- package/bundle/typescript/src/commands/game/sideQuest/game.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/minigames/hack-minigame.d.ts +16 -0
- package/bundle/typescript/src/commands/game/sideQuest/minigames/hack-minigame.js +101 -0
- package/bundle/typescript/src/commands/game/sideQuest/minigames/hack-minigame.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/minigames/lockpick-minigame.d.ts +15 -0
- package/bundle/typescript/src/commands/game/sideQuest/minigames/lockpick-minigame.js +95 -0
- package/bundle/typescript/src/commands/game/sideQuest/minigames/lockpick-minigame.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/door.d.ts +10 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/door.js +15 -2
- package/bundle/typescript/src/commands/game/sideQuest/models/door.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/inventory.d.ts +10 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/inventory.js +54 -4
- package/bundle/typescript/src/commands/game/sideQuest/models/inventory.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/item.d.ts +41 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/item.js +106 -3
- package/bundle/typescript/src/commands/game/sideQuest/models/item.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.d.ts +25 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +220 -47
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/player.d.ts +213 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/player.js +525 -9
- package/bundle/typescript/src/commands/game/sideQuest/models/player.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.d.ts +75 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.js +174 -6
- package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/room.d.ts +20 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/room.js +39 -18
- package/bundle/typescript/src/commands/game/sideQuest/models/room.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.d.ts +69 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +175 -2
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/scenes/scene1.json +80 -1
- package/bundle/typescript/src/commands/game/sideQuest/scenes/scene2.json +7 -2
- package/bundle/typescript/src/commands/game/sideQuest/types/player-equipment.d.ts +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/seed/exit-seed.d.ts +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/seed/item-seed.d.ts +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/seed/npc-seed.d.ts +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/seed/player-seed.d.ts +34 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/seed/puzzle-seed.d.ts +15 -1
- package/bundle/typescript/src/commands/game/sideQuest/types/seed/room-seed.d.ts +3 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/seed/scene-seed.d.ts +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/seed/win-condition-seed.d.ts +15 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/seed/win-condition-seed.js +2 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/seed/win-condition-seed.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/shared/abstracts.d.ts +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/shared/abstracts.js +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/shared/abstracts.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/types/shared/item-enum.d.ts +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/shared/item-enum.js +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/shared/item-enum.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/ui.d.ts +3 -0
- package/bundle/typescript/src/commands/game/sideQuest/ui.js +95 -5
- package/bundle/typescript/src/commands/game/sideQuest/ui.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/auto-fight.d.ts +60 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/auto-fight.js +138 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/auto-fight.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.d.ts +143 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +328 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/comm-style.d.ts +2 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/comm-style.js +12 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/comm-style.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/dice.d.ts +22 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/dice.js +34 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/dice.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/fuzzy-match.d.ts +36 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/fuzzy-match.js +92 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/fuzzy-match.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.d.ts +44 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +193 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/pursuit.d.ts +16 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/pursuit.js +36 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/pursuit.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js +54 -7
- package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js.map +1 -1
- package/bundle/typescript/src/commands/set/global.sub.cmd.js +59 -5
- package/bundle/typescript/src/commands/set/global.sub.cmd.js.map +1 -1
- package/bundle/typescript/src/tools/ai/ai.class.d.ts +35 -0
- package/bundle/typescript/src/tools/ai/ai.class.js +112 -0
- package/bundle/typescript/src/tools/ai/ai.class.js.map +1 -0
- package/bundle/typescript/src/tools/fsi/fsi.interfaces.d.ts +1 -0
- package/bundle/typescript/src/tools/fsi/fsi.interfaces.js.map +1 -1
- package/bundle/typescript/src/tools/https/https.class.d.ts +2 -1
- package/bundle/typescript/src/tools/https/https.class.js +43 -0
- package/bundle/typescript/src/tools/https/https.class.js.map +1 -1
- package/bundle/typescript/src/tools/https/https.interface.d.ts +8 -0
- package/package.json +1 -1
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { AI } from '../../../../tools/ai/ai.class.js';
|
|
4
|
+
import { Player } from '../models/player.js';
|
|
5
|
+
import { Room } from '../models/room.js';
|
|
6
|
+
import { Logger } from '../utilities/logger.js';
|
|
7
|
+
import { SceneSynthesizer } from './scene-factory.js';
|
|
8
|
+
// The fixer's name is a fixed Shadowrun-lore convention (see the FIXER
|
|
9
|
+
// CONVENTION comment in buildSchemaGuide below), not something generation
|
|
10
|
+
// ever varies -- exported so callers that need to identify "the fixer"
|
|
11
|
+
// specifically (as opposed to whoever a job's winCondition.toNpc happens to
|
|
12
|
+
// be, which no longer has to be the same NPC) can check by name instead of
|
|
13
|
+
// duplicating the literal string.
|
|
14
|
+
export const FIXER_NAME = 'Mr. Johnson';
|
|
15
|
+
function buildSchemaGuide(tier, previousFixerName) {
|
|
16
|
+
const scope = tier <= 1
|
|
17
|
+
? '3 to 4 rooms, exactly 1 NPC, and exactly 1 puzzle'
|
|
18
|
+
: tier === 2
|
|
19
|
+
? '4 to 5 rooms, 2 NPCs, and 1 to 2 puzzles'
|
|
20
|
+
: '5 to 6 rooms, 2 to 3 NPCs, and 2 puzzles';
|
|
21
|
+
const continuity = previousFixerName
|
|
22
|
+
? `\nThis is a CONTINUATION of an ongoing relationship with the same fixer, "${previousFixerName}" --
|
|
23
|
+
who commissioned the runner's last job and is the one lining up this one too. Reuse
|
|
24
|
+
"${previousFixerName}" as this scene's fixer/job-giver (same name, same voice/personality),
|
|
25
|
+
rather than inventing a new fixer character. Do not reintroduce them as a stranger; the
|
|
26
|
+
runner already knows them.`
|
|
27
|
+
: '';
|
|
28
|
+
return `
|
|
29
|
+
You are generating a small, self-contained Shadowrun-flavored text adventure "run" as JSON.
|
|
30
|
+
This is job #${tier} in an ongoing campaign for the same runner -- make it noticeably more
|
|
31
|
+
elaborate and higher-stakes than a first job would be, in proportion to how high the job
|
|
32
|
+
number is.
|
|
33
|
+
${continuity}
|
|
34
|
+
|
|
35
|
+
Output ONLY raw JSON matching this exact shape -- no markdown fences, no commentary, no
|
|
36
|
+
trailing text before or after the JSON object:
|
|
37
|
+
|
|
38
|
+
{
|
|
39
|
+
"name": string,
|
|
40
|
+
"story": string, // 1-2 SHORT sentences, shown to the player before anything else
|
|
41
|
+
// happens. This is a premise teaser, not a briefing -- name the
|
|
42
|
+
// general shape of the trouble (a theft, a debt, a betrayal) and
|
|
43
|
+
// nothing more. Never reveal the objective, a target's name or
|
|
44
|
+
// location, a passphrase, a twist, or anything a player would need
|
|
45
|
+
// to actually complete the run -- all of that belongs in the fixer's
|
|
46
|
+
// "dialogue" instead, learned by "call"ing them once play begins (see
|
|
47
|
+
// the fixer convention below). If this reads like a back-cover blurb
|
|
48
|
+
// rather than a full plot summary, it's the right length.
|
|
49
|
+
"currency": { "type": "nuyen", "authorityRoomTags": ["Bank"] },
|
|
50
|
+
"residenceTiers": ["Street", "Squatter", "Low", "Middle", "High", "Luxury"],
|
|
51
|
+
"rooms": [
|
|
52
|
+
{
|
|
53
|
+
"name": string,
|
|
54
|
+
"description": string,
|
|
55
|
+
"roomType": string,
|
|
56
|
+
"isStartRoom"?: boolean, // set true on EXACTLY ONE room
|
|
57
|
+
"owner"?: string, // an NPC's name, if that NPC's room
|
|
58
|
+
"reachable"?: boolean, // defaults to true; set false ONLY for a fixer/employer's
|
|
59
|
+
// remote hideout the player can never physically enter --
|
|
60
|
+
// see the fixer convention below
|
|
61
|
+
"hasNode"?: boolean, // a live Matrix host in this room: a jacked-in player ("jack in")
|
|
62
|
+
// can hack it from inside the Matrix to release the room's
|
|
63
|
+
// locked doors FOR REAL. Mark 1-2 rooms in any corp/tech
|
|
64
|
+
// location; leave street/wilderness rooms without. Ice NPCs
|
|
65
|
+
// ("plane": "matrix") and paydata items ("plane": "matrix")
|
|
66
|
+
// belong ONLY in hasNode rooms.
|
|
67
|
+
"warded"?: boolean, // an astral barrier: projecting mages ("project") cannot enter
|
|
68
|
+
// this room astrally. Use on magically-secured rooms (a mage's
|
|
69
|
+
// sanctum, a corp magical-security floor) -- it makes astral
|
|
70
|
+
// scouting a puzzle, not a skeleton key. Use sparingly.
|
|
71
|
+
"exits": [
|
|
72
|
+
{ "direction": "north"|"east"|"south"|"west"|"up"|"down", "targetRoom": string, "keyRequired"?: string,
|
|
73
|
+
"puzzle"?: string } // name of a "puzzles[]" entry that guards this door instead of (or as
|
|
74
|
+
// well as) "keyRequired" -- see DOORS AS PUZZLES below
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
],
|
|
78
|
+
"npcs": [
|
|
79
|
+
{
|
|
80
|
+
"plane"?: "meat"|"matrix"|"astral", // default "meat". "matrix" = ICE: a security
|
|
81
|
+
// program guarding a hasNode room -- only a jacked-in player
|
|
82
|
+
// ever meets it (it fights with Logic-based pools; give it a
|
|
83
|
+
// combat block with "logic" 4-7 and set its startLocation to
|
|
84
|
+
// the hasNode room). "astral" = a SPIRIT: met only by
|
|
85
|
+
// projecting mages -- give it "willpower"/"magic" 4-6 and
|
|
86
|
+
// "intuition" 4+. Use at most one or two per scene, and only
|
|
87
|
+
// where the location concept earns them.
|
|
88
|
+
"description": string, // private notes on personality/motives; never shown to the player directly
|
|
89
|
+
"dialogue": string[], // 2-4 short in-character lines. If this NPC is the fixer/job-giver,
|
|
90
|
+
// THIS is where the actual briefing lives -- the objective, the
|
|
91
|
+
// target, the stakes -- since "story" deliberately withholds it.
|
|
92
|
+
"player": { "name": string, "startLocation": string, "lifestyleChoices": [],
|
|
93
|
+
"combat"?: { "body": number, "agility": number, "reaction": number, "combatSkill": number,
|
|
94
|
+
"magic"?: number, "strength"?: number, "willpower"?: number, "logic"?: number,
|
|
95
|
+
"intuition"?: number, "charisma"?: number } }
|
|
96
|
+
// combat is OPTIONAL -- omit it for anyone who'd never be in a fight (a fixer, a
|
|
97
|
+
// bartender). When set, each attribute is 1-8: a soft civilian 1-2, a street-level
|
|
98
|
+
// runner 3-4 (the default when omitted), a hardened enforcer or bodyguard 5-6,
|
|
99
|
+
// elite corp security 7-8. Give a combat block (and an equipped-quality weapon/
|
|
100
|
+
// armor in "items" heldBy them) to any NPC the story expects might end up in a
|
|
101
|
+
// firefight -- combat is dice-pool based and gear quality feeds the dice.
|
|
102
|
+
// "magic" is innate arcane talent: omit (or 0) for the mundane majority. Set 3-6
|
|
103
|
+
// ONLY for a character whose concept is genuinely a mage/shaman/adept -- they'll
|
|
104
|
+
// know every spell (manabolt/heal/armor) innately with no gear needed, cast from
|
|
105
|
+
// raw talent, and should be described accordingly (their "description" should say
|
|
106
|
+
// they're Awakened). A Focus item still boosts them. Use sparingly: one Awakened
|
|
107
|
+
// NPC is a memorable threat, a scene full of them is noise.
|
|
108
|
+
// The optional extended attributes (all default ~3): "strength" (melee damage,
|
|
109
|
+
// carry), "willpower" (stun track, drain resist -- give mages 5+), "logic"
|
|
110
|
+
// (first aid, hacking -- a decker/street doc concept wants 5+), "intuition"
|
|
111
|
+
// (defense, perception), "charisma" (social presence). Set only the ones that
|
|
112
|
+
// define the character's concept; leave the rest to defaults.
|
|
113
|
+
// Rarer talents: "resonance" (technomancer -- rides the Matrix with no deck;
|
|
114
|
+
// use for a technomancer character concept only) and "adept": true (physical
|
|
115
|
+
// adept -- magic 4-6 channeled into meat combat, no spells, no projection).
|
|
116
|
+
// Never combine magic-casting, adept, and resonance on one character.
|
|
117
|
+
// A rival DECKER concept: "logic" 5-7 plus a Cyberdeck item heldBy them --
|
|
118
|
+
// their AI can then crack the player's PAN mid-fight ("hack <name>": jams
|
|
119
|
+
// the player's smartgun, cuts their AR). One per scene at most, and only
|
|
120
|
+
// where a decker belongs.
|
|
121
|
+
}
|
|
122
|
+
],
|
|
123
|
+
"items": [
|
|
124
|
+
{
|
|
125
|
+
"name": string,
|
|
126
|
+
"description": string,
|
|
127
|
+
"size": "Tiny"|"Small"|"Medium"|"Large"|"VeryLarge",
|
|
128
|
+
"category": "Weapon"|"MeleeWeapon"|"RangedWeapon"|"Armor"|"BodyArmor"|"ArmorClothing"|"Helmet"|"Hat"|"LegArmor"|"ArmArmor"|"Gloves"|"Commlink"|"Cyberdeck"|"Drone"|"Credstick"|"CurrencyContainer"|"Key"|"Trinket"|"Document"|"Datachip"|"Vice"|"Book"|"Tool"|"Clothing"|"Glasses"|"Boots"|"Medical"|"StimPatch"|"SpellFormula"|"Grimoire"|"Focus"|"Ammunition",
|
|
129
|
+
// "Cyberdeck" = the hardware REQUIRED to jack into the Matrix (a Commlink is
|
|
130
|
+
// calls/AR only). Its "rating" scales program damage, firewall, and its own
|
|
131
|
+
// damage track. Place one as loot/reward only when the story earns it -- a
|
|
132
|
+
// dead decker's kit, a smuggled prototype. If the scene has hasNode rooms
|
|
133
|
+
// with matrix-plane paydata, remember the player may need a deck to get it
|
|
134
|
+
// (deckers and technomancers bring their own way in).
|
|
135
|
+
// "Drone" = a launchable surveillance drone: the "drone" command sweeps all
|
|
136
|
+
// adjacent rooms for occupants/locks/nodes. Rigger flavor; rare loot.
|
|
137
|
+
// "Ammunition" = a spare clip/magazine: consumed by "reload" to refill a firearm
|
|
138
|
+
// (guns hold 10 rounds and every shot spends one). If a scene arms its NPCs or
|
|
139
|
+
// player with guns and expects a real firefight, place 1-2 Ammunition items
|
|
140
|
+
// somewhere findable.
|
|
141
|
+
// "Medical" = a single-use healing item (medkit, trauma kit): using it heals
|
|
142
|
+
// PHYSICAL combat damage, scaling with "rating", and consumes it. If this scene's
|
|
143
|
+
// story can plausibly end in a firefight (armed NPCs, a "will kill on sight"
|
|
144
|
+
// guard), place 1-2 Medical items somewhere findable -- a run with combat and no
|
|
145
|
+
// way to patch up is a meat grinder.
|
|
146
|
+
// "StimPatch" = a single-use combat drug: recovers STUN damage (spell drain,
|
|
147
|
+
// knockout track), scaling with "rating", but the crash costs 1 physical box.
|
|
148
|
+
// Worth placing one wherever magic gear appears, since casters accumulate stun.
|
|
149
|
+
//
|
|
150
|
+
// MAGIC GEAR (casting is gear-driven -- no innate mage stat):
|
|
151
|
+
// - "SpellFormula": teaches ONE spell; its "name" MUST contain that spell's name.
|
|
152
|
+
// The only spells that exist are "Manabolt", "Heal", and "Armor" -- e.g.
|
|
153
|
+
// "Spell Formula: Manabolt", "Dog-Eared Heal Formula". Never invent other spells.
|
|
154
|
+
// - "Grimoire": a spellbook -- teaches ALL three spells at once. Rarer find.
|
|
155
|
+
// - "Focus": an amulet/wand/talisman that boosts casting power, scaling with
|
|
156
|
+
// "rating" -- the magic equivalent of a better gun.
|
|
157
|
+
// Magic is optional flavor: most scenes need none. But if any puzzle here uses
|
|
158
|
+
// "bypassType": "spell", you MUST place at least one magic item (formula, grimoire,
|
|
159
|
+
// or focus) somewhere findable -- attempting to cast at a ward now requires magic
|
|
160
|
+
// gear, so a warded barrier with no magic gear in the scene has no bypass route.
|
|
161
|
+
// Anything wearable (a hoodie, gloves, a hat, boots...) MUST use one of
|
|
162
|
+
// the above wearable categories exactly -- never a generic catch-all
|
|
163
|
+
// like "Trinket" or "Document" for something meant to be worn.
|
|
164
|
+
"shape": string,
|
|
165
|
+
"color": string,
|
|
166
|
+
"texture": string,
|
|
167
|
+
"rating": "Common"|"Standard"|"Rare"|"Legendary"|"MilitaryGrade",
|
|
168
|
+
"weight": number, // kilograms, and it's REAL -- actors have a 50kg carry cap. Keep
|
|
169
|
+
// items individually realistic (0.05 for a chip, ~1-2 for a pistol,
|
|
170
|
+
// ~3-5 for armor); never give a carryable item a silly weight.
|
|
171
|
+
"currencyAmount"?: number, // only for Credstick/CurrencyContainer items
|
|
172
|
+
"room"?: string, // set this OR heldBy, never both, and never on a puzzle's reward item
|
|
173
|
+
"heldBy"?: string, // must be an NPC's name from "npcs", never the player
|
|
174
|
+
"owner"?: string,
|
|
175
|
+
"transferable"?: boolean,
|
|
176
|
+
"plane"?: "meat"|"matrix"|"astral", // default "meat". "matrix" = PAYDATA: a file that
|
|
177
|
+
// exists only on the grid -- visible and takeable ONLY while the
|
|
178
|
+
// player is jacked in ("jack in"). Place it in a "hasNode" room
|
|
179
|
+
// (its "room"), category "Datachip" or "Document", weight 0. A
|
|
180
|
+
// matrix-plane win-condition item makes the whole job a datarun:
|
|
181
|
+
// the player MUST jack in, likely past ice, to finish. Use it
|
|
182
|
+
// when the story is about stealing data, not things.
|
|
183
|
+
"details"?: string // deeper text shown only when the player closely examines or reads
|
|
184
|
+
// this item (via "read", for a Book) -- NOT the same as "description",
|
|
185
|
+
// which is always-visible flavor text shown just by looking at or
|
|
186
|
+
// listing the item. A puzzle's clue text (see below) MUST go in
|
|
187
|
+
// "details", never in "description" -- put it in "description" and the
|
|
188
|
+
// player can never actually read it out.
|
|
189
|
+
}
|
|
190
|
+
],
|
|
191
|
+
"puzzles": [
|
|
192
|
+
{
|
|
193
|
+
"name": string,
|
|
194
|
+
"room"?: string, // must match a room name -- OMIT this instead if "exits[].puzzle" (see DOORS
|
|
195
|
+
// AS PUZZLES below) already places this puzzle on a door; a puzzle belongs
|
|
196
|
+
// to exactly one of the two, never both and never neither.
|
|
197
|
+
"riddle": string, // What the player sees when they search this room (or approach this door),
|
|
198
|
+
// shown verbatim -- NOT a cryptic riddle or poem to decode. Start with "You
|
|
199
|
+
// see" (or "You find", "Set into the wall is", etc.) and name the barrier
|
|
200
|
+
// plainly and concretely -- "You see a maglock safe.", "You see a biometric
|
|
201
|
+
// scanner beside the door." -- then say what it demands (a code, a
|
|
202
|
+
// fingerprint, a name spoken aloud, a passphrase), the way an NPC would
|
|
203
|
+
// describe it, never as wordplay. See BYPASS TYPE below.
|
|
204
|
+
"requiredPhrase": string, // the exact code/passphrase/word that satisfies it -- ALL LOWERCASE, 2-5 plain words, no punctuation
|
|
205
|
+
"rewardItem"?: string, // must match an item name from "items" with no "room"/"heldBy" set. Required
|
|
206
|
+
// for a "room"-anchored puzzle (this IS what solving it gets the player).
|
|
207
|
+
// Omit for a door-anchored puzzle (exits[].puzzle) -- unlocking the door is
|
|
208
|
+
// already the reward; only add one if there's a genuine bonus find too.
|
|
209
|
+
"successMessage": string,
|
|
210
|
+
"failureMessage": string,
|
|
211
|
+
"requiresPuzzle"?: string, // another puzzle's "name" that must be solved FIRST -- see below
|
|
212
|
+
"lockedMessage"?: string, // shown if the player tries to solve this before requiresPuzzle is met
|
|
213
|
+
"bypassType"?: "lockpick"|"hack"|"spell" // see BYPASS TYPE below -- omit if this barrier has no
|
|
214
|
+
// physical/technical/magical route past it
|
|
215
|
+
}
|
|
216
|
+
],
|
|
217
|
+
// BYPASS TYPE: decide what a puzzle concretely IS before writing its "riddle" --
|
|
218
|
+
// a maglock, a keypad, a biometric scanner, a matrix node, a warded sigil, a
|
|
219
|
+
// guard who has to be talked past. That choice drives BOTH fields together:
|
|
220
|
+
// "riddle" describes THAT thing plainly (never as a cryptic riddle-poem), and
|
|
221
|
+
// "bypassType" names the matching physical/technical/magical route past it, if
|
|
222
|
+
// any: a literal lock, maglock, or keypad -> "lockpick", a terminal/console/
|
|
223
|
+
// matrix node or electronic scanner -> "hack", a ward or arcane sigil -> "spell".
|
|
224
|
+
// Leave "bypassType" unset only for a barrier with no such route (e.g. a purely
|
|
225
|
+
// social "convince the guard" puzzle) -- don't force one in just to use the
|
|
226
|
+
// mechanic, but don't invent a riddle-poem instead of a real barrier either.
|
|
227
|
+
//
|
|
228
|
+
// Either way, "requiredPhrase" is always a concrete, literal credential -- the
|
|
229
|
+
// code, the passphrase, the fingerprint owner's name, the words of the ward --
|
|
230
|
+
// never a wordplay answer to a puzzle. "riddle" states what the barrier wants
|
|
231
|
+
// without giving that credential away; the player finds it via a clue (below).
|
|
232
|
+
// When "bypassType" IS set, that same credential doubles as the safe route:
|
|
233
|
+
// "solve" with the right answer always works, while "pick"/"hack"/"cast" is
|
|
234
|
+
// the risky shortcut that skips finding it. That route is player-initiated
|
|
235
|
+
// and a genuine coin flip -- but failure is FATAL, ending the run outright
|
|
236
|
+
// (tripped alarm, security response, backlash -- whatever fits the barrier).
|
|
237
|
+
// Only give a puzzle "bypassType" when a real physical/technical/magical
|
|
238
|
+
// route past it makes sense; never attach it just to decorate a puzzle,
|
|
239
|
+
// since it's a real life-or-death choice the player is being offered.
|
|
240
|
+
//
|
|
241
|
+
// For EVERY puzzle above, the answer must be discoverable somewhere OTHER
|
|
242
|
+
// than the puzzle's own room (never the same room -- the player must go
|
|
243
|
+
// find it elsewhere). Either of these satisfies that, and the second is
|
|
244
|
+
// usually simpler to get exactly right:
|
|
245
|
+
// (a) A companion clue item in "items": category "Book", "room" set to
|
|
246
|
+
// a different room, whose "details" field directly states the
|
|
247
|
+
// puzzle's exact "requiredPhrase" (a diary entry, scrawled note, or
|
|
248
|
+
// manual that gives the answer away once someone reads it).
|
|
249
|
+
// (b) The "requiredPhrase" IS a real item's exact "name" (any category),
|
|
250
|
+
// placed with "room" set to a different room -- e.g. a riddle whose
|
|
251
|
+
// answer is "obsidian keycard" and there's a real item literally
|
|
252
|
+
// named "Obsidian Keycard" sitting elsewhere. Finding it and seeing
|
|
253
|
+
// its name is the clue; no separate documentation needed.
|
|
254
|
+
//
|
|
255
|
+
// DOORS AS PUZZLES: a locked door can be secured by a real puzzle instead of
|
|
256
|
+
// (or as well as) a physical "keyRequired" item -- set "exits[].puzzle" to
|
|
257
|
+
// that puzzle's "name", and leave that puzzle's own "room" unset (it
|
|
258
|
+
// belongs to the door connecting two rooms, not either one). Everything
|
|
259
|
+
// above still applies exactly the same way -- it needs a "riddle"
|
|
260
|
+
// description, a discoverable clue elsewhere, and an optional matching
|
|
261
|
+
// "bypassType" -- the only difference is the payoff: solving or bypassing
|
|
262
|
+
// it unlocks the door rather than granting an item, so "rewardItem" should
|
|
263
|
+
// normally be omitted. Use this for a door that's more interesting than a
|
|
264
|
+
// "find the physical key" errand -- a keypad, a biometric scanner, a
|
|
265
|
+
// warded threshold -- and keep using plain "keyRequired" for a door that's
|
|
266
|
+
// just a mundane lock with a key sitting somewhere.
|
|
267
|
+
//
|
|
268
|
+
// FIXER CONVENTION: in Shadowrun, a fixer (whoever commissions a run) works
|
|
269
|
+
// over comms and essentially never meets a runner in person. If this scene's
|
|
270
|
+
// premise involves a job-giver/employer character (almost always true), name
|
|
271
|
+
// them EXACTLY "${FIXER_NAME}" -- in "npcs[].player.name", and in
|
|
272
|
+
// "winCondition.toNpc" too IF he's also the win condition's target (see
|
|
273
|
+
// BROKER, NOT ALWAYS THE DESTINATION below -- he often isn't). This is
|
|
274
|
+
// deliberate: in Shadowrun, "${FIXER_NAME}" is the in-universe slang for an
|
|
275
|
+
// anonymous corporate employer, not a real name -- every job's fixer goes by
|
|
276
|
+
// it regardless of who's actually behind the alias, and the runner never
|
|
277
|
+
// learns their real identity. Never invent a different name for this role.
|
|
278
|
+
// Give him his own room and mark that room "reachable": false -- an office,
|
|
279
|
+
// den, or safehouse the player will never set foot in. Give that room NO
|
|
280
|
+
// exits (or only exits to other "reachable": false rooms). "call" is the
|
|
281
|
+
// only way to reach him, and it requires a commlink on BOTH ends, equipped
|
|
282
|
+
// by the caller:
|
|
283
|
+
// - Give the player a "Commlink"-category item in "startingItems" (or
|
|
284
|
+
// placed somewhere findable in a reachable room).
|
|
285
|
+
// - Give ${FIXER_NAME} his own "Commlink"-category item with "heldBy" set
|
|
286
|
+
// to his name -- without one, he can never be reached at all.
|
|
287
|
+
//
|
|
288
|
+
// BROKER, NOT ALWAYS THE DESTINATION: ${FIXER_NAME}'s job is to commission
|
|
289
|
+
// the run and point the runner at what matters -- he does NOT have to be
|
|
290
|
+
// "winCondition.toNpc" himself. Often the more interesting choice is a
|
|
291
|
+
// DIFFERENT NPC entirely: someone ${FIXER_NAME} mentions in his own
|
|
292
|
+
// "dialogue" (a buyer, a target, a contact on the ground) who the runner
|
|
293
|
+
// then has to track down, meet, or call to actually finish the job. Both
|
|
294
|
+
// patterns are valid -- don't default to ${FIXER_NAME} as the
|
|
295
|
+
// winCondition.toNpc out of habit if the story is more interesting with
|
|
296
|
+
// someone else in that role. If you do introduce a separate contact this
|
|
297
|
+
// way, they need everything any other reachable NPC needs (a real
|
|
298
|
+
// startLocation; a commlink of their own if "call" is how the player is
|
|
299
|
+
// meant to reach them and their room is "reachable": false).
|
|
300
|
+
"player": {
|
|
301
|
+
"name": "Maka",
|
|
302
|
+
"startLocation": string, // must match the isStartRoom room's name
|
|
303
|
+
"lifestyleChoices": [{ "type": "residence", "metadata": { "tier": "Street", "location": string } }],
|
|
304
|
+
"startingItems": [ /* 3-5 items, same shape as above minus room/heldBy/owner */ ]
|
|
305
|
+
},
|
|
306
|
+
"winCondition": {
|
|
307
|
+
"type": "give",
|
|
308
|
+
"item": string, // must match an items[].name -- a MacGuffin the player must find and deliver
|
|
309
|
+
"toNpc": string, // must match an npcs[].player.name -- who wants it
|
|
310
|
+
"rewardItem"?: string, // optional bonus payoff item; must match an items[].name with no room/heldBy
|
|
311
|
+
"payout": number, // REQUIRED: the job's agreed pay in nuyen. The engine ACTUALLY transfers
|
|
312
|
+
// this to the player on completion, and every NPC is told the exact
|
|
313
|
+
// figure -- so this IS the number the fixer quotes. A round, streetwise
|
|
314
|
+
// figure scaled to the job number: ~2000-6000 for job #1, roughly
|
|
315
|
+
// half again more per job after that.
|
|
316
|
+
"completionMessage": string // shown to the player when they complete the job
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
Hard rules (a broken reference here will crash the game on load):
|
|
321
|
+
- Every "exits[].targetRoom" MUST exactly match a "rooms[].name".
|
|
322
|
+
- Every "npcs[].player.startLocation" MUST exactly match a "rooms[].name".
|
|
323
|
+
- Every "items[].room" (if set) MUST exactly match a "rooms[].name".
|
|
324
|
+
- Every "items[].heldBy" (if set) MUST exactly match an "npcs[].player.name".
|
|
325
|
+
- Every puzzle in "puzzles[]" must be placed EXACTLY ONE way: either "puzzles[].room" matches a
|
|
326
|
+
"rooms[].name", OR exactly one "exits[].puzzle" matches its "name" (DOORS AS PUZZLES above) -- never
|
|
327
|
+
both, never neither.
|
|
328
|
+
- Every "exits[].puzzle" (if set) MUST exactly match a "puzzles[].name".
|
|
329
|
+
- "puzzles[].rewardItem" (if set) MUST exactly match an "items[].name", and that item must have neither "room" nor
|
|
330
|
+
"heldBy" set -- it doesn't exist anywhere in the world yet; solving the puzzle is what brings it into the
|
|
331
|
+
player's possession. If an NPC is guarding this reward, that's already implied by the puzzle needing to be
|
|
332
|
+
solved in their room -- do NOT ALSO make the reward item "heldBy" that NPC, which would mean two different,
|
|
333
|
+
disconnected copies of "the same" item end up existing (one granted by solving, one still sitting on the NPC).
|
|
334
|
+
- "puzzles[].rewardItem" must NEVER be the same item as "winCondition.item" -- they need opposite placement
|
|
335
|
+
(one must have no room/heldBy, the other must have one), so no single item can satisfy both. If the story
|
|
336
|
+
wants the winCondition macguffin to feel puzzle-guarded, give the puzzle its OWN separate rewardItem instead
|
|
337
|
+
(a key, a note, access -- something that leads to the macguffin), and place "winCondition.item" itself
|
|
338
|
+
normally, with "room" or "heldBy" set.
|
|
339
|
+
- Exactly one room must have "isStartRoom": true, and it must match "player.startLocation".
|
|
340
|
+
- EVERY puzzle needs a discoverable clue in a room OTHER than that puzzle's "room" (for a
|
|
341
|
+
door-anchored puzzle, which has no "room" of its own, any room with the clue satisfies this) --
|
|
342
|
+
either pattern (a) or (b) above. A puzzle backed by neither, or a room-anchored one whose only
|
|
343
|
+
clue sits in its own room, is invalid -- the player must always have a discoverable way to find
|
|
344
|
+
the answer, and finding it must take some legwork.
|
|
345
|
+
- "puzzles[].requiresPuzzle" (if set) MUST exactly match another "puzzles[].name" in this
|
|
346
|
+
same scene, must not reference itself, and chains must never cycle back on themselves
|
|
347
|
+
(A requires B requires A). A chain can be any length a puzzle count actually supports.
|
|
348
|
+
- If a puzzle's riddle, clue, or the scene's "story" implies that doing something in one
|
|
349
|
+
place enables or unlocks something elsewhere (e.g. "cut the power to blind the cameras"),
|
|
350
|
+
that MUST be modeled as a real two-step (or longer) puzzle chain using "requiresPuzzle" --
|
|
351
|
+
never write a riddle or clue that implies a mechanic the JSON doesn't actually back up.
|
|
352
|
+
If there's only one puzzle in this scene, there's nothing to chain -- keep it self-
|
|
353
|
+
contained and don't reference another location or action that doesn't exist.
|
|
354
|
+
- "winCondition.item" MUST exactly match an "items[].name", and that item MUST have either
|
|
355
|
+
"room" or "heldBy" set -- it needs a concrete, findable location in the world, exactly
|
|
356
|
+
like every other item. It must NOT be "heldBy" the "winCondition.toNpc" itself (the
|
|
357
|
+
player has to find it, not already have delivered it) -- place it in a room, or held by
|
|
358
|
+
a DIFFERENT NPC.
|
|
359
|
+
- "winCondition.toNpc" MUST exactly match an "npcs[].player.name".
|
|
360
|
+
- "winCondition.rewardItem" (if set) MUST exactly match an "items[].name" with neither
|
|
361
|
+
"room" nor "heldBy" set (same rule as a puzzle reward item).
|
|
362
|
+
- This win-condition item is the CENTRAL plot object of the run's "story" -- something
|
|
363
|
+
stolen, owed, or lost that the winCondition.toNpc wants back or wants delivered.
|
|
364
|
+
- "exits[].keyRequired" (if set) MUST exactly match an "items[].name", and that item MUST
|
|
365
|
+
have "room" or "heldBy" set to a concrete location -- never invent a key name that isn't
|
|
366
|
+
a real item. Never place that key's item in a room that is ONLY reachable by first going
|
|
367
|
+
through the very door it unlocks (that's an unsolvable soft-lock). The player must always
|
|
368
|
+
be able to reach the key through some path that doesn't require already being unlocked.
|
|
369
|
+
- A room may be marked "reachable": false to represent somewhere the player can never
|
|
370
|
+
physically enter (a fixer's remote hideout, most commonly -- see the fixer convention
|
|
371
|
+
above). Such a room MUST have no exits, or exits ONLY to other "reachable": false rooms --
|
|
372
|
+
never an exit connecting it to a normally-reachable room, which would defeat the point.
|
|
373
|
+
- Nothing the player must retrieve to finish the run -- a puzzle, a door's "keyRequired"
|
|
374
|
+
item, or the "winCondition.item" -- may be placed inside a "reachable": false room. The
|
|
375
|
+
player can never go get it.
|
|
376
|
+
- If "winCondition.toNpc" is placed in a "reachable": false room, "call" (which requires a
|
|
377
|
+
commlink on both ends) is the only way to reach them, so BOTH of these must hold: the
|
|
378
|
+
player must have a way to get a "Commlink"-category item (in "startingItems", or findable
|
|
379
|
+
in a reachable room), AND "winCondition.toNpc" must itself hold a "Commlink"-category item
|
|
380
|
+
("heldBy" set to their name) -- without one, they can never receive a call at all.
|
|
381
|
+
|
|
382
|
+
Scope: ${scope}. Plus 4 to 6 general world items, one clue item per puzzle, the winCondition
|
|
383
|
+
item and optional reward item, and the player's 3-5 starting items. Give it a gritty, noir
|
|
384
|
+
Shadowrun tone: a fixer, a job, a locked door, a debt, a betrayal -- something in that vein.
|
|
385
|
+
This is a fresh run, distinct from a generic "protect the lock" or "corp blackmail vault"
|
|
386
|
+
premise.
|
|
387
|
+
`.trim();
|
|
388
|
+
}
|
|
389
|
+
export class SceneSeedGenerator {
|
|
390
|
+
static maxAttempts = 3;
|
|
391
|
+
/**
|
|
392
|
+
* `tier` is 1 for a first job, 2+ for each successive "continue" --
|
|
393
|
+
* scales both the scope guidance in the prompt and, indirectly, the
|
|
394
|
+
* story's stakes. `previousFixerName`, when given, tells the model to
|
|
395
|
+
* keep using the same job-giver identity rather than inventing a new one
|
|
396
|
+
* every time "continue" is used.
|
|
397
|
+
*/
|
|
398
|
+
static async generate(logFilePath, tier = 1, previousFixerName) {
|
|
399
|
+
if (!AI.isConfigured())
|
|
400
|
+
return null;
|
|
401
|
+
const logger = Logger.getInstance(logFilePath, { children: [], render: () => { } });
|
|
402
|
+
let lastError;
|
|
403
|
+
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
|
|
404
|
+
try {
|
|
405
|
+
logger.write(`SceneSeedGenerator: tier ${tier} attempt ${attempt} of ${this.maxAttempts}`);
|
|
406
|
+
const candidate = await this.requestCandidate(tier, previousFixerName, lastError);
|
|
407
|
+
await this.dryRunValidate(candidate);
|
|
408
|
+
logger.write(`SceneSeedGenerator: generated a valid scene "${candidate.name}" on attempt ${attempt}`);
|
|
409
|
+
this.saveSeedToDisk(candidate, logFilePath, tier);
|
|
410
|
+
return candidate;
|
|
411
|
+
}
|
|
412
|
+
catch (err) {
|
|
413
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
414
|
+
logger.write(`SceneSeedGenerator: attempt ${attempt} failed: ${lastError}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
logger.write(`SceneSeedGenerator: giving up after ${this.maxAttempts} attempts, falling back to a static scene`);
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Saves a validated scene alongside that session's game log (same
|
|
422
|
+
* directory as `logFilePath`) so a run can be inspected or replayed later
|
|
423
|
+
* -- AI-generated scenes previously only existed in memory for the
|
|
424
|
+
* process that generated them.
|
|
425
|
+
*/
|
|
426
|
+
static saveSeedToDisk(candidate, logFilePath, tier) {
|
|
427
|
+
const logger = Logger.getInstance();
|
|
428
|
+
const sessionDir = path.dirname(logFilePath);
|
|
429
|
+
const seedPath = path.join(sessionDir, `scene-job-${tier}.json`);
|
|
430
|
+
try {
|
|
431
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
432
|
+
fs.writeFileSync(seedPath, JSON.stringify(candidate, null, 2));
|
|
433
|
+
logger.write(`SceneSeedGenerator: saved generated scene to ${seedPath}`);
|
|
434
|
+
}
|
|
435
|
+
catch (err) {
|
|
436
|
+
logger.error(`SceneSeedGenerator: failed to save generated scene to ${seedPath}: ${err}`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
static async requestCandidate(tier, previousFixerName, previousError) {
|
|
440
|
+
const schemaGuide = buildSchemaGuide(tier, previousFixerName);
|
|
441
|
+
const prompt = previousError
|
|
442
|
+
? `${schemaGuide}\n\nYour previous attempt failed with this error -- fix it and try again: ${previousError}`
|
|
443
|
+
: schemaGuide;
|
|
444
|
+
let reply = await AI.ask(prompt);
|
|
445
|
+
// Models sometimes wrap their response in a markdown code fence despite instructions not to.
|
|
446
|
+
reply = reply.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
|
|
447
|
+
return JSON.parse(reply);
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Runs the candidate through the exact same synthesis pipeline a real
|
|
451
|
+
* session would use, purely to surface referential-integrity errors
|
|
452
|
+
* (missing room/item/npc names) before committing to it. Discards the
|
|
453
|
+
* resulting Scene either way -- the real session re-synthesizes fresh
|
|
454
|
+
* (with AI flavor enabled) once this seed is handed off.
|
|
455
|
+
*/
|
|
456
|
+
static async dryRunValidate(candidate) {
|
|
457
|
+
// Must run BEFORE synthesis: a room/npc/item with a missing or blank
|
|
458
|
+
// "name" doesn't fail synthesis with a useful error -- it crashes deep
|
|
459
|
+
// inside it with a raw JS TypeError ("Cannot read properties of
|
|
460
|
+
// undefined (reading 'normalize')"), found via a real generation where
|
|
461
|
+
// that opaque message became the "previous attempt failed" feedback fed
|
|
462
|
+
// back into the retry, giving the model nothing to actually act on.
|
|
463
|
+
this.validateBasicStructure(candidate);
|
|
464
|
+
const nexus = new Room({ name: 'Nexus', description: 'A featureless room where lost souls drift.', roomType: 'void' });
|
|
465
|
+
const throwawayPlayer = new Player({ playerName: candidate?.player?.name || 'Maka', startLocation: nexus });
|
|
466
|
+
// Referential-integrity errors in winCondition (bad item/toNpc/rewardItem
|
|
467
|
+
// names) are already caught by synthesis itself -- scene-factory.ts
|
|
468
|
+
// throws on those. This only checks the one semantic rule synthesis
|
|
469
|
+
// can't: the item must actually be findable, not already delivered.
|
|
470
|
+
await SceneSynthesizer.synthesizeFromJson(candidate, throwawayPlayer, false);
|
|
471
|
+
this.validateClueItems(candidate);
|
|
472
|
+
this.validateWinCondition(candidate);
|
|
473
|
+
this.validatePuzzleChain(candidate);
|
|
474
|
+
this.validateDoorKeys(candidate);
|
|
475
|
+
this.validateRewardItemPlacement(candidate);
|
|
476
|
+
this.validatePuzzlePlacement(candidate);
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* The one structural guarantee every other check assumes: every room,
|
|
480
|
+
* NPC, and item actually has a real, non-blank "name". Nothing about the
|
|
481
|
+
* JSON shape stops a model from emitting `"name": ""` or omitting the
|
|
482
|
+
* field outright, and synthesis doesn't validate it either -- it just
|
|
483
|
+
* uses whatever's there as a map key and crashes with an opaque error the
|
|
484
|
+
* moment something tries to look it up.
|
|
485
|
+
*/
|
|
486
|
+
static validateBasicStructure(candidate) {
|
|
487
|
+
const isBlank = (name) => typeof name !== 'string' || name.trim().length === 0;
|
|
488
|
+
(candidate.rooms ?? []).forEach((room, i) => {
|
|
489
|
+
if (isBlank(room?.name)) {
|
|
490
|
+
throw new Error(`"rooms[${i}]" is missing a "name" (or it's blank) -- every room needs a real, non-empty name.`);
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
(candidate.npcs ?? []).forEach((npc, i) => {
|
|
494
|
+
if (isBlank(npc?.player?.name)) {
|
|
495
|
+
throw new Error(`"npcs[${i}].player.name" is missing (or it's blank) -- every NPC needs a real, non-empty name.`);
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
(candidate.items ?? []).forEach((item, i) => {
|
|
499
|
+
if (isBlank(item?.name)) {
|
|
500
|
+
throw new Error(`"items[${i}]" is missing a "name" (or it's blank) -- every item needs a real, non-empty name.`);
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
(candidate.puzzles ?? []).forEach((puzzle, i) => {
|
|
504
|
+
if (isBlank(puzzle?.name)) {
|
|
505
|
+
throw new Error(`"puzzles[${i}]" is missing a "name" (or it's blank) -- every puzzle needs a real, non-empty name.`);
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* A puzzle must belong to exactly one place: a room (via "puzzles[].room")
|
|
511
|
+
* or a door (via some "exits[].puzzle" naming it) -- see DOORS AS PUZZLES.
|
|
512
|
+
* Synthesis itself doesn't catch either failure mode: an orphaned puzzle
|
|
513
|
+
* (neither) silently never appears anywhere in the world, and a
|
|
514
|
+
* double-placed one (both) would exist as two disconnected instances of
|
|
515
|
+
* "the same" barrier -- solvable in the room without ever needing to open
|
|
516
|
+
* the door, or vice versa.
|
|
517
|
+
*/
|
|
518
|
+
static validatePuzzlePlacement(candidate) {
|
|
519
|
+
const rooms = candidate.rooms ?? [];
|
|
520
|
+
const puzzles = candidate.puzzles ?? [];
|
|
521
|
+
const puzzleNames = new Set(puzzles.map(p => p.name));
|
|
522
|
+
const exitReferences = new Map();
|
|
523
|
+
for (const room of rooms) {
|
|
524
|
+
for (const exit of room.exits ?? []) {
|
|
525
|
+
if (!exit.puzzle)
|
|
526
|
+
continue;
|
|
527
|
+
if (!puzzleNames.has(exit.puzzle)) {
|
|
528
|
+
throw new Error(`Exit from "${room.name}" to "${exit.targetRoom}" references puzzle "${exit.puzzle}", which doesn't match any "puzzles[].name".`);
|
|
529
|
+
}
|
|
530
|
+
exitReferences.set(exit.puzzle, (exitReferences.get(exit.puzzle) ?? 0) + 1);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
for (const puzzle of puzzles) {
|
|
534
|
+
const doorCount = exitReferences.get(puzzle.name) ?? 0;
|
|
535
|
+
if (puzzle.room && doorCount > 0) {
|
|
536
|
+
throw new Error(`Puzzle "${puzzle.name}" is placed both in room "${puzzle.room}" AND on a door (via "exits[].puzzle") -- it must be exactly one or the other. Remove one placement.`);
|
|
537
|
+
}
|
|
538
|
+
if (!puzzle.room && doorCount === 0) {
|
|
539
|
+
throw new Error(`Puzzle "${puzzle.name}" isn't placed anywhere -- set its "room", or reference it from an "exits[].puzzle".`);
|
|
540
|
+
}
|
|
541
|
+
if (doorCount > 1) {
|
|
542
|
+
throw new Error(`Puzzle "${puzzle.name}" is referenced by more than one "exits[].puzzle" -- a single puzzle can only guard one door.`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* The schema says a reward item must have neither "room" nor "heldBy" --
|
|
548
|
+
* solving the puzzle (or completing the win condition) is what's supposed
|
|
549
|
+
* to bring it into existence for the player -- but nothing ever actually
|
|
550
|
+
* enforced that. Found via a real session where a puzzle's rewardItem was
|
|
551
|
+
* ALSO "heldBy" the very NPC guarding it: solving the puzzle granted a
|
|
552
|
+
* second, phantom copy of the item while the NPC's own real one stayed
|
|
553
|
+
* put (and later dropped normally when killed) -- so the player ended up
|
|
554
|
+
* with two disconnected copies of "the same" item, and the NPC's own
|
|
555
|
+
* dialogue reflected the same confused premise (accusing the player of
|
|
556
|
+
* theft for solving a puzzle it had just dared them to solve).
|
|
557
|
+
*/
|
|
558
|
+
static validateRewardItemPlacement(candidate) {
|
|
559
|
+
const itemByName = new Map((candidate.items ?? []).map(i => [i.name, i]));
|
|
560
|
+
for (const puzzle of candidate.puzzles ?? []) {
|
|
561
|
+
if (!puzzle.rewardItem)
|
|
562
|
+
continue;
|
|
563
|
+
const reward = itemByName.get(puzzle.rewardItem);
|
|
564
|
+
if (reward && (reward.room || reward.heldBy)) {
|
|
565
|
+
throw new Error(`Puzzle "${puzzle.name}"'s rewardItem "${puzzle.rewardItem}" already has "${reward.room ? 'room' : 'heldBy'}" set -- a reward item can't already exist somewhere in the world, since solving the puzzle is what's supposed to bring it into the player's possession. Remove "${reward.room ? 'room' : 'heldBy'}" from that item.`);
|
|
566
|
+
}
|
|
567
|
+
// A puzzle reward and the winCondition.item are opposite by definition
|
|
568
|
+
// (one MUST have no room/heldBy, the other MUST have one), so reusing
|
|
569
|
+
// the same item name for both is unsatisfiable no matter which way it's
|
|
570
|
+
// fixed -- caught via a real session where the model bounced between
|
|
571
|
+
// "remove room" and "not placed anywhere" across three straight
|
|
572
|
+
// attempts on the exact same item, because neither error alone said
|
|
573
|
+
// the two roles can't share an item. Named here explicitly so a retry
|
|
574
|
+
// fixes the actual problem instead of re-tripping the other rule.
|
|
575
|
+
if (candidate.winCondition && puzzle.rewardItem === candidate.winCondition.item) {
|
|
576
|
+
throw new Error(`Puzzle "${puzzle.name}"'s rewardItem and "winCondition.item" are the same item ("${puzzle.rewardItem}") -- they can't be, since a puzzle reward must have no "room"/"heldBy" while the winCondition.item must have one. Use two different items: the winCondition.item pre-placed somewhere in the world, and a separate rewardItem this puzzle grants.`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
const wcRewardName = candidate.winCondition?.rewardItem;
|
|
580
|
+
if (wcRewardName) {
|
|
581
|
+
const reward = itemByName.get(wcRewardName);
|
|
582
|
+
if (reward && (reward.room || reward.heldBy)) {
|
|
583
|
+
throw new Error(`Win condition rewardItem "${wcRewardName}" already has "${reward.room ? 'room' : 'heldBy'}" set -- same rule as a puzzle's rewardItem. Remove it.`);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Maps every item that has a concrete location (room, or heldBy an NPC
|
|
589
|
+
* whose startLocation gives it one) to that room's name. Shared by the
|
|
590
|
+
* reachability BFS and the win-condition checks below.
|
|
591
|
+
*/
|
|
592
|
+
static buildItemLocationMap(candidate) {
|
|
593
|
+
const npcStartByName = new Map((candidate.npcs ?? []).map(n => [n.player.name, n.player.startLocation]));
|
|
594
|
+
const itemLocation = new Map();
|
|
595
|
+
for (const item of candidate.items ?? []) {
|
|
596
|
+
if (item.room) {
|
|
597
|
+
itemLocation.set(item.name, item.room);
|
|
598
|
+
}
|
|
599
|
+
else if (item.heldBy) {
|
|
600
|
+
const loc = npcStartByName.get(item.heldBy);
|
|
601
|
+
if (loc)
|
|
602
|
+
itemLocation.set(item.name, loc);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return itemLocation;
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Lock-and-key reachability BFS from the start room: a room becomes
|
|
609
|
+
* reachable once some already-reachable room has an unlocked exit to it,
|
|
610
|
+
* or a locked exit whose key has already been found in a reachable
|
|
611
|
+
* location. Rooms marked "reachable: false" are excluded from this
|
|
612
|
+
* traversal on purpose -- see validateDoorKeys.
|
|
613
|
+
*/
|
|
614
|
+
static computeReachableRooms(candidate) {
|
|
615
|
+
const rooms = candidate.rooms ?? [];
|
|
616
|
+
const itemLocation = this.buildItemLocationMap(candidate);
|
|
617
|
+
const startRoom = rooms.find(r => r.isStartRoom)?.name;
|
|
618
|
+
const reachable = new Set();
|
|
619
|
+
if (!startRoom || !rooms.some(r => r.name === startRoom))
|
|
620
|
+
return reachable;
|
|
621
|
+
reachable.add(startRoom);
|
|
622
|
+
let grew = true;
|
|
623
|
+
while (grew) {
|
|
624
|
+
grew = false;
|
|
625
|
+
for (const room of rooms) {
|
|
626
|
+
if (!reachable.has(room.name))
|
|
627
|
+
continue;
|
|
628
|
+
for (const exit of room.exits ?? []) {
|
|
629
|
+
if (reachable.has(exit.targetRoom))
|
|
630
|
+
continue;
|
|
631
|
+
const keyLocation = exit.keyRequired ? itemLocation.get(exit.keyRequired) : undefined;
|
|
632
|
+
// A puzzle-guarded exit (see DOORS AS PUZZLES) is always treated
|
|
633
|
+
// as eventually passable: validateClueItems already guarantees
|
|
634
|
+
// every puzzle's answer is discoverable somewhere, and even
|
|
635
|
+
// without it, pick/hack/cast is a real (if risky) route through.
|
|
636
|
+
// Only a plain "keyRequired" door needs its key traced to a
|
|
637
|
+
// reachable location first.
|
|
638
|
+
const passable = !!exit.puzzle || !exit.keyRequired || (keyLocation !== undefined && reachable.has(keyLocation));
|
|
639
|
+
if (passable) {
|
|
640
|
+
reachable.add(exit.targetRoom);
|
|
641
|
+
grew = true;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return reachable;
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Synthesis itself doesn't catch a locked door whose "keyRequired" names
|
|
650
|
+
* an item that doesn't exist, or one that's placed somewhere only
|
|
651
|
+
* reachable by first passing through that same door -- both leave the
|
|
652
|
+
* player permanently stuck with no way to unlock it (the real bug this
|
|
653
|
+
* guards against, found via a real session where a player was stonewalled
|
|
654
|
+
* behind a locked door for an entire play session). Also enforces the
|
|
655
|
+
* "reachable: false" contract (used for a fixer's inaccessible hideout,
|
|
656
|
+
* say): such a room must never connect to a normally-reachable one, in
|
|
657
|
+
* either direction, and nothing the player needs to retrieve may live
|
|
658
|
+
* inside one.
|
|
659
|
+
*/
|
|
660
|
+
static validateDoorKeys(candidate) {
|
|
661
|
+
const rooms = candidate.rooms ?? [];
|
|
662
|
+
const items = candidate.items ?? [];
|
|
663
|
+
const roomByName = new Map(rooms.map(r => [r.name, r]));
|
|
664
|
+
for (const room of rooms) {
|
|
665
|
+
for (const exit of room.exits ?? []) {
|
|
666
|
+
if (!exit.keyRequired)
|
|
667
|
+
continue;
|
|
668
|
+
const item = items.find(i => i.name === exit.keyRequired);
|
|
669
|
+
if (!item) {
|
|
670
|
+
throw new Error(`Door from "${room.name}" to "${exit.targetRoom}" requires key "${exit.keyRequired}", which doesn't match any item in "items".`);
|
|
671
|
+
}
|
|
672
|
+
if (!item.room && !item.heldBy) {
|
|
673
|
+
throw new Error(`Key item "${exit.keyRequired}" (for the door from "${room.name}" to "${exit.targetRoom}") has neither "room" nor "heldBy" set -- it isn't placed anywhere in the world, so it can never actually be found.`);
|
|
674
|
+
}
|
|
675
|
+
if (item.room && roomByName.get(item.room)?.reachable === false) {
|
|
676
|
+
throw new Error(`Key item "${exit.keyRequired}" is placed in room "${item.room}", which is marked "reachable: false" -- it could never actually be retrieved.`);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
// A "reachable: false" room (a fixer's hideout, say) must never connect
|
|
681
|
+
// to a normally-reachable one -- the engine wires doors bidirectionally
|
|
682
|
+
// regardless of which side declares the exit, so this has to check
|
|
683
|
+
// every declared exit's endpoints, not just exits declared from the
|
|
684
|
+
// unreachable room's own side.
|
|
685
|
+
for (const room of rooms) {
|
|
686
|
+
for (const exit of room.exits ?? []) {
|
|
687
|
+
const targetRoom = roomByName.get(exit.targetRoom);
|
|
688
|
+
if (!targetRoom)
|
|
689
|
+
continue; // caught by synthesis itself
|
|
690
|
+
if ((room.reachable === false) !== (targetRoom.reachable === false)) {
|
|
691
|
+
throw new Error(`Exit from "${room.name}" to "${exit.targetRoom}" connects a "reachable: false" room to a normally-reachable one -- this would let the player walk into (or the NPC walk out of) a room meant to be inaccessible. A "reachable: false" room must only connect to other "reachable: false" rooms, or have no exits at all.`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
const startRoom = rooms.find(r => r.isStartRoom)?.name;
|
|
696
|
+
if (!startRoom || !roomByName.has(startRoom))
|
|
697
|
+
return; // caught elsewhere
|
|
698
|
+
const reachable = this.computeReachableRooms(candidate);
|
|
699
|
+
const unreachable = rooms.filter(r => r.reachable !== false && !reachable.has(r.name));
|
|
700
|
+
if (unreachable.length > 0) {
|
|
701
|
+
throw new Error(`These rooms are unreachable from the start room "${startRoom}" given the locked doors and key placements: ${unreachable.map(r => r.name).join(', ')}. Every door's key must be reachable before the door that needs it (or mark the room "reachable": false if it's meant to be inaccessible, like a fixer's hideout).`);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* requiresPuzzle references are structurally valid JSON either way (a
|
|
706
|
+
* bad name or a cycle won't make synthesis itself throw), so this walks
|
|
707
|
+
* each puzzle's prerequisite chain looking for a reference to a puzzle
|
|
708
|
+
* that doesn't exist or a cycle. Chains of any length are fine -- only
|
|
709
|
+
* missing references and cycles are rejected.
|
|
710
|
+
*/
|
|
711
|
+
static validatePuzzleChain(candidate) {
|
|
712
|
+
const puzzles = candidate.puzzles ?? [];
|
|
713
|
+
const byName = new Map(puzzles.map(p => [p.name, p]));
|
|
714
|
+
for (const start of puzzles) {
|
|
715
|
+
const visited = new Set([start.name]);
|
|
716
|
+
let current = start;
|
|
717
|
+
while (current.requiresPuzzle) {
|
|
718
|
+
const next = byName.get(current.requiresPuzzle);
|
|
719
|
+
if (!next) {
|
|
720
|
+
throw new Error(`Puzzle "${current.name}" requires puzzle "${current.requiresPuzzle}", which doesn't exist.`);
|
|
721
|
+
}
|
|
722
|
+
if (visited.has(next.name)) {
|
|
723
|
+
throw new Error(`Puzzle chain starting at "${start.name}" cycles back to "${next.name}".`);
|
|
724
|
+
}
|
|
725
|
+
visited.add(next.name);
|
|
726
|
+
current = next;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Every AI-generated scene must have a completable win condition -- this
|
|
732
|
+
* is what the "continue into a new job" loop hooks into. Also guards
|
|
733
|
+
* against a win condition item that's already sitting in the target
|
|
734
|
+
* NPC's own inventory, which would make the "find and deliver it"
|
|
735
|
+
* premise nonsensical.
|
|
736
|
+
*/
|
|
737
|
+
static validateWinCondition(candidate) {
|
|
738
|
+
const wc = candidate.winCondition;
|
|
739
|
+
if (!wc) {
|
|
740
|
+
throw new Error('Scene has no "winCondition". Every generated run needs one.');
|
|
741
|
+
}
|
|
742
|
+
const item = (candidate.items ?? []).find(i => i.name === wc.item);
|
|
743
|
+
if (!item) {
|
|
744
|
+
throw new Error(`Win condition item "${wc.item}" is not in "items".`);
|
|
745
|
+
}
|
|
746
|
+
if (!item.room && !item.heldBy) {
|
|
747
|
+
throw new Error(`Win condition item "${wc.item}" has neither "room" nor "heldBy" set -- it isn't placed anywhere in the world, so it can never actually be found.`);
|
|
748
|
+
}
|
|
749
|
+
// The payout is what the game actually pays AND what every NPC quotes
|
|
750
|
+
// -- a missing or nonsense figure breaks the promise/payment linkage
|
|
751
|
+
// this field exists for.
|
|
752
|
+
if (typeof wc.payout !== 'number' || !isFinite(wc.payout) || wc.payout <= 0) {
|
|
753
|
+
throw new Error(`"winCondition.payout" must be a positive number of nuyen (the job's agreed pay -- the engine transfers it on completion and the fixer quotes it). Got: ${JSON.stringify(wc.payout)}.`);
|
|
754
|
+
}
|
|
755
|
+
if (item.heldBy && item.heldBy === wc.toNpc) {
|
|
756
|
+
throw new Error(`Win condition item "${wc.item}" is already heldBy "${wc.toNpc}" -- it must be findable elsewhere, not already delivered.`);
|
|
757
|
+
}
|
|
758
|
+
const rooms = candidate.rooms ?? [];
|
|
759
|
+
if (item.room && rooms.find(r => r.name === item.room)?.reachable === false) {
|
|
760
|
+
throw new Error(`Win condition item "${wc.item}" is placed in room "${item.room}", which is marked "reachable: false" -- the player could never retrieve it.`);
|
|
761
|
+
}
|
|
762
|
+
// If the job-giver lives somewhere the player can never physically walk
|
|
763
|
+
// into (the fixer convention -- see buildSchemaGuide), "call" is the
|
|
764
|
+
// only way to complete a "give" condition, and "call" requires a
|
|
765
|
+
// commlink on BOTH ends.
|
|
766
|
+
if (wc.type === 'give' && wc.toNpc) {
|
|
767
|
+
const toNpcData = (candidate.npcs ?? []).find(n => n.player.name === wc.toNpc);
|
|
768
|
+
const toNpcRoom = rooms.find(r => r.name === toNpcData?.player.startLocation);
|
|
769
|
+
if (toNpcRoom?.reachable === false) {
|
|
770
|
+
const reachable = this.computeReachableRooms(candidate);
|
|
771
|
+
const playerHasCommlink = (candidate.player?.startingItems ?? []).some(i => i.category === 'Commlink') ||
|
|
772
|
+
(candidate.items ?? []).some(i => i.category === 'Commlink' && i.room && reachable.has(i.room));
|
|
773
|
+
if (!playerHasCommlink) {
|
|
774
|
+
throw new Error(`Win condition target "${wc.toNpc}" is in a "reachable: false" room ("${toNpcRoom.name}"), so the player can only contact them via "call" -- but no Commlink-category item is available (not in "player.startingItems", and none findable in a reachable room). Give the player a way to get a commlink.`);
|
|
775
|
+
}
|
|
776
|
+
const fixerHasCommlink = (candidate.items ?? []).some(i => i.category === 'Commlink' && i.heldBy === wc.toNpc);
|
|
777
|
+
if (!fixerHasCommlink) {
|
|
778
|
+
throw new Error(`Win condition target "${wc.toNpc}" is in a "reachable: false" room, reachable only via "call" -- but they don't hold a Commlink-category item themselves. Add one to "items" with "heldBy": "${wc.toNpc}".`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Enforces the "always some way to find the solution" guarantee: every
|
|
785
|
+
* puzzle must have a discoverable clue, placed in a different room. None
|
|
786
|
+
* of this is caught by synthesis (a puzzle with no clue, or an empty one,
|
|
787
|
+
* is still structurally valid), so it's checked separately and treated
|
|
788
|
+
* the same as any other validation failure -- it triggers a retry.
|
|
789
|
+
*
|
|
790
|
+
* Two patterns both count. Found via a real generation that kept getting
|
|
791
|
+
* rejected despite being genuinely solvable: a riddle whose answer is a
|
|
792
|
+
* real item's own name (e.g. "obsidian keycard", matching an actual item
|
|
793
|
+
* called "Obsidian Keycard" sitting in another room) needs no separate
|
|
794
|
+
* documentation -- finding it and seeing its name on screen already
|
|
795
|
+
* reveals the answer. A Book clue is still required for anything else.
|
|
796
|
+
*/
|
|
797
|
+
static validateClueItems(candidate) {
|
|
798
|
+
const items = candidate.items ?? [];
|
|
799
|
+
for (const puzzle of candidate.puzzles ?? []) {
|
|
800
|
+
const requiredPhrase = (puzzle.requiredPhrase ?? '').toLowerCase();
|
|
801
|
+
const hasBookClue = items.some(item => item.category === 'Book' &&
|
|
802
|
+
item.room &&
|
|
803
|
+
item.room !== puzzle.room &&
|
|
804
|
+
item.details &&
|
|
805
|
+
item.details.trim().length > 0 &&
|
|
806
|
+
(!requiredPhrase || item.details.toLowerCase().includes(requiredPhrase)));
|
|
807
|
+
const hasNamedItemClue = requiredPhrase.length > 0 && items.some(item => item.room &&
|
|
808
|
+
item.room !== puzzle.room &&
|
|
809
|
+
item.name.toLowerCase() === requiredPhrase);
|
|
810
|
+
if (!hasBookClue && !hasNamedItemClue) {
|
|
811
|
+
throw new Error(`Puzzle "${puzzle.name}" has no discoverable clue: no Book-category item in a different room whose "details" contains the answer "${puzzle.requiredPhrase}", and no item elsewhere literally named "${puzzle.requiredPhrase}". Every puzzle needs one or the other.`);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
//# sourceMappingURL=scene-seed-generator.js.map
|