@gtanczyk/diorama 0.0.0-stage → 0.2.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 (302) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +157 -2
  3. package/app/bin/diorama.mjs +13 -0
  4. package/app/src/actions/budget.ts +160 -0
  5. package/app/src/actions/build.ts +158 -0
  6. package/app/src/actions/cast.ts +267 -0
  7. package/app/src/actions/depth.ts +95 -0
  8. package/app/src/actions/extend.ts +36 -0
  9. package/app/src/actions/faces.ts +160 -0
  10. package/app/src/actions/feedback.ts +87 -0
  11. package/app/src/actions/fix.ts +382 -0
  12. package/app/src/actions/history.ts +93 -0
  13. package/app/src/actions/idea-preview.ts +65 -0
  14. package/app/src/actions/idea-review.ts +102 -0
  15. package/app/src/actions/idea-sketch.ts +172 -0
  16. package/app/src/actions/index.ts +32 -0
  17. package/app/src/actions/items.ts +94 -0
  18. package/app/src/actions/kids.ts +215 -0
  19. package/app/src/actions/language.ts +52 -0
  20. package/app/src/actions/models.ts +64 -0
  21. package/app/src/actions/motion.ts +119 -0
  22. package/app/src/actions/pictures.ts +49 -0
  23. package/app/src/actions/reactions.ts +36 -0
  24. package/app/src/actions/registry.ts +101 -0
  25. package/app/src/actions/share.ts +32 -0
  26. package/app/src/actions/sound.ts +73 -0
  27. package/app/src/actions/start.ts +154 -0
  28. package/app/src/actions/step-work.ts +150 -0
  29. package/app/src/actions/story.ts +101 -0
  30. package/app/src/actions/studio.ts +133 -0
  31. package/app/src/actions/style.ts +72 -0
  32. package/app/src/actions/video.ts +46 -0
  33. package/app/src/actions/view.ts +109 -0
  34. package/app/src/actions/weather.ts +38 -0
  35. package/app/src/actions/world.ts +290 -0
  36. package/app/src/commands/action.ts +39 -0
  37. package/app/src/commands/backdrop.ts +188 -0
  38. package/app/src/commands/browsers.ts +21 -0
  39. package/app/src/commands/build.ts +503 -0
  40. package/app/src/commands/cast.ts +161 -0
  41. package/app/src/commands/clip.ts +431 -0
  42. package/app/src/commands/derive.ts +167 -0
  43. package/app/src/commands/doctor.ts +74 -0
  44. package/app/src/commands/explore.ts +123 -0
  45. package/app/src/commands/export.ts +158 -0
  46. package/app/src/commands/extend.ts +75 -0
  47. package/app/src/commands/faces.ts +80 -0
  48. package/app/src/commands/icons.ts +69 -0
  49. package/app/src/commands/index.ts +37 -0
  50. package/app/src/commands/language.ts +87 -0
  51. package/app/src/commands/layout.ts +31 -0
  52. package/app/src/commands/masks.ts +117 -0
  53. package/app/src/commands/mcp.ts +52 -0
  54. package/app/src/commands/model.ts +284 -0
  55. package/app/src/commands/paint.ts +182 -0
  56. package/app/src/commands/plates.ts +97 -0
  57. package/app/src/commands/play.ts +195 -0
  58. package/app/src/commands/playthrough.ts +111 -0
  59. package/app/src/commands/publish.ts +78 -0
  60. package/app/src/commands/reactions.ts +40 -0
  61. package/app/src/commands/reel.ts +46 -0
  62. package/app/src/commands/setup.ts +85 -0
  63. package/app/src/commands/sound.ts +100 -0
  64. package/app/src/commands/sprites.ts +232 -0
  65. package/app/src/commands/story.ts +37 -0
  66. package/app/src/commands/studio.ts +65 -0
  67. package/app/src/commands/style.ts +87 -0
  68. package/app/src/commands/verify.ts +45 -0
  69. package/app/src/commands/voice-retry.ts +14 -0
  70. package/app/src/commands/voices.ts +145 -0
  71. package/app/src/commands/weather.ts +52 -0
  72. package/app/src/commands/world.ts +104 -0
  73. package/app/src/film/clip.ts +386 -0
  74. package/app/src/film/reel.ts +126 -0
  75. package/app/src/film/screencast.ts +123 -0
  76. package/app/src/image/mask.ts +29 -0
  77. package/app/src/image/overlay.ts +38 -0
  78. package/app/src/image/raster.ts +224 -0
  79. package/app/src/main.ts +28 -0
  80. package/app/src/meta/choice.ts +27 -0
  81. package/app/src/meta/http.ts +99 -0
  82. package/app/src/meta/memo.ts +105 -0
  83. package/app/src/meta/muse.ts +70 -0
  84. package/app/src/meta/openrouter.ts +93 -0
  85. package/app/src/meta/prices.ts +25 -0
  86. package/app/src/meta/sam.ts +83 -0
  87. package/app/src/meta/schema.ts +63 -0
  88. package/app/src/meta/spark.ts +220 -0
  89. package/app/src/meta/step.ts +14 -0
  90. package/app/src/meta/voice-file.ts +78 -0
  91. package/app/src/meta/voice.ts +103 -0
  92. package/app/src/model/animation.ts +38 -0
  93. package/app/src/model/blender/relax.py +284 -0
  94. package/app/src/model/blender.ts +115 -0
  95. package/app/src/model/build.ts +50 -0
  96. package/app/src/model/face.ts +21 -0
  97. package/app/src/model/fit.ts +181 -0
  98. package/app/src/model/glb.ts +241 -0
  99. package/app/src/model/plan.ts +151 -0
  100. package/app/src/model/quality.ts +48 -0
  101. package/app/src/model/space.ts +168 -0
  102. package/app/src/model/sprites.ts +293 -0
  103. package/app/src/model/views.ts +163 -0
  104. package/app/src/play/creature.ts +43 -0
  105. package/app/src/play/depth-occlusion.ts +150 -0
  106. package/app/src/play/exit-outline.ts +48 -0
  107. package/app/src/play/face3d.ts +34 -0
  108. package/app/src/play/focus.ts +78 -0
  109. package/app/src/play/geometry.ts +272 -0
  110. package/app/src/play/gestures.ts +23 -0
  111. package/app/src/play/glow.ts +187 -0
  112. package/app/src/play/lipsync.ts +69 -0
  113. package/app/src/play/living.ts +83 -0
  114. package/app/src/play/outline-path.ts +130 -0
  115. package/app/src/play/parallax.ts +200 -0
  116. package/app/src/play/path.ts +262 -0
  117. package/app/src/play/preview.ts +71 -0
  118. package/app/src/play/reactions.ts +92 -0
  119. package/app/src/play/rig.ts +172 -0
  120. package/app/src/play/shadow.ts +253 -0
  121. package/app/src/play/sound.ts +51 -0
  122. package/app/src/play/weather.ts +58 -0
  123. package/app/src/qa/explore.ts +136 -0
  124. package/app/src/qa/probe.ts +139 -0
  125. package/app/src/qa/route.ts +118 -0
  126. package/app/src/registry.ts +23 -0
  127. package/app/src/scene/animation-source.ts +36 -0
  128. package/app/src/scene/assets.ts +37 -0
  129. package/app/src/scene/autofix.ts +133 -0
  130. package/app/src/scene/backdrop.ts +326 -0
  131. package/app/src/scene/blockin.ts +189 -0
  132. package/app/src/scene/brief.ts +74 -0
  133. package/app/src/scene/carry.ts +177 -0
  134. package/app/src/scene/cast-fixes.ts +33 -0
  135. package/app/src/scene/cast.ts +199 -0
  136. package/app/src/scene/corrections.ts +164 -0
  137. package/app/src/scene/critic.ts +169 -0
  138. package/app/src/scene/depthmap.ts +149 -0
  139. package/app/src/scene/derive.ts +475 -0
  140. package/app/src/scene/diagnose.ts +132 -0
  141. package/app/src/scene/face-calibration.ts +65 -0
  142. package/app/src/scene/face-registration.ts +21 -0
  143. package/app/src/scene/face-work.ts +71 -0
  144. package/app/src/scene/faces-data.ts +48 -0
  145. package/app/src/scene/faces-settings.ts +20 -0
  146. package/app/src/scene/faces.ts +435 -0
  147. package/app/src/scene/glows.ts +153 -0
  148. package/app/src/scene/held.ts +109 -0
  149. package/app/src/scene/icons.ts +185 -0
  150. package/app/src/scene/layers.ts +822 -0
  151. package/app/src/scene/layout.ts +216 -0
  152. package/app/src/scene/locate.ts +118 -0
  153. package/app/src/scene/maskcheck.ts +43 -0
  154. package/app/src/scene/masks.ts +392 -0
  155. package/app/src/scene/occluders.ts +123 -0
  156. package/app/src/scene/occlusion-check.ts +138 -0
  157. package/app/src/scene/paint-layout.ts +135 -0
  158. package/app/src/scene/paint.ts +81 -0
  159. package/app/src/scene/photo.ts +123 -0
  160. package/app/src/scene/plates.ts +256 -0
  161. package/app/src/scene/playbook.ts +121 -0
  162. package/app/src/scene/preview.ts +23 -0
  163. package/app/src/scene/reactions.ts +118 -0
  164. package/app/src/scene/restyle.ts +53 -0
  165. package/app/src/scene/rig-check.ts +68 -0
  166. package/app/src/scene/rig.ts +379 -0
  167. package/app/src/scene/scale.ts +148 -0
  168. package/app/src/scene/shadows.ts +215 -0
  169. package/app/src/scene/stairs.ts +183 -0
  170. package/app/src/scene/status.ts +32 -0
  171. package/app/src/scene/verify.ts +156 -0
  172. package/app/src/scene/walk.ts +326 -0
  173. package/app/src/scene/warp.ts +419 -0
  174. package/app/src/scene/weather.ts +118 -0
  175. package/app/src/scene/words.ts +65 -0
  176. package/app/src/secrets.ts +219 -0
  177. package/app/src/setup/checks.ts +110 -0
  178. package/app/src/sound/cost.ts +74 -0
  179. package/app/src/sound/data.ts +89 -0
  180. package/app/src/sound/fixes.ts +23 -0
  181. package/app/src/sound/issues.ts +21 -0
  182. package/app/src/sound/language.ts +11 -0
  183. package/app/src/sound/lipsync-make.ts +98 -0
  184. package/app/src/sound/lipsync.ts +24 -0
  185. package/app/src/sound/make.ts +229 -0
  186. package/app/src/sound/musicgen.py +20 -0
  187. package/app/src/sound/pcm.ts +280 -0
  188. package/app/src/sound/plan.ts +154 -0
  189. package/app/src/sound/retry.ts +43 -0
  190. package/app/src/sound/settings.ts +28 -0
  191. package/app/src/sound/sources.ts +81 -0
  192. package/app/src/sound/spoken.ts +70 -0
  193. package/app/src/sound/voices.ts +579 -0
  194. package/app/src/speech/transcribe.ts +38 -0
  195. package/app/src/speech/types.ts +18 -0
  196. package/app/src/story/check.ts +230 -0
  197. package/app/src/story/design.ts +108 -0
  198. package/app/src/story/engine.ts +233 -0
  199. package/app/src/story/fixes.ts +54 -0
  200. package/app/src/story/npc-guidance.ts +7 -0
  201. package/app/src/story/phrases.ts +4 -0
  202. package/app/src/story/schema.ts +92 -0
  203. package/app/src/studio/agent.ts +555 -0
  204. package/app/src/studio/discovery.ts +28 -0
  205. package/app/src/studio/events.ts +48 -0
  206. package/app/src/studio/history.ts +124 -0
  207. package/app/src/studio/jobs.ts +291 -0
  208. package/app/src/studio/locks.ts +33 -0
  209. package/app/src/studio/mcp.ts +86 -0
  210. package/app/src/studio/operation.ts +18 -0
  211. package/app/src/studio/repair.ts +31 -0
  212. package/app/src/studio/server.ts +423 -0
  213. package/app/src/studio/trouble.ts +48 -0
  214. package/app/src/world/design.ts +72 -0
  215. package/app/src/world/drawings.ts +198 -0
  216. package/app/src/world/extend-stage.ts +109 -0
  217. package/app/src/world/extend.ts +123 -0
  218. package/app/src/world/extension-progress.ts +10 -0
  219. package/app/src/world/files.ts +62 -0
  220. package/app/src/world/idea-review.ts +32 -0
  221. package/app/src/world/language.ts +116 -0
  222. package/app/src/world/photo-limits.ts +2 -0
  223. package/app/src/world/pool.ts +41 -0
  224. package/app/src/world/schedule.ts +64 -0
  225. package/app/src/world/schema.ts +74 -0
  226. package/app/src/world/sketch.ts +81 -0
  227. package/app/src/world/style-plan.ts +90 -0
  228. package/app/src/world/style-reference.ts +16 -0
  229. package/app/src/world/styled.ts +51 -0
  230. package/app/src/world/told.ts +105 -0
  231. package/app/src/world/video-limits.ts +3 -0
  232. package/app/src/world/video.ts +98 -0
  233. package/app/studio/activity.tsx +124 -0
  234. package/app/studio/agent.tsx +384 -0
  235. package/app/studio/api.ts +111 -0
  236. package/app/studio/atelier.tsx +234 -0
  237. package/app/studio/audio-missing.tsx +25 -0
  238. package/app/studio/audio-setup.tsx +33 -0
  239. package/app/studio/blender-setup.tsx +30 -0
  240. package/app/studio/build.tsx +679 -0
  241. package/app/studio/camera.tsx +59 -0
  242. package/app/studio/cast-work.tsx +207 -0
  243. package/app/studio/cast.tsx +178 -0
  244. package/app/studio/concept-edit.tsx +47 -0
  245. package/app/studio/creator-idea.tsx +46 -0
  246. package/app/studio/custom-look.tsx +36 -0
  247. package/app/studio/faces-setup.tsx +28 -0
  248. package/app/studio/favicon.svg +11 -0
  249. package/app/studio/figure.tsx +314 -0
  250. package/app/studio/fonts/OFL.txt +93 -0
  251. package/app/studio/fonts/fonts.css +7 -0
  252. package/app/studio/fonts/fraunces-italic-latin-ext.woff2 +0 -0
  253. package/app/studio/fonts/fraunces-italic-latin.woff2 +0 -0
  254. package/app/studio/fonts/fraunces-normal-latin-ext.woff2 +0 -0
  255. package/app/studio/fonts/fraunces-normal-latin.woff2 +0 -0
  256. package/app/studio/fonts/instrument-sans-normal-latin-ext.woff2 +0 -0
  257. package/app/studio/fonts/instrument-sans-normal-latin.woff2 +0 -0
  258. package/app/studio/icons.tsx +42 -0
  259. package/app/studio/idea-card.tsx +42 -0
  260. package/app/studio/idea-review.tsx +181 -0
  261. package/app/studio/idea-storyboard.tsx +134 -0
  262. package/app/studio/index.html +15 -0
  263. package/app/studio/job-words.ts +33 -0
  264. package/app/studio/lightbox.tsx +49 -0
  265. package/app/studio/main.tsx +1329 -0
  266. package/app/studio/nav.ts +14 -0
  267. package/app/studio/place.tsx +264 -0
  268. package/app/studio/review-sketch.tsx +47 -0
  269. package/app/studio/say-it.tsx +49 -0
  270. package/app/studio/step-work.tsx +134 -0
  271. package/app/studio/story.tsx +394 -0
  272. package/app/studio/style-menu.tsx +81 -0
  273. package/app/studio/style.css +1390 -0
  274. package/app/studio/sure.tsx +54 -0
  275. package/app/studio/tip.tsx +74 -0
  276. package/app/studio/tsconfig.json +5 -0
  277. package/app/studio/video.tsx +73 -0
  278. package/app/studio/world.tsx +188 -0
  279. package/app/tsconfig.json +13 -0
  280. package/app/web/audio.ts +315 -0
  281. package/app/web/face.ts +131 -0
  282. package/app/web/face3d.ts +35 -0
  283. package/app/web/glows.ts +387 -0
  284. package/app/web/hero3d.ts +728 -0
  285. package/app/web/index.html +118 -0
  286. package/app/web/living.ts +404 -0
  287. package/app/web/main.ts +3769 -0
  288. package/app/web/pad.ts +174 -0
  289. package/app/web/reactions.ts +79 -0
  290. package/app/web/sheet.ts +36 -0
  291. package/app/web/synth.ts +140 -0
  292. package/app/web/tsconfig.json +5 -0
  293. package/app/web/ui.ts +263 -0
  294. package/app/web/weather.ts +306 -0
  295. package/app/web/words.ts +12 -0
  296. package/docs/characters.md +58 -0
  297. package/docs/npc-motion.md +69 -0
  298. package/docs/scenes.md +75 -0
  299. package/docs/setup.md +60 -0
  300. package/docs/sound.md +23 -0
  301. package/docs/studio.md +62 -0
  302. package/package.json +70 -4
@@ -0,0 +1,230 @@
1
+ // The story gate: every name must exist in the world, every ending must be reachable, and no
2
+ // reachable state may cut the player off from all endings. Finds a shortest route to each ending too.
3
+ import type { World } from '../world/schema.ts';
4
+ import { actions, apply, describe, pickInteraction, startState, stateKey, type Action, type State } from './engine.ts';
5
+ import { Story } from './schema.ts';
6
+
7
+ export type Route = { ending: string; steps: string[]; actions: Action[] };
8
+ export type StoryReport = { issues: string[]; states: number; routes: Route[] };
9
+
10
+ const MAX_STATES = 200_000;
11
+
12
+ /** Whether the story needs a thing for more than a look line (what it gives, a lock, a change, a talk): then it stays. */
13
+ export function storyNeeds(raw: unknown, place: string, thing: string): boolean {
14
+ const story = Story.safeParse(raw);
15
+ if (!story.success) return JSON.stringify(raw ?? '').includes(`"${thing}"`);
16
+ const s = story.data;
17
+ return s.interactions.some((i) => i.place === place && i.thing === thing && (i.verb !== 'look' || i.do.length > 0))
18
+ || s.locks.some((l) => l.place === place && l.via === thing)
19
+ || s.changes.some((c) => c.place === place && c.thing === thing)
20
+ || s.patrols?.some((p) => p.place === place && p.via.includes(thing)) === true
21
+ || s.dialogues.some((d) => d.place === place && d.npc === thing);
22
+ }
23
+
24
+ export function storyNameIssues(world: World, story: Story): string[] {
25
+ const issues: string[] = [];
26
+ const place = (key: string) => world.places.find((p) => p.key === key);
27
+ const items = new Set(story.items.map((i) => i.key));
28
+ const endings = new Set(story.endings.map((e) => e.key));
29
+ const setFlags = new Set<string>();
30
+ const given = new Set<string>();
31
+ const allEffects = [...story.interactions.flatMap((i) => i.do), ...story.dialogues.flatMap((d) => d.lines.flatMap((l) => l.choices.flatMap((c) => c.do)))];
32
+ const moves = allEffects.flatMap((e) => 'moveNpc' in e ? [e.moveNpc] : []);
33
+ const isNpc = (key: string) => world.places.some((p) => p.things.some((t) => t.key === key && t.role === 'npc'));
34
+ const canVisit = (npc: string, placeKey: string) => isNpc(npc) && moves.some((m) => m.npc === npc && m.to === placeKey);
35
+ for (const e of allEffects) {
36
+ if ('set' in e) setFlags.add(e.set);
37
+ if ('give' in e) given.add(e.give);
38
+ if ('give' in e || 'take' in e) { const k = 'give' in e ? e.give : (e as { take: string }).take; if (!items.has(k)) issues.push(`effect uses unknown item "${k}"`); }
39
+ if ('go' in e && !place(e.go)) issues.push(`effect goes to unknown place "${e.go}"`);
40
+ if ('ending' in e && !endings.has(e.ending)) issues.push(`effect ends with unknown ending "${e.ending}"`);
41
+ if ('moveNpc' in e) {
42
+ const { npc, to } = e.moveNpc;
43
+ const homes = world.places.filter((p) => p.things.some((t) => t.key === npc && t.role === 'npc'));
44
+ if (homes.length !== 1) issues.push(`moveNpc: "${npc}" must be an NPC declared in exactly one place`);
45
+ if (!place(to)) issues.push(`moveNpc: unknown place "${to}"`);
46
+ if (world.places.some((p) => p.things.some((t) => t.key === npc && t.role !== 'npc'))) issues.push(`moveNpc: "${npc}" also names an object`);
47
+ }
48
+ }
49
+ const checkWhen = (where: string, when: string[]) => {
50
+ for (const fact of when) {
51
+ const body = fact.replace(/^!/, '');
52
+ if (body.startsWith('has:') && !items.has(body.slice(4))) issues.push(`${where}: unknown item in "${fact}"`);
53
+ else if (body.startsWith('at:') && !place(body.slice(3))) issues.push(`${where}: unknown place in "${fact}"`);
54
+ else if (!body.includes(':') && !fact.startsWith('!') && !setFlags.has(body)) issues.push(`${where}: flag "${body}" is never set`);
55
+ }
56
+ };
57
+ const thingIn = (where: string, placeKey: string, thing: string, role?: string) => {
58
+ const p = place(placeKey);
59
+ if (!p) return issues.push(`${where}: unknown place "${placeKey}"`);
60
+ const t = p.things.find((x) => x.key === thing);
61
+ if (!t && role !== 'exit' && canVisit(thing, placeKey)) return;
62
+ if (!t) issues.push(`${where}: "${thing}" is not a thing in ${placeKey}`);
63
+ else if (role && t.role !== role) issues.push(`${where}: "${thing}" must have role "${role}"`);
64
+ };
65
+ story.interactions.forEach((it, i) => {
66
+ const where = `interaction ${i + 1} (${it.verb} ${it.thing})`;
67
+ // A look line on a prop the person took off the place's list is never clicked; it does no harm.
68
+ if (it.verb === 'look' && !it.do.length && place(it.place)?.taken?.includes(it.thing)) return;
69
+ thingIn(where, it.place, it.thing, it.verb === 'talk' ? 'npc' : undefined);
70
+ // Clicking an exit always walks through it (and obeys its lock), so an exit only answers "look".
71
+ const exit = place(it.place)?.things.find((t) => t.key === it.thing)?.role === 'exit';
72
+ if (exit && it.verb !== 'look') issues.push(`${where}: "${it.thing}" is an exit; clicking it goes through, so it can only have "look" interactions (use a lock to stop it)`);
73
+ if (it.with && !items.has(it.with)) issues.push(`${where}: unknown item "${it.with}"`);
74
+ checkWhen(where, it.when);
75
+ });
76
+ for (const lock of story.locks) {
77
+ thingIn(`lock on ${lock.via}`, lock.place, lock.via, 'exit');
78
+ checkWhen(`lock on ${lock.via}`, lock.until);
79
+ }
80
+ story.changes.forEach((c, i) => {
81
+ thingIn(`change ${i + 1} (${c.thing})`, c.place, c.thing);
82
+ checkWhen(`change ${i + 1} (${c.thing})`, c.when);
83
+ });
84
+ for (const d of story.dialogues) {
85
+ const where = `dialogue with ${d.npc}`;
86
+ thingIn(where, d.place, d.npc, 'npc');
87
+ checkWhen(where, d.when);
88
+ const keys = new Set(d.lines.map((l) => l.key));
89
+ if (!keys.has(d.start)) issues.push(`${where}: start line "${d.start}" is missing`);
90
+ for (const l of d.lines) for (const c of l.choices) {
91
+ if (c.next && !keys.has(c.next)) issues.push(`${where}: line "${l.key}" leads to missing "${c.next}"`);
92
+ checkWhen(`${where}, line ${l.key}`, c.when);
93
+ }
94
+ }
95
+ for (const item of items) if (!given.has(item)) issues.push(`item "${item}" is never given`);
96
+ for (const patrol of story.patrols ?? []) {
97
+ const where = `patrol of ${patrol.npc} in ${patrol.place}`;
98
+ thingIn(where, patrol.place, patrol.npc, 'npc');
99
+ checkWhen(where, patrol.when);
100
+ if (new Set(patrol.via).size < 2) issues.push(`${where}: needs at least two different stops`);
101
+ for (const key of patrol.via) {
102
+ thingIn(where, patrol.place, key);
103
+ if (isNpc(key)) issues.push(`${where}: stop "${key}" must be a fixed object, not a person`);
104
+ }
105
+ }
106
+ return issues;
107
+ }
108
+
109
+ /** Breadth-first over every state the player can reach. */
110
+ export function checkStory(world: World, raw: unknown): StoryReport & { story?: Story } {
111
+ const parsed = Story.safeParse(raw);
112
+ if (!parsed.success) return { issues: parsed.error.issues.map((i) => `${i.path.join('.') || 'story'}: ${i.message}`), states: 0, routes: [] };
113
+ const story = parsed.data;
114
+ const nameIssues = storyNameIssues(world, story);
115
+ if (nameIssues.length) return { issues: nameIssues, states: 0, routes: [] };
116
+
117
+ const start = startState(world);
118
+ const seen = new Map<string, { state: State; from?: string; via?: Action }>([[stateKey(start), { state: start }]]);
119
+ const edges = new Map<string, string[]>();
120
+ const queue = [stateKey(start)];
121
+ const fired = new Set<number>();
122
+ const movementIssues = new Set<string>();
123
+ while (queue.length && seen.size < MAX_STATES) {
124
+ const key = queue.shift()!;
125
+ const { state } = seen.get(key)!;
126
+ const out: string[] = [];
127
+ for (const action of actions(world, story, state)) {
128
+ const step = apply(world, story, state, action);
129
+ for (const issue of step.issues ?? []) movementIssues.add(issue);
130
+ if (action.kind === 'do') fired.add(pickInteraction(story, state, action, world));
131
+ const nextKey = stateKey(step.state);
132
+ out.push(nextKey);
133
+ if (!seen.has(nextKey)) { seen.set(nextKey, { state: step.state, from: key, via: action }); queue.push(nextKey); }
134
+ }
135
+ edges.set(key, out);
136
+ }
137
+ const issues: string[] = [...movementIssues];
138
+ if (seen.size >= MAX_STATES) issues.push(`more than ${MAX_STATES} states; the story is too loose to check`);
139
+
140
+ const actionsTo = (key: string) => {
141
+ const out: Action[] = [];
142
+ for (let at = seen.get(key); at?.from; at = seen.get(at.from)) out.unshift(at.via!);
143
+ return out;
144
+ };
145
+ const routeTo = (key: string) => actionsTo(key).map(describe);
146
+ const routes: Route[] = [];
147
+ for (const ending of story.endings) {
148
+ const hit = [...seen.entries()].find(([, v]) => v.state.ending === ending.key);
149
+ if (hit) routes.push({ ending: ending.key, steps: routeTo(hit[0]), actions: actionsTo(hit[0]) });
150
+ else issues.push(`ending "${ending.key}" cannot be reached`);
151
+ }
152
+
153
+ // States that can still reach an ending, found backwards from the ending states.
154
+ const back = new Map<string, string[]>();
155
+ for (const [from, tos] of edges) for (const to of tos) back.set(to, [...(back.get(to) ?? []), from]);
156
+ const alive = new Set([...seen.keys()].filter((k) => seen.get(k)!.state.ending));
157
+ const stack = [...alive];
158
+ while (stack.length) for (const from of back.get(stack.pop()!) ?? []) if (!alive.has(from)) { alive.add(from); stack.push(from); }
159
+ const stuck = [...seen.keys()].filter((k) => !alive.has(k) && edges.has(k));
160
+ if (stuck.length) {
161
+ const first = stuck.reduce((a, b) => (routeTo(a).length <= routeTo(b).length ? a : b));
162
+ issues.push(`${stuck.length} reachable state(s) can never reach an ending, e.g. after: ${routeTo(first).join(' → ') || 'the start'}`);
163
+ }
164
+
165
+ story.interactions.forEach((it, i) => {
166
+ if (!fired.has(i)) issues.push(`interaction ${i + 1} (${it.verb} ${it.thing} in ${it.place}) can never happen`);
167
+ });
168
+ return { issues, states: seen.size, routes, story: issues.length ? undefined : story };
169
+ }
170
+
171
+ /** Word limits per kind of line: everything said is read or voiced, so less is better. */
172
+ export const BREVITY = { intro: 30, say: 14, nope: 8, npcLine: 20, choice: 8, ending: 25, linesPerDialogue: 6 } as const;
173
+
174
+ const words = (text: string) => text.trim().split(/\s+/).filter(Boolean).length;
175
+
176
+ /** Lines over the limits, for the designer to shorten. Not part of checkStory: a person may write longer by hand. */
177
+ export function brevityIssues(story: Story): string[] {
178
+ const issues: string[] = [];
179
+ const over = (where: string, text: string, max: number) => {
180
+ if (words(text) > max) issues.push(`${where} has ${words(text)} words, keep it to ${max}: "${text}"`);
181
+ };
182
+ over('intro', story.intro, BREVITY.intro);
183
+ story.interactions.forEach((it, i) => over(`interaction ${i + 1} (${it.verb} ${it.thing}) say`, it.say, BREVITY.say));
184
+ story.locks.forEach((l) => over(`lock on ${l.via} say`, l.say, BREVITY.say));
185
+ story.items.forEach((it) => it.nope && over(`item ${it.key} nope`, it.nope, BREVITY.nope));
186
+ for (const d of story.dialogues) {
187
+ if (d.lines.length > BREVITY.linesPerDialogue) issues.push(`dialogue with ${d.npc} has ${d.lines.length} lines, keep it to ${BREVITY.linesPerDialogue}`);
188
+ for (const l of d.lines) {
189
+ over(`${d.npc} line ${l.key}`, l.says, BREVITY.npcLine);
190
+ l.choices.forEach((c, i) => over(`${d.npc} line ${l.key} choice ${i + 1}`, c.text, BREVITY.choice));
191
+ }
192
+ }
193
+ story.endings.forEach((e) => over(`ending ${e.key} text`, e.text, BREVITY.ending));
194
+ return issues;
195
+ }
196
+
197
+ const asks = (text: string) => /\?["'”’)]*\s*$/.test(text.trim());
198
+
199
+ /** Questions the player cannot answer: a "say" is not a conversation, and a line without choices ends one. */
200
+ export function questionIssues(story: Story): string[] {
201
+ const issues: string[] = [];
202
+ story.interactions.forEach((it, i) => {
203
+ if (asks(it.say)) issues.push(`interaction ${i + 1} (${it.verb} ${it.thing}) say ends with a question the player cannot answer: "${it.say}". Make it a statement, or ask it in a dialogue line with choices`);
204
+ });
205
+ for (const d of story.dialogues) {
206
+ for (const l of d.lines) {
207
+ if (asks(l.says) && l.choices.length === 0) issues.push(`${d.npc} line ${l.key} asks "${l.says}" but has no choices, so the conversation ends on the question. Give it answers, or make it a statement`);
208
+ }
209
+ }
210
+ return issues;
211
+ }
212
+
213
+ /**
214
+ * What plays but does not show: an item thing whose item has another key stays lying in the painting after it is
215
+ * taken, and a change on a person is never drawn (people are characters over the painting).
216
+ */
217
+ export function paintingIssues(world: World, story: Story): string[] {
218
+ const issues: string[] = [];
219
+ const role = (place: string, thing: string) => world.places.find((p) => p.key === place)?.things.find((t) => t.key === thing)?.role;
220
+ story.interactions.forEach((it, i) => {
221
+ const gives = it.do.flatMap((e) => ('give' in e ? [e.give] : []));
222
+ if (it.verb === 'take' && role(it.place, it.thing) === 'item' && gives.length && !gives.includes(it.thing)) {
223
+ issues.push(`interaction ${i + 1} (take ${it.thing}): "${it.thing}" is an item thing, so the item it gives must have the key "${it.thing}"`);
224
+ }
225
+ });
226
+ story.changes.forEach((c, i) => {
227
+ if (role(c.place, c.thing) === 'npc') issues.push(`change ${i + 1} (${c.thing}): "${c.thing}" is a person; a change can only repaint a thing in the painting, not a person`);
228
+ });
229
+ return issues;
230
+ }
@@ -0,0 +1,108 @@
1
+ // The story step: world.json becomes story.json. Spark writes it; the checker plays every state
2
+ // and sends dead ends and unreachable endings back.
3
+ import type { Memo } from '../meta/memo.ts';
4
+ import { askSparkJson } from '../meta/spark.ts';
5
+ import type { World } from '../world/schema.ts';
6
+ import { BREVITY, brevityIssues, checkStory, paintingIssues, questionIssues, type StoryReport } from './check.ts';
7
+ import { fromWire, WireStory, type Story } from './schema.ts';
8
+ import { z } from 'zod';
9
+ import { NPC_STORY_GUIDANCE } from './npc-guidance.ts';
10
+
11
+ const MIN_STEPS = 6;
12
+
13
+ const EXAMPLE = `{
14
+ "intro": "two or three sentences shown before the first place",
15
+ "items": [{ "key": "<thing key with role item, or a new key for something given>", "name": "short name", "nope": "what the hero says trying it where it does not help" }],
16
+ "locks": [{ "place": "<place>", "via": "<exit thing>", "until": ["<fact>"], "say": "why it will not open yet" }],
17
+ "interactions": [{
18
+ "place": "<place>", "thing": "<thing key in that place>", "verb": "look | use | take | talk",
19
+ "with": "<item key, only when using an item on the thing>",
20
+ "when": ["<fact>"], "say": "what the hero says or sees", "do": [{ "set": "flag" }], "once": false
21
+ }],
22
+ "dialogues": [{
23
+ "npc": "<thing key with role npc>", "place": "<place>", "when": [], "start": "<line key>",
24
+ "lines": [{ "key": "kebab-key", "says": "what the npc says", "choices": [{ "text": "what the hero answers", "when": [], "do": [], "next": "<line key or leave out to end>" }] }]
25
+ }],
26
+ "changes": [{ "place": "<place>", "thing": "<thing key>", "when": ["<fact>"], "looks": "how it looks once changed, a few words" }],
27
+ "patrols": [],
28
+ "endings": [{ "key": "kebab-key", "title": "short", "text": "two sentences" }]
29
+ }`;
30
+
31
+ /** For players of 5 to 7 who cannot read yet: they hear every line, so it has to be plain and kind. */
32
+ const KIDS = `
33
+ - The players are 5 to 7 years old and cannot read: every line is read aloud to them. Use the words a small child uses, short sentences, no irony.
34
+ - Puzzles a small child solves by trying: give the thing to someone who asks for it, use an item on the thing that obviously needs it. The npc says plainly what they need.
35
+ - Friendly and gentle: nobody is hurt, nothing is scary, both endings are happy (one may be a little funny).`;
36
+
37
+ function brief(world: World): string {
38
+ return `You write the puzzles and dialogue of a short 2D point-and-click adventure. The world is fixed:
39
+ ${JSON.stringify(world, null, 2)}
40
+
41
+ Write story.json for it. Rules:
42
+ - 3 to 6 minutes of play: 2 to 4 items, 1 or 2 locked exits, one conversation with real choices, 2 endings.
43
+ - A fact is "flag", "!flag", "has:item", "!has:item" or "at:place". A condition is a list of facts that must all hold.
44
+ - Effects: {"set": flag}, {"clear": flag}, {"give": item}, {"take": item}, {"go": place}, {"ending": ending key}.
45
+ ${NPC_STORY_GUIDANCE}
46
+ - For structured wire effects, a move is {"op":"moveNpc","key":"<npc key>","to":"<place key>"}.
47
+ - Clicking picks the first interaction listed for that thing, verb and item whose condition holds, so list special cases before the plain one.
48
+ - Every thing should answer "look". Every item thing has a "take" with "once": true and gives the item keyed like the thing; taking it lifts it out of the painting. Something found in another thing (a fish from a stall) is a new item key; that thing stays.
49
+ - Exits move the hero by themselves when clicked: give an exit only "look" interactions, never use/take/talk or a go effect; stop it with a lock.
50
+ - Unlock with a flag set when the problem is solved, not with "has:item", so the player is never locked out after giving the item away.
51
+ - When solving something visibly changes a thing (a fire lit, a gate open, a lamp glowing), add a change for it; the painting is updated when its condition holds. At least one change, never on a person (npc).
52
+ - No ending may be reachable in fewer than ${MIN_STEPS} clicks; each ending needs a solved puzzle.
53
+ - Every ending must be reachable, and from any state the player must still be able to reach an ending.
54
+ - Only a dialogue line with choices may ask the player something. A "say", and a line without choices, ends on a statement, never a question: the player has no way to answer it.
55
+ - Write for actors, not a task checklist. Give each character a distinct personality, motive and way of talking, grounded in the drawings and this story. Include brief reactions, gentle humour and reciprocal exchanges. Let relationships develop when the player helps; do not make every character simply order an item and redirect the player.
56
+ - The hero speaks in first person, reacting in their own voice: "Oof, this can is heavy!", never player-facing narration such as "You take the watering can" or "Bierzesz konewkę". NPCs speak as themselves; only the intro and endings are narrated. Do not put stage directions or emotion tags into spoken text.
57
+ - Keep each line easy to follow, with room for personality: the intro at most ${BREVITY.intro} words, a "say" at most ${BREVITY.say}, an npc line at most ${BREVITY.npcLine}, an answer choice at most ${BREVITY.choice}, an ending at most ${BREVITY.ending}, an item's \"nope\" at most ${BREVITY.nope}, and at most ${BREVITY.linesPerDialogue} lines per conversation. One idea per line; concise does not mean robotic or purely instructional.
58
+ - Text is ${world.language ?? 'English'}, short and specific to this world. No names from existing films, books, games or brands.${world.audience === 'kids' ? KIDS : ''}
59
+
60
+ Reply with JSON only, shaped like:
61
+ ${EXAMPLE}`;
62
+ }
63
+
64
+ /** Up to four tries; each retry shows Spark its last answer and everything the checker found. */
65
+ export async function designStory(memo: Memo, world: World, wish?: string): Promise<{ story: Story; report: StoryReport }> {
66
+ // The person's wish goes last, so a story without one asks Spark exactly what it asked before.
67
+ const ask = (w: World) => brief(w) + (wish ? `\n\nThe author asks for this; follow it, keeping every ending reachable and the exit rules: ${wish}` : '');
68
+ let prompt = ask(world);
69
+ // A story every ending of which plays, kept in case the editor never runs out of notes on its wording.
70
+ let playable: { story: Story; report: StoryReport; notes: string[] } | undefined;
71
+ for (let attempt = 1; attempt <= 4; attempt++) {
72
+ // Claude is held to the wire shape (effects as {op, key}); Spark answers in Story's own shape. Both read the same.
73
+ const answer = fromWire(await askSparkJson(memo, { label: `story#${attempt}`, prompt, schema: WireStory }));
74
+ const report = checkStory(world, answer);
75
+ if (report.story) report.issues.push(...brevityIssues(report.story), ...questionIssues(report.story), ...paintingIssues(world, report.story));
76
+ for (const r of report.routes) {
77
+ if (r.steps.length < MIN_STEPS) report.issues.push(`ending "${r.ending}" is reached in only ${r.steps.length} clicks: ${r.steps.join(' → ')}`);
78
+ }
79
+ if (report.story && report.issues.length === 0) {
80
+ const notes = await dialogueIssues(memo, world, report.story);
81
+ if (!notes.length) return { story: report.story, report };
82
+ if (!playable || notes.length <= playable.notes.length) playable = { story: report.story, report: { ...report, issues: [] }, notes };
83
+ report.issues.push(...notes);
84
+ }
85
+ console.log(` story try ${attempt}: ${report.issues.length} issue(s): ${report.issues.slice(0, 3).join('; ')}`);
86
+ prompt = `${ask(world)}\n\nYour last answer was:\n${JSON.stringify(answer)}\n\nA checker played every possible state and found these problems. Fix every one and reply with the whole JSON again:\n- ${report.issues.join('\n- ')}`;
87
+ }
88
+ // Wording notes are taste; a story that plays is the step's job. The person can reword any line in the Studio.
89
+ if (playable) {
90
+ console.log(` story: kept the playable version with the fewest wording notes (${playable.notes.length}): ${playable.notes.slice(0, 3).join('; ')}`);
91
+ return { story: playable.story, report: playable.report };
92
+ }
93
+ throw new Error('Spark gave no playable story in 4 tries');
94
+ }
95
+
96
+ /** Editorial review is separate from reachability: a playable checklist can still be a poor conversation. */
97
+ export async function dialogueIssues(memo: Memo, world: World, story: Story): Promise<string[]> {
98
+ const review = await askSparkJson(memo, {
99
+ label: 'story-dialogue-review',
100
+ schema: z.object({ issues: z.array(z.string()) }),
101
+ prompt: `Review the spoken script of this game in ${world.language ?? 'English'} for ${world.audience === 'kids' ? 'children aged 5–7' : 'its intended audience'}.
102
+ World: ${JSON.stringify(world)}
103
+ Script: ${JSON.stringify(story)}
104
+ Check native natural phrasing, consistent speaker perspective, distinct character personalities, reactions and meaningful exchanges beyond fetching items. Hero remarks and choices must be things the hero says, not a narrator telling the player what they do. Keep age-appropriate simplicity without making every voice generic or babyish. Do not demand longer dialogue just for length. Check that humour and reactions stay consistent with the story and do not obscure the next action.
105
+ Return JSON {"issues": ["exact text path and concrete correction needed"]}; [] if it works. At most 6 substantive issues. Do not judge mechanics or rewrite the story here.`,
106
+ });
107
+ return z.object({ issues: z.array(z.string()).max(6) }).parse(review).issues;
108
+ }
@@ -0,0 +1,233 @@
1
+ // The story rules as pure functions over a plain state. The browser runtime plays with them,
2
+ // and the checker walks every state with them, so what the checker proves is what the player gets.
3
+ import type { World } from '../world/schema.ts';
4
+ import type { Effect, Story } from './schema.ts';
5
+ import { goodbye } from './phrases.ts';
6
+
7
+ export type State = {
8
+ place: string;
9
+ flags: string[];
10
+ items: string[];
11
+ /** Indexes of `once` interactions already fired. */
12
+ spent: number[];
13
+ talk?: { dialogue: number; line: string };
14
+ ending?: string;
15
+ /** Only people relocated by the story: absence preserves their world.json placement. */
16
+ npcs?: Record<string, string>;
17
+ };
18
+
19
+ export type Action =
20
+ | { kind: 'go'; via: string }
21
+ | { kind: 'do'; thing: string; verb: string; with?: string }
22
+ | { kind: 'talk'; npc: string }
23
+ | { kind: 'choose'; index: number }
24
+ | { kind: 'leave' };
25
+
26
+ export type Step = { state: State; say: string[]; issues?: string[] };
27
+
28
+ /** Who says an interaction's line: a person talked to answers in their own voice; everything else is the hero's remark. */
29
+ export function speakerOf(world: World, it: { place: string; thing: string; verb: string }): string {
30
+ if (it.verb !== 'talk') return world.hero.key;
31
+ const local = world.places.find((p) => p.key === it.place)?.things.find((t) => t.key === it.thing);
32
+ const npc = local ? local.role === 'npc' : world.places.some((p) => p.things.some((t) => t.key === it.thing && t.role === 'npc'));
33
+ return npc ? it.thing : world.hero.key;
34
+ }
35
+
36
+ export function startState(world: World): State {
37
+ return { place: world.start, flags: [], items: [], spent: [] };
38
+ }
39
+
40
+ export function holds(state: State, when: readonly string[]): boolean {
41
+ return when.every((fact) => {
42
+ const negated = fact.startsWith('!');
43
+ const body = negated ? fact.slice(1) : fact;
44
+ const value = body.startsWith('has:') ? state.items.includes(body.slice(4))
45
+ : body.startsWith('at:') ? state.place === body.slice(3)
46
+ : state.flags.includes(body);
47
+ return value !== negated;
48
+ });
49
+ }
50
+
51
+ /** The interaction a click would fire right now, if any: the first one listed whose condition holds. */
52
+ export function npcHere(world: World, state: State, npc: string): boolean {
53
+ return state.npcs?.[npc] ? state.npcs[npc] === state.place
54
+ : !!world.places.find((p) => p.key === state.place)?.things.some((t) => t.key === npc && t.role === 'npc');
55
+ }
56
+
57
+ export function pickInteraction(story: Story, state: State, a: { thing: string; verb: string; with?: string }, world: World): number {
58
+ const local = world.places.find((p) => p.key === state.place)?.things.find((t) => t.key === a.thing);
59
+ if ((!local || local.role === 'npc') && world.places.some((p) => p.things.some((t) => t.key === a.thing && t.role === 'npc')) && !npcHere(world, state, a.thing)) return -1;
60
+ return story.interactions.findIndex((it, i) =>
61
+ it.place === state.place && it.thing === a.thing && it.verb === a.verb && it.with === a.with
62
+ && !state.spent.includes(i) && holds(state, it.when));
63
+ }
64
+
65
+ export function pickDialogue(story: Story, state: State, npc: string, world: World): number {
66
+ if (!npcHere(world, state, npc)) return -1;
67
+ return story.dialogues.findIndex((d) => d.place === state.place && d.npc === npc && holds(state, d.when));
68
+ }
69
+
70
+ export function lockOn(story: Story, state: State, via: string) {
71
+ return story.locks.find((l) => l.place === state.place && l.via === via && !holds(state, l.until));
72
+ }
73
+
74
+ /**
75
+ * The next step toward facts the player still lacks (a lock's `until`): the first interaction that sets a missing
76
+ * fact and can be done now, the item for it in the bag. A nudge for a stuck player; undefined when the way there
77
+ * starts somewhere the player has not been yet (an item still to find).
78
+ */
79
+ export function hintFor(story: Story, state: State, facts: readonly string[]): { place: string; thing: string; with?: string } | undefined {
80
+ for (const fact of facts) {
81
+ if (fact.startsWith('!') || holds(state, [fact])) continue;
82
+ const it = story.interactions.find((i, k) => !state.spent.includes(k) && holds(state, i.when) && i.do.some((d) => 'set' in d && d.set === fact)
83
+ && (!i.with || state.items.includes(i.with)));
84
+ if (it) return { place: it.place, thing: it.thing, ...(it.with && { with: it.with }) };
85
+ }
86
+ return undefined;
87
+ }
88
+
89
+ /** Where an item in hand is wanted in this place now, when it was tried on the wrong thing. */
90
+ export function useFor(story: Story, state: State, item: string): string | undefined {
91
+ return story.interactions.find((i, k) => i.place === state.place && i.with === item && !state.spent.includes(k) && holds(state, i.when))?.thing;
92
+ }
93
+
94
+ /** Everything that would change the state from here. Clicks that only print a refusal are left out. */
95
+ export function actions(world: World, story: Story, state: State): Action[] {
96
+ if (state.ending) return [];
97
+ if (state.talk) {
98
+ const line = lineOf(story, state)!;
99
+ const open = line.choices.flatMap((c, index) => (holds(state, c.when) ? [{ kind: 'choose' as const, index }] : []));
100
+ return open.length ? open : [{ kind: 'leave' }];
101
+ }
102
+ const place = world.places.find((p) => p.key === state.place)!;
103
+ const out: Action[] = [];
104
+ for (const exit of place.exits) if (!lockOn(story, state, exit.via)) out.push({ kind: 'go', via: exit.via });
105
+ const seen = new Set<string>();
106
+ for (const it of story.interactions) {
107
+ const id = `${it.thing}|${it.verb}|${it.with ?? ''}`;
108
+ if (it.place !== state.place || seen.has(id)) continue;
109
+ if (it.with && !state.items.includes(it.with)) continue;
110
+ const a = { kind: 'do' as const, thing: it.thing, verb: it.verb, ...(it.with ? { with: it.with } : {}) };
111
+ if (pickInteraction(story, state, a, world) >= 0) { seen.add(id); out.push(a); }
112
+ }
113
+ for (const d of story.dialogues) {
114
+ if (d.place === state.place && !out.some((a) => a.kind === 'talk' && a.npc === d.npc) && pickDialogue(story, state, d.npc, world) >= 0) {
115
+ out.push({ kind: 'talk', npc: d.npc });
116
+ }
117
+ }
118
+ return out;
119
+ }
120
+
121
+ export function apply(world: World, story: Story, state: State, action: Action): Step {
122
+ const next: State = { ...state, flags: [...state.flags], items: [...state.items], spent: [...state.spent], ...(state.npcs && { npcs: { ...state.npcs } }) };
123
+ const say: string[] = [];
124
+ const issues: string[] = [];
125
+ switch (action.kind) {
126
+ case 'go': {
127
+ const lock = lockOn(story, state, action.via);
128
+ if (lock) return { state, say: [lock.say] };
129
+ const exit = world.places.find((p) => p.key === state.place)?.exits.find((e) => e.via === action.via);
130
+ if (!exit) throw new Error(`no exit "${action.via}" in ${state.place}`);
131
+ next.place = exit.to;
132
+ break;
133
+ }
134
+ case 'do': {
135
+ const i = pickInteraction(story, state, action, world);
136
+ if (i < 0) return { state, say: [] };
137
+ const it = story.interactions[i];
138
+ say.push(it.say);
139
+ if (it.once) next.spent.push(i);
140
+ issues.push(...run(world, next, it.do));
141
+ break;
142
+ }
143
+ case 'talk': {
144
+ const d = pickDialogue(story, state, action.npc, world);
145
+ if (d < 0) return { state, say: [] };
146
+ next.talk = { dialogue: d, line: story.dialogues[d].start };
147
+ say.push(lineOf(story, next)!.says);
148
+ break;
149
+ }
150
+ case 'choose': {
151
+ const choice = lineOf(story, state)?.choices[action.index];
152
+ if (!choice || !holds(state, choice.when)) return { state, say: [] };
153
+ say.push(choice.text);
154
+ issues.push(...run(world, next, choice.do));
155
+ next.talk = choice.next && !next.ending ? { dialogue: state.talk!.dialogue, line: choice.next } : undefined;
156
+ if (next.talk) say.push(lineOf(story, next)!.says);
157
+ break;
158
+ }
159
+ case 'leave':
160
+ if (state.talk) say.push(goodbye(world.language));
161
+ next.talk = undefined;
162
+ break;
163
+ }
164
+ next.flags.sort();
165
+ next.items.sort();
166
+ next.spent.sort((a, b) => a - b);
167
+ if (!next.talk) delete next.talk;
168
+ if (next.npcs) next.npcs = Object.fromEntries(Object.entries(next.npcs).sort(([a], [b]) => a.localeCompare(b)));
169
+ return { state: next, say, ...(issues.length && { issues }) };
170
+ }
171
+
172
+ function lineOf(story: Story, state: State) {
173
+ if (!state.talk) return undefined;
174
+ return story.dialogues[state.talk.dialogue].lines.find((l) => l.key === state.talk!.line);
175
+ }
176
+
177
+ function run(world: World, state: State, effects: Effect[]) {
178
+ const issues: string[] = [];
179
+ for (const e of effects) {
180
+ if ('set' in e && !state.flags.includes(e.set)) state.flags.push(e.set);
181
+ if ('clear' in e) state.flags = state.flags.filter((f) => f !== e.clear);
182
+ if ('give' in e && !state.items.includes(e.give)) state.items.push(e.give);
183
+ if ('take' in e) state.items = state.items.filter((i) => i !== e.take);
184
+ if ('go' in e) state.place = e.go;
185
+ if ('ending' in e) state.ending = e.ending;
186
+ if ('moveNpc' in e) {
187
+ const { npc, to } = e.moveNpc;
188
+ const from = state.npcs?.[npc] ?? world.places.find((p) => p.things.some((t) => t.key === npc && t.role === 'npc'))?.key;
189
+ // A repeated action is harmless; an NPC cannot teleport across an unrelated place.
190
+ if (from && from !== to && world.places.find((p) => p.key === from)?.exits.some((exit) => exit.to === to))
191
+ state.npcs = { ...state.npcs, [npc]: to };
192
+ else if (from !== to) issues.push(`NPC "${npc}" cannot move from "${from ?? 'unknown'}" to "${to}" without a connecting exit`);
193
+ }
194
+ }
195
+ return issues;
196
+ }
197
+
198
+ export function stateKey(state: State): string {
199
+ return JSON.stringify(state);
200
+ }
201
+
202
+ export function describe(action: Action): string {
203
+ switch (action.kind) {
204
+ case 'go': return `go ${action.via}`;
205
+ case 'do': return `${action.verb} ${action.thing}${action.with ? ` with ${action.with}` : ''}`;
206
+ case 'talk': return `talk ${action.npc}`;
207
+ case 'choose': return `choose ${action.index + 1}`;
208
+ case 'leave': return 'leave';
209
+ }
210
+ }
211
+
212
+ /** Things the player can carry off: a once-only "take" that gives an item. Each one needs a clean plate under it. */
213
+ /**
214
+ * A take carries the thing itself off only when it gives the item of the same key (the brief keys an item after its
215
+ * thing). Taking a dried fish from the market stalls gives an item and leaves the stalls standing.
216
+ */
217
+ export const carriesOff = (it: Story['interactions'][number]) => it.verb === 'take' && it.do.some((e) => 'give' in e && e.give === it.thing);
218
+
219
+ export function removableThings(story: Story): { place: string; thing: string }[] {
220
+ const out = story.interactions.filter((it) => it.once && carriesOff(it));
221
+ return [...new Map(out.map((it) => [`${it.place}|${it.thing}`, { place: it.place, thing: it.thing }])).values()];
222
+ }
223
+
224
+ /** Things already carried off in this state, to be painted over with their clean plate. */
225
+ export function hiddenThings(story: Story, state: State): { place: string; thing: string }[] {
226
+ return state.spent.map((i) => story.interactions[i]).filter(carriesOff)
227
+ .map((it) => ({ place: it.place, thing: it.thing }));
228
+ }
229
+
230
+ /** Indexes into story.changes whose condition holds in this state: the runtime draws their plates. */
231
+ export function activeChanges(story: Story, state: State): number[] {
232
+ return story.changes.flatMap((c, i) => (holds(state, c.when) ? [i] : []));
233
+ }
@@ -0,0 +1,54 @@
1
+ // The person's changes to the story, kept in story-fixes.json and applied by every rebuild of story.json:
2
+ // a wish Spark writes the story to (puzzles, conditions, endings), and lines reworded by hand.
3
+ import fs from 'node:fs';
4
+ import { z } from 'zod';
5
+ import type { WorldFiles } from '../world/files.ts';
6
+ import type { Story } from './schema.ts';
7
+
8
+ export const StoryFixes = z.object({
9
+ wish: z.string().min(5).optional(),
10
+ /** Reworded text by its place in story.json, e.g. "dialogues.0.lines.2.says". */
11
+ texts: z.record(z.string(), z.object({ text: z.string().min(1), at: z.string() })).default({}),
12
+ });
13
+ export type StoryFixes = z.infer<typeof StoryFixes>;
14
+
15
+ /** The fields that are only words: changing them never changes what the player can do. */
16
+ export const TEXT_KEYS = ['intro', 'say', 'says', 'text', 'title', 'name', 'looks'] as const;
17
+
18
+ export const storyFixesFile = (files: WorldFiles) => files.path('story-fixes.json');
19
+
20
+ export function readStoryFixes(files: WorldFiles): StoryFixes {
21
+ const file = storyFixesFile(files);
22
+ return fs.existsSync(file) ? StoryFixes.parse(files.readJson(file)) : { texts: {} };
23
+ }
24
+
25
+ /** The object holding a text field and the field's name, or null when the path names no text. */
26
+ function locate(story: unknown, path: string): [Record<string, unknown>, string] | null {
27
+ const parts = path.split('.');
28
+ const last = parts.pop()!;
29
+ if (!(TEXT_KEYS as readonly string[]).includes(last)) return null;
30
+ let at: unknown = story;
31
+ for (const p of parts) {
32
+ if (Array.isArray(at) && /^\d+$/.test(p)) at = at[Number(p)];
33
+ else if (at && typeof at === 'object' && !Array.isArray(at) && /^[a-z]+$/.test(p)) at = (at as Record<string, unknown>)[p];
34
+ else return null;
35
+ }
36
+ return at && typeof at === 'object' && typeof (at as Record<string, unknown>)[last] === 'string' ? [at as Record<string, unknown>, last] : null;
37
+ }
38
+
39
+ export function textAt(story: Story, path: string): string | null {
40
+ const hit = locate(story, path);
41
+ return hit ? (hit[0][hit[1]] as string) : null;
42
+ }
43
+
44
+ /** The story with the reworded lines in; paths that no longer name a text are left out and listed. */
45
+ export function applyTexts(story: Story, fixes: StoryFixes): { story: Story; skipped: string[] } {
46
+ const out = structuredClone(story);
47
+ const skipped: string[] = [];
48
+ for (const [path, { text }] of Object.entries(fixes.texts)) {
49
+ const hit = locate(out, path);
50
+ if (hit) hit[0][hit[1]] = text;
51
+ else skipped.push(path);
52
+ }
53
+ return { story: out, skipped };
54
+ }
@@ -0,0 +1,7 @@
1
+ // Shared authoring rules for new stories, story extensions and the Studio agent.
2
+ export const NPC_STORY_GUIDANCE = `NPC story stages and movement:
3
+ - Give a character only the simple story stages needed by the author's idea, such as waiting for a key, key received, then in the garden. Represent progress with ordinary story flags named after the NPC (for example gardener-key-received and gardener-outside), not a new state schema or autonomous memory/goals system.
4
+ - Set or clear those flags in the interaction or dialogue choice that causes the event. Use when conditions on dialogues, interactions and optional patrols so the character's words and behaviour match the current stage. Put specific branches before a fallback; earlier-stage branches must stop matching once progress changes. A flag does not move an NPC by itself.
5
+ - Walking is optional for each NPC. Omit patrols or use [] when a person stays still. Use a short purposeful patrol only when it fits the character and the scene; there is no quota of walking NPCs. A patrol is {"npc":"<npc key>","place":"<place>","via":["<fixed thing>","<other fixed thing>"],"when":[],"pause":2}; via names at least two different non-NPC things in that place, pause is seconds. First matching patrol wins; NPCs stop for conversations.
6
+ - An interaction or dialogue choice can relocate a person with {"moveNpc":{"npc":"<npc key>","to":"<neighbouring place>"}} in do. A person may travel once without having any patrol. The event must have a connecting exit from that NPC's current location. Declare the NPC only in its original world place; use the same key for destination dialogue and interactions. Location is recorded separately in state.npcs; at:place describes the player's location.
7
+ - Gate one-time departures and source/destination story branches with persistent flags or once, so replaying a conversation cannot send someone from the wrong place. Include a useful response in the destination, keep every interaction reachable and every remaining puzzle solvable, and do not require the player to catch a moving NPC or wait for a timer.`;
@@ -0,0 +1,4 @@
1
+ /** Phrases supplied by the game rather than the story, shared by play and voice generation. */
2
+ export function goodbye(language?: string): string {
3
+ return language === 'Polish' ? 'Do widzenia.' : 'Goodbye.';
4
+ }