@maka/maka-cli 5.4.0 → 5.6.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/archetypes.d.ts +20 -1
- package/bundle/typescript/src/commands/game/sideQuest/archetypes.js +76 -11
- package/bundle/typescript/src/commands/game/sideQuest/archetypes.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/advance.d.ts +16 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/advance.js +65 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/advance.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/assense.d.ts +2 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/assense.js +72 -29
- package/bundle/typescript/src/commands/game/sideQuest/commands/assense.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +21 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.d.ts +16 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js +39 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/brandish.d.ts +21 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/brandish.js +82 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/brandish.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/call.d.ts +13 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/call.js +93 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/call.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +21 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/drone.js +13 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/drone.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/drop.js +3 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/drop.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/equip.js +19 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/equip.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/equipment.js +9 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/equipment.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/go.d.ts +2 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +33 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/go.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +43 -20
- package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.d.ts +15 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.js +75 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/inv.js +5 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/inv.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +16 -4
- package/bundle/typescript/src/commands/game/sideQuest/commands/look.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/persuade.d.ts +18 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/persuade.js +100 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/persuade.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/pick.d.ts +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/pick.js +22 -7
- package/bundle/typescript/src/commands/game/sideQuest/commands/pick.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/say.js +8 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/say.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +55 -8
- package/bundle/typescript/src/commands/game/sideQuest/commands/search.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js +11 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/sneak.d.ts +15 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/sneak.js +35 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/sneak.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/spells.js +21 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/spells.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/subdue.js +4 -4
- package/bundle/typescript/src/commands/game/sideQuest/commands/subdue.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +70 -13
- package/bundle/typescript/src/commands/game/sideQuest/commands/take.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/talk.js +8 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/talk.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/unequip.js +6 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/unequip.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/use.js +5 -4
- package/bundle/typescript/src/commands/game/sideQuest/commands/use.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/room-factory.js +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/factories/room-factory.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.d.ts +175 -0
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +673 -0
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js +1 -0
- 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 +64 -9
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js +265 -434
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/game.d.ts +69 -1
- package/bundle/typescript/src/commands/game/sideQuest/game.js +388 -67
- package/bundle/typescript/src/commands/game/sideQuest/game.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.d.ts +2 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +92 -3
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/player.d.ts +66 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/player.js +151 -15
- package/bundle/typescript/src/commands/game/sideQuest/models/player.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.js +29 -4
- package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/room.d.ts +4 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/room.js +14 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/room.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.d.ts +4 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +35 -3
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/scenes/scene1.json +275 -105
- package/bundle/typescript/src/commands/game/sideQuest/scenes/scene2.json +662 -233
- package/bundle/typescript/src/commands/game/sideQuest/types/seed/player-seed.d.ts +8 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/seed/puzzle-seed.d.ts +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/types/seed/room-seed.d.ts +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/seed/scene-seed.d.ts +6 -3
- package/bundle/typescript/src/commands/game/sideQuest/ui.js +19 -20
- package/bundle/typescript/src/commands/game/sideQuest/ui.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.d.ts +6 -4
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +31 -5
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.d.ts +34 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +72 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js +27 -3
- package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js.map +1 -1
- package/bundle/typescript/src/tools/ai/ai.class.d.ts +8 -1
- package/bundle/typescript/src/tools/ai/ai.class.js +8 -3
- package/bundle/typescript/src/tools/ai/ai.class.js.map +1 -1
- package/bundle/typescript/src/tools/https/https.class.d.ts +2 -2
- package/bundle/typescript/src/tools/https/https.class.js +10 -4
- package/bundle/typescript/src/tools/https/https.class.js.map +1 -1
- package/bundle/typescript/src/tools/https/https.interface.d.ts +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
import { SKILL_NAMES } from '../models/player.js';
|
|
2
|
+
/**
|
|
3
|
+
* CHUNKED scene generation (see SceneSeedGenerator.generate for the
|
|
4
|
+
* pipeline that drives this module). One monolithic whole-scene completion
|
|
5
|
+
* kept hitting the response token ceiling as scenes grew -- a real session
|
|
6
|
+
* lost four of six tier-2 attempts to JSON truncated mid-string -- and a
|
|
7
|
+
* single validation miss threw the entire scene away. So generation now
|
|
8
|
+
* runs in passes:
|
|
9
|
+
*
|
|
10
|
+
* 1. SKELETON: the whole scene's STRUCTURE -- rooms with exits/locks/
|
|
11
|
+
* nodes, the NPC roster with locations, puzzle placements, every
|
|
12
|
+
* item's name/role/location, and the win condition. Compact (one-line
|
|
13
|
+
* concepts instead of prose), so every cross-reference the validators
|
|
14
|
+
* check lives in one small completion.
|
|
15
|
+
* 2. DETAIL CHUNKS, each its own small completion against the fixed
|
|
16
|
+
* skeleton: room prose; NPC voices/stats; puzzle riddles and
|
|
17
|
+
* passphrases; item descriptions (clue items get their puzzle's exact
|
|
18
|
+
* phrase baked into "details" -- which is why items runs AFTER
|
|
19
|
+
* puzzles).
|
|
20
|
+
* 3. Assembly + the same validators as ever; a failure regenerates ONLY
|
|
21
|
+
* the chunk responsible.
|
|
22
|
+
*
|
|
23
|
+
* No single completion carries the whole scene, so scene scale is no
|
|
24
|
+
* longer bounded by the provider's output budget.
|
|
25
|
+
*/
|
|
26
|
+
// The fixer's name is a fixed Shadowrun-lore convention, not something
|
|
27
|
+
// generation ever varies (in-universe slang for an anonymous employer).
|
|
28
|
+
export const FIXER_NAME = 'Mr. Johnson';
|
|
29
|
+
// =========================== Player dossier ==============================
|
|
30
|
+
/**
|
|
31
|
+
* A compact character sheet injected into the skeleton prompt so a job is
|
|
32
|
+
* built FOR the runner actually playing it (archetype, stats, toolkit).
|
|
33
|
+
* Returns '' when no player is available.
|
|
34
|
+
*/
|
|
35
|
+
export function buildPlayerDossier(player) {
|
|
36
|
+
if (!player)
|
|
37
|
+
return '';
|
|
38
|
+
const a = player;
|
|
39
|
+
const attrs = `body ${a.body}, agility ${a.agility}, reaction ${a.reaction}, strength ${a.strength}, ` +
|
|
40
|
+
`willpower ${a.willpower}, logic ${a.logic}, intuition ${a.intuition}, charisma ${a.charisma}` +
|
|
41
|
+
(a.magic > 0 ? `, magic ${a.magic}${a.adept ? ' (adept)' : ''}` : '') +
|
|
42
|
+
(a.resonance > 0 ? `, resonance ${a.resonance}` : '');
|
|
43
|
+
const topSkills = SKILL_NAMES
|
|
44
|
+
.filter(s => a.skillRating(s) > 0)
|
|
45
|
+
.sort((x, y) => a.skillRating(y) - a.skillRating(x))
|
|
46
|
+
.slice(0, 6)
|
|
47
|
+
.map(s => `${s} ${a.skillRating(s)}`)
|
|
48
|
+
.join(', ');
|
|
49
|
+
const carried = a.getCarriedWeapon();
|
|
50
|
+
const holsteredBonus = carried && !a.weaponDrawn ? carried.qualityBonus : 0;
|
|
51
|
+
const attack = a.getAttackPool() + holsteredBonus;
|
|
52
|
+
const defense = a.getDefensePool();
|
|
53
|
+
const deck = a.getAnyCyberdeck();
|
|
54
|
+
const caps = [];
|
|
55
|
+
if (a.adept) {
|
|
56
|
+
caps.push('Physical ADEPT: magic fuels their meat combat; NO spellcasting, NO astral projection -- spell formulas, grimoires, and foci are all useless to them');
|
|
57
|
+
}
|
|
58
|
+
else if (a.isAwakened()) {
|
|
59
|
+
caps.push('Awakened MAGE: casts manabolt/heal/armor innately, can astrally project ("project") and assense');
|
|
60
|
+
}
|
|
61
|
+
if (a.isTechnomancer()) {
|
|
62
|
+
caps.push('TECHNOMANCER: jacks into the Matrix with no deck (living persona)');
|
|
63
|
+
}
|
|
64
|
+
if (deck) {
|
|
65
|
+
caps.push(`Carries a cyberdeck (${deck.name}${deck.isBricked ? ', currently BRICKED' : ''}): can jack in, hack nodes, fight ice, lift matrix paydata, and crack PANs`);
|
|
66
|
+
}
|
|
67
|
+
if (a.inventory.getAllItems().some(i => String(i.category).toLowerCase() === 'drone')) {
|
|
68
|
+
caps.push('Has a surveillance drone: can scout all adjacent rooms at will');
|
|
69
|
+
}
|
|
70
|
+
if (a.charisma >= 5) {
|
|
71
|
+
caps.push(`A talker (charisma ${a.charisma}): social leverage and negotiation are their strength`);
|
|
72
|
+
}
|
|
73
|
+
if (a.logic >= 5 && !deck && !a.isTechnomancer()) {
|
|
74
|
+
caps.push(`Sharp technical mind (logic ${a.logic})`);
|
|
75
|
+
}
|
|
76
|
+
if (caps.length === 0) {
|
|
77
|
+
caps.push('No magic, no resonance, no deck, no drone -- a mundane who solves problems with gear, grit, and gunfire');
|
|
78
|
+
}
|
|
79
|
+
const gearBits = [];
|
|
80
|
+
if (carried)
|
|
81
|
+
gearBits.push(`weapon: ${carried.name}`);
|
|
82
|
+
const armor = a.getArmorValue();
|
|
83
|
+
if (armor > 0)
|
|
84
|
+
gearBits.push(`armor +${armor} soak`);
|
|
85
|
+
return `
|
|
86
|
+
THE RUNNER (build this job FOR this specific character):
|
|
87
|
+
- Name: ${a.name}${a.archetypeName ? ` -- archetype: ${a.archetypeName}` : ''}
|
|
88
|
+
- Attributes: ${attrs}
|
|
89
|
+
${topSkills ? `- Top skills: ${topSkills}` : ''}
|
|
90
|
+
- Combat calibration: attack pool ~${attack}d6, defense pool ~${defense}d6${gearBits.length > 0 ? ` (${gearBits.join(', ')})` : ''}
|
|
91
|
+
${caps.map(c => `- ${c}`).join('\n')}
|
|
92
|
+
|
|
93
|
+
Tailoring rules:
|
|
94
|
+
- Include at least one route, subplot, or payoff that rewards THIS runner's toolkit (matrix
|
|
95
|
+
content and hasNode rooms for a decker/technomancer; astral angles or Awakened opposition
|
|
96
|
+
for a mage; melee-shaped trouble for an adept; social leverage and "con"-bypass barriers
|
|
97
|
+
for a talker; recon value for a drone owner). Pick what fits this character and this
|
|
98
|
+
story -- don't bolt on every plane at once.
|
|
99
|
+
- Never gate FINISHING the run behind a capability they lack: no matrix-plane winCondition
|
|
100
|
+
item unless they can already jack in (deck or resonance) or a findable Cyberdeck is placed
|
|
101
|
+
in a reachable room; no ward/"spell"-bypass-only route for someone who can't cast.
|
|
102
|
+
- Don't hand them loot they can't use as a puzzle rewardItem or the winCondition rewardItem
|
|
103
|
+
(spellcasting gear for an adept, a second cyberdeck or drone for someone who already has
|
|
104
|
+
one). Useless-to-them gear may still exist as ordinary world dressing or valuables.
|
|
105
|
+
- The runner ALREADY OWNS everything the dossier lists -- never duplicate any of it (their
|
|
106
|
+
deck, drone, weapon, focus, commlink) in the "starting"-role items. Round those out with
|
|
107
|
+
complementary basics instead: ammunition, a medkit, armor, tools -- or fewer items.
|
|
108
|
+
- Scale opposition to the calibration pools above: rank-and-file threats should roll a few
|
|
109
|
+
dice below the runner's pools, and a centerpiece enforcer can match or slightly exceed
|
|
110
|
+
them. Don't send soft civilians at a combat monster, or elite security at a runner built
|
|
111
|
+
for talking their way through.
|
|
112
|
+
`.trim();
|
|
113
|
+
}
|
|
114
|
+
// ============================ Prompt builders ============================
|
|
115
|
+
const JSON_ONLY = `Output ONLY raw JSON -- no markdown fences, no commentary, no text before or after it.`;
|
|
116
|
+
function repairNote(previousError) {
|
|
117
|
+
return previousError
|
|
118
|
+
? `\n\nYour previous attempt failed with this error -- fix it specifically and try again: ${previousError}`
|
|
119
|
+
: '';
|
|
120
|
+
}
|
|
121
|
+
export function buildSkeletonPrompt(tier, previousFixerName, player, previousError) {
|
|
122
|
+
// Tier 1 is the only fresh start (the launch menu's "Generate a new
|
|
123
|
+
// Run"); every Johnson-commissioned draft is tier 2+ and the runner
|
|
124
|
+
// arrives fully equipped -- authoring "starting" items there was pure
|
|
125
|
+
// waste, since synthesis skips granting them on continuations anyway.
|
|
126
|
+
const isContinuation = tier > 1;
|
|
127
|
+
// NPC counts EXCLUDE the fixer: he's overhead (unreachable, no gameplay).
|
|
128
|
+
const scope = tier <= 1
|
|
129
|
+
? '3 to 4 rooms, exactly 1 NPC besides the fixer, and exactly 1 puzzle'
|
|
130
|
+
: tier === 2
|
|
131
|
+
? '4 to 6 rooms, 2 NPCs besides the fixer, and 1 to 2 puzzles'
|
|
132
|
+
: '6 to 8 rooms, 3 to 4 NPCs besides the fixer, and 2 to 3 puzzles';
|
|
133
|
+
const continuity = previousFixerName
|
|
134
|
+
? `\nThis is a CONTINUATION of an ongoing relationship with the same fixer, "${previousFixerName}" --
|
|
135
|
+
who commissioned the runner's last job and is lining up this one too. Reuse "${previousFixerName}"
|
|
136
|
+
as this scene's fixer (same voice/personality); do not reintroduce them as a stranger.`
|
|
137
|
+
: '';
|
|
138
|
+
const dossier = buildPlayerDossier(player);
|
|
139
|
+
return `
|
|
140
|
+
You are generating the STRUCTURE (skeleton) of a self-contained Shadowrun-flavored text
|
|
141
|
+
adventure "run". This is job #${tier} in an ongoing campaign -- scale stakes accordingly.
|
|
142
|
+
Prose comes later in separate passes: here, every "concept" is ONE tight sentence of intent,
|
|
143
|
+
and everything else is structure. Get the STRUCTURE exactly right -- every name is a
|
|
144
|
+
cross-reference other passes and the game engine will rely on verbatim.
|
|
145
|
+
${continuity}
|
|
146
|
+
${dossier ? `\n${dossier}\n` : ''}
|
|
147
|
+
${JSON_ONLY}
|
|
148
|
+
|
|
149
|
+
{
|
|
150
|
+
"name": string, // the run's title
|
|
151
|
+
"story": string, // 1-2 SHORT teaser sentences shown before play -- name the shape of the
|
|
152
|
+
// trouble, never the objective, target names, locations, or passphrases
|
|
153
|
+
// (those are learned by calling the fixer).
|
|
154
|
+
"completionMessage": string, // 1-2 sentences shown when the job completes
|
|
155
|
+
"rooms": [{
|
|
156
|
+
"name": string, "concept": string, "roomType"?: string,
|
|
157
|
+
"isStartRoom"?: boolean, // EXACTLY ONE room
|
|
158
|
+
"reachable"?: boolean, // false ONLY for the fixer's remote room -- see FIXER below
|
|
159
|
+
"owner"?: string, // an NPC's name, if it's their room
|
|
160
|
+
"hasNode"?: boolean, // a live Matrix host: hackable from inside the Matrix; releases the
|
|
161
|
+
// room's maglocks AND unseals its matrix-plane files (the host is
|
|
162
|
+
// the vault). 1-2 rooms in corp/tech locations only. Ice NPCs and
|
|
163
|
+
// matrix-plane items belong ONLY in hasNode rooms, and any hasNode
|
|
164
|
+
// room holding matrix items MUST have an ice NPC stationed in it.
|
|
165
|
+
"nodeAccess"?: "wireless"|"wired", // "wired" = AIR-GAPPED: only hackable by jacking in
|
|
166
|
+
// physically inside the room. Use for at most one, most-sensitive
|
|
167
|
+
// host; never make its meat-side route impossible.
|
|
168
|
+
"warded"?: boolean, // astral barrier: projecting mages can't enter. Use sparingly.
|
|
169
|
+
"exits": [{ "direction": "north"|"east"|"south"|"west"|"up"|"down", "targetRoom": string,
|
|
170
|
+
"keyRequired"?: string, // an items[].name with role "key"
|
|
171
|
+
"puzzle"?: string }] // a puzzles[].name guarding this door. A door puzzle
|
|
172
|
+
// lives ON THE DOOR ONLY: that puzzle must have NO
|
|
173
|
+
// "room" field of its own (setting both is the most
|
|
174
|
+
// common structural mistake -- don't).
|
|
175
|
+
// Doors are TWO-WAY automatically: declare each connection from ONE
|
|
176
|
+
// side only (with its keyRequired/puzzle on that one declaration) --
|
|
177
|
+
// never declare the reverse direction too.
|
|
178
|
+
}],
|
|
179
|
+
"npcs": [{
|
|
180
|
+
"name": string, "concept": string, "startLocation": string,
|
|
181
|
+
"plane"?: "meat"|"matrix"|"astral" // "matrix" = ICE guarding a hasNode room;
|
|
182
|
+
// "astral" = a spirit. Default meat.
|
|
183
|
+
}],
|
|
184
|
+
"puzzles": [{
|
|
185
|
+
"name": string, "concept": string, // what the barrier physically IS (a maglock, a
|
|
186
|
+
// keypad, a scanner, a doorman, a ward)
|
|
187
|
+
"room"?: string, // room-anchored placement -- OMIT when an exits[].puzzle references it
|
|
188
|
+
"bypassType"?: "lockpick"|"hack"|"spell"|"con", // the matching risky route: literal
|
|
189
|
+
// lock -> "lockpick"; terminal/scanner -> "hack"; ward -> "spell";
|
|
190
|
+
// a PERSON/voice-gate that could be talked past -> "con". Omit if
|
|
191
|
+
// no such route. EXACTLY these lowercase strings.
|
|
192
|
+
"requiresPuzzle"?: string, // another puzzle solved FIRST (no cycles)
|
|
193
|
+
"rewardItem"?: string // an items[].name with role "reward" -- REQUIRED for room-anchored
|
|
194
|
+
// puzzles (solving it is how the player gets that item); omit for
|
|
195
|
+
// door puzzles (the open door is the reward)
|
|
196
|
+
}],
|
|
197
|
+
"items": [{
|
|
198
|
+
"name": string,
|
|
199
|
+
"role": "winCondition"|"reward"|"key"|"clue"|"loot"|"starting",
|
|
200
|
+
"category": string, // one of: Weapon, MeleeWeapon, RangedWeapon, Armor, BodyArmor,
|
|
201
|
+
// ArmorClothing, Helmet, Hat, LegArmor, ArmArmor, Gloves, Commlink,
|
|
202
|
+
// Cyberdeck, Drone, Credstick, CurrencyContainer, Key, Trinket,
|
|
203
|
+
// Document, Datachip, Vice, Book, Tool, Clothing, Glasses, Boots,
|
|
204
|
+
// Medical, StimPatch, SpellFormula, Grimoire, Focus, Ammunition
|
|
205
|
+
"room"?: string, "heldBy"?: string, // exactly one of these for roles winCondition/key/
|
|
206
|
+
// clue/loot; NEITHER for roles reward/starting
|
|
207
|
+
"plane"?: "meat"|"matrix", // "matrix" = paydata: only in a hasNode room, category
|
|
208
|
+
// Datachip/Document. A matrix-plane winCondition item makes
|
|
209
|
+
// the job a datarun (its room MUST have ice).
|
|
210
|
+
"forPuzzle"?: string // role "clue" only: which puzzle's passphrase this clue reveals.
|
|
211
|
+
// EVERY puzzle needs exactly one clue item, category "Book",
|
|
212
|
+
// placed in a DIFFERENT room than that puzzle (door puzzles: any
|
|
213
|
+
// room). The clue's text is written in a later pass.
|
|
214
|
+
// ${isContinuation ? 'Role "starting" is FORBIDDEN in this scene -- the runner arrives fully equipped (see dossier).' : 'Role "starting" items are the fresh runner\'s basic kit.'}
|
|
215
|
+
}],
|
|
216
|
+
"winCondition": {
|
|
217
|
+
"item": string, // the items[].name with role "winCondition" -- MUST have room or heldBy
|
|
218
|
+
// set (never heldBy "toNpc" -- the player has to find it), and it is
|
|
219
|
+
// the central plot object of "story"
|
|
220
|
+
"toNpc": string, // an npcs[].name -- who wants it delivered
|
|
221
|
+
"payout": number, // the job's pay in nuyen; the engine actually transfers this and the
|
|
222
|
+
// fixer quotes it. ~2000-6000 for job #1, roughly half again more per
|
|
223
|
+
// job after that
|
|
224
|
+
"rewardItem"?: string // optional bonus: an items[].name with role "reward"
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
FIXER: the job-giver is named EXACTLY "${FIXER_NAME}" (in-universe slang for an anonymous
|
|
229
|
+
employer -- never invent another name for the role). Give him his own room marked
|
|
230
|
+
"reachable": false with NO exits to reachable rooms; "call" is the only way to reach him.
|
|
231
|
+
He MUST hold a Commlink-category item (role "loot", heldBy him), and the player must have a
|
|
232
|
+
commlink available (${isContinuation ? 'the runner already carries their own -- see the dossier' : '"starting" role, or findable in a reachable room'}). He does NOT have to
|
|
233
|
+
be winCondition.toNpc -- a different on-the-ground contact is often the better story; any
|
|
234
|
+
such contact in an unreachable room needs a commlink of their own too.
|
|
235
|
+
|
|
236
|
+
Hard structural rules (validated mechanically -- a violation is rejected):
|
|
237
|
+
- Every exits[].targetRoom, npcs[].startLocation, items[].room name a real rooms[].name;
|
|
238
|
+
every items[].heldBy names a real npcs[].name; every exits[].puzzle names a real
|
|
239
|
+
puzzles[].name; every keyRequired names a real items[].name with role "key".
|
|
240
|
+
- Exactly one room has isStartRoom true. Never lock the start room's only exits behind keys
|
|
241
|
+
placed beyond them: every key must be reachable WITHOUT passing through the door it opens.
|
|
242
|
+
- A "reachable": false room never connects to a reachable one, and nothing the player must
|
|
243
|
+
retrieve (winCondition item, keys, clues) is ever placed in one.
|
|
244
|
+
- Each puzzle is placed EXACTLY one way: its own "room", or exactly one exits[].puzzle
|
|
245
|
+
reference. Each puzzle has exactly one "clue"-role Book item (forPuzzle set) in a
|
|
246
|
+
DIFFERENT room -- EXCEPT when the puzzle's bypassType matches a skill the runner's
|
|
247
|
+
dossier shows real strength in (a lockpick barrier for a master locksmith, a con
|
|
248
|
+
barrier for a silver-tongued face): their skill IS the way past, and the clue may be
|
|
249
|
+
omitted. When in doubt, include the clue. requiresPuzzle chains never cycle.
|
|
250
|
+
- The winCondition item must not be heldBy winCondition.toNpc. If a matrix-plane item is the
|
|
251
|
+
winCondition item, its room has hasNode true AND a "matrix"-plane NPC stationed there.
|
|
252
|
+
- ${isContinuation
|
|
253
|
+
? 'ZERO "starting"-role items -- the runner arrives with everything they own. 4-6'
|
|
254
|
+
: '3-5 "starting"-role items (basics the runner doesn\'t already own -- see the dossier), 4-6'}
|
|
255
|
+
"loot"-role world items, one "clue" per puzzle.
|
|
256
|
+
|
|
257
|
+
Scope: ${scope}. Gritty noir Shadowrun tone: a fixer, a job, a debt, a betrayal -- and a
|
|
258
|
+
fresh premise, not a generic "corp blackmail vault" rerun.${repairNote(previousError)}
|
|
259
|
+
`.trim();
|
|
260
|
+
}
|
|
261
|
+
/** Shared context header for the detail passes: the fixed skeleton. */
|
|
262
|
+
function skeletonContext(skeleton) {
|
|
263
|
+
return `The scene's FIXED skeleton (all names are final -- reference them EXACTLY, never invent new ones):
|
|
264
|
+
${JSON.stringify(skeleton, null, 1)}`;
|
|
265
|
+
}
|
|
266
|
+
export function buildRoomsPrompt(skeleton, previousError) {
|
|
267
|
+
return `
|
|
268
|
+
You are writing the room descriptions for a Shadowrun text adventure.
|
|
269
|
+
|
|
270
|
+
${skeletonContext(skeleton)}
|
|
271
|
+
|
|
272
|
+
For EVERY room in the skeleton, write evocative 2-3 sentence prose expanding its "concept" --
|
|
273
|
+
gritty cyberpunk noir, concrete sensory detail, no mechanics talk and no spoilers (never
|
|
274
|
+
reveal passphrases, hidden items, or plot twists in a room description).
|
|
275
|
+
|
|
276
|
+
${JSON_ONLY}
|
|
277
|
+
[ { "name": string, "description": string, "roomType": string } ]
|
|
278
|
+
One entry per skeleton room, "name" copied exactly.${repairNote(previousError)}
|
|
279
|
+
`.trim();
|
|
280
|
+
}
|
|
281
|
+
export function buildNpcsPrompt(skeleton, previousError) {
|
|
282
|
+
return `
|
|
283
|
+
You are writing the characters for a Shadowrun text adventure.
|
|
284
|
+
|
|
285
|
+
${skeletonContext(skeleton)}
|
|
286
|
+
|
|
287
|
+
For EVERY npc in the skeleton, expand its "concept":
|
|
288
|
+
- "description": private GM notes on personality/motives (never shown verbatim to the player).
|
|
289
|
+
- "dialogue": 2-4 short in-character lines. For "${FIXER_NAME}" (the fixer) this is the REAL
|
|
290
|
+
briefing -- the objective (the winCondition item), where to start looking, the stakes, and
|
|
291
|
+
the exact payout figure from the skeleton (the engine pays exactly that number). When
|
|
292
|
+
referring to places, use EXACT room names from the skeleton -- never invent locations.
|
|
293
|
+
- "combat": OPTIONAL attribute block -- omit for anyone who'd never fight. Each attribute
|
|
294
|
+
1-8 (soft civilian 1-2, street-level 3-4, hardened enforcer 5-6, elite 7-8):
|
|
295
|
+
{ "body", "agility", "reaction", "combatSkill", "strength"?, "willpower"?, "logic"?,
|
|
296
|
+
"intuition"?, "charisma"?, "magic"?, "resonance"?, "adept"?,
|
|
297
|
+
"skills"?: { "firearms"?, "blades"?, "unarmed"?, "sneaking"?, "perception"?, "hacking"?,
|
|
298
|
+
"locksmith"?, "negotiation"?, "con"?, "intimidation"?, "assensing"? } }
|
|
299
|
+
"magic" 3-6 ONLY for a genuinely Awakened concept; "plane":"matrix" ice fights with
|
|
300
|
+
logic 4-7; give combat blocks to anyone the story expects in a firefight. "skills" is
|
|
301
|
+
optional flavor for defined concepts (a sharpshooter's "firearms": 6).
|
|
302
|
+
|
|
303
|
+
${JSON_ONLY}
|
|
304
|
+
[ { "name": string, "description": string, "dialogue": string[], "combat"?: { ... } } ]
|
|
305
|
+
One entry per skeleton npc, "name" copied exactly.${repairNote(previousError)}
|
|
306
|
+
`.trim();
|
|
307
|
+
}
|
|
308
|
+
export function buildPuzzlesPrompt(skeleton, previousError) {
|
|
309
|
+
if (skeleton.puzzles.length === 0)
|
|
310
|
+
return '';
|
|
311
|
+
return `
|
|
312
|
+
You are writing the barriers (puzzles) for a Shadowrun text adventure.
|
|
313
|
+
|
|
314
|
+
${skeletonContext(skeleton)}
|
|
315
|
+
|
|
316
|
+
For EVERY puzzle in the skeleton, expand its "concept" (what the barrier physically is --
|
|
317
|
+
that's fixed, as is its bypassType):
|
|
318
|
+
- "riddle": what the player sees on approaching it, shown verbatim -- PLAIN and concrete,
|
|
319
|
+
never a cryptic riddle-poem. Start with "You see" (or similar), name the barrier, then
|
|
320
|
+
what it demands (a code, a phrase, a name), the way an NPC would describe it. Never state
|
|
321
|
+
the credential itself.
|
|
322
|
+
- "requiredPhrase": the exact credential -- ALL LOWERCASE, 2-5 plain words, no punctuation.
|
|
323
|
+
A concrete literal thing (a code, a passphrase, a name), never wordplay.
|
|
324
|
+
- "successMessage" / "failureMessage": one sentence each, in-fiction.
|
|
325
|
+
- "lockedMessage": only for puzzles with "requiresPuzzle" -- what the player sees trying it
|
|
326
|
+
too early, hinting at the prerequisite without naming mechanics.
|
|
327
|
+
|
|
328
|
+
${JSON_ONLY}
|
|
329
|
+
[ { "name": string, "riddle": string, "requiredPhrase": string, "successMessage": string,
|
|
330
|
+
"failureMessage": string, "lockedMessage"?: string } ]
|
|
331
|
+
One entry per skeleton puzzle, "name" copied exactly.${repairNote(previousError)}
|
|
332
|
+
`.trim();
|
|
333
|
+
}
|
|
334
|
+
export function buildItemsPrompt(skeleton, puzzles, previousError) {
|
|
335
|
+
const clueSpecs = skeleton.items
|
|
336
|
+
.filter(i => i.role === 'clue' && i.forPuzzle)
|
|
337
|
+
.map(i => {
|
|
338
|
+
const phrase = puzzles.find(p => p.name === i.forPuzzle)?.requiredPhrase;
|
|
339
|
+
return `- "${i.name}" is the clue for puzzle "${i.forPuzzle}": its "details" text MUST contain the exact phrase "${phrase ?? 'UNKNOWN'}" verbatim (a diary entry, scrawled note, or manual that gives it away once read).`;
|
|
340
|
+
})
|
|
341
|
+
.join('\n');
|
|
342
|
+
return `
|
|
343
|
+
You are writing the items for a Shadowrun text adventure.
|
|
344
|
+
|
|
345
|
+
${skeletonContext(skeleton)}
|
|
346
|
+
|
|
347
|
+
For EVERY item in the skeleton, expand it into a full item ("name" and "category" are fixed):
|
|
348
|
+
- "description": one sentence of always-visible flavor.
|
|
349
|
+
- "details": deeper text shown on close examination or reading. CLUE ITEMS (below) put
|
|
350
|
+
their puzzle's credential HERE -- never in "description".
|
|
351
|
+
- "size": "Tiny"|"Small"|"Medium"|"Large"|"VeryLarge"; "rating": "Common"|"Standard"|"Rare"|
|
|
352
|
+
"Legendary"|"MilitaryGrade" (rating scales combat/heal/deck power -- reserve Rare+ for
|
|
353
|
+
earned rewards); "shape"/"color"/"texture": short literals; "weight": realistic kilograms
|
|
354
|
+
(0.05 chip, ~2 pistol, ~4 armor); "currencyAmount" only for Credstick/CurrencyContainer.
|
|
355
|
+
|
|
356
|
+
Clue requirements (mechanically validated -- the exact phrase must appear in "details"):
|
|
357
|
+
${clueSpecs || '- (no clue items in this scene)'}
|
|
358
|
+
|
|
359
|
+
${JSON_ONLY}
|
|
360
|
+
[ { "name": string, "description": string, "size": string, "category": string,
|
|
361
|
+
"shape": string, "color": string, "texture": string, "rating": string, "weight": number,
|
|
362
|
+
"currencyAmount"?: number, "details"?: string, "transferable"?: boolean } ]
|
|
363
|
+
One entry per skeleton item, "name" and "category" copied exactly.${repairNote(previousError)}
|
|
364
|
+
`.trim();
|
|
365
|
+
}
|
|
366
|
+
// ========================= Parse / validate / assemble ====================
|
|
367
|
+
/**
|
|
368
|
+
* Whether THIS runner can reliably work a barrier of the given bypassType
|
|
369
|
+
* with their own hands -- no written-down credential required. This is
|
|
370
|
+
* what loosens the "every puzzle needs a discoverable clue" rule when the
|
|
371
|
+
* scene is generated against a dossier: a lockpick maglock in a scene
|
|
372
|
+
* built for a master locksmith doesn't need its code lying in a book --
|
|
373
|
+
* the pick IS their route. Deliberately conservative: EXPLICIT skill
|
|
374
|
+
* ratings only (the combatSkill fallback would let any bruiser "qualify"
|
|
375
|
+
* as a hacker), threshold 3 (trained professional), and with no dossier
|
|
376
|
+
* at all the strict clue rule stays.
|
|
377
|
+
*/
|
|
378
|
+
export function canBypassUnaided(player, bypassType) {
|
|
379
|
+
if (!player || !bypassType)
|
|
380
|
+
return false;
|
|
381
|
+
const rated = (skill) => player.skills[skill] ?? 0;
|
|
382
|
+
switch (bypassType) {
|
|
383
|
+
case 'lockpick': return rated('locksmith') >= 3;
|
|
384
|
+
case 'hack': return rated('hacking') >= 3 || player.isTechnomancer() || !!player.getAnyCyberdeck();
|
|
385
|
+
case 'spell': return player.isAwakened() && !player.adept;
|
|
386
|
+
case 'con': return rated('con') >= 3 || player.charisma >= 5;
|
|
387
|
+
default: return false;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
/** Strips accidental markdown fences and parses; throws with a useful message. */
|
|
391
|
+
export function parseJsonReply(reply) {
|
|
392
|
+
const cleaned = reply.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
|
|
393
|
+
return JSON.parse(cleaned);
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Fast structural checks on the skeleton BEFORE any detail pass spends
|
|
397
|
+
* tokens building on it. Deeper semantics (reachability, clue coverage,
|
|
398
|
+
* matrix defense...) are still enforced by the assembled-scene validators.
|
|
399
|
+
*/
|
|
400
|
+
export function validateSkeleton(skeleton, player) {
|
|
401
|
+
const fail = (msg) => { throw new Error(`Skeleton invalid: ${msg}`); };
|
|
402
|
+
if (!Array.isArray(skeleton?.rooms) || skeleton.rooms.length === 0)
|
|
403
|
+
fail('no rooms.');
|
|
404
|
+
if (!Array.isArray(skeleton?.npcs) || skeleton.npcs.length === 0)
|
|
405
|
+
fail('no npcs.');
|
|
406
|
+
if (!Array.isArray(skeleton?.items))
|
|
407
|
+
fail('no items array.');
|
|
408
|
+
if (!skeleton.winCondition?.item || !skeleton.winCondition?.toNpc)
|
|
409
|
+
fail('winCondition needs "item" and "toNpc".');
|
|
410
|
+
if (typeof skeleton.winCondition.payout !== 'number' || skeleton.winCondition.payout <= 0)
|
|
411
|
+
fail('winCondition.payout must be a positive number.');
|
|
412
|
+
const roomNames = new Set(skeleton.rooms.map(r => r.name));
|
|
413
|
+
const npcNames = new Set(skeleton.npcs.map(n => n.name));
|
|
414
|
+
const itemNames = new Set(skeleton.items.map(i => i.name));
|
|
415
|
+
const puzzleNames = new Set((skeleton.puzzles ?? []).map(p => p.name));
|
|
416
|
+
const startRooms = skeleton.rooms.filter(r => r.isStartRoom);
|
|
417
|
+
if (startRooms.length !== 1)
|
|
418
|
+
fail(`exactly one room must have "isStartRoom": true (found ${startRooms.length}).`);
|
|
419
|
+
const doorRefs = new Map();
|
|
420
|
+
for (const room of skeleton.rooms) {
|
|
421
|
+
for (const exit of room.exits ?? []) {
|
|
422
|
+
if (!roomNames.has(exit.targetRoom))
|
|
423
|
+
fail(`exit from "${room.name}" targets unknown room "${exit.targetRoom}".`);
|
|
424
|
+
if (exit.puzzle) {
|
|
425
|
+
if (!puzzleNames.has(exit.puzzle))
|
|
426
|
+
fail(`exit from "${room.name}" references unknown puzzle "${exit.puzzle}".`);
|
|
427
|
+
doorRefs.set(exit.puzzle, (doorRefs.get(exit.puzzle) ?? 0) + 1);
|
|
428
|
+
}
|
|
429
|
+
if (exit.keyRequired && !itemNames.has(exit.keyRequired))
|
|
430
|
+
fail(`exit from "${room.name}" requires unknown key item "${exit.keyRequired}".`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
for (const npc of skeleton.npcs) {
|
|
434
|
+
if (!roomNames.has(npc.startLocation))
|
|
435
|
+
fail(`npc "${npc.name}" starts in unknown room "${npc.startLocation}".`);
|
|
436
|
+
}
|
|
437
|
+
for (const item of skeleton.items) {
|
|
438
|
+
if (item.room && !roomNames.has(item.room))
|
|
439
|
+
fail(`item "${item.name}" placed in unknown room "${item.room}".`);
|
|
440
|
+
if (item.heldBy && !npcNames.has(item.heldBy))
|
|
441
|
+
fail(`item "${item.name}" heldBy unknown npc "${item.heldBy}".`);
|
|
442
|
+
if (item.forPuzzle && !puzzleNames.has(item.forPuzzle))
|
|
443
|
+
fail(`clue item "${item.name}" references unknown puzzle "${item.forPuzzle}".`);
|
|
444
|
+
}
|
|
445
|
+
// The three structural faults real generations actually shipped -- an
|
|
446
|
+
// unplaced-or-double-placed puzzle, a puzzle with no clue item at all
|
|
447
|
+
// (unfixable by ANY items pass), and unreachable rooms -- must die HERE,
|
|
448
|
+
// before four detail passes build on bad bones.
|
|
449
|
+
for (const puzzle of skeleton.puzzles ?? []) {
|
|
450
|
+
if (puzzle.room && !roomNames.has(puzzle.room))
|
|
451
|
+
fail(`puzzle "${puzzle.name}" placed in unknown room "${puzzle.room}".`);
|
|
452
|
+
if (puzzle.rewardItem && !itemNames.has(puzzle.rewardItem))
|
|
453
|
+
fail(`puzzle "${puzzle.name}" rewards unknown item "${puzzle.rewardItem}".`);
|
|
454
|
+
const refs = doorRefs.get(puzzle.name) ?? 0;
|
|
455
|
+
if (puzzle.room && refs > 0)
|
|
456
|
+
fail(`puzzle "${puzzle.name}" is placed both in room "${puzzle.room}" AND on a door (exits[].puzzle) -- exactly one or the other.`);
|
|
457
|
+
if (!puzzle.room && refs === 0)
|
|
458
|
+
fail(`puzzle "${puzzle.name}" isn't placed anywhere -- set its "room", or reference it from one exits[].puzzle.`);
|
|
459
|
+
if (refs > 1)
|
|
460
|
+
fail(`puzzle "${puzzle.name}" is referenced by ${refs} doors -- a puzzle can only guard one.`);
|
|
461
|
+
const clue = skeleton.items.find(i => i.role === 'clue' && i.forPuzzle === puzzle.name);
|
|
462
|
+
if (!clue) {
|
|
463
|
+
// Loosened when the dossier says the runner can work this barrier
|
|
464
|
+
// unaided -- their bypass skill IS the way past (player note from a
|
|
465
|
+
// real failed generation: "it just needs a way past, and if the
|
|
466
|
+
// player has the means, it should be allowed").
|
|
467
|
+
if (!canBypassUnaided(player, puzzle.bypassType)) {
|
|
468
|
+
fail(`puzzle "${puzzle.name}" has no "clue"-role item with "forPuzzle": "${puzzle.name}" and its bypassType (${puzzle.bypassType ?? 'none'}) is not something this runner reliably handles -- add a "clue"-role Book item in a different room, or give the barrier a bypassType matching the runner's dossier strengths.`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
if (!clue.room)
|
|
473
|
+
fail(`clue item "${clue.name}" needs a "room" (a different one than its puzzle's).`);
|
|
474
|
+
if (puzzle.room && clue.room === puzzle.room)
|
|
475
|
+
fail(`clue item "${clue.name}" sits in the SAME room as its puzzle "${puzzle.name}" -- place the clue in a different room.`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (!itemNames.has(skeleton.winCondition.item))
|
|
479
|
+
fail(`winCondition.item "${skeleton.winCondition.item}" is not in items.`);
|
|
480
|
+
if (!npcNames.has(skeleton.winCondition.toNpc))
|
|
481
|
+
fail(`winCondition.toNpc "${skeleton.winCondition.toNpc}" is not in npcs.`);
|
|
482
|
+
// Lock-and-key reachability. CRITICAL: edges are BIDIRECTIONAL -- the
|
|
483
|
+
// engine mirrors every declared exit into a shared two-way Door
|
|
484
|
+
// (room.addExit writes both sides), so a one-sided skeleton exit still
|
|
485
|
+
// connects both rooms in play. The first version of this walk was
|
|
486
|
+
// directed-only and falsely stranded rooms reachable via the mirrored
|
|
487
|
+
// direction, burning entire generations on a phantom fault.
|
|
488
|
+
const npcRoom = new Map(skeleton.npcs.map(n => [n.name, n.startLocation]));
|
|
489
|
+
const itemRoom = new Map();
|
|
490
|
+
for (const item of skeleton.items) {
|
|
491
|
+
if (item.room)
|
|
492
|
+
itemRoom.set(item.name, item.room);
|
|
493
|
+
else if (item.heldBy) {
|
|
494
|
+
const loc = npcRoom.get(item.heldBy);
|
|
495
|
+
if (loc)
|
|
496
|
+
itemRoom.set(item.name, loc);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
const edges = [];
|
|
500
|
+
for (const room of skeleton.rooms) {
|
|
501
|
+
for (const exit of room.exits ?? []) {
|
|
502
|
+
edges.push({ a: room.name, b: exit.targetRoom, keyRequired: exit.keyRequired, puzzle: exit.puzzle });
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
const reachable = new Set([startRooms[0].name]);
|
|
506
|
+
let grew = true;
|
|
507
|
+
while (grew) {
|
|
508
|
+
grew = false;
|
|
509
|
+
for (const edge of edges) {
|
|
510
|
+
const from = reachable.has(edge.a) ? edge.a : reachable.has(edge.b) ? edge.b : undefined;
|
|
511
|
+
const to = from === edge.a ? edge.b : edge.a;
|
|
512
|
+
if (!from || reachable.has(to))
|
|
513
|
+
continue;
|
|
514
|
+
const keyLoc = edge.keyRequired ? itemRoom.get(edge.keyRequired) : undefined;
|
|
515
|
+
const passable = !!edge.puzzle || !edge.keyRequired || (keyLoc !== undefined && reachable.has(keyLoc));
|
|
516
|
+
if (passable) {
|
|
517
|
+
reachable.add(to);
|
|
518
|
+
grew = true;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
const stranded = skeleton.rooms.filter(r => r.reachable !== false && !reachable.has(r.name));
|
|
523
|
+
if (stranded.length > 0) {
|
|
524
|
+
fail(`these rooms are unreachable from the start room given the locked doors and key placements: ${stranded.map(r => r.name).join(', ')}. Every key must be findable WITHOUT passing through the door it opens, and every normal room needs a route in (or mark it "reachable": false).`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Auto-fixes skeleton faults that have exactly one sane resolution,
|
|
529
|
+
* instead of burning a whole retry on them. Today: a puzzle both placed
|
|
530
|
+
* in a room AND referenced by exactly one door keeps the DOOR placement
|
|
531
|
+
* (the model near-always meant "the door in that room" -- this exact
|
|
532
|
+
* fault killed two consecutive real generations). Returns notes for the
|
|
533
|
+
* log.
|
|
534
|
+
*/
|
|
535
|
+
export function normalizeSkeleton(skeleton) {
|
|
536
|
+
const notes = [];
|
|
537
|
+
const doorRefs = new Map();
|
|
538
|
+
for (const room of skeleton.rooms ?? []) {
|
|
539
|
+
for (const exit of room.exits ?? []) {
|
|
540
|
+
if (exit.puzzle)
|
|
541
|
+
doorRefs.set(exit.puzzle, (doorRefs.get(exit.puzzle) ?? 0) + 1);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
for (const puzzle of skeleton.puzzles ?? []) {
|
|
545
|
+
if (puzzle.room && (doorRefs.get(puzzle.name) ?? 0) === 1) {
|
|
546
|
+
notes.push(`normalized: puzzle "${puzzle.name}" was double-placed (room + door); kept the door placement.`);
|
|
547
|
+
delete puzzle.room;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return notes;
|
|
551
|
+
}
|
|
552
|
+
/** Merges the skeleton's structure with the detail passes into a full seed. */
|
|
553
|
+
export function assembleScene(skeleton, details, playerName) {
|
|
554
|
+
const roomDetail = new Map(details.rooms.map(r => [r.name, r]));
|
|
555
|
+
const npcDetail = new Map(details.npcs.map(n => [n.name, n]));
|
|
556
|
+
const puzzleDetail = new Map(details.puzzles.map(p => [p.name, p]));
|
|
557
|
+
const itemDetail = new Map(details.items.map(i => [i.name, i]));
|
|
558
|
+
const rooms = skeleton.rooms.map(room => ({
|
|
559
|
+
name: room.name,
|
|
560
|
+
description: roomDetail.get(room.name)?.description ?? room.concept,
|
|
561
|
+
roomType: roomDetail.get(room.name)?.roomType ?? room.roomType ?? 'Generic',
|
|
562
|
+
isStartRoom: room.isStartRoom,
|
|
563
|
+
reachable: room.reachable,
|
|
564
|
+
hasNode: room.hasNode,
|
|
565
|
+
nodeAccess: room.nodeAccess,
|
|
566
|
+
warded: room.warded,
|
|
567
|
+
owner: room.owner,
|
|
568
|
+
exits: (room.exits ?? []).map(e => ({
|
|
569
|
+
direction: e.direction,
|
|
570
|
+
targetRoom: e.targetRoom,
|
|
571
|
+
keyRequired: e.keyRequired,
|
|
572
|
+
puzzle: e.puzzle,
|
|
573
|
+
})),
|
|
574
|
+
}));
|
|
575
|
+
const npcs = skeleton.npcs.map(npc => {
|
|
576
|
+
const detail = npcDetail.get(npc.name);
|
|
577
|
+
return {
|
|
578
|
+
plane: npc.plane,
|
|
579
|
+
description: detail?.description ?? npc.concept,
|
|
580
|
+
dialogue: detail?.dialogue ?? [],
|
|
581
|
+
player: {
|
|
582
|
+
name: npc.name,
|
|
583
|
+
startLocation: npc.startLocation,
|
|
584
|
+
lifestyleChoices: [],
|
|
585
|
+
combat: detail?.combat,
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
});
|
|
589
|
+
const toItemJson = (skel, placed) => {
|
|
590
|
+
const detail = itemDetail.get(skel.name);
|
|
591
|
+
return {
|
|
592
|
+
name: skel.name,
|
|
593
|
+
description: detail?.description ?? skel.name,
|
|
594
|
+
size: detail?.size ?? 'Small',
|
|
595
|
+
category: skel.category,
|
|
596
|
+
shape: detail?.shape ?? 'Nondescript',
|
|
597
|
+
color: detail?.color ?? 'Gray',
|
|
598
|
+
texture: detail?.texture ?? 'Worn',
|
|
599
|
+
rating: detail?.rating ?? 'Common',
|
|
600
|
+
weight: typeof detail?.weight === 'number' ? detail.weight : 0.5,
|
|
601
|
+
currencyAmount: detail?.currencyAmount,
|
|
602
|
+
details: detail?.details,
|
|
603
|
+
transferable: detail?.transferable,
|
|
604
|
+
plane: skel.plane,
|
|
605
|
+
room: placed ? skel.room : undefined,
|
|
606
|
+
heldBy: placed ? skel.heldBy : undefined,
|
|
607
|
+
};
|
|
608
|
+
};
|
|
609
|
+
const startingSkels = skeleton.items.filter(i => i.role === 'starting');
|
|
610
|
+
const worldSkels = skeleton.items.filter(i => i.role !== 'starting');
|
|
611
|
+
const items = worldSkels.map(skel => toItemJson(skel, skel.role !== 'reward'));
|
|
612
|
+
const startingItems = startingSkels.map(skel => toItemJson(skel, false));
|
|
613
|
+
const puzzles = (skeleton.puzzles ?? []).map(puzzle => {
|
|
614
|
+
const detail = puzzleDetail.get(puzzle.name);
|
|
615
|
+
return {
|
|
616
|
+
name: puzzle.name,
|
|
617
|
+
room: puzzle.room,
|
|
618
|
+
riddle: detail?.riddle ?? puzzle.concept,
|
|
619
|
+
requiredPhrase: detail?.requiredPhrase ?? '',
|
|
620
|
+
successMessage: detail?.successMessage ?? 'It opens.',
|
|
621
|
+
failureMessage: detail?.failureMessage ?? 'Nothing happens.',
|
|
622
|
+
lockedMessage: detail?.lockedMessage,
|
|
623
|
+
requiresPuzzle: puzzle.requiresPuzzle,
|
|
624
|
+
rewardItem: puzzle.rewardItem,
|
|
625
|
+
bypassType: puzzle.bypassType,
|
|
626
|
+
};
|
|
627
|
+
});
|
|
628
|
+
const startRoom = skeleton.rooms.find(r => r.isStartRoom)?.name ?? skeleton.rooms[0]?.name ?? '';
|
|
629
|
+
return {
|
|
630
|
+
name: skeleton.name,
|
|
631
|
+
story: skeleton.story,
|
|
632
|
+
currency: { type: 'nuyen', authorityRoomTags: ['Bank'] },
|
|
633
|
+
residenceTiers: ['Street', 'Squatter', 'Low', 'Middle', 'High', 'Luxury'],
|
|
634
|
+
rooms,
|
|
635
|
+
npcs,
|
|
636
|
+
items,
|
|
637
|
+
puzzles,
|
|
638
|
+
player: {
|
|
639
|
+
name: playerName,
|
|
640
|
+
startLocation: startRoom,
|
|
641
|
+
lifestyleChoices: [{ type: 'residence', metadata: { tier: 'Street', location: startRoom } }],
|
|
642
|
+
startingItems,
|
|
643
|
+
},
|
|
644
|
+
winCondition: {
|
|
645
|
+
type: 'give',
|
|
646
|
+
item: skeleton.winCondition.item,
|
|
647
|
+
toNpc: skeleton.winCondition.toNpc,
|
|
648
|
+
rewardItem: skeleton.winCondition.rewardItem,
|
|
649
|
+
payout: skeleton.winCondition.payout,
|
|
650
|
+
completionMessage: skeleton.completionMessage,
|
|
651
|
+
},
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Maps a validation failure to the chunk that owns the broken content, so
|
|
656
|
+
* a repair regenerates ONE pass instead of the whole scene. Anything
|
|
657
|
+
* structural (topology, placement, rosters) is skeleton-owned and restarts
|
|
658
|
+
* the pipeline with the error as feedback.
|
|
659
|
+
*/
|
|
660
|
+
export function classifyRepair(message) {
|
|
661
|
+
if (/no discoverable clue/i.test(message))
|
|
662
|
+
return 'items';
|
|
663
|
+
if (/startingItems|Win condition (item|rewardItem)|items\[/i.test(message))
|
|
664
|
+
return 'items';
|
|
665
|
+
if (/requiredPhrase|riddle|lockedMessage/i.test(message))
|
|
666
|
+
return 'puzzles';
|
|
667
|
+
if (/npcs\[|dialogue/i.test(message))
|
|
668
|
+
return 'npcs';
|
|
669
|
+
if (/rooms\[\d+\][^]*name/i.test(message))
|
|
670
|
+
return 'skeleton';
|
|
671
|
+
return 'skeleton';
|
|
672
|
+
}
|
|
673
|
+
//# sourceMappingURL=scene-chunks.js.map
|