@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,215 @@
1
+ // A workshop with children: photos of their drawings and recordings of them telling the story, kept with a
2
+ // world that does not exist yet (drawings/, told/), heard at once, and turned into the idea the world is built from.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { z } from 'zod';
6
+ import { previewDrawing } from '../scene/preview.ts';
7
+ import { drawingFiles, keptName, Kind, normaliseDrawing, oneHero, PICTURE, readDrawing, readDrawings, readDrawn, readKinds } from '../world/drawings.ts';
8
+ import { WorldFiles } from '../world/files.ts';
9
+ import { Slug } from '../world/schema.ts';
10
+ import { hear, hearAll, Idea, ideaFromTold, narrate, SOUND, toldFiles } from '../world/told.ts';
11
+ import { ActionError, defineAction, runAction } from './registry.ts';
12
+
13
+ /** One change at a time per world: photos arrive together, and each takes the next number and joins drawings.json. */
14
+ const queues = new Map<string, Promise<unknown>>();
15
+ function serial<T>(slug: string, run: () => Promise<T>): Promise<T> {
16
+ const next = (queues.get(slug) ?? Promise.resolve()).catch(() => {}).then(run);
17
+ queues.set(slug, next);
18
+ return next;
19
+ }
20
+
21
+ const stamp = (file: string) => Math.round(fs.statSync(file).mtimeMs);
22
+
23
+ function readIdea(files: WorldFiles): Idea | null {
24
+ const parsed = Idea.safeParse(fs.existsSync(files.path('told', 'idea.json')) ? JSON.parse(fs.readFileSync(files.path('told', 'idea.json'), 'utf8')) : null);
25
+ return parsed.success ? parsed.data : null;
26
+ }
27
+
28
+ function status(files: WorldFiles) {
29
+ const drawn = readDrawn(files);
30
+ return {
31
+ drawings: drawingFiles(files).map((file) => {
32
+ const preview = files.path('drawings', 'preview', file);
33
+ return { file: `drawings/${file}`, v: stamp(files.path('drawings', file)), drawn: drawn.find((d) => d.file === file) ?? null, preview: fs.existsSync(preview) ? { file: `drawings/preview/${file}`, v: stamp(preview) } : null };
34
+ }),
35
+ told: toldFiles(files).map((clip) => {
36
+ const text = files.path('told', clip.replace(/\.[^.]+$/, '.txt'));
37
+ return { clip, file: `told/${clip}`, text: fs.existsSync(text) ? fs.readFileSync(text, 'utf8').trim() : null };
38
+ }),
39
+ idea: readIdea(files),
40
+ reading: fs.existsSync(files.path('idea.mp3')) ? { file: 'idea.mp3', v: stamp(files.path('idea.mp3')) } : null,
41
+ built: fs.existsSync(files.path('world.json')),
42
+ };
43
+ }
44
+
45
+ defineAction({
46
+ name: 'kids_status',
47
+ reads: true,
48
+ description: 'A workshop world\'s material: the photos of children\'s drawings (and what each became), the recordings of them telling the story with what was heard, and the idea written from them.',
49
+ input: z.object({ slug: Slug }),
50
+ async run({ slug }, ctx) {
51
+ return status(new WorldFiles(slug, ctx.root));
52
+ },
53
+ });
54
+
55
+ defineAction({
56
+ name: 'kids_add',
57
+ description: 'Keep a photo of a child\'s drawing (kind drawing) or a recording of children telling their story (kind told) with a world, even before it is built. A recording is heard at once (Muse Voice Transcribe) and its text returned.',
58
+ input: z.object({
59
+ slug: Slug,
60
+ kind: z.enum(['drawing', 'told']),
61
+ name: z.string().min(1).max(120).describe('the file name, for its extension'),
62
+ data: z.string().min(10).describe('the file, base64'),
63
+ }),
64
+ async run({ slug, kind, name, data }, ctx) {
65
+ const files = new WorldFiles(slug, ctx.root);
66
+ const bytes = Buffer.from(data.replace(/^data:[^,]*,/, ''), 'base64');
67
+ if (kind === 'drawing') {
68
+ if (!PICTURE.test(name)) throw new ActionError(400, `${name}: a drawing is a photo (png, jpg, webp, heic)`);
69
+ const image = await normaliseDrawing(bytes);
70
+ const file = await serial(slug, async () => {
71
+ const file = `${String(drawingFiles(files).length + 1).padStart(2, '0')}-${keptName(name)}.png`;
72
+ files.write(files.path('drawings', file), image);
73
+ return file;
74
+ });
75
+ // Read at once (what it is, its key, how it looks), so its preview can start while the next one is drawn.
76
+ const drawn = await readDrawing(files.memo, image, file, [...new Set(readDrawn(files).map((d) => d.key))]);
77
+ await serial(slug, async () => {
78
+ const kind = readKinds(files)[file];
79
+ const all = [...readDrawn(files).filter((d) => d.file !== file), { ...drawn, ...(kind && { kind }) }];
80
+ // An existing game's hero is already fixed. Do not promote its first newly drawn NPC to hero.
81
+ files.write(files.path('drawings.json'), fs.existsSync(files.path('world.json')) && kind !== 'hero' ? all : oneHero(all, kind === 'hero' ? drawn.key : undefined));
82
+ });
83
+ return { file: `drawings/${file}`, drawn };
84
+ }
85
+ if (!SOUND.test(name)) throw new ActionError(400, `${name}: a recording is webm, m4a, mp3, wav or ogg`);
86
+ const clip = await serial(slug, async () => {
87
+ const clip = `${String(toldFiles(files).length + 1).padStart(2, '0')}-${keptName(name)}${path.extname(name).toLowerCase()}`;
88
+ files.write(files.path('told', clip), bytes);
89
+ return clip;
90
+ });
91
+ return { clip, text: await hear(files, clip) };
92
+ },
93
+ });
94
+
95
+ // A chat picture is already on disk: the agent must not have to read or transmit its base64 itself.
96
+ const pictureAdds = new Map<string, Promise<unknown>>();
97
+ defineAction({
98
+ name: 'kids_from_picture',
99
+ description: 'Save and read a drawing already attached to the Studio conversation. Pass its studio/pictures/NN.jpg path from the message. Returns the drawings/ path for kids_status and world_extend; repeated calls for the same picture reuse it.',
100
+ input: z.object({ slug: Slug, picture: z.string().regex(/^studio\/pictures\/[0-9]{2,4}\.jpg$/) }),
101
+ async run({ slug, picture }, ctx) {
102
+ const key = `${ctx.root}/${slug}/${picture}`;
103
+ const pending = pictureAdds.get(key);
104
+ if (pending) return pending;
105
+ const task = (async () => {
106
+ const files = new WorldFiles(slug, ctx.root);
107
+ const source = files.path(picture);
108
+ if (!fs.existsSync(source)) throw new ActionError(404, `${slug} has no ${picture}`);
109
+ const index = files.path('drawings', 'picture-sources.json');
110
+ const read = (): Record<string, string> => fs.existsSync(index) ? JSON.parse(fs.readFileSync(index, 'utf8')) as Record<string, string> : {};
111
+ const old = read()[picture];
112
+ if (old && fs.existsSync(files.path(old))) {
113
+ const drawn = readDrawn(files).find(d => `drawings/${d.file}` === old);
114
+ if (drawn) return { file: old, drawn, reused: true };
115
+ }
116
+ const normalised = await normaliseDrawing(fs.readFileSync(source));
117
+ const match = readDrawn(files).find(d => fs.existsSync(files.path('drawings', d.file)) && fs.readFileSync(files.path('drawings', d.file)).equals(normalised));
118
+ if (match) {
119
+ const file = `drawings/${match.file}`;
120
+ files.write(index, { ...read(), [picture]: file });
121
+ return { file, drawn: match, reused: true };
122
+ }
123
+ const added = await runAction('kids_add', { slug, kind: 'drawing', name: path.basename(picture), data: fs.readFileSync(source).toString('base64') }, ctx) as { file: string; drawn: unknown };
124
+ files.write(index, { ...read(), [picture]: added.file });
125
+ return { ...added, reused: false };
126
+ })();
127
+ pictureAdds.set(key, task);
128
+ try { return await task; } finally { pictureAdds.delete(key); }
129
+ },
130
+ });
131
+
132
+ defineAction({
133
+ name: 'kids_remove',
134
+ description: 'Take a drawing or a recording out of a workshop world (the file as kids_status lists it, drawings/… or told/…).',
135
+ input: z.object({ slug: Slug, file: z.string().regex(/^(drawings|told)\/[a-z0-9-]+\.[a-z0-9]+$/) }),
136
+ async run({ slug, file }, ctx) {
137
+ return serial(slug, async () => remove(new WorldFiles(slug, ctx.root), file));
138
+ },
139
+ });
140
+
141
+ function remove(files: WorldFiles, file: string) {
142
+ const full = files.path(file);
143
+ if (!fs.existsSync(full)) throw new ActionError(404, `${files.slug} has no ${file}`);
144
+ fs.rmSync(full);
145
+ fs.rmSync(full.replace(/\.[^.]+$/, '.txt'), { force: true });
146
+ if (file.startsWith('drawings/')) {
147
+ const name = file.slice('drawings/'.length);
148
+ fs.rmSync(files.path('drawings', 'preview', name), { force: true });
149
+ if (fs.existsSync(files.path('drawings.json'))) files.write(files.path('drawings.json'), oneHero(readDrawn(files).filter((d) => d.file !== name)));
150
+ }
151
+ return status(files);
152
+ }
153
+
154
+ defineAction({
155
+ name: 'kids_idea',
156
+ description: 'Write the idea of a workshop world from everything the children said and drew: Spark joins what they told and drew into one short story in their language, says which drawn character is the hero, and a storyteller voice reads it aloud (idea.mp3). '
157
+ + 'The idea can be edited before world_build.',
158
+ input: z.object({ slug: Slug, note: z.string().max(2000).optional().describe('what the grown-up adds or wants'), read: z.boolean().optional().describe('read it aloud (idea.mp3); default true'), language: z.string().min(2).max(30).optional().describe('the workshop\'s language ("Polish"), whatever was spoken') }),
159
+ async run({ slug, note, read, language }, ctx) {
160
+ const files = new WorldFiles(slug, ctx.root);
161
+ const said = await hearAll(files);
162
+ if (said.length === 0 && drawingFiles(files).length === 0) throw new ActionError(400, 'record the children telling their story or add a drawing first');
163
+ const drawn = await readDrawings(files.memo, files);
164
+ const idea = await ideaFromTold(files.memo, said.map((s) => s.text), drawn, note ?? '', language);
165
+ // The story says who leads, unless the grown-up picked the hero.
166
+ if (idea.hero && !Object.values(readKinds(files)).includes('hero')) oneHero(drawn, idea.hero);
167
+ await serial(slug, async () => {
168
+ if (drawn.length) files.write(files.path('drawings.json'), drawn);
169
+ files.write(files.path('told', 'idea.json'), idea);
170
+ });
171
+ // Read aloud straight away; a story without a voice still stands.
172
+ if (read !== false) await narrate(files, idea).catch((e: Error) => console.log(`no reading of the story: ${e.message}`));
173
+ return status(files);
174
+ },
175
+ });
176
+
177
+ defineAction({
178
+ name: 'kids_kind',
179
+ description: 'Say what a drawing is in the game (hero, npc, item, prop or place), over what Spark saw; kind null gives the choice back to Spark. A changed character is painted again by kids_preview; write the idea again (kids_idea) afterwards.',
180
+ input: z.object({ slug: Slug, file: z.string().regex(/^drawings\/[a-z0-9-]+\.png$/), kind: Kind.nullable() }),
181
+ async run({ slug, file, kind }, ctx) {
182
+ return serial(slug, () => setKind(new WorldFiles(slug, ctx.root), file, kind));
183
+ },
184
+ });
185
+
186
+ async function setKind(files: WorldFiles, file: string, kind: Kind | null) {
187
+ const name = file.slice('drawings/'.length);
188
+ if (!fs.existsSync(files.path(file))) throw new ActionError(404, `${files.slug} has no ${file}`);
189
+ const kinds = readKinds(files);
190
+ if (kind) kinds[name] = kind;
191
+ else delete kinds[name];
192
+ files.write(files.path('drawings', 'kinds.json'), kinds);
193
+ // Shown at once; the next kids_idea writes the story with it.
194
+ const drawn = readDrawn(files);
195
+ const entry = drawn.find((d) => d.file === name);
196
+ if (entry && kind) {
197
+ entry.kind = kind;
198
+ files.write(files.path('drawings.json'), kind === 'hero' || kind === 'npc' || kind === 'player' ? oneHero(drawn, kind === 'hero' ? entry.key : drawn.find((d) => d.kind === 'hero' && d !== entry)?.key) : drawn);
199
+ }
200
+ return status(files);
201
+ }
202
+
203
+ defineAction({
204
+ name: 'kids_preview',
205
+ description: 'Bring one drawing to life at once (about half a minute): a character as the figure the game will use, a place or a thing as a quick painting (drawings/preview/<file>). For the children to see while the rest is drawn.',
206
+ input: z.object({ slug: Slug, file: z.string().regex(/^drawings\/[a-z0-9-]+\.png$/) }),
207
+ async run({ slug, file }, ctx) {
208
+ const files = new WorldFiles(slug, ctx.root);
209
+ const name = file.slice('drawings/'.length);
210
+ const drawn = readDrawn(files).find((d) => d.file === name);
211
+ if (!drawn) throw new ActionError(404, `${file} is not read yet: add it with kids_add`);
212
+ files.write(files.path('drawings', 'preview', name), await previewDrawing(files.memo, drawn, fs.readFileSync(files.path(file))));
213
+ return status(files);
214
+ },
215
+ });
@@ -0,0 +1,52 @@
1
+ // The language a world is told in: world_language translates every word the player reads or hears and has the
2
+ // voices cast and read again in it.
3
+ import fs from 'node:fs';
4
+ import { z } from 'zod';
5
+ import { credits } from '../sound/cost.ts';
6
+ import { elevenKey } from '../sound/sources.ts';
7
+ import { Story } from '../story/schema.ts';
8
+ import { agentBlocked } from '../studio/locks.ts';
9
+ import { WorldFiles } from '../world/files.ts';
10
+ import { languagePlan, planLine } from '../world/language.ts';
11
+ import { Slug } from '../world/schema.ts';
12
+ import { ActionError, defineAction } from './registry.ts';
13
+
14
+ defineAction({
15
+ name: 'world_language',
16
+ description: 'Tell the world in another language ("English", "Polish", "German"): the title, pitch, character and item names and every line of the story '
17
+ + '(intro, remarks, dialogue, answers, item names, endings) are translated in one cheap Spark pass, with names kept the same everywhere; the game\'s '
18
+ + 'own words (hints, goodbye, buttons) follow world.json\'s language by themselves. Keys, puzzles, paintings, masks and sounds stay. Then the voices are '
19
+ + 'cast again as native speakers of the new language (no accent of the old one), every spoken line is read again and the talking faces follow. '
20
+ + 'voices false translates only (cheap; the game shows text until run_step voices). Without go it only answers what it redoes and about what '
21
+ + 'it costs: tell the person, then call again with go true once they agree. Runs as a job; history_undo takes it back. Then play_reload.',
22
+ input: z.object({
23
+ slug: Slug,
24
+ language: z.string().trim().min(2).max(30).describe('The new language as its English name: "English", "Polish".'),
25
+ voices: z.boolean().optional().describe('false: translate the text only and leave the voices for later. Default true.'),
26
+ go: z.boolean().optional().describe('true runs it; leave out to get the estimate first.'),
27
+ }),
28
+ job: true,
29
+ async run({ slug, language, voices = true, go }, ctx) {
30
+ const files = new WorldFiles(slug, ctx.root);
31
+ if (!fs.existsSync(files.path('world.json'))) throw new ActionError(404, `no world ${slug} yet`);
32
+ const name = language[0].toUpperCase() + language.slice(1);
33
+ const world = files.world();
34
+ const story = fs.existsSync(files.path('story.json')) ? Story.parse(files.readJson(files.path('story.json'))) : null;
35
+ const plan = languagePlan(files, world, story, name);
36
+ if (!go) {
37
+ const left = await credits(elevenKey());
38
+ return {
39
+ estimate: plan,
40
+ line: voices ? planLine(plan) : `${plan.texts} texts translated from ${plan.from} to ${plan.to} in one Spark pass; voices left for later`,
41
+ ...(left && voices && { elevenLabsCreditsLeft: left.left }),
42
+ next: 'tell the person what it redoes and costs; call again with go: true when they agree',
43
+ };
44
+ }
45
+ const blocked = agentBlocked(files.dir, ctx.by);
46
+ if (blocked) throw new ActionError(423, blocked);
47
+ const busy = ctx.jobs.busy(slug);
48
+ if (busy) throw new ActionError(409, `${busy.label} is ${busy.state}; wait before changing the language`);
49
+ const job = ctx.jobs.start(slug, ['language', slug, name, ...(voices ? [] : ['--text-only'])], `language ${name}`, ctx.by);
50
+ return { job, line: voices ? planLine(plan) : 'text only' };
51
+ },
52
+ });
@@ -0,0 +1,64 @@
1
+ // Studio Setup's model pick: which model answers Spark's asks and which paints, with what each needs.
2
+ import { z } from 'zod';
3
+ import { readModelChoice, writeModelChoice } from '../meta/choice.ts';
4
+ import { imageVia } from '../meta/muse.ts';
5
+ import { sparkVia } from '../meta/spark.ts';
6
+ import { envValue } from '../secrets.ts';
7
+ import { defineAction } from './registry.ts';
8
+
9
+ const google = () => !!envValue('GEMINI_API_KEY');
10
+ const openrouter = () => !!envValue('OPENROUTER_API_KEY');
11
+
12
+ /** What Setup offers; `ready` says whether this machine has the key it needs. */
13
+ function options() {
14
+ const anthropic = !!envValue('DIORAMA_ANTHROPIC_API_KEY');
15
+ const gemini = google() || openrouter();
16
+ return {
17
+ spark: [
18
+ { id: 'meta', label: 'Muse Spark (Meta)', ready: !!envValue('META_API_KEY') },
19
+ { id: 'claude', label: 'Claude Sonnet 5.5', ready: anthropic },
20
+ { id: 'openrouter:anthropic/claude-sonnet-5.5', label: 'Claude Sonnet 5.5 via OpenRouter', ready: openrouter() },
21
+ ],
22
+ image: [
23
+ { id: 'meta', label: 'Muse Image (Meta)', ready: !!envValue('META_API_KEY') },
24
+ { id: 'gemini-flash', label: 'Gemini 3.1 Flash Image', ready: gemini },
25
+ { id: 'gemini-lite', label: 'Gemini 3.1 Flash Lite Image', ready: gemini },
26
+ { id: 'nano-banana', label: 'Nano Banana 2.1', ready: gemini },
27
+ { id: 'gemini-pro', label: 'Gemini 3 Pro Image', ready: gemini },
28
+ ],
29
+ };
30
+ }
31
+
32
+ function state() {
33
+ const picked = readModelChoice();
34
+ return {
35
+ spark: process.env.DIORAMA_SPARK_VIA || picked.spark || 'meta',
36
+ image: process.env.DIORAMA_IMAGE_VIA || picked.image || 'meta',
37
+ /** Set where the Studio was started: Setup shows it and cannot change it. */
38
+ env: { spark: process.env.DIORAMA_SPARK_VIA ?? null, image: process.env.DIORAMA_IMAGE_VIA ?? null },
39
+ resolved: { spark: sparkVia(), image: imageVia() },
40
+ options: options(),
41
+ };
42
+ }
43
+
44
+ defineAction({
45
+ name: 'models_get',
46
+ reads: true,
47
+ description: 'Which models make worlds now: Spark\'s asks (Muse Spark or Claude) and pictures (Muse Image or a Gemini image model), and what Setup offers.',
48
+ input: z.object({}),
49
+ async run() {
50
+ return state();
51
+ },
52
+ });
53
+
54
+ defineAction({
55
+ name: 'models_set',
56
+ description: 'Pick the model for Spark\'s asks (meta, claude, openrouter:<model>) and for pictures (meta, gemini-flash, gemini-lite, nano-banana, gemini-pro). '
57
+ + 'Other models\' answers keep their own memo keys; meta goes back to the Meta answers. The hackathon demo stays on meta.',
58
+ input: z.object({ spark: z.string().min(1).optional(), image: z.string().min(1).optional() }),
59
+ async run({ spark, image }) {
60
+ const was = readModelChoice();
61
+ writeModelChoice({ spark: spark ?? was.spark, image: image ?? was.image });
62
+ return state();
63
+ },
64
+ });
@@ -0,0 +1,119 @@
1
+ // place_motion: what moves in a place as the game plays it (water, fog, flames, an NPC breathing), for an agent
2
+ // that cannot watch the game: a few frames from the real play page, and the pixels that changed between them.
3
+ import fs from 'node:fs';
4
+ import type { AddressInfo } from 'node:net';
5
+ import sharp from 'sharp';
6
+ import { z } from 'zod';
7
+ import { chromium } from '../commands/explore.ts';
8
+ import { serveWorld } from '../commands/play.ts';
9
+ import { svgLabel } from '../image/overlay.ts';
10
+ import { Geometry } from '../play/geometry.ts';
11
+ import { WorldFiles } from '../world/files.ts';
12
+ import { Slug } from '../world/schema.ts';
13
+ import { ActionError, defineAction } from './registry.ts';
14
+
15
+ const FRAMES = 6;
16
+ const EVERY_MS = 400;
17
+ /** A pixel moved when its brightness changed by more than this between two frames (0-255): above noise, below a slow ripple. */
18
+ const MOVED = 6;
19
+
20
+ type Shot = { screenshot(o?: object): Promise<Buffer>; goto(url: string): Promise<unknown>; waitForFunction(js: string, arg?: unknown, o?: object): Promise<unknown>; waitForTimeout(ms: number): Promise<void>; evaluate<T>(js: string): Promise<T> };
21
+
22
+ defineAction({
23
+ name: 'place_motion',
24
+ reads: true,
25
+ description: 'Watch a place in the real game for a few seconds and see what moves, since you cannot play it: '
26
+ + `${FRAMES} frames ${EVERY_MS} ms apart from the play page. You get a picture of the place with every pixel that moved tinted magenta, `
27
+ + 'a strip of three frames, and in words how much of the picture moved and, per thing, how much of its box moved. '
28
+ + 'Use it after a change to motion (water flow, fog, fire, weather, an animated thing) to check it shows, before telling the person it works. Free: no model calls.',
29
+ input: z.object({ slug: Slug, place: z.string().min(1) }),
30
+ async run({ slug, place }, ctx) {
31
+ const files = new WorldFiles(slug, ctx.root);
32
+ if (!fs.existsSync(files.path('places', place, 'geometry.json'))) throw new ActionError(404, `${slug}/${place} is not playable yet: build it to the derive step first`);
33
+ const g = Geometry.parse(files.readJson(files.path('places', place, 'geometry.json')));
34
+ const server = await serveWorld(files, 0);
35
+ const browser = await (await chromium()).launch({ args: ['--autoplay-policy=no-user-gesture-required'] });
36
+ const frames: Buffer[] = [];
37
+ let shows: Record<string, unknown> = {};
38
+ try {
39
+ const page = (await browser.newPage({ viewport: { width: 1200, height: 896 } })) as unknown as Shot;
40
+ await page.goto(`http://localhost:${(server.address() as AddressInfo).port}/?place=${encodeURIComponent(place)}&capture=1&cursor=0&sound=0&parallax=1`);
41
+ await page.waitForFunction('window.diorama && window.diorama.place', undefined, { timeout: 30000 });
42
+ // parallax=1: the game as played. Capture mode leaves parallax off by default, and without it the page loads no
43
+ // backdrop, so no water or mist layer either: every place then read "mist: none" however its fog was painted.
44
+ // The place fades in and the hero lands: let it settle first.
45
+ await page.waitForTimeout(1500);
46
+ // What the game loaded to move here: a 0% with no layer means the layer never reached the game.
47
+ shows = await page.evaluate<Record<string, unknown>>('(() => { const p = window.diorama.place; return { water: p.water ? `flowing ${p.water.flow.join(",") === "0,0" ? "still (swells)" : p.water.flow.join(",")}` : "none", mist: p.mist ? "drifting" : "none", clouds: p.clouds ? "drifting" : "none", waterfalls: (p.falls || []).length ? `${p.falls.length} pouring` : "none", animals: (p.critters || []).length ? `${p.critters.length} swimming or flying` : "none", weather: (() => { const w = window.diorama.data && window.diorama.data.places[p.key] && window.diorama.data.places[p.key].weather; return w ? `${w.fall}${w.fall !== "none" ? ` ${w.strength}` : ""}, fog ${w.fog}` : "none"; })() }; })()');
48
+ // Frames EVERY_MS of game time apart, not wall time: a headless browser draws ~12 frames a second, and capture
49
+ // mode steps 1/60 s per frame, so 2 s of waiting showed 0.4 s of the game and slow swells or drifting mist
50
+ // looked still.
51
+ for (let i = 0; i < FRAMES; i++) {
52
+ if (i) {
53
+ const t = await page.evaluate<number>('window.diorama.time');
54
+ await page.waitForFunction(`window.diorama.time >= ${t + EVERY_MS / 1000}`, undefined, { timeout: 30000 });
55
+ }
56
+ frames.push(await page.screenshot({ type: 'png' }));
57
+ }
58
+ } finally {
59
+ await browser.close();
60
+ server.close();
61
+ }
62
+
63
+ const raw = await Promise.all(frames.map((f) => sharp(f).greyscale().raw().toBuffer({ resolveWithObject: true })));
64
+ const { width: W, height: H } = raw[0].info;
65
+ const moved = new Uint8Array(W * H);
66
+ for (let k = 1; k < raw.length; k++) {
67
+ const [a, b] = [raw[k - 1].data, raw[k].data];
68
+ for (let i = 0; i < moved.length; i++) if (Math.abs(a[i] - b[i]) > MOVED) moved[i] = 1;
69
+ }
70
+ const share = (x0: number, y0: number, x1: number, y1: number) => {
71
+ // Boxes are in the painting's frame (1200 x 896), the screenshot may differ a little.
72
+ const [sx, sy] = [W / 1200, H / 896];
73
+ let n = 0;
74
+ let on = 0;
75
+ for (let y = Math.max(0, Math.floor(y0 * sy)); y < Math.min(H, Math.ceil(y1 * sy)); y++) {
76
+ for (let x = Math.max(0, Math.floor(x0 * sx)); x < Math.min(W, Math.ceil(x1 * sx)); x++) (n++, (on += moved[y * W + x]));
77
+ }
78
+ return n ? Math.round((on / n) * 100) : 0;
79
+ };
80
+ const total = share(0, 0, 1200, 896);
81
+ const things = Object.fromEntries(g.things.filter((t) => t.role !== 'npc').map((t) => [t.key, `${share(...t.box)}% of its box moved`]));
82
+
83
+ // The first frame, dimmed, with what moved in magenta; then three frames side by side.
84
+ const base = await sharp(frames[0]).removeAlpha().raw().toBuffer();
85
+ const tint = Buffer.alloc(W * H * 3);
86
+ for (let i = 0; i < W * H; i++) {
87
+ const m = moved[i];
88
+ tint[i * 3] = m ? 255 : base[i * 3] * 0.55;
89
+ tint[i * 3 + 1] = m ? 40 : base[i * 3 + 1] * 0.55;
90
+ tint[i * 3 + 2] = m ? 220 : base[i * 3 + 2] * 0.55;
91
+ }
92
+ const label = Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">${svgLabel(12, 28, `moved in ${(FRAMES - 1) * EVERY_MS / 1000} s: ${total}% of the picture`, [255, 255, 255])}</svg>`);
93
+ const whatMoved = await sharp(tint, { raw: { width: W, height: H, channels: 3 } }).composite([{ input: label }]).jpeg({ quality: 75 }).toBuffer();
94
+ const tile = 400;
95
+ const tileH = Math.round((tile * H) / W);
96
+ const picks = [0, Math.floor(FRAMES / 2), FRAMES - 1];
97
+ const strip = await sharp({ create: { width: tile * 3, height: tileH, channels: 3, background: '#111' } })
98
+ .composite(await Promise.all(picks.map(async (k, i) => ({ input: await sharp(frames[k]).resize(tile, tileH).png().toBuffer(), left: i * tile, top: 0 }))))
99
+ .jpeg({ quality: 72 }).toBuffer();
100
+ fs.writeFileSync(files.path('places', place, 'view-motion.jpg'), whatMoved);
101
+ // What the game loaded to move by itself: only then is a still frame a fault.
102
+ const expected = [
103
+ ...(shows.water && shows.water !== 'none' ? [`water (${shows.water})`] : []),
104
+ ...(shows.mist && shows.mist !== 'none' ? ['drifting mist'] : []),
105
+ ...(typeof shows.weather === 'string' && shows.weather !== 'none' && !/^none, fog 0$/.test(shows.weather) ? [`weather (${shows.weather})`] : []),
106
+ ];
107
+ return {
108
+ place,
109
+ inTheGame: shows,
110
+ moved: `${total}% of the picture changed over ${(FRAMES - 1) * EVERY_MS / 1000} s`,
111
+ ...(total < 1 && { note: expected.length
112
+ ? `almost nothing moves, though the game loaded ${expected.join(', ')}: that motion is not showing`
113
+ : 'nothing here is set to move by itself (no water, mist, weather or fog): a still frame while the hero stands is expected, not a fault. The place still moves with the camera and the hero as it is played.' }),
114
+ things,
115
+ picture: `places/${place}/view-motion.jpg`,
116
+ images: [{ mimeType: 'image/jpeg', data: whatMoved.toString('base64') }, { mimeType: 'image/jpeg', data: strip.toString('base64') }],
117
+ };
118
+ },
119
+ });
@@ -0,0 +1,49 @@
1
+ // Pictures the person hands the agent with a message (pasted, uploaded, a webcam photo): kept with the world
2
+ // under studio/pictures/, and shown to the agent as an image, so it sees what the person means.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import sharp from 'sharp';
6
+ import { z } from 'zod';
7
+ import { Slug } from '../world/schema.ts';
8
+ import { ActionError, defineAction } from './registry.ts';
9
+
10
+ const PICTURE = /\.(png|jpe?g|webp|gif|heic|heif|avif)$/i;
11
+ const FILE = /^studio\/pictures\/[0-9]{2,4}\.jpg$/;
12
+
13
+ defineAction({
14
+ name: 'picture_add',
15
+ description: 'Keep a picture the person hands you with a message (pasted, uploaded or a webcam photo) with a world, as studio/pictures/NN.jpg. For the Studio\'s chat; the agent reads one with picture_view.',
16
+ input: z.object({
17
+ slug: Slug,
18
+ name: z.string().min(1).max(120).describe('the file name, for its extension'),
19
+ data: z.string().min(10).describe('the file, base64 (a data: URL is fine)'),
20
+ }),
21
+ async run({ slug, name, data }, ctx) {
22
+ if (!PICTURE.test(name)) throw new ActionError(400, `${name}: a picture is png, jpg, webp, gif, heic or avif`);
23
+ const dir = path.join(ctx.root, slug, 'studio', 'pictures');
24
+ let jpeg: Buffer;
25
+ try {
26
+ jpeg = await sharp(Buffer.from(data.replace(/^data:[^,]*,/, ''), 'base64')).rotate()
27
+ .resize(1600, 1600, { fit: 'inside', withoutEnlargement: true }).flatten({ background: '#ffffff' }).jpeg({ quality: 85 }).toBuffer();
28
+ } catch {
29
+ throw new ActionError(400, `${name} is not a picture this machine can read`);
30
+ }
31
+ fs.mkdirSync(dir, { recursive: true });
32
+ const taken = fs.readdirSync(dir).map((f) => Number.parseInt(f, 10)).filter(Number.isFinite);
33
+ const file = `${String(Math.max(0, ...taken) + 1).padStart(2, '0')}.jpg`;
34
+ fs.writeFileSync(path.join(dir, file), jpeg);
35
+ return { file: `studio/pictures/${file}` };
36
+ },
37
+ });
38
+
39
+ defineAction({
40
+ name: 'picture_view',
41
+ reads: true,
42
+ description: 'Look at a picture the person handed you with their message: a message that starts with "[Pictures: studio/pictures/01.jpg, …]" comes with them. Call this for each one before you answer.',
43
+ input: z.object({ slug: Slug, file: z.string().regex(FILE).describe('as the message names it, studio/pictures/NN.jpg') }),
44
+ async run({ slug, file }, ctx) {
45
+ const at = path.join(ctx.root, slug, file);
46
+ if (!fs.existsSync(at)) throw new ActionError(404, `${slug} has no ${file}`);
47
+ return { picture: file, images: [{ mimeType: 'image/jpeg', data: fs.readFileSync(at).toString('base64') }] };
48
+ },
49
+ });
@@ -0,0 +1,36 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { z } from 'zod';
4
+ import { Slug } from '../world/schema.ts';
5
+ import { agentBlocked } from '../studio/locks.ts';
6
+ import { ActionError, defineAction } from './registry.ts';
7
+
8
+ defineAction({
9
+ name: 'reactions_set',
10
+ description: 'Choose or adjust subtle scene reactions: footsteps on each surface, crossing a local floor area, and physical use/take interactions. A hint in the author\'s words guides the automatic vision-language planner; use "choose automatically" to reset the preference. Keeps the hint across rebuilds, runs reactions for this place, then play_reload. It never repaints the scene.',
11
+ input: z.object({ slug: Slug, place: Slug, hint: z.string().min(3).max(2000) }),
12
+ job: true,
13
+ async run({ slug, place, hint }, ctx) {
14
+ const dir = path.join(ctx.root, slug), blocked = agentBlocked(dir, ctx.by);
15
+ if (blocked) throw new ActionError(423, blocked);
16
+ const at = path.join(dir, 'places', place);
17
+ if (!fs.existsSync(path.join(at, 'geometry.json'))) throw new ActionError(404, `${place} has no geometry yet`);
18
+ return ctx.jobs.start(slug, ['reactions', slug, place], `reactions ${place}`, ctx.by, () => {
19
+ fs.writeFileSync(path.join(at, 'reactions-wish.json'), JSON.stringify({ hint }, null, 2) + '\n');
20
+ });
21
+ },
22
+ });
23
+ defineAction({
24
+ name: 'reactions_status', reads: true,
25
+ description: 'Read the chosen scene reactions and author preference. Area cells are summarized; triggers list step/enter and exact story interactions. Missing means the reactions step has not run.',
26
+ input: z.object({ slug: Slug, place: Slug }),
27
+ async run({ slug, place }, ctx) {
28
+ const at = path.join(ctx.root, slug, 'places', place), file = path.join(at, 'reactions.json');
29
+ const wish = path.join(at, 'reactions-wish.json');
30
+ const hint = fs.existsSync(wish) ? JSON.parse(fs.readFileSync(wish, 'utf8')).hint : undefined;
31
+ if (!fs.existsSync(file)) return { made: false, hint };
32
+ const { Reactions } = await import('../play/reactions.ts');
33
+ const rules = Reactions.parse(JSON.parse(fs.readFileSync(file, 'utf8')));
34
+ return { made: true, hint, ...rules, areas: rules.areas.map(({ cells, ...a }) => ({ ...a, cells: cells.reduce((n, row) => n + [...row].filter((c) => c === '#').length, 0) })) };
35
+ },
36
+ });
@@ -0,0 +1,101 @@
1
+ // One definition per action: name, what it does, its input (zod) and run(). From that single entry come the
2
+ // studio's POST /api/actions/<name>, the Muse Code tool of the same name (diorama mcp) and
3
+ // `diorama action <name> '<json>'`, so nothing the GUI can do is out of reach of a prompt.
4
+ import { z } from 'zod';
5
+ import type { Events } from '../studio/events.ts';
6
+ import type { Jobs } from '../studio/jobs.ts';
7
+
8
+ export type ActionContext = {
9
+ /** Who asked: the person in the GUI, the agent through MCP, or someone at a terminal. */
10
+ by: 'user' | 'agent' | 'cli';
11
+ /** Where worlds live (worlds/ under the working directory). */
12
+ root: string;
13
+ jobs: Jobs;
14
+ events: Events;
15
+ /**
16
+ * For the agent: calls `listener` with what the person writes to it while it works, until the returned
17
+ * function is called. A long wait (job_wait) ends early on it, so the agent hears the person at once.
18
+ */
19
+ onPersonSays?: (listener: (text: string) => void) => () => void;
20
+ };
21
+
22
+ export type Action<I extends z.ZodTypeAny = z.ZodTypeAny> = {
23
+ name: string;
24
+ description: string;
25
+ input: I;
26
+ /** Starts work that outlives the call (a job), rather than answering right away. */
27
+ job?: boolean;
28
+ /** Only reads: the activity log leaves it out, since the page itself polls these. */
29
+ reads?: boolean;
30
+ run(input: z.infer<I>, ctx: ActionContext): Promise<unknown>;
31
+ };
32
+
33
+ export const actions = new Map<string, Action>();
34
+
35
+ export function defineAction<I extends z.ZodTypeAny>(action: Action<I>) {
36
+ if (!/^[a-z][a-z0-9_]*$/.test(action.name)) throw new Error(`action names are snake_case: ${action.name}`);
37
+ actions.set(action.name, action as Action);
38
+ }
39
+
40
+ /** Validates, runs and announces an action; every caller goes through here, so the feed sees all of them. */
41
+ export async function runAction(name: string, raw: unknown, ctx: ActionContext): Promise<unknown> {
42
+ const action = actions.get(name);
43
+ if (!action) throw new ActionError(404, `no action ${name}; try one of ${[...actions.keys()].join(', ')}`);
44
+ const parsed = action.input.safeParse(raw ?? {});
45
+ if (!parsed.success) throw new ActionError(400, `${name}: ${parsed.error.issues.map((i) => `${i.path.join('.') || 'input'} ${i.message}`).join('; ')}`);
46
+ const world = (parsed.data as { slug?: string }).slug;
47
+ // A file sent as base64 (a drawing, a recording) is shown by its size, not spilled into the feed.
48
+ const shown = Object.fromEntries(Object.entries(parsed.data as object).map(([k, v]) => [k, typeof v === 'string' && v.length > 4000 ? `[${Math.round(v.length * 0.75 / 1024)} KB]` : v]));
49
+ ctx.events.publish('action.start', { action: name, by: ctx.by, world, input: shown });
50
+ try {
51
+ const result = await action.run(parsed.data, ctx);
52
+ ctx.events.publish('action.end', { action: name, by: ctx.by, world, ok: true });
53
+ return result;
54
+ } catch (error) {
55
+ ctx.events.publish('action.end', { action: name, by: ctx.by, world, ok: false, error: error instanceof Error ? error.message : String(error) });
56
+ throw error;
57
+ }
58
+ }
59
+
60
+ export class ActionError extends Error {
61
+ constructor(readonly status: number, message: string) {
62
+ super(message);
63
+ }
64
+ }
65
+
66
+ /** The JSON schema of an action's input, for MCP tool listings and the GUI. */
67
+ export function describeActions() {
68
+ return [...actions.values()].map((a) => ({ name: a.name, description: a.description, job: !!a.job, reads: !!a.reads, input: zodToJson(a.input) }));
69
+ }
70
+
71
+ /** Enough of JSON Schema for flat action inputs: objects of strings, numbers, booleans, enums and arrays. */
72
+ export function zodToJson(schema: z.ZodTypeAny): Record<string, unknown> {
73
+ const def = schema._def as { typeName: string; description?: string };
74
+ const withDesc = (out: Record<string, unknown>) => (schema.description ? { ...out, description: schema.description } : out);
75
+ switch (def.typeName) {
76
+ case 'ZodObject': {
77
+ const shape = (schema as z.ZodObject<z.ZodRawShape>).shape;
78
+ const required = Object.entries(shape).filter(([, v]) => !(v as z.ZodTypeAny).isOptional()).map(([k]) => k);
79
+ return withDesc({ type: 'object', properties: Object.fromEntries(Object.entries(shape).map(([k, v]) => [k, zodToJson(v as z.ZodTypeAny)])), required, additionalProperties: false });
80
+ }
81
+ case 'ZodLiteral': return withDesc({ const: (schema as z.ZodLiteral<unknown>).value });
82
+ case 'ZodDiscriminatedUnion': return withDesc({ oneOf: (schema as z.ZodDiscriminatedUnion<string, z.ZodDiscriminatedUnionOption<string>[]>).options.map(zodToJson) });
83
+ case 'ZodString': return withDesc({ type: 'string' });
84
+ case 'ZodNumber': return withDesc({ type: 'number' });
85
+ case 'ZodBoolean': return withDesc({ type: 'boolean' });
86
+ case 'ZodEnum': return withDesc({ type: 'string', enum: (schema as z.ZodEnum<[string]>).options });
87
+ case 'ZodTuple': {
88
+ const items = (schema as z.ZodTuple).items.map((i: z.ZodTypeAny) => zodToJson(i));
89
+ return withDesc({ type: 'array', prefixItems: items, items: items[0] ?? {}, minItems: items.length, maxItems: items.length });
90
+ }
91
+ case 'ZodRecord': return withDesc({ type: 'object', additionalProperties: zodToJson((schema as z.ZodRecord).valueSchema) });
92
+ case 'ZodEffects': return { ...zodToJson((schema as z.ZodEffects<z.ZodTypeAny>).innerType()), ...(schema.description && { description: schema.description }) };
93
+ case 'ZodArray': return withDesc({ type: 'array', items: zodToJson((schema as z.ZodArray<z.ZodTypeAny>).element) });
94
+ case 'ZodOptional':
95
+ case 'ZodDefault': {
96
+ const inner = def.typeName === 'ZodOptional' ? (schema as z.ZodOptional<z.ZodTypeAny>).unwrap() : (schema as z.ZodDefault<z.ZodTypeAny>).removeDefault();
97
+ return { ...zodToJson(inner), ...(schema.description && { description: schema.description }) };
98
+ }
99
+ default: return withDesc({});
100
+ }
101
+ }