@maka/maka-cli 5.4.0 → 5.5.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.
Files changed (122) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/archetypes.d.ts +20 -1
  3. package/bundle/typescript/src/commands/game/sideQuest/archetypes.js +76 -11
  4. package/bundle/typescript/src/commands/game/sideQuest/archetypes.js.map +1 -1
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/advance.d.ts +16 -0
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/advance.js +65 -0
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/advance.js.map +1 -0
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/assense.d.ts +2 -0
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/assense.js +72 -29
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/assense.js.map +1 -1
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +21 -2
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js.map +1 -1
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.d.ts +16 -0
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js +39 -0
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js.map +1 -0
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/brandish.d.ts +21 -0
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/brandish.js +82 -0
  18. package/bundle/typescript/src/commands/game/sideQuest/commands/brandish.js.map +1 -0
  19. package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.js +1 -1
  20. package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.js.map +1 -1
  21. package/bundle/typescript/src/commands/game/sideQuest/commands/call.js +71 -1
  22. package/bundle/typescript/src/commands/game/sideQuest/commands/call.js.map +1 -1
  23. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +21 -2
  24. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js.map +1 -1
  25. package/bundle/typescript/src/commands/game/sideQuest/commands/drone.js +13 -1
  26. package/bundle/typescript/src/commands/game/sideQuest/commands/drone.js.map +1 -1
  27. package/bundle/typescript/src/commands/game/sideQuest/commands/drop.js +3 -0
  28. package/bundle/typescript/src/commands/game/sideQuest/commands/drop.js.map +1 -1
  29. package/bundle/typescript/src/commands/game/sideQuest/commands/equip.js +19 -2
  30. package/bundle/typescript/src/commands/game/sideQuest/commands/equip.js.map +1 -1
  31. package/bundle/typescript/src/commands/game/sideQuest/commands/equipment.js +9 -2
  32. package/bundle/typescript/src/commands/game/sideQuest/commands/equipment.js.map +1 -1
  33. package/bundle/typescript/src/commands/game/sideQuest/commands/go.d.ts +2 -0
  34. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +33 -1
  35. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js.map +1 -1
  36. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +43 -20
  37. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js.map +1 -1
  38. package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.d.ts +15 -0
  39. package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.js +75 -0
  40. package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.js.map +1 -0
  41. package/bundle/typescript/src/commands/game/sideQuest/commands/inv.js +5 -1
  42. package/bundle/typescript/src/commands/game/sideQuest/commands/inv.js.map +1 -1
  43. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +16 -4
  44. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js.map +1 -1
  45. package/bundle/typescript/src/commands/game/sideQuest/commands/persuade.d.ts +18 -0
  46. package/bundle/typescript/src/commands/game/sideQuest/commands/persuade.js +100 -0
  47. package/bundle/typescript/src/commands/game/sideQuest/commands/persuade.js.map +1 -0
  48. package/bundle/typescript/src/commands/game/sideQuest/commands/pick.d.ts +1 -0
  49. package/bundle/typescript/src/commands/game/sideQuest/commands/pick.js +22 -7
  50. package/bundle/typescript/src/commands/game/sideQuest/commands/pick.js.map +1 -1
  51. package/bundle/typescript/src/commands/game/sideQuest/commands/say.js +8 -0
  52. package/bundle/typescript/src/commands/game/sideQuest/commands/say.js.map +1 -1
  53. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +55 -8
  54. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js.map +1 -1
  55. package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js +11 -2
  56. package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js.map +1 -1
  57. package/bundle/typescript/src/commands/game/sideQuest/commands/sneak.d.ts +15 -0
  58. package/bundle/typescript/src/commands/game/sideQuest/commands/sneak.js +35 -0
  59. package/bundle/typescript/src/commands/game/sideQuest/commands/sneak.js.map +1 -0
  60. package/bundle/typescript/src/commands/game/sideQuest/commands/spells.js +21 -0
  61. package/bundle/typescript/src/commands/game/sideQuest/commands/spells.js.map +1 -1
  62. package/bundle/typescript/src/commands/game/sideQuest/commands/subdue.js +4 -4
  63. package/bundle/typescript/src/commands/game/sideQuest/commands/subdue.js.map +1 -1
  64. package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +70 -13
  65. package/bundle/typescript/src/commands/game/sideQuest/commands/take.js.map +1 -1
  66. package/bundle/typescript/src/commands/game/sideQuest/commands/talk.js +8 -0
  67. package/bundle/typescript/src/commands/game/sideQuest/commands/talk.js.map +1 -1
  68. package/bundle/typescript/src/commands/game/sideQuest/commands/unequip.js +6 -3
  69. package/bundle/typescript/src/commands/game/sideQuest/commands/unequip.js.map +1 -1
  70. package/bundle/typescript/src/commands/game/sideQuest/commands/use.js +5 -4
  71. package/bundle/typescript/src/commands/game/sideQuest/commands/use.js.map +1 -1
  72. package/bundle/typescript/src/commands/game/sideQuest/factories/room-factory.js +1 -0
  73. package/bundle/typescript/src/commands/game/sideQuest/factories/room-factory.js.map +1 -1
  74. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.d.ts +175 -0
  75. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +673 -0
  76. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js.map +1 -0
  77. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js +1 -0
  78. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js.map +1 -1
  79. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.d.ts +64 -9
  80. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js +265 -434
  81. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js.map +1 -1
  82. package/bundle/typescript/src/commands/game/sideQuest/game.d.ts +69 -1
  83. package/bundle/typescript/src/commands/game/sideQuest/game.js +388 -67
  84. package/bundle/typescript/src/commands/game/sideQuest/game.js.map +1 -1
  85. package/bundle/typescript/src/commands/game/sideQuest/models/npc.d.ts +2 -0
  86. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +92 -3
  87. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js.map +1 -1
  88. package/bundle/typescript/src/commands/game/sideQuest/models/player.d.ts +66 -1
  89. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +151 -15
  90. package/bundle/typescript/src/commands/game/sideQuest/models/player.js.map +1 -1
  91. package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.js +29 -4
  92. package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.js.map +1 -1
  93. package/bundle/typescript/src/commands/game/sideQuest/models/room.d.ts +4 -0
  94. package/bundle/typescript/src/commands/game/sideQuest/models/room.js +14 -0
  95. package/bundle/typescript/src/commands/game/sideQuest/models/room.js.map +1 -1
  96. package/bundle/typescript/src/commands/game/sideQuest/models/scene.d.ts +4 -0
  97. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +35 -3
  98. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js.map +1 -1
  99. package/bundle/typescript/src/commands/game/sideQuest/scenes/scene1.json +275 -105
  100. package/bundle/typescript/src/commands/game/sideQuest/scenes/scene2.json +662 -233
  101. package/bundle/typescript/src/commands/game/sideQuest/types/seed/player-seed.d.ts +8 -0
  102. package/bundle/typescript/src/commands/game/sideQuest/types/seed/puzzle-seed.d.ts +1 -1
  103. package/bundle/typescript/src/commands/game/sideQuest/types/seed/room-seed.d.ts +1 -0
  104. package/bundle/typescript/src/commands/game/sideQuest/types/seed/scene-seed.d.ts +6 -3
  105. package/bundle/typescript/src/commands/game/sideQuest/ui.js +19 -20
  106. package/bundle/typescript/src/commands/game/sideQuest/ui.js.map +1 -1
  107. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.d.ts +6 -4
  108. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +31 -5
  109. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js.map +1 -1
  110. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.d.ts +34 -1
  111. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +72 -0
  112. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js.map +1 -1
  113. package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js +27 -3
  114. package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js.map +1 -1
  115. package/bundle/typescript/src/tools/ai/ai.class.d.ts +8 -1
  116. package/bundle/typescript/src/tools/ai/ai.class.js +8 -3
  117. package/bundle/typescript/src/tools/ai/ai.class.js.map +1 -1
  118. package/bundle/typescript/src/tools/https/https.class.d.ts +2 -2
  119. package/bundle/typescript/src/tools/https/https.class.js +10 -4
  120. package/bundle/typescript/src/tools/https/https.class.js.map +1 -1
  121. package/bundle/typescript/src/tools/https/https.interface.d.ts +2 -0
  122. 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
- // The fixer's name is a fixed Shadowrun-lore convention (see the FIXER
9
- // CONVENTION comment in buildSchemaGuide below), not something generation
10
- // ever varies -- exported so callers that need to identify "the fixer"
11
- // specifically (as opposed to whoever a job's winCondition.toNpc happens to
12
- // be, which no longer has to be the same NPC) can check by name instead of
13
- // duplicating the literal string.
14
- export const FIXER_NAME = 'Mr. Johnson';
15
- function buildSchemaGuide(tier, previousFixerName) {
16
- const scope = tier <= 1
17
- ? '3 to 4 rooms, exactly 1 NPC, and exactly 1 puzzle'
18
- : tier === 2
19
- ? '4 to 5 rooms, 2 NPCs, and 1 to 2 puzzles'
20
- : '5 to 6 rooms, 2 to 3 NPCs, and 2 puzzles';
21
- const continuity = previousFixerName
22
- ? `\nThis is a CONTINUATION of an ongoing relationship with the same fixer, "${previousFixerName}" --
23
- who commissioned the runner's last job and is the one lining up this one too. Reuse
24
- "${previousFixerName}" as this scene's fixer/job-giver (same name, same voice/personality),
25
- rather than inventing a new fixer character. Do not reintroduce them as a stranger; the
26
- runner already knows them.`
27
- : '';
28
- return `
29
- You are generating a small, self-contained Shadowrun-flavored text adventure "run" as JSON.
30
- This is job #${tier} in an ongoing campaign for the same runner -- make it noticeably more
31
- elaborate and higher-stakes than a first job would be, in proportion to how high the job
32
- number is.
33
- ${continuity}
34
-
35
- Output ONLY raw JSON matching this exact shape -- no markdown fences, no commentary, no
36
- trailing text before or after the JSON object:
37
-
38
- {
39
- "name": string,
40
- "story": string, // 1-2 SHORT sentences, shown to the player before anything else
41
- // happens. This is a premise teaser, not a briefing -- name the
42
- // general shape of the trouble (a theft, a debt, a betrayal) and
43
- // nothing more. Never reveal the objective, a target's name or
44
- // location, a passphrase, a twist, or anything a player would need
45
- // to actually complete the run -- all of that belongs in the fixer's
46
- // "dialogue" instead, learned by "call"ing them once play begins (see
47
- // the fixer convention below). If this reads like a back-cover blurb
48
- // rather than a full plot summary, it's the right length.
49
- "currency": { "type": "nuyen", "authorityRoomTags": ["Bank"] },
50
- "residenceTiers": ["Street", "Squatter", "Low", "Middle", "High", "Luxury"],
51
- "rooms": [
52
- {
53
- "name": string,
54
- "description": string,
55
- "roomType": string,
56
- "isStartRoom"?: boolean, // set true on EXACTLY ONE room
57
- "owner"?: string, // an NPC's name, if that NPC's room
58
- "reachable"?: boolean, // defaults to true; set false ONLY for a fixer/employer's
59
- // remote hideout the player can never physically enter --
60
- // see the fixer convention below
61
- "hasNode"?: boolean, // a live Matrix host in this room: a jacked-in player ("jack in")
62
- // can hack it from inside the Matrix to release the room's
63
- // locked doors FOR REAL. Mark 1-2 rooms in any corp/tech
64
- // location; leave street/wilderness rooms without. Ice NPCs
65
- // ("plane": "matrix") and paydata items ("plane": "matrix")
66
- // belong ONLY in hasNode rooms.
67
- "warded"?: boolean, // an astral barrier: projecting mages ("project") cannot enter
68
- // this room astrally. Use on magically-secured rooms (a mage's
69
- // sanctum, a corp magical-security floor) -- it makes astral
70
- // scouting a puzzle, not a skeleton key. Use sparingly.
71
- "exits": [
72
- { "direction": "north"|"east"|"south"|"west"|"up"|"down", "targetRoom": string, "keyRequired"?: string,
73
- "puzzle"?: string } // name of a "puzzles[]" entry that guards this door instead of (or as
74
- // well as) "keyRequired" -- see DOORS AS PUZZLES below
75
- ]
76
- }
77
- ],
78
- "npcs": [
79
- {
80
- "plane"?: "meat"|"matrix"|"astral", // default "meat". "matrix" = ICE: a security
81
- // program guarding a hasNode room -- only a jacked-in player
82
- // ever meets it (it fights with Logic-based pools; give it a
83
- // combat block with "logic" 4-7 and set its startLocation to
84
- // the hasNode room). "astral" = a SPIRIT: met only by
85
- // projecting mages -- give it "willpower"/"magic" 4-6 and
86
- // "intuition" 4+. Use at most one or two per scene, and only
87
- // where the location concept earns them.
88
- "description": string, // private notes on personality/motives; never shown to the player directly
89
- "dialogue": string[], // 2-4 short in-character lines. If this NPC is the fixer/job-giver,
90
- // THIS is where the actual briefing lives -- the objective, the
91
- // target, the stakes -- since "story" deliberately withholds it.
92
- "player": { "name": string, "startLocation": string, "lifestyleChoices": [],
93
- "combat"?: { "body": number, "agility": number, "reaction": number, "combatSkill": number,
94
- "magic"?: number, "strength"?: number, "willpower"?: number, "logic"?: number,
95
- "intuition"?: number, "charisma"?: number } }
96
- // combat is OPTIONAL -- omit it for anyone who'd never be in a fight (a fixer, a
97
- // bartender). When set, each attribute is 1-8: a soft civilian 1-2, a street-level
98
- // runner 3-4 (the default when omitted), a hardened enforcer or bodyguard 5-6,
99
- // elite corp security 7-8. Give a combat block (and an equipped-quality weapon/
100
- // armor in "items" heldBy them) to any NPC the story expects might end up in a
101
- // firefight -- combat is dice-pool based and gear quality feeds the dice.
102
- // "magic" is innate arcane talent: omit (or 0) for the mundane majority. Set 3-6
103
- // ONLY for a character whose concept is genuinely a mage/shaman/adept -- they'll
104
- // know every spell (manabolt/heal/armor) innately with no gear needed, cast from
105
- // raw talent, and should be described accordingly (their "description" should say
106
- // they're Awakened). A Focus item still boosts them. Use sparingly: one Awakened
107
- // NPC is a memorable threat, a scene full of them is noise.
108
- // The optional extended attributes (all default ~3): "strength" (melee damage,
109
- // carry), "willpower" (stun track, drain resist -- give mages 5+), "logic"
110
- // (first aid, hacking -- a decker/street doc concept wants 5+), "intuition"
111
- // (defense, perception), "charisma" (social presence). Set only the ones that
112
- // define the character's concept; leave the rest to defaults.
113
- // Rarer talents: "resonance" (technomancer -- rides the Matrix with no deck;
114
- // use for a technomancer character concept only) and "adept": true (physical
115
- // adept -- magic 4-6 channeled into meat combat, no spells, no projection).
116
- // Never combine magic-casting, adept, and resonance on one character.
117
- // A rival DECKER concept: "logic" 5-7 plus a Cyberdeck item heldBy them --
118
- // their AI can then crack the player's PAN mid-fight ("hack <name>": jams
119
- // the player's smartgun, cuts their AR). One per scene at most, and only
120
- // where a decker belongs.
121
- }
122
- ],
123
- "items": [
124
- {
125
- "name": string,
126
- "description": string,
127
- "size": "Tiny"|"Small"|"Medium"|"Large"|"VeryLarge",
128
- "category": "Weapon"|"MeleeWeapon"|"RangedWeapon"|"Armor"|"BodyArmor"|"ArmorClothing"|"Helmet"|"Hat"|"LegArmor"|"ArmArmor"|"Gloves"|"Commlink"|"Cyberdeck"|"Drone"|"Credstick"|"CurrencyContainer"|"Key"|"Trinket"|"Document"|"Datachip"|"Vice"|"Book"|"Tool"|"Clothing"|"Glasses"|"Boots"|"Medical"|"StimPatch"|"SpellFormula"|"Grimoire"|"Focus"|"Ammunition",
129
- // "Cyberdeck" = the hardware REQUIRED to jack into the Matrix (a Commlink is
130
- // calls/AR only). Its "rating" scales program damage, firewall, and its own
131
- // damage track. Place one as loot/reward only when the story earns it -- a
132
- // dead decker's kit, a smuggled prototype. If the scene has hasNode rooms
133
- // with matrix-plane paydata, remember the player may need a deck to get it
134
- // (deckers and technomancers bring their own way in).
135
- // "Drone" = a launchable surveillance drone: the "drone" command sweeps all
136
- // adjacent rooms for occupants/locks/nodes. Rigger flavor; rare loot.
137
- // "Ammunition" = a spare clip/magazine: consumed by "reload" to refill a firearm
138
- // (guns hold 10 rounds and every shot spends one). If a scene arms its NPCs or
139
- // player with guns and expects a real firefight, place 1-2 Ammunition items
140
- // somewhere findable.
141
- // "Medical" = a single-use healing item (medkit, trauma kit): using it heals
142
- // PHYSICAL combat damage, scaling with "rating", and consumes it. If this scene's
143
- // story can plausibly end in a firefight (armed NPCs, a "will kill on sight"
144
- // guard), place 1-2 Medical items somewhere findable -- a run with combat and no
145
- // way to patch up is a meat grinder.
146
- // "StimPatch" = a single-use combat drug: recovers STUN damage (spell drain,
147
- // knockout track), scaling with "rating", but the crash costs 1 physical box.
148
- // Worth placing one wherever magic gear appears, since casters accumulate stun.
149
- //
150
- // MAGIC GEAR (casting is gear-driven -- no innate mage stat):
151
- // - "SpellFormula": teaches ONE spell; its "name" MUST contain that spell's name.
152
- // The only spells that exist are "Manabolt", "Heal", and "Armor" -- e.g.
153
- // "Spell Formula: Manabolt", "Dog-Eared Heal Formula". Never invent other spells.
154
- // - "Grimoire": a spellbook -- teaches ALL three spells at once. Rarer find.
155
- // - "Focus": an amulet/wand/talisman that boosts casting power, scaling with
156
- // "rating" -- the magic equivalent of a better gun.
157
- // Magic is optional flavor: most scenes need none. But if any puzzle here uses
158
- // "bypassType": "spell", you MUST place at least one magic item (formula, grimoire,
159
- // or focus) somewhere findable -- attempting to cast at a ward now requires magic
160
- // gear, so a warded barrier with no magic gear in the scene has no bypass route.
161
- // Anything wearable (a hoodie, gloves, a hat, boots...) MUST use one of
162
- // the above wearable categories exactly -- never a generic catch-all
163
- // like "Trinket" or "Document" for something meant to be worn.
164
- "shape": string,
165
- "color": string,
166
- "texture": string,
167
- "rating": "Common"|"Standard"|"Rare"|"Legendary"|"MilitaryGrade",
168
- "weight": number, // kilograms, and it's REAL -- actors have a 50kg carry cap. Keep
169
- // items individually realistic (0.05 for a chip, ~1-2 for a pistol,
170
- // ~3-5 for armor); never give a carryable item a silly weight.
171
- "currencyAmount"?: number, // only for Credstick/CurrencyContainer items
172
- "room"?: string, // set this OR heldBy, never both, and never on a puzzle's reward item
173
- "heldBy"?: string, // must be an NPC's name from "npcs", never the player
174
- "owner"?: string,
175
- "transferable"?: boolean,
176
- "plane"?: "meat"|"matrix"|"astral", // default "meat". "matrix" = PAYDATA: a file that
177
- // exists only on the grid -- visible and takeable ONLY while the
178
- // player is jacked in ("jack in"). Place it in a "hasNode" room
179
- // (its "room"), category "Datachip" or "Document", weight 0. A
180
- // matrix-plane win-condition item makes the whole job a datarun:
181
- // the player MUST jack in, likely past ice, to finish. Use it
182
- // when the story is about stealing data, not things.
183
- "details"?: string // deeper text shown only when the player closely examines or reads
184
- // this item (via "read", for a Book) -- NOT the same as "description",
185
- // which is always-visible flavor text shown just by looking at or
186
- // listing the item. A puzzle's clue text (see below) MUST go in
187
- // "details", never in "description" -- put it in "description" and the
188
- // player can never actually read it out.
189
- }
190
- ],
191
- "puzzles": [
192
- {
193
- "name": string,
194
- "room"?: string, // must match a room name -- OMIT this instead if "exits[].puzzle" (see DOORS
195
- // AS PUZZLES below) already places this puzzle on a door; a puzzle belongs
196
- // to exactly one of the two, never both and never neither.
197
- "riddle": string, // What the player sees when they search this room (or approach this door),
198
- // shown verbatim -- NOT a cryptic riddle or poem to decode. Start with "You
199
- // see" (or "You find", "Set into the wall is", etc.) and name the barrier
200
- // plainly and concretely -- "You see a maglock safe.", "You see a biometric
201
- // scanner beside the door." -- then say what it demands (a code, a
202
- // fingerprint, a name spoken aloud, a passphrase), the way an NPC would
203
- // describe it, never as wordplay. See BYPASS TYPE below.
204
- "requiredPhrase": string, // the exact code/passphrase/word that satisfies it -- ALL LOWERCASE, 2-5 plain words, no punctuation
205
- "rewardItem"?: string, // must match an item name from "items" with no "room"/"heldBy" set. Required
206
- // for a "room"-anchored puzzle (this IS what solving it gets the player).
207
- // Omit for a door-anchored puzzle (exits[].puzzle) -- unlocking the door is
208
- // already the reward; only add one if there's a genuine bonus find too.
209
- "successMessage": string,
210
- "failureMessage": string,
211
- "requiresPuzzle"?: string, // another puzzle's "name" that must be solved FIRST -- see below
212
- "lockedMessage"?: string, // shown if the player tries to solve this before requiresPuzzle is met
213
- "bypassType"?: "lockpick"|"hack"|"spell" // see BYPASS TYPE below -- omit if this barrier has no
214
- // physical/technical/magical route past it
215
- }
216
- ],
217
- // BYPASS TYPE: decide what a puzzle concretely IS before writing its "riddle" --
218
- // a maglock, a keypad, a biometric scanner, a matrix node, a warded sigil, a
219
- // guard who has to be talked past. That choice drives BOTH fields together:
220
- // "riddle" describes THAT thing plainly (never as a cryptic riddle-poem), and
221
- // "bypassType" names the matching physical/technical/magical route past it, if
222
- // any: a literal lock, maglock, or keypad -> "lockpick", a terminal/console/
223
- // matrix node or electronic scanner -> "hack", a ward or arcane sigil -> "spell".
224
- // Leave "bypassType" unset only for a barrier with no such route (e.g. a purely
225
- // social "convince the guard" puzzle) -- don't force one in just to use the
226
- // mechanic, but don't invent a riddle-poem instead of a real barrier either.
227
- //
228
- // Either way, "requiredPhrase" is always a concrete, literal credential -- the
229
- // code, the passphrase, the fingerprint owner's name, the words of the ward --
230
- // never a wordplay answer to a puzzle. "riddle" states what the barrier wants
231
- // without giving that credential away; the player finds it via a clue (below).
232
- // When "bypassType" IS set, that same credential doubles as the safe route:
233
- // "solve" with the right answer always works, while "pick"/"hack"/"cast" is
234
- // the risky shortcut that skips finding it. That route is player-initiated
235
- // and a genuine coin flip -- but failure is FATAL, ending the run outright
236
- // (tripped alarm, security response, backlash -- whatever fits the barrier).
237
- // Only give a puzzle "bypassType" when a real physical/technical/magical
238
- // route past it makes sense; never attach it just to decorate a puzzle,
239
- // since it's a real life-or-death choice the player is being offered.
240
- //
241
- // For EVERY puzzle above, the answer must be discoverable somewhere OTHER
242
- // than the puzzle's own room (never the same room -- the player must go
243
- // find it elsewhere). Either of these satisfies that, and the second is
244
- // usually simpler to get exactly right:
245
- // (a) A companion clue item in "items": category "Book", "room" set to
246
- // a different room, whose "details" field directly states the
247
- // puzzle's exact "requiredPhrase" (a diary entry, scrawled note, or
248
- // manual that gives the answer away once someone reads it).
249
- // (b) The "requiredPhrase" IS a real item's exact "name" (any category),
250
- // placed with "room" set to a different room -- e.g. a riddle whose
251
- // answer is "obsidian keycard" and there's a real item literally
252
- // named "Obsidian Keycard" sitting elsewhere. Finding it and seeing
253
- // its name is the clue; no separate documentation needed.
254
- //
255
- // DOORS AS PUZZLES: a locked door can be secured by a real puzzle instead of
256
- // (or as well as) a physical "keyRequired" item -- set "exits[].puzzle" to
257
- // that puzzle's "name", and leave that puzzle's own "room" unset (it
258
- // belongs to the door connecting two rooms, not either one). Everything
259
- // above still applies exactly the same way -- it needs a "riddle"
260
- // description, a discoverable clue elsewhere, and an optional matching
261
- // "bypassType" -- the only difference is the payoff: solving or bypassing
262
- // it unlocks the door rather than granting an item, so "rewardItem" should
263
- // normally be omitted. Use this for a door that's more interesting than a
264
- // "find the physical key" errand -- a keypad, a biometric scanner, a
265
- // warded threshold -- and keep using plain "keyRequired" for a door that's
266
- // just a mundane lock with a key sitting somewhere.
267
- //
268
- // FIXER CONVENTION: in Shadowrun, a fixer (whoever commissions a run) works
269
- // over comms and essentially never meets a runner in person. If this scene's
270
- // premise involves a job-giver/employer character (almost always true), name
271
- // them EXACTLY "${FIXER_NAME}" -- in "npcs[].player.name", and in
272
- // "winCondition.toNpc" too IF he's also the win condition's target (see
273
- // BROKER, NOT ALWAYS THE DESTINATION below -- he often isn't). This is
274
- // deliberate: in Shadowrun, "${FIXER_NAME}" is the in-universe slang for an
275
- // anonymous corporate employer, not a real name -- every job's fixer goes by
276
- // it regardless of who's actually behind the alias, and the runner never
277
- // learns their real identity. Never invent a different name for this role.
278
- // Give him his own room and mark that room "reachable": false -- an office,
279
- // den, or safehouse the player will never set foot in. Give that room NO
280
- // exits (or only exits to other "reachable": false rooms). "call" is the
281
- // only way to reach him, and it requires a commlink on BOTH ends, equipped
282
- // by the caller:
283
- // - Give the player a "Commlink"-category item in "startingItems" (or
284
- // placed somewhere findable in a reachable room).
285
- // - Give ${FIXER_NAME} his own "Commlink"-category item with "heldBy" set
286
- // to his name -- without one, he can never be reached at all.
287
- //
288
- // BROKER, NOT ALWAYS THE DESTINATION: ${FIXER_NAME}'s job is to commission
289
- // the run and point the runner at what matters -- he does NOT have to be
290
- // "winCondition.toNpc" himself. Often the more interesting choice is a
291
- // DIFFERENT NPC entirely: someone ${FIXER_NAME} mentions in his own
292
- // "dialogue" (a buyer, a target, a contact on the ground) who the runner
293
- // then has to track down, meet, or call to actually finish the job. Both
294
- // patterns are valid -- don't default to ${FIXER_NAME} as the
295
- // winCondition.toNpc out of habit if the story is more interesting with
296
- // someone else in that role. If you do introduce a separate contact this
297
- // way, they need everything any other reachable NPC needs (a real
298
- // startLocation; a commlink of their own if "call" is how the player is
299
- // meant to reach them and their room is "reachable": false).
300
- "player": {
301
- "name": "Maka",
302
- "startLocation": string, // must match the isStartRoom room's name
303
- "lifestyleChoices": [{ "type": "residence", "metadata": { "tier": "Street", "location": string } }],
304
- "startingItems": [ /* 3-5 items, same shape as above minus room/heldBy/owner */ ]
305
- },
306
- "winCondition": {
307
- "type": "give",
308
- "item": string, // must match an items[].name -- a MacGuffin the player must find and deliver
309
- "toNpc": string, // must match an npcs[].player.name -- who wants it
310
- "rewardItem"?: string, // optional bonus payoff item; must match an items[].name with no room/heldBy
311
- "payout": number, // REQUIRED: the job's agreed pay in nuyen. The engine ACTUALLY transfers
312
- // this to the player on completion, and every NPC is told the exact
313
- // figure -- so this IS the number the fixer quotes. A round, streetwise
314
- // figure scaled to the job number: ~2000-6000 for job #1, roughly
315
- // half again more per job after that.
316
- "completionMessage": string // shown to the player when they complete the job
317
- }
318
- }
319
-
320
- Hard rules (a broken reference here will crash the game on load):
321
- - Every "exits[].targetRoom" MUST exactly match a "rooms[].name".
322
- - Every "npcs[].player.startLocation" MUST exactly match a "rooms[].name".
323
- - Every "items[].room" (if set) MUST exactly match a "rooms[].name".
324
- - Every "items[].heldBy" (if set) MUST exactly match an "npcs[].player.name".
325
- - Every puzzle in "puzzles[]" must be placed EXACTLY ONE way: either "puzzles[].room" matches a
326
- "rooms[].name", OR exactly one "exits[].puzzle" matches its "name" (DOORS AS PUZZLES above) -- never
327
- both, never neither.
328
- - Every "exits[].puzzle" (if set) MUST exactly match a "puzzles[].name".
329
- - "puzzles[].rewardItem" (if set) MUST exactly match an "items[].name", and that item must have neither "room" nor
330
- "heldBy" set -- it doesn't exist anywhere in the world yet; solving the puzzle is what brings it into the
331
- player's possession. If an NPC is guarding this reward, that's already implied by the puzzle needing to be
332
- solved in their room -- do NOT ALSO make the reward item "heldBy" that NPC, which would mean two different,
333
- disconnected copies of "the same" item end up existing (one granted by solving, one still sitting on the NPC).
334
- - "puzzles[].rewardItem" must NEVER be the same item as "winCondition.item" -- they need opposite placement
335
- (one must have no room/heldBy, the other must have one), so no single item can satisfy both. If the story
336
- wants the winCondition macguffin to feel puzzle-guarded, give the puzzle its OWN separate rewardItem instead
337
- (a key, a note, access -- something that leads to the macguffin), and place "winCondition.item" itself
338
- normally, with "room" or "heldBy" set.
339
- - Exactly one room must have "isStartRoom": true, and it must match "player.startLocation".
340
- - EVERY puzzle needs a discoverable clue in a room OTHER than that puzzle's "room" (for a
341
- door-anchored puzzle, which has no "room" of its own, any room with the clue satisfies this) --
342
- either pattern (a) or (b) above. A puzzle backed by neither, or a room-anchored one whose only
343
- clue sits in its own room, is invalid -- the player must always have a discoverable way to find
344
- the answer, and finding it must take some legwork.
345
- - "puzzles[].requiresPuzzle" (if set) MUST exactly match another "puzzles[].name" in this
346
- same scene, must not reference itself, and chains must never cycle back on themselves
347
- (A requires B requires A). A chain can be any length a puzzle count actually supports.
348
- - If a puzzle's riddle, clue, or the scene's "story" implies that doing something in one
349
- place enables or unlocks something elsewhere (e.g. "cut the power to blind the cameras"),
350
- that MUST be modeled as a real two-step (or longer) puzzle chain using "requiresPuzzle" --
351
- never write a riddle or clue that implies a mechanic the JSON doesn't actually back up.
352
- If there's only one puzzle in this scene, there's nothing to chain -- keep it self-
353
- contained and don't reference another location or action that doesn't exist.
354
- - "winCondition.item" MUST exactly match an "items[].name", and that item MUST have either
355
- "room" or "heldBy" set -- it needs a concrete, findable location in the world, exactly
356
- like every other item. It must NOT be "heldBy" the "winCondition.toNpc" itself (the
357
- player has to find it, not already have delivered it) -- place it in a room, or held by
358
- a DIFFERENT NPC.
359
- - "winCondition.toNpc" MUST exactly match an "npcs[].player.name".
360
- - "winCondition.rewardItem" (if set) MUST exactly match an "items[].name" with neither
361
- "room" nor "heldBy" set (same rule as a puzzle reward item).
362
- - This win-condition item is the CENTRAL plot object of the run's "story" -- something
363
- stolen, owed, or lost that the winCondition.toNpc wants back or wants delivered.
364
- - "exits[].keyRequired" (if set) MUST exactly match an "items[].name", and that item MUST
365
- have "room" or "heldBy" set to a concrete location -- never invent a key name that isn't
366
- a real item. Never place that key's item in a room that is ONLY reachable by first going
367
- through the very door it unlocks (that's an unsolvable soft-lock). The player must always
368
- be able to reach the key through some path that doesn't require already being unlocked.
369
- - A room may be marked "reachable": false to represent somewhere the player can never
370
- physically enter (a fixer's remote hideout, most commonly -- see the fixer convention
371
- above). Such a room MUST have no exits, or exits ONLY to other "reachable": false rooms --
372
- never an exit connecting it to a normally-reachable room, which would defeat the point.
373
- - Nothing the player must retrieve to finish the run -- a puzzle, a door's "keyRequired"
374
- item, or the "winCondition.item" -- may be placed inside a "reachable": false room. The
375
- player can never go get it.
376
- - If "winCondition.toNpc" is placed in a "reachable": false room, "call" (which requires a
377
- commlink on both ends) is the only way to reach them, so BOTH of these must hold: the
378
- player must have a way to get a "Commlink"-category item (in "startingItems", or findable
379
- in a reachable room), AND "winCondition.toNpc" must itself hold a "Commlink"-category item
380
- ("heldBy" set to their name) -- without one, they can never receive a call at all.
381
-
382
- Scope: ${scope}. Plus 4 to 6 general world items, one clue item per puzzle, the winCondition
383
- item and optional reward item, and the player's 3-5 starting items. Give it a gritty, noir
384
- Shadowrun tone: a fixer, a job, a locked door, a debt, a betrayal -- something in that vein.
385
- This is a fresh run, distinct from a generic "protect the lock" or "corp blackmail vault"
386
- premise.
387
- `.trim();
388
- }
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
- static maxAttempts = 3;
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
- * `tier` is 1 for a first job, 2+ for each successive "continue" --
393
- * scales both the scope guidance in the prompt and, indirectly, the
394
- * story's stakes. `previousFixerName`, when given, tells the model to
395
- * keep using the same job-giver identity rather than inventing a new one
396
- * every time "continue" is used.
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.maxAttempts; attempt++) {
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
- logger.write(`SceneSeedGenerator: tier ${tier} attempt ${attempt} of ${this.maxAttempts}`);
406
- const candidate = await this.requestCandidate(tier, previousFixerName, lastError);
407
- await this.dryRunValidate(candidate);
408
- logger.write(`SceneSeedGenerator: generated a valid scene "${candidate.name}" on attempt ${attempt}`);
409
- this.saveSeedToDisk(candidate, logFilePath, tier);
410
- return candidate;
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
- logger.write(`SceneSeedGenerator: giving up after ${this.maxAttempts} attempts, falling back to a static scene`);
418
- return null;
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 room of rooms) {
626
- if (!reachable.has(room.name))
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
- for (const exit of room.exits ?? []) {
629
- if (reachable.has(exit.targetRoom))
630
- continue;
631
- const keyLocation = exit.keyRequired ? itemLocation.get(exit.keyRequired) : undefined;
632
- // A puzzle-guarded exit (see DOORS AS PUZZLES) is always treated
633
- // as eventually passable: validateClueItems already guarantees
634
- // every puzzle's answer is discoverable somewhere, and even
635
- // without it, pick/hack/cast is a real (if risky) route through.
636
- // Only a plain "keyRequired" door needs its key traced to a
637
- // reachable location first.
638
- const passable = !!exit.puzzle || !exit.keyRequired || (keyLocation !== undefined && reachable.has(keyLocation));
639
- if (passable) {
640
- reachable.add(exit.targetRoom);
641
- grew = true;
642
- }
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
- const playerHasCommlink = (candidate.player?.startingItems ?? []).some(i => i.category === 'Commlink') ||
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
- if (!hasBookClue && !hasNamedItemClue) {
811
- throw new Error(`Puzzle "${puzzle.name}" has no discoverable clue: no Book-category item in a different room whose "details" contains the answer "${puzzle.requiredPhrase}", and no item elsewhere literally named "${puzzle.requiredPhrase}". Every puzzle needs one or the other.`);
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
  }