@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
|
@@ -5,417 +5,161 @@ import { Player } from '../models/player.js';
|
|
|
5
5
|
import { Room } from '../models/room.js';
|
|
6
6
|
import { Logger } from '../utilities/logger.js';
|
|
7
7
|
import { SceneSynthesizer } from './scene-factory.js';
|
|
8
|
-
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
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
|
-
}
|
|
8
|
+
import { buildSkeletonPrompt, buildRoomsPrompt, buildNpcsPrompt, buildPuzzlesPrompt, buildItemsPrompt, parseJsonReply, validateSkeleton, normalizeSkeleton, assembleScene, classifyRepair, canBypassUnaided, } from './scene-chunks.js';
|
|
9
|
+
// The fixer name convention and the player dossier live with the prompt
|
|
10
|
+
// builders now (see scene-chunks.ts); re-exported so the existing
|
|
11
|
+
// import sites (call.ts, game.ts) keep working unchanged.
|
|
12
|
+
export { FIXER_NAME } from './scene-chunks.js';
|
|
389
13
|
export class SceneSeedGenerator {
|
|
390
|
-
|
|
14
|
+
/** Fresh-skeleton pipeline attempts (structural faults promote to a new one). */
|
|
15
|
+
static maxPipelineAttempts = 2;
|
|
16
|
+
/** Per-pipeline chunk repairs before demanding a fresh skeleton. */
|
|
17
|
+
static maxRepairRounds = 3;
|
|
18
|
+
/** Attempts per individual AI call (skeleton or one detail chunk). */
|
|
19
|
+
static maxCallAttempts = 2;
|
|
20
|
+
// Per-call response budgets. Each is a fraction of a whole scene -- the
|
|
21
|
+
// point of chunking: no single completion carries everything, so scene
|
|
22
|
+
// scale is no longer bounded by the provider's output ceiling (a real
|
|
23
|
+
// session lost four of six whole-scene attempts to JSON truncated
|
|
24
|
+
// mid-string at the old fixed cap).
|
|
25
|
+
static SKELETON_TOKENS = 4000;
|
|
26
|
+
static ROOMS_TOKENS = 3000;
|
|
27
|
+
static NPCS_TOKENS = 6000;
|
|
28
|
+
static PUZZLES_TOKENS = 3000;
|
|
29
|
+
static ITEMS_TOKENS = 6000;
|
|
391
30
|
/**
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
31
|
+
* CHUNKED generation (see scene-chunks.ts for the shapes and prompts):
|
|
32
|
+
* one compact SKELETON pass fixes the scene's whole structure -- every
|
|
33
|
+
* cross-reference the validators check -- then small detail passes flesh
|
|
34
|
+
* out rooms, NPCs, puzzles, and items (items last: clue items bake in
|
|
35
|
+
* the puzzle passphrases). The assembled scene runs through the same
|
|
36
|
+
* validators as ever; a failure regenerates only the chunk responsible
|
|
37
|
+
* (classifyRepair) instead of throwing the whole scene away.
|
|
38
|
+
*
|
|
39
|
+
* `tier` scales scope and stakes; `previousFixerName` keeps the same
|
|
40
|
+
* job-giver identity across "continue"; `player` becomes the dossier
|
|
41
|
+
* that tailors the job to the runner's archetype and toolkit.
|
|
397
42
|
*/
|
|
398
|
-
static async generate(logFilePath, tier = 1, previousFixerName) {
|
|
43
|
+
static async generate(logFilePath, tier = 1, previousFixerName, player) {
|
|
399
44
|
if (!AI.isConfigured())
|
|
400
45
|
return null;
|
|
401
46
|
const logger = Logger.getInstance(logFilePath, { children: [], render: () => { } });
|
|
47
|
+
const playerName = player?.name ?? 'Maka';
|
|
48
|
+
// Two budgets, deliberately separate: PIPELINE attempts (a fresh
|
|
49
|
+
// skeleton + full chunk set -- structural faults land here) and, per
|
|
50
|
+
// pipeline, chunk REPAIR rounds. A real generation burned its whole
|
|
51
|
+
// repair budget on two skeleton restarts and died with one round left
|
|
52
|
+
// for an items fault; skeleton restarts no longer eat repair rounds.
|
|
53
|
+
let carriedError;
|
|
54
|
+
for (let pipeline = 1; pipeline <= this.maxPipelineAttempts; pipeline++) {
|
|
55
|
+
try {
|
|
56
|
+
const skeleton = await this.requestSkeleton(tier, previousFixerName, player, logger, carriedError);
|
|
57
|
+
const details = await this.requestAllDetails(skeleton, logger);
|
|
58
|
+
let structuralFault;
|
|
59
|
+
for (let round = 0; round <= this.maxRepairRounds; round++) {
|
|
60
|
+
const candidate = assembleScene(skeleton, details, playerName);
|
|
61
|
+
try {
|
|
62
|
+
await this.dryRunValidate(candidate, player);
|
|
63
|
+
logger.write(`SceneSeedGenerator: assembled valid scene "${candidate.name}" (pipeline ${pipeline}, ${round} repair round(s))`);
|
|
64
|
+
this.saveSeedToDisk(candidate, logFilePath, tier, skeleton);
|
|
65
|
+
return candidate;
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
69
|
+
const target = classifyRepair(message);
|
|
70
|
+
if (target === 'skeleton' || round === this.maxRepairRounds) {
|
|
71
|
+
structuralFault = message;
|
|
72
|
+
logger.write(`SceneSeedGenerator: pipeline ${pipeline} needs a fresh skeleton (${target === 'skeleton' ? 'structural fault' : 'repair budget exhausted'}): ${message}`);
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
logger.write(`SceneSeedGenerator: validation failed -> repairing ${target}: ${message}`);
|
|
76
|
+
if (target === 'rooms') {
|
|
77
|
+
details.rooms = await this.requestChunk('rooms', buildRoomsPrompt(skeleton, message), this.ROOMS_TOKENS, logger);
|
|
78
|
+
}
|
|
79
|
+
else if (target === 'npcs') {
|
|
80
|
+
details.npcs = await this.requestChunk('npcs', buildNpcsPrompt(skeleton, message), this.NPCS_TOKENS, logger);
|
|
81
|
+
}
|
|
82
|
+
else if (target === 'puzzles') {
|
|
83
|
+
details.puzzles = await this.requestChunk('puzzles', buildPuzzlesPrompt(skeleton, message), this.PUZZLES_TOKENS, logger);
|
|
84
|
+
// New passphrases invalidate the baked-in clue text.
|
|
85
|
+
details.items = await this.requestChunk('items', buildItemsPrompt(skeleton, details.puzzles), this.ITEMS_TOKENS, logger);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
details.items = await this.requestChunk('items', buildItemsPrompt(skeleton, details.puzzles, message), this.ITEMS_TOKENS, logger);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
carriedError = structuralFault;
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
carriedError = err instanceof Error ? err.message : String(err);
|
|
96
|
+
logger.write(`SceneSeedGenerator: pipeline ${pipeline} failed: ${carriedError}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
logger.write(`SceneSeedGenerator: giving up (${carriedError ?? 'unknown'}) -- falling back to a static scene`);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
/** The skeleton pass: structure only, fast-validated before any detail pass builds on it. */
|
|
103
|
+
static async requestSkeleton(tier, previousFixerName, player, logger, previousError) {
|
|
104
|
+
let lastError = previousError;
|
|
105
|
+
for (let attempt = 1; attempt <= this.maxCallAttempts; attempt++) {
|
|
106
|
+
const reply = await AI.ask(buildSkeletonPrompt(tier, previousFixerName, player, lastError), { maxTokens: this.SKELETON_TOKENS });
|
|
107
|
+
try {
|
|
108
|
+
const skeleton = parseJsonReply(reply);
|
|
109
|
+
skeleton.puzzles = skeleton.puzzles ?? [];
|
|
110
|
+
for (const note of normalizeSkeleton(skeleton)) {
|
|
111
|
+
logger.write(`SceneSeedGenerator: ${note}`);
|
|
112
|
+
}
|
|
113
|
+
validateSkeleton(skeleton, player);
|
|
114
|
+
logger.write(`SceneSeedGenerator: skeleton "${skeleton.name}" -- ${skeleton.rooms.length} rooms, ${skeleton.npcs.length} npcs, ${skeleton.puzzles.length} puzzles, ${skeleton.items.length} items`);
|
|
115
|
+
return skeleton;
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
119
|
+
logger.write(`SceneSeedGenerator: skeleton attempt ${attempt} failed: ${lastError}`);
|
|
120
|
+
if (attempt === this.maxCallAttempts)
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
throw new Error('unreachable');
|
|
125
|
+
}
|
|
126
|
+
/** One detail chunk: prompt -> JSON, with one retry feeding the parse error back. */
|
|
127
|
+
static async requestChunk(label, prompt, maxTokens, logger) {
|
|
402
128
|
let lastError;
|
|
403
|
-
for (let attempt = 1; attempt <= this.
|
|
129
|
+
for (let attempt = 1; attempt <= this.maxCallAttempts; attempt++) {
|
|
130
|
+
const fullPrompt = lastError
|
|
131
|
+
? `${prompt}\n\nYour previous attempt failed with this error -- fix it and try again: ${lastError}`
|
|
132
|
+
: prompt;
|
|
133
|
+
const reply = await AI.ask(fullPrompt, { maxTokens });
|
|
404
134
|
try {
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
logger.write(`SceneSeedGenerator: generated a valid scene "${candidate.name}" on attempt ${attempt}`);
|
|
409
|
-
this.saveSeedToDisk(candidate, logFilePath, tier);
|
|
410
|
-
return candidate;
|
|
135
|
+
const parsed = parseJsonReply(reply);
|
|
136
|
+
logger.write(`SceneSeedGenerator: chunk "${label}" ok (attempt ${attempt})`);
|
|
137
|
+
return parsed;
|
|
411
138
|
}
|
|
412
139
|
catch (err) {
|
|
413
140
|
lastError = err instanceof Error ? err.message : String(err);
|
|
414
|
-
logger.write(`SceneSeedGenerator: attempt ${attempt} failed: ${lastError}`);
|
|
141
|
+
logger.write(`SceneSeedGenerator: chunk "${label}" attempt ${attempt} failed: ${lastError}`);
|
|
142
|
+
if (attempt === this.maxCallAttempts)
|
|
143
|
+
throw err;
|
|
415
144
|
}
|
|
416
145
|
}
|
|
417
|
-
|
|
418
|
-
|
|
146
|
+
throw new Error('unreachable');
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Rooms, NPCs, and puzzles are independent given the skeleton -- they
|
|
150
|
+
* run in parallel. Items runs after puzzles: each clue item's "details"
|
|
151
|
+
* must contain its puzzle's exact passphrase.
|
|
152
|
+
*/
|
|
153
|
+
static async requestAllDetails(skeleton, logger) {
|
|
154
|
+
const [rooms, npcs, puzzles] = await Promise.all([
|
|
155
|
+
this.requestChunk('rooms', buildRoomsPrompt(skeleton), this.ROOMS_TOKENS, logger),
|
|
156
|
+
this.requestChunk('npcs', buildNpcsPrompt(skeleton), this.NPCS_TOKENS, logger),
|
|
157
|
+
skeleton.puzzles.length > 0
|
|
158
|
+
? this.requestChunk('puzzles', buildPuzzlesPrompt(skeleton), this.PUZZLES_TOKENS, logger)
|
|
159
|
+
: Promise.resolve([]),
|
|
160
|
+
]);
|
|
161
|
+
const items = await this.requestChunk('items', buildItemsPrompt(skeleton, puzzles), this.ITEMS_TOKENS, logger);
|
|
162
|
+
return { rooms, npcs, puzzles, items };
|
|
419
163
|
}
|
|
420
164
|
/**
|
|
421
165
|
* Saves a validated scene alongside that session's game log (same
|
|
@@ -423,29 +167,25 @@ export class SceneSeedGenerator {
|
|
|
423
167
|
* -- AI-generated scenes previously only existed in memory for the
|
|
424
168
|
* process that generated them.
|
|
425
169
|
*/
|
|
426
|
-
static saveSeedToDisk(candidate, logFilePath, tier) {
|
|
170
|
+
static saveSeedToDisk(candidate, logFilePath, tier, skeleton) {
|
|
427
171
|
const logger = Logger.getInstance();
|
|
428
172
|
const sessionDir = path.dirname(logFilePath);
|
|
429
173
|
const seedPath = path.join(sessionDir, `scene-job-${tier}.json`);
|
|
430
174
|
try {
|
|
431
175
|
fs.mkdirSync(sessionDir, { recursive: true });
|
|
432
176
|
fs.writeFileSync(seedPath, JSON.stringify(candidate, null, 2));
|
|
177
|
+
// The skeleton rides along for post-mortems: it shows what the
|
|
178
|
+
// structure pass planned, separate from what the detail passes made
|
|
179
|
+
// of it.
|
|
180
|
+
if (skeleton) {
|
|
181
|
+
fs.writeFileSync(path.join(sessionDir, `scene-job-${tier}-skeleton.json`), JSON.stringify(skeleton, null, 2));
|
|
182
|
+
}
|
|
433
183
|
logger.write(`SceneSeedGenerator: saved generated scene to ${seedPath}`);
|
|
434
184
|
}
|
|
435
185
|
catch (err) {
|
|
436
186
|
logger.error(`SceneSeedGenerator: failed to save generated scene to ${seedPath}: ${err}`);
|
|
437
187
|
}
|
|
438
188
|
}
|
|
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
189
|
/**
|
|
450
190
|
* Runs the candidate through the exact same synthesis pipeline a real
|
|
451
191
|
* session would use, purely to surface referential-integrity errors
|
|
@@ -453,7 +193,7 @@ export class SceneSeedGenerator {
|
|
|
453
193
|
* resulting Scene either way -- the real session re-synthesizes fresh
|
|
454
194
|
* (with AI flavor enabled) once this seed is handed off.
|
|
455
195
|
*/
|
|
456
|
-
static async dryRunValidate(candidate) {
|
|
196
|
+
static async dryRunValidate(candidate, player) {
|
|
457
197
|
// Must run BEFORE synthesis: a room/npc/item with a missing or blank
|
|
458
198
|
// "name" doesn't fail synthesis with a useful error -- it crashes deep
|
|
459
199
|
// inside it with a raw JS TypeError ("Cannot read properties of
|
|
@@ -468,8 +208,11 @@ export class SceneSeedGenerator {
|
|
|
468
208
|
// throws on those. This only checks the one semantic rule synthesis
|
|
469
209
|
// can't: the item must actually be findable, not already delivered.
|
|
470
210
|
await SceneSynthesizer.synthesizeFromJson(candidate, throwawayPlayer, false);
|
|
471
|
-
this.validateClueItems(candidate);
|
|
472
|
-
this.validateWinCondition(candidate);
|
|
211
|
+
this.validateClueItems(candidate, player);
|
|
212
|
+
this.validateWinCondition(candidate, player);
|
|
213
|
+
this.validateMatrixDefense(candidate);
|
|
214
|
+
this.validateStartingItems(candidate, player);
|
|
215
|
+
this.validateBypassTypes(candidate);
|
|
473
216
|
this.validatePuzzleChain(candidate);
|
|
474
217
|
this.validateDoorKeys(candidate);
|
|
475
218
|
this.validateRewardItemPlacement(candidate);
|
|
@@ -618,28 +361,36 @@ export class SceneSeedGenerator {
|
|
|
618
361
|
const reachable = new Set();
|
|
619
362
|
if (!startRoom || !rooms.some(r => r.name === startRoom))
|
|
620
363
|
return reachable;
|
|
364
|
+
// Edges are BIDIRECTIONAL: the engine mirrors every declared exit
|
|
365
|
+
// into a shared two-way Door (Room.addExit writes both sides), so a
|
|
366
|
+
// one-sided declaration -- the chunked skeleton's house style --
|
|
367
|
+
// still connects both rooms in play. The old directed-only walk
|
|
368
|
+
// falsely stranded rooms reachable via the mirrored direction.
|
|
369
|
+
const edges = [];
|
|
370
|
+
for (const room of rooms) {
|
|
371
|
+
for (const exit of room.exits ?? []) {
|
|
372
|
+
edges.push({ a: room.name, b: exit.targetRoom, keyRequired: exit.keyRequired, puzzle: exit.puzzle });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
621
375
|
reachable.add(startRoom);
|
|
622
376
|
let grew = true;
|
|
623
377
|
while (grew) {
|
|
624
378
|
grew = false;
|
|
625
|
-
for (const
|
|
626
|
-
|
|
379
|
+
for (const edge of edges) {
|
|
380
|
+
const from = reachable.has(edge.a) ? edge.a : reachable.has(edge.b) ? edge.b : undefined;
|
|
381
|
+
const to = from === edge.a ? edge.b : edge.a;
|
|
382
|
+
if (!from || reachable.has(to))
|
|
627
383
|
continue;
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
const passable = !!exit.puzzle || !exit.keyRequired || (keyLocation !== undefined && reachable.has(keyLocation));
|
|
639
|
-
if (passable) {
|
|
640
|
-
reachable.add(exit.targetRoom);
|
|
641
|
-
grew = true;
|
|
642
|
-
}
|
|
384
|
+
const keyLocation = edge.keyRequired ? itemLocation.get(edge.keyRequired) : undefined;
|
|
385
|
+
// A puzzle-guarded exit (see DOORS AS PUZZLES) is always treated
|
|
386
|
+
// as eventually passable: the clue/competence rules guarantee a
|
|
387
|
+
// route, and pick/hack/cast/bluff is a real (if risky) shortcut.
|
|
388
|
+
// Only a plain "keyRequired" door needs its key traced to a
|
|
389
|
+
// reachable location first.
|
|
390
|
+
const passable = !!edge.puzzle || !edge.keyRequired || (keyLocation !== undefined && reachable.has(keyLocation));
|
|
391
|
+
if (passable) {
|
|
392
|
+
reachable.add(to);
|
|
393
|
+
grew = true;
|
|
643
394
|
}
|
|
644
395
|
}
|
|
645
396
|
}
|
|
@@ -734,7 +485,7 @@ export class SceneSeedGenerator {
|
|
|
734
485
|
* NPC's own inventory, which would make the "find and deliver it"
|
|
735
486
|
* premise nonsensical.
|
|
736
487
|
*/
|
|
737
|
-
static validateWinCondition(candidate) {
|
|
488
|
+
static validateWinCondition(candidate, player) {
|
|
738
489
|
const wc = candidate.winCondition;
|
|
739
490
|
if (!wc) {
|
|
740
491
|
throw new Error('Scene has no "winCondition". Every generated run needs one.');
|
|
@@ -768,7 +519,12 @@ export class SceneSeedGenerator {
|
|
|
768
519
|
const toNpcRoom = rooms.find(r => r.name === toNpcData?.player.startLocation);
|
|
769
520
|
if (toNpcRoom?.reachable === false) {
|
|
770
521
|
const reachable = this.computeReachableRooms(candidate);
|
|
771
|
-
|
|
522
|
+
// Continuation drafts author no startingItems -- the dossier
|
|
523
|
+
// player's OWN kit (carried or equipped commlink) counts.
|
|
524
|
+
const ownsCommlink = !!player && (player.inventory.getAllItems().some(i => String(i.category).toLowerCase() === 'commlink') ||
|
|
525
|
+
player.equipment.head?.commlink != null);
|
|
526
|
+
const playerHasCommlink = ownsCommlink ||
|
|
527
|
+
(candidate.player?.startingItems ?? []).some(i => i.category === 'Commlink') ||
|
|
772
528
|
(candidate.items ?? []).some(i => i.category === 'Commlink' && i.room && reachable.has(i.room));
|
|
773
529
|
if (!playerHasCommlink) {
|
|
774
530
|
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.`);
|
|
@@ -780,6 +536,77 @@ export class SceneSeedGenerator {
|
|
|
780
536
|
}
|
|
781
537
|
}
|
|
782
538
|
}
|
|
539
|
+
/**
|
|
540
|
+
* bypassType is a closed enum the engine indexes into (puzzle.ts
|
|
541
|
+
* BYPASS_FLAVOR) -- an unvalidated value used to reach a live session
|
|
542
|
+
* and crash every "search" in that puzzle's room (the model emitted
|
|
543
|
+
* "con" for a social barrier before that type existed). The lookups are
|
|
544
|
+
* defensive now, but a bad value would still mean a barrier whose
|
|
545
|
+
* bypass route silently doesn't exist -- reject it with the exact
|
|
546
|
+
* allowed list instead.
|
|
547
|
+
*/
|
|
548
|
+
static validateBypassTypes(candidate) {
|
|
549
|
+
const allowed = new Set(['lockpick', 'hack', 'spell', 'con']);
|
|
550
|
+
for (const puzzle of candidate.puzzles ?? []) {
|
|
551
|
+
if (puzzle.bypassType && !allowed.has(puzzle.bypassType)) {
|
|
552
|
+
throw new Error(`Puzzle "${puzzle.name}" has bypassType "${puzzle.bypassType}", which isn't valid -- use exactly one of "lockpick", "hack", "spell", "con" (lowercase), or omit the field for a barrier with no bypass route.`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Mechanical teeth behind the dossier's "never duplicate their gear"
|
|
558
|
+
* rule: the prompt guidance alone was violated on its very first outing
|
|
559
|
+
* (an Adept's startingItems included "Weapon Focus Katana" verbatim, and
|
|
560
|
+
* the player watched the duplicate bounce off their occupied weapon
|
|
561
|
+
* slot). Rejects any startingItem whose name matches something the
|
|
562
|
+
* dossier player already carries or wears.
|
|
563
|
+
*/
|
|
564
|
+
static validateStartingItems(candidate, player) {
|
|
565
|
+
if (!player)
|
|
566
|
+
return;
|
|
567
|
+
const owned = new Set(player.inventory.getAllItems().map(i => i.name.toLowerCase()));
|
|
568
|
+
for (const group of Object.values(player.equipment)) {
|
|
569
|
+
if (!group)
|
|
570
|
+
continue;
|
|
571
|
+
for (const item of Object.values(group)) {
|
|
572
|
+
if (item)
|
|
573
|
+
owned.add(item.name.toLowerCase());
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
for (const starting of candidate.player?.startingItems ?? []) {
|
|
577
|
+
if (starting?.name && owned.has(starting.name.toLowerCase())) {
|
|
578
|
+
throw new Error(`"player.startingItems" includes "${starting.name}", which the runner ALREADY OWNS (see THE RUNNER dossier) -- remove it. Substitute a complementary basic they don't have (ammunition, a medkit, a tool), or simply grant fewer items.`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* A matrix-plane win-condition item must sit in a hasNode room with an
|
|
584
|
+
* ice NPC guarding it -- otherwise the whole run is a free walk: jack
|
|
585
|
+
* in, ride the grid (physical locks don't gate personas), take the
|
|
586
|
+
* file, done. Found via a real session that completed exactly that way
|
|
587
|
+
* with zero resistance. Hand-in-hand with the host-vault mechanic
|
|
588
|
+
* (matrix items stay sealed until the host is cracked -- see hack.ts /
|
|
589
|
+
* take.ts), this guarantees a datarun always has teeth.
|
|
590
|
+
*/
|
|
591
|
+
static validateMatrixDefense(candidate) {
|
|
592
|
+
const wc = candidate.winCondition;
|
|
593
|
+
if (!wc)
|
|
594
|
+
return;
|
|
595
|
+
const item = (candidate.items ?? []).find(i => i.name === wc.item);
|
|
596
|
+
if (!item || item.plane !== 'matrix')
|
|
597
|
+
return;
|
|
598
|
+
if (!item.room) {
|
|
599
|
+
throw new Error(`Matrix-plane win condition item "${wc.item}" must be placed in a room (a hasNode room), not heldBy an NPC -- data lives on a host.`);
|
|
600
|
+
}
|
|
601
|
+
const room = (candidate.rooms ?? []).find(r => r.name === item.room);
|
|
602
|
+
if (!room?.hasNode) {
|
|
603
|
+
throw new Error(`Matrix-plane win condition item "${wc.item}" is in room "${item.room}", which has no "hasNode": true -- matrix files can only exist on a host. Mark that room hasNode.`);
|
|
604
|
+
}
|
|
605
|
+
const iceGuard = (candidate.npcs ?? []).some(n => n.plane === 'matrix' && n.player.startLocation === item.room);
|
|
606
|
+
if (!iceGuard) {
|
|
607
|
+
throw new Error(`Matrix-plane win condition item "${wc.item}" sits in hasNode room "${item.room}" with NO ice guarding it -- add an npc with "plane": "matrix", "startLocation": "${item.room}", and a combat block (logic 4-7). An undefended data vault makes the run a free win.`);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
783
610
|
/**
|
|
784
611
|
* Enforces the "always some way to find the solution" guarantee: every
|
|
785
612
|
* puzzle must have a discoverable clue, placed in a different room. None
|
|
@@ -794,7 +621,7 @@ export class SceneSeedGenerator {
|
|
|
794
621
|
* documentation -- finding it and seeing its name on screen already
|
|
795
622
|
* reveals the answer. A Book clue is still required for anything else.
|
|
796
623
|
*/
|
|
797
|
-
static validateClueItems(candidate) {
|
|
624
|
+
static validateClueItems(candidate, player) {
|
|
798
625
|
const items = candidate.items ?? [];
|
|
799
626
|
for (const puzzle of candidate.puzzles ?? []) {
|
|
800
627
|
const requiredPhrase = (puzzle.requiredPhrase ?? '').toLowerCase();
|
|
@@ -807,8 +634,12 @@ export class SceneSeedGenerator {
|
|
|
807
634
|
const hasNamedItemClue = requiredPhrase.length > 0 && items.some(item => item.room &&
|
|
808
635
|
item.room !== puzzle.room &&
|
|
809
636
|
item.name.toLowerCase() === requiredPhrase);
|
|
810
|
-
|
|
811
|
-
|
|
637
|
+
// Loosened against the dossier: a barrier whose bypassType matches
|
|
638
|
+
// the runner's own trained means (a lockpick door for a master
|
|
639
|
+
// locksmith) doesn't strictly need its credential written down --
|
|
640
|
+
// the skill IS the way past. Strict rule stays without a dossier.
|
|
641
|
+
if (!hasBookClue && !hasNamedItemClue && !canBypassUnaided(player, puzzle.bypassType)) {
|
|
642
|
+
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}", no item elsewhere literally named "${puzzle.requiredPhrase}", and its bypassType (${puzzle.bypassType ?? 'none'}) is not a means this runner reliably has. Every puzzle needs one of the three.`);
|
|
812
643
|
}
|
|
813
644
|
}
|
|
814
645
|
}
|