@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,158 @@
1
+ import { WorldFiles } from '../world/files.ts';
2
+ import { placesFirst } from './faces.ts';
3
+ // Building: the whole pipeline or one step, as a job; plus the job list and cancel.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { z } from 'zod';
7
+ import { STEPS } from '../commands/build.ts';
8
+ import { Slug } from '../world/schema.ts';
9
+ import { agentBlocked } from '../studio/locks.ts';
10
+ import { ActionError, defineAction } from './registry.ts';
11
+
12
+ const Step = z.enum(STEPS);
13
+
14
+ /** Places the last build set aside at a step (build.json `places[].from`), optionally only one. */
15
+ export function brokenPlaces(dir: string, only?: string): Array<{ place: string; from: string }> {
16
+ const log = path.join(dir, 'build.json');
17
+ if (!fs.existsSync(log)) return [];
18
+ const places = (JSON.parse(fs.readFileSync(log, 'utf8')) as { places?: Array<{ place: string; from?: string }> }).places ?? [];
19
+ return places.filter((r): r is { place: string; from: string } => !!r.from && (!only || r.place === only));
20
+ }
21
+
22
+ defineAction({
23
+ name: 'world_build',
24
+ description: 'Build a world: every step from world to verify, or a range. A new world needs an idea. Runs as a job; follow it with world_status or the live feed.',
25
+ input: z.object({
26
+ slug: Slug,
27
+ idea: z.string().min(10).optional().describe('What the world is about; needed when the build starts at the world step.'),
28
+ from: Step.optional(),
29
+ to: Step.optional(),
30
+ places: z.number().int().min(1).max(8).optional(),
31
+ }),
32
+ job: true,
33
+ async run({ slug, idea, from, to, places }, ctx) {
34
+ const blocked = agentBlocked(path.join(ctx.root, slug), ctx.by);
35
+ if (blocked) throw new ActionError(423, blocked);
36
+ const fresh = !fs.existsSync(path.join(ctx.root, slug, 'world.json'));
37
+ if (fresh && !idea && (!from || from === 'world')) throw new Error(`${slug} is a new world: give it an idea`);
38
+ // An existing world picks up at the first step it has not finished.
39
+ const log = path.join(ctx.root, slug, 'build.json');
40
+ const done = !fresh && fs.existsSync(log) ? (JSON.parse(fs.readFileSync(log, 'utf8')) as { steps?: object }).steps ?? {} : {};
41
+ const unfinished = fresh ? 'world' : STEPS.find((s) => s !== 'world' && !(s in done)) ?? 'verify';
42
+ const broken = brokenPlaces(path.join(ctx.root, slug)).map((p) => p.from).filter((s): s is typeof STEPS[number] => (STEPS as readonly string[]).includes(s));
43
+ const start = from ?? [unfinished, ...broken].sort((a, b) => STEPS.indexOf(a) - STEPS.indexOf(b))[0];
44
+ const args = ['build', slug, ...(idea ? [idea] : []), '--from', start, ...(to ? ['--to', to] : []), ...(places ? ['--places', String(places)] : [])];
45
+ return ctx.jobs.start(slug, args, `build ${start} > ${to ?? STEPS[STEPS.length - 1]}`, ctx.by);
46
+ },
47
+ });
48
+
49
+ defineAction({
50
+ name: 'run_step',
51
+ description: `Run one build step on a world (${STEPS.join(', ')}). Runs as a job. With place: that place only, from the step through verify.`,
52
+ input: z.object({ slug: Slug, step: Step, place: Slug.optional().describe('Only this place: rebuilt from the step through verify') }),
53
+ job: true,
54
+ async run({ slug, step, place }, ctx) {
55
+ if (step === 'world') throw new Error('the world step makes a new world: use world_build with an idea');
56
+ const blocked = agentBlocked(path.join(ctx.root, slug), ctx.by);
57
+ if (blocked) throw new ActionError(423, blocked);
58
+ if (step === 'faces') { const later = placesFirst(new WorldFiles(slug, ctx.root), slug, ctx); if (later) throw new ActionError(409, later); }
59
+ if (place) return ctx.jobs.start(slug, ['build', slug, '--from', step, '--to', 'verify', '--place', place], `${step} · ${place}`, ctx.by);
60
+ return ctx.jobs.start(slug, ['build', slug, '--from', step, '--to', step], step, ctx.by);
61
+ },
62
+ });
63
+
64
+ defineAction({
65
+ name: 'job_list',
66
+ reads: true,
67
+ description: 'Jobs this studio ran or queued, newest last, optionally for one world.',
68
+ input: z.object({ slug: Slug.optional() }),
69
+ async run({ slug }, ctx) {
70
+ return ctx.jobs.list(slug);
71
+ },
72
+ });
73
+
74
+ defineAction({
75
+ name: 'job_inspect',
76
+ reads: true,
77
+ description: 'Inspect a job without waiting: elapsed and quiet time, timestamped recent logs and failures, current locally observed model/API operations and their duration, and voice/sound item counts. These describe the Studio process, not the remote provider internals; silence alone does not prove a hang. Use this when a job appears slow, and before deciding what to do. A staged extension is not in the game until it succeeds.',
78
+ input: z.object({ id: z.string().min(1), limit: z.number().int().min(1).max(100).optional() }),
79
+ async run({ id, limit }, ctx) {
80
+ if (!ctx.jobs.get(id)) throw new ActionError(404, `no job ${id}`);
81
+ return ctx.jobs.inspect(id, limit ?? 40);
82
+ },
83
+ });
84
+
85
+ defineAction({
86
+ name: 'job_wait',
87
+ reads: true,
88
+ description: 'Wait for a job to end (or up to timeoutSeconds, default 120) and return its state plus recent activity, locally observed operations and progress counts. A return with state running means it is still running. It returns early with personWrote when the person writes to you meanwhile: deal with that message before waiting again. job_inspect shows more activity without waiting.',
89
+ input: z.object({ id: z.string().min(1), timeoutSeconds: z.number().int().min(1).max(600).optional() }),
90
+ async run({ id, timeoutSeconds }, ctx) {
91
+ // The person writing to the agent ends the wait at once: a steer only reaches the agent between tool calls,
92
+ // so a two-minute wait would hold their message (\"never mind the freckles\") until the job had gone on.
93
+ const heard = new AbortController();
94
+ let said: string | undefined;
95
+ const off = ctx.onPersonSays?.((text) => {
96
+ said = text;
97
+ heard.abort();
98
+ });
99
+ let job;
100
+ try {
101
+ job = await ctx.jobs.wait(id, (timeoutSeconds ?? 120) * 1000, heard.signal);
102
+ } finally {
103
+ off?.();
104
+ }
105
+ const interrupted = said !== undefined && (job.state === 'running' || job.state === 'queued')
106
+ ? { personWrote: said, hint: 'The person wrote to you while you waited; their message follows this result. Read it and act on it first (answer, change course, or job_cancel this job if they want something else) before waiting again.' }
107
+ : {};
108
+ // Still queued: say what it waits behind, so a wait never hangs on a job that has not started.
109
+ const ahead = job.state === 'queued' ? ctx.jobs.list(job.world).find((j) => j.state === 'running') : undefined;
110
+ return { ...job, ...(job.state === 'queued' && { waitingBehind: ahead ? `${ahead.label} (${ahead.id}), running since ${ahead.startedAt}` : 'nothing; it starts next', hint: ahead ? 'Not started yet. If what runs ahead is not more important, job_cancel it; otherwise do something else and wait again.' : undefined }), ...interrupted, telemetry: ctx.jobs.inspect(id, 12) };
111
+ },
112
+ });
113
+
114
+ defineAction({
115
+ name: 'job_cancel',
116
+ description: 'Stop a running or queued job.',
117
+ input: z.object({ id: z.string().min(1) }),
118
+ async run({ id }, ctx) {
119
+ return ctx.jobs.cancel(id);
120
+ },
121
+ });
122
+
123
+ defineAction({
124
+ name: 'job_retry',
125
+ description: 'Run a failed or stopped job again; a build picks up at the step it failed on, or earlier at the step where a place was set aside.',
126
+ input: z.object({ id: z.string().min(1) }),
127
+ job: true,
128
+ async run({ id }, ctx) {
129
+ const job = ctx.jobs.get(id);
130
+ if (!job) throw new ActionError(404, `no job ${id}`);
131
+ if (job.state === 'running' || job.state === 'queued') throw new ActionError(409, `${job.label} is still ${job.state}`);
132
+ const args = [...job.args];
133
+ const place = args.includes('--place') ? args[args.indexOf('--place') + 1] : undefined;
134
+ const blocked = agentBlocked(path.join(ctx.root, job.world), ctx.by, place);
135
+ if (blocked) throw new ActionError(423, blocked);
136
+ const from = args.indexOf('--from');
137
+ // Steps before the failed one are done; start where it broke. A place set aside earlier in the build (its paint
138
+ // refused, say) broke at its own step while the others went on: start at the earliest such step, or the retry
139
+ // runs only the last step, which skips that place again.
140
+ if (args[0] === 'build' && from >= 0 && job.step && (STEPS as readonly string[]).includes(job.step)) {
141
+ const steps = STEPS as readonly string[];
142
+ const broke = brokenPlaces(path.join(ctx.root, job.world), place).map((r) => r.from).filter((s) => steps.includes(s));
143
+ args[from + 1] = [job.step, ...broke].sort((a, b) => steps.indexOf(a) - steps.indexOf(b))[0];
144
+ }
145
+ return ctx.jobs.retry(job.id, args, ctx.by);
146
+ },
147
+ });
148
+
149
+ defineAction({
150
+ name: 'playthrough',
151
+ description: 'Let the bot play the world in a browser along the story\'s routes and check each ending is reached; says where it got stuck. Runs as a job.',
152
+ input: z.object({ slug: Slug, ending: z.string().min(1).optional().describe('One ending; leave out for all of them.') }),
153
+ job: true,
154
+ async run({ slug, ending }, ctx) {
155
+ if (!fs.existsSync(path.join(ctx.root, slug, 'story.json'))) throw new ActionError(404, `${slug} has no story yet: build it to the story step first`);
156
+ return ctx.jobs.start(slug, ['playthrough', slug, ...(ending ? [ending] : [])], `playthrough${ending ? ` ${ending}` : ''}`, ctx.by);
157
+ },
158
+ });
@@ -0,0 +1,267 @@
1
+ // The cast: the hero and every NPC, how each looks, which parts the runtime has for them (cut-out, rig,
2
+ // 3D model, sprite sheets) and which voice speaks their lines. Fixes go to cast/fixes.json.
3
+ import fs from 'node:fs';
4
+ import { animationCurrent } from '../scene/animation-source.ts';
5
+ import path from 'node:path';
6
+ import { z } from 'zod';
7
+ import { CastFixes, castFixesFile, readCastFixes } from '../scene/cast-fixes.ts';
8
+ import { DESIGNED, designDescriptions, needsDesign, stockVoices, voiceCandidates, Voices } from '../sound/voices.ts';
9
+ import { agentBlocked } from '../studio/locks.ts';
10
+ import { players } from '../world/drawings.ts';
11
+ import { modelProblem } from '../model/glb.ts';
12
+ import { POSE_OK } from '../model/sprites.ts';
13
+ import { readBodies } from '../scene/cast.ts';
14
+ import { WorldFiles } from '../world/files.ts';
15
+ import { liveVoiceFailures } from '../sound/data.ts';
16
+ import { voiceRetryTarget } from '../sound/retry.ts';
17
+ import { Slug } from '../world/schema.ts';
18
+ import { faceStatus } from './faces.ts';
19
+ import { ActionError, defineAction, type ActionContext } from './registry.ts';
20
+
21
+ const stamp = (file: string) => (fs.existsSync(file) ? Math.round(fs.statSync(file).mtimeMs) : null);
22
+ const ModelAttempt = z.object({ state: z.enum(['ready', 'fallback', 'failed']), reason: z.string().nullable(), at: z.string() });
23
+ const RigAttempt = z.object({ ok: z.boolean(), reason: z.string().nullable(), at: z.string() });
24
+
25
+ defineAction({
26
+ name: 'voice_retry',
27
+ description: 'Retry one missing spoken line using its exact speaker key and text from cast_status voiceFailures. Keeps the selected voice and all other recordings. Rejected takes get fresh seeds; a transcription outage reuses saved audio. Costs ElevenLabs/verification calls when needed; wait with job_wait, then check cast_status and play_reload.',
28
+ input: z.object({ slug: Slug, key: Slug, text: z.string().min(1).max(5000) }),
29
+ job: true,
30
+ async run({ slug, key, text }, ctx) {
31
+ const files = new WorldFiles(slug, ctx.root);
32
+ const blocked = agentBlocked(files.dir, ctx.by);
33
+ if (blocked) throw new ActionError(423, blocked);
34
+ voiceRetryTarget(files, key, text);
35
+ return ctx.jobs.start(slug, ['voice-retry', slug, key, text], `retry line ${key}`, ctx.by);
36
+ },
37
+ });
38
+
39
+ function pendingModel(ctx: ActionContext, slug: string, key: string) {
40
+ return ctx.jobs.list(slug).find((j) => (j.state === 'queued' || j.state === 'running')
41
+ && j.args.length >= 3 && j.args[0] === 'model' && j.args[1] === slug && j.args[2] === key);
42
+ }
43
+
44
+ function characters(files: WorldFiles) {
45
+ const world = files.world();
46
+ const npcs = new Map(world.places.flatMap((p) => p.things.filter((t) => t.role === 'npc').map((t) => [t.key, { ...t, place: p.key }] as const)));
47
+ return [
48
+ { key: world.hero.key, looks: world.hero.looks, role: 'hero' as 'hero' | 'npc' | 'player', place: null as string | null },
49
+ ...[...npcs.values()].map((n) => ({ key: n.key, looks: n.looks, role: 'npc' as const, place: n.place })),
50
+ // Heroes for more gamepads (drawings marked player): painted and rigged like the hero, in no place.
51
+ ...players(files).map((p) => ({ key: p.key, looks: p.looks, role: 'player' as const, place: null })),
52
+ ];
53
+ }
54
+
55
+ function prepareFix(ctx: ActionContext, slug: string, key: string, change: (f: CastFixes[string]) => void) {
56
+ const files = new WorldFiles(slug, ctx.root);
57
+ if (key !== 'narrator' && !characters(files).some((c) => c.key === key)) throw new ActionError(404, `${slug} has no hero or NPC called ${key}`);
58
+ const blocked = agentBlocked(files.dir, ctx.by);
59
+ if (blocked) throw new ActionError(423, blocked);
60
+ return () => {
61
+ const fixes = readCastFixes(files);
62
+ const fix = (fixes[key] ??= {});
63
+ change(fix);
64
+ fs.mkdirSync(path.dirname(castFixesFile(files)), { recursive: true });
65
+ files.write(castFixesFile(files), CastFixes.parse(fixes));
66
+ };
67
+ }
68
+
69
+ defineAction({
70
+ name: 'cast_status',
71
+ reads: true,
72
+ description: 'Every character of a world (hero and NPCs): how they look (and the person\'s fixes), which parts exist (cut-out figure, rig, 3D model, sprite sheets; '
73
+ + 'rigIssue says why there is no 2D walk, modelIssue why a model is not used, e.g. flat), how the game moves them (moves: sprites, 3D model, 2D rig or the cut-out sliding as one piece), '
74
+ + 'their voice and how many lines they speak, with a sample, and voiceFailures for lines the story still has that have no recording.',
75
+ input: z.object({ slug: Slug }),
76
+ async run({ slug }, ctx) {
77
+ const files = new WorldFiles(slug, ctx.root);
78
+ if (!fs.existsSync(files.path('world.json'))) throw new ActionError(404, `no world ${slug} yet`);
79
+ const fixes = readCastFixes(files);
80
+ const voices = fs.existsSync(files.path('voices.json')) ? Voices.safeParse(files.readJson(files.path('voices.json'))) : null;
81
+ const pic = (f: string) => (fs.existsSync(files.path(f)) ? { file: f, v: stamp(files.path(f)) } : null);
82
+ const bodies = readBodies(files, fs.existsSync);
83
+ const failures = liveVoiceFailures(files);
84
+ const narrator = voices?.success && voices.data.cast.narrator ? [{ key: 'narrator', looks: 'reads the intro and the endings', role: 'narrator' as const, place: null }] : [];
85
+ return [...characters(files), ...narrator].map((c) => {
86
+ const lines = voices?.success ? voices.data.lines.filter((l) => l.speaker === c.key) : [];
87
+ return {
88
+ ...c,
89
+ looksNow: fixes[c.key]?.looks ?? c.looks,
90
+ fix: fixes[c.key] ?? null,
91
+ figure: pic(`cast/${c.key}.png`),
92
+ // What the cut-out keeps beyond the body (cast_holds) and the share of its height the body takes.
93
+ cut: fs.existsSync(files.path('cast', c.key, 'cut.json')) ? files.readJson(files.path('cast', c.key, 'cut.json')) : null,
94
+ face: faceStatus(files, c.key),
95
+ painting: pic(`cast/${c.key}-painting.png`),
96
+ rig: animationCurrent(files,c.key,'rig') && fs.existsSync(files.path('cast', c.key, 'rig.json')),
97
+ ...(() => {
98
+ const file = files.path('cast', c.key, 'rig-attempt.json');
99
+ const a = fs.existsSync(file) ? RigAttempt.safeParse(files.readJson(file)) : null;
100
+ return { rigIssue: !animationCurrent(files,c.key,'rig') ? 'The figure changed; rebuild the 2D walk.' : a?.success && !a.data.ok ? a.data.reason : null };
101
+ })(),
102
+ // Metres tall and whether upright on two legs; not upright (a creature) moves as one body, no 2D rig or sheets.
103
+ body: bodies[c.key] ?? null,
104
+ ...(() => {
105
+ // A flat model (and the sheets painted over it) is not used by the game; say why instead.
106
+ const has = fs.existsSync(files.path('cast', c.key, 'model', 'model.json'));
107
+ const attemptFile = files.path('cast', c.key, 'model', 'attempt.json');
108
+ const parsed = fs.existsSync(attemptFile) ? ModelAttempt.safeParse(files.readJson(attemptFile)) : null;
109
+ const modelAttempt = parsed?.success ? parsed.data : null;
110
+ const modelIssue = has ? !animationCurrent(files,c.key,'model') ? 'The figure changed; rebuild the 3D model.' : modelProblem(files.path('cast', c.key, 'model')) : modelAttempt?.reason ?? null;
111
+ const pending = pendingModel(ctx, slug, c.key);
112
+ const sprites = has && !modelIssue && animationCurrent(files,c.key,'sprites') && fs.existsSync(files.path('cast', c.key, 'sprites', 'sprites.json'));
113
+ const rigged = animationCurrent(files,c.key,'rig') && fs.existsSync(files.path('cast', c.key, 'rig.json'));
114
+ const creature = bodies[c.key]?.upright === false;
115
+ // What the game does with them, in the order it prefers: the person sees the fallback, not a silent gap.
116
+ const moves = !fs.existsSync(files.path('cast', `${c.key}.png`)) ? 'not painted: a stand-in'
117
+ : sprites ? 'walks with painted sprite sheets' : has && !modelIssue ? (creature ? 'moves as its 3D model (hops, breathes, blinks)' : 'walks as its 3D model')
118
+ : rigged ? 'walks with its 2D rig (the painting bent by its bones)' : 'the cut-out slides as one piece (no rig, no model)';
119
+ // Walk sheets whose frames left the pose (Muse turned some frames to another facing): the walk stutters there.
120
+ const sheet = sprites ? z.object({ facings: z.record(z.string(), z.object({ iou: z.array(z.number()).optional() })).optional() }).safeParse(files.readJson(files.path('cast', c.key, 'sprites', 'sprites.json'))) : null;
121
+ const mean = (xs: number[]) => xs.reduce((a, b) => a + b, 0) / xs.length;
122
+ const spriteIssues = !animationCurrent(files,c.key,'sprites') ? ['The figure or model changed; rebuild the sprite sheets.'] : sheet?.success ? Object.entries(sheet.data.facings ?? {}).filter(([, f]) => f.iou?.length && mean(f.iou) < POSE_OK).map(([f, x]) => `walk ${f}: frames left the pose (pose IoU ${mean(x.iou!).toFixed(2)}); sprites_redo paints new takes`) : [];
123
+ return {
124
+ model: has && !modelIssue, sprites, modelIssue, modelAttempt, moves, spriteIssues,
125
+ modelJob: pending ? { id: pending.id, state: pending.state } : null,
126
+ // When each was last written, so Studio's preview reloads them after a rebuild.
127
+ stamps: { rig: stamp(files.path('cast', c.key, 'rig.json')), model: has && !modelIssue ? stamp(files.path('cast', c.key, 'model', 'model.json')) : null, sprites: sprites ? stamp(files.path('cast', c.key, 'sprites', 'sprites.json')) : null },
128
+ };
129
+ })(),
130
+ voice: voices?.success ? voices.data.cast[c.key] ?? null : null,
131
+ lines: lines.length,
132
+ flagged: lines.filter((l) => l.flags.some((f) => f.startsWith('heard'))).length,
133
+ voiceFailures: failures.filter((l) => l.speaker === c.key),
134
+ voiceWarnings: voices?.success ? voices.data.cast[c.key]?.warnings ?? [] : [],
135
+ sample: lines[0] ? { text: lines[0].text, file: lines[0].file } : null,
136
+ };
137
+ });
138
+ },
139
+ });
140
+
141
+ defineAction({
142
+ name: 'cast_redo',
143
+ description: 'Paint a character again: with a new description of how they look (kept across rebuilds), or simply another take. '
144
+ + 'Repaints and cuts out the figure (Muse + SAM); run_step model and sprites afterwards if the character walks with sprites. Runs as a job.',
145
+ input: z.object({ slug: Slug, key: z.string().min(1), looks: z.string().min(10).optional(), anotherTake: z.boolean().optional() }),
146
+ job: true,
147
+ async run({ slug, key, looks, anotherTake }, ctx) {
148
+ if (!looks && !anotherTake) throw new ActionError(400, 'give new looks, or anotherTake: true');
149
+ const prepare = prepareFix(ctx, slug, key, (f) => {
150
+ if (looks) f.looks = looks;
151
+ if (anotherTake) f.take = (f.take ?? 0) + 1;
152
+ });
153
+ return ctx.jobs.start(slug, ['cast', slug, key], `cast ${key}`, ctx.by, prepare);
154
+ },
155
+ });
156
+
157
+ defineAction({
158
+ name: 'cast_holds',
159
+ description: 'Keep what a character holds or carries on their cut-out (a giant slice of toast over the head, a lantern, a net), however big: SAM finds each '
160
+ + 'thing by its name and it is cut out with them and walks with their hands. Without holds, what their description says they hold is used (read by Spark); '
161
+ + 'holds names the things yourself in a few words each ("slice of toast"), [] keeps nothing extra. Kept across rebuilds. Cuts the figure again from its '
162
+ + 'painting and makes its 2D walk again: no Muse calls, a few SAM calls. Use it when the person says the figure lost what it holds. '
163
+ + 'A 3D model and its sprites never hold anything (held things tear its rig): when the held thing is who they are, say so. Then play_reload. Runs as a job.',
164
+ input: z.object({ slug: Slug, key: z.string().min(1), holds: z.array(z.string().min(2).max(60)).max(4).optional() }),
165
+ job: true,
166
+ async run({ slug, key, holds }, ctx) {
167
+ const prepare = prepareFix(ctx, slug, key, (f) => {
168
+ if (holds) f.holds = holds;
169
+ });
170
+ return ctx.jobs.start(slug, ['rig', slug, key], `cut out ${key} with what they hold`, ctx.by, prepare);
171
+ },
172
+ });
173
+
174
+ defineAction({
175
+ name: 'cast_animate',
176
+ description: 'Give a character a 3D model now, also one the build skips (an NPC with no lines, a creature or an animated object): '
177
+ + 'Muse paints it from 4 sides, TRELLIS builds the model (HF, only where the HF token works). A creature (not upright, cast_status body) gets no skeleton: '
178
+ + 'in the game it hops, squashes as it lands, leans, breathes and blinks as one body. Kept across rebuilds (the model step builds it every time). For a walking or speaking person, the same job also builds sprites and chooses 16-frame UV automatically when supported. Runs as a job.',
179
+ input: z.object({ slug: Slug, key: z.string().min(1) }),
180
+ job: true,
181
+ async run({ slug, key }, ctx) {
182
+ const prepare = prepareFix(ctx, slug, key, (f) => void (f.animate = true));
183
+ const pending = pendingModel(ctx, slug, key);
184
+ if (pending) return pending;
185
+ return ctx.jobs.start(slug, ['model', slug, key, '--sprites'], `animate ${key}`, ctx.by, prepare);
186
+ },
187
+ });
188
+
189
+ defineAction({
190
+ name: 'cast_relax',
191
+ description: 'Put 3D figures into a calm rest pose with the local Blender (no calls, free): arms down by the sides and feet under the hips, '
192
+ + 'then their UV sprites are drawn again. Use it when the person says characters walk straddle-legged, with arms out to the sides, like zombies, '
193
+ + 'or hands stick out. Needs Blender (blender_get). Without key, every character with a 3D model. Then play_reload. Runs as a job.',
194
+ input: z.object({ slug: Slug, key: z.string().min(1).optional() }),
195
+ job: true,
196
+ async run({ slug, key }, ctx) {
197
+ return ctx.jobs.start(slug, ['model', slug, ...(key ? [key] : []), '--relax'], key ? `relax ${key}` : 'relax the cast', ctx.by);
198
+ },
199
+ });
200
+
201
+ defineAction({
202
+ name: 'cast_rewalk',
203
+ description: 'Make a character\'s 2D walk again from its painting (no Muse calls): the painting bent by its bones as one piece, so an apron, a coat '
204
+ + 'or a skirt stretches with the legs and never tears, and what they hold (a lantern, a net) stays on. Use it when the person says a walk tears, '
205
+ + 'a leg or arm comes off, or the figure has holes; then play_reload. Without key, every character. Runs as a job.',
206
+ input: z.object({ slug: Slug, key: z.string().min(1).optional() }),
207
+ job: true,
208
+ async run({ slug, key }, ctx) {
209
+ return ctx.jobs.start(slug, ['rig', slug, ...(key ? [key] : [])], key ? `walk ${key}` : 'walk the cast', ctx.by);
210
+ },
211
+ });
212
+
213
+ defineAction({
214
+ name: 'sprites_redo',
215
+ description: 'Paint a character\'s walk and talk sprite sheets again over their 3D model (new Muse takes, the picture of the character stays): '
216
+ + 'for cast_status spriteIssues (walk frames that left the pose) or when the person says the walk looks wrong in one direction. '
217
+ + 'Up to 5 takes per facing, the one that keeps the pose wins; kept across rebuilds. About 2-10 Muse Image calls per facing. Runs as a job; then play_reload.',
218
+ input: z.object({ slug: Slug, key: z.string().min(1) }),
219
+ job: true,
220
+ async run({ slug, key }, ctx) {
221
+ const files = new WorldFiles(slug, ctx.root);
222
+ if (!fs.existsSync(files.path('cast', key, 'model', 'model.json')) || modelProblem(files.path('cast', key, 'model'))) {
223
+ throw new ActionError(409, `${key} has no usable 3D model, so no sheets to paint: cast_animate makes one first`);
224
+ }
225
+ const prepare = prepareFix(ctx, slug, key, (f) => void (f.spriteTake = (f.spriteTake ?? 0) + 1));
226
+ return ctx.jobs.start(slug, ['sprites', slug, key, '--tries', '3'], `sheets ${key}`, ctx.by, prepare);
227
+ },
228
+ });
229
+
230
+ defineAction({
231
+ name: 'voice_list',
232
+ reads: true,
233
+ description: 'The stock ElevenLabs voices a character can be given: id, name and what they sound like. With slug and key, '
234
+ + 'the voices that fit that character (gender and age Spark saw in their figure) come first, marked fits. The first entry, id "design", is a voice ElevenLabs Voice Design makes from a description: '
235
+ + 'children, teens and the old always get one, since no stock voice is a child or truly old.',
236
+ input: z.object({ slug: Slug.optional(), key: z.string().min(1).optional() }),
237
+ async run({ slug, key }, ctx) {
238
+ let stock;
239
+ try {
240
+ stock = await stockVoices();
241
+ } catch (error) {
242
+ throw new ActionError(503, `no voices: ${error instanceof Error ? error.message : error}`);
243
+ }
244
+ const files = slug ? new WorldFiles(slug, ctx.root) : undefined;
245
+ const voices = files && fs.existsSync(files.path('voices.json')) ? Voices.safeParse(files.readJson(files.path('voices.json'))) : null;
246
+ const profile = key && voices?.success ? voices.data.cast[key]?.profile : undefined;
247
+ const fit = new Set(profile ? voiceCandidates(stock, profile).map((v) => v.voice_id) : []);
248
+ const list = stock.map((v) => ({ id: v.voice_id, name: v.name, sounds: [...Object.values(v.labels ?? {}), v.description].filter(Boolean).join(', '), ...(profile && { fits: fit.has(v.voice_id) && !needsDesign(profile) }) }));
249
+ const now = key && voices?.success ? voices.data.cast[key]?.design?.description : undefined;
250
+ list.unshift({ id: DESIGNED, name: 'Designed for this character', sounds: now ?? (profile ? designDescriptions(profile)[0] : 'made from a description'), ...(profile && { fits: true }) });
251
+ return profile ? { character: { key, ...profile }, voices: [...list.filter((v) => v.fits), ...list.filter((v) => !v.fits)] } : list;
252
+ },
253
+ });
254
+
255
+ defineAction({
256
+ name: 'voice_set',
257
+ description: 'Give a character another voice (an id from voice_list), optionally steadier (stability up) or more expressive (style up). '
258
+ + 'voice "design" with design (English, how they sound: "a very old man, gravelly, slow") makes a new voice with ElevenLabs Voice Design; never name a child or an age in years there, ElevenLabs refuses it nor "girl" or "boy" ("youthful high-pitched male character voice for an animated game" works). '
259
+ + 'Kept across rebuilds; speaks the lines again (only that character\'s lines cost anything). Runs as a job.',
260
+ input: z.object({ slug: Slug, key: z.string().min(1), voice: z.string().min(1), design: z.string().min(10).optional(), stability: z.number().min(0).max(1).optional(), style: z.number().min(0).max(1).optional() }),
261
+ job: true,
262
+ async run({ slug, key, voice, design, stability, style }, ctx) {
263
+ if (design && voice !== DESIGNED) throw new ActionError(400, `design goes with voice "${DESIGNED}"`);
264
+ const prepare = prepareFix(ctx, slug, key, (f) => void (f.voice = { voice, why: 'chosen by the person', ...(design && { design }), ...(stability !== undefined && { stability }), ...(style !== undefined && { style }) }));
265
+ return ctx.jobs.start(slug, ['voices', slug], `voice ${key}`, ctx.by, prepare);
266
+ },
267
+ });
@@ -0,0 +1,95 @@
1
+ // Depth: how a place moves as a diorama (the backdrop step's parallax layers, flowing water and drifting painted fog).
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { z } from 'zod';
5
+ import { Depth } from '../scene/layers.ts';
6
+ import { parseCell } from '../scene/locate.ts';
7
+ import { agentBlocked } from '../studio/locks.ts';
8
+ import { Slug } from '../world/schema.ts';
9
+ import { ActionError, defineAction } from './registry.ts';
10
+
11
+ defineAction({
12
+ name: 'water_flow',
13
+ description: 'Set which way the water of a place flows, when the person says it runs the wrong way or should not flow: '
14
+ + 'reverse (the other way round), still (a pond, a harbour basin), or auto (as Spark reads the picture), with an optional hint in words '
15
+ + '("downstream is toward the bridge"). Kept in places/<place>/depth.json across rebuilds; reruns the backdrop step of that place as a job.',
16
+ input: z.object({
17
+ slug: Slug,
18
+ place: z.string().min(1),
19
+ flow: z.enum(['auto', 'reverse', 'still']),
20
+ hint: z.string().min(3).optional().describe('Where the water runs, in words, for Spark to follow.'),
21
+ }),
22
+ job: true,
23
+ async run({ slug, place, flow, hint }, ctx) {
24
+ const dir = path.join(ctx.root, slug);
25
+ const blocked = agentBlocked(dir, ctx.by);
26
+ if (blocked) throw new ActionError(423, blocked);
27
+ const placeDir = path.join(dir, 'places', place);
28
+ if (!fs.existsSync(path.join(placeDir, 'geometry.json'))) throw new ActionError(404, `${slug}/${place} has no geometry yet: build it first`);
29
+ const file = path.join(placeDir, 'depth.json');
30
+ return ctx.jobs.start(slug, ['backdrop', slug, place], `water ${place}: ${flow}`, ctx.by, () => {
31
+ const was = fs.existsSync(file) ? Depth.parse(JSON.parse(fs.readFileSync(file, 'utf8'))) : Depth.parse({});
32
+ const depth: Depth = { ...was, flow, ...(hint !== undefined && { flowHint: hint }) };
33
+ fs.writeFileSync(file, `${JSON.stringify(depth, null, 2)}\n`);
34
+ });
35
+ },
36
+ });
37
+
38
+ const Cell = z.string().refine((c) => !!parseCell(c), 'a grid cell like C12: a letter per 60 px column from A, a number per 60 px row from 1');
39
+
40
+ defineAction({
41
+ name: 'water_area',
42
+ description: 'Say which 60 px grid cells are water that should move (add) or are not water (remove), when the person says only part of '
43
+ + 'a river, lake or sea moves, or something that is not water ripples. Cells are named like C12 (column letter from A at the left, '
44
+ + 'row number from 1 at the top, as on the grid of place_view). An added cell flows along the edge SAM sees there, or whole. '
45
+ + 'Kept in places/<place>/depth.json across rebuilds; reruns the backdrop step of that place as a job.',
46
+ input: z.object({ slug: Slug, place: z.string().min(1), add: z.array(Cell).optional(), remove: z.array(Cell).optional() }),
47
+ job: true,
48
+ async run({ slug, place, add, remove }, ctx) {
49
+ if (!add?.length && !remove?.length) throw new ActionError(400, 'give cells to add or remove');
50
+ const dir = path.join(ctx.root, slug);
51
+ const blocked = agentBlocked(dir, ctx.by);
52
+ if (blocked) throw new ActionError(423, blocked);
53
+ const placeDir = path.join(dir, 'places', place);
54
+ if (!fs.existsSync(path.join(placeDir, 'geometry.json'))) throw new ActionError(404, `${slug}/${place} has no geometry yet: build it first`);
55
+ const file = path.join(placeDir, 'depth.json');
56
+ const up = (cells?: string[]) => (cells ?? []).map((c) => c.trim().toUpperCase());
57
+ return ctx.jobs.start(slug, ['backdrop', slug, place], `water area ${place}`, ctx.by, () => {
58
+ const was = fs.existsSync(file) ? Depth.parse(JSON.parse(fs.readFileSync(file, 'utf8'))) : Depth.parse({});
59
+ const depth: Depth = {
60
+ ...was,
61
+ waterAdd: [...new Set([...was.waterAdd.filter((c) => !up(remove).includes(c)), ...up(add)])],
62
+ waterRemove: [...new Set([...was.waterRemove.filter((c) => !up(add).includes(c)), ...up(remove)])],
63
+ };
64
+ fs.writeFileSync(file, `${JSON.stringify(depth, null, 2)}\n`);
65
+ });
66
+ },
67
+ });
68
+
69
+ defineAction({
70
+ name: 'fog_drift',
71
+ description: 'Set how the fog or mist painted into a place drifts (the backdrop step cuts it with SAM and the page slides it), '
72
+ + 'when the person says the fog stands still, drifts the wrong way, or too fast or slow: auto (with the wind), left, right, '
73
+ + 'or still (stays as painted), and speed 0.25..3 (1 by default). Kept in places/<place>/depth.json across rebuilds; reruns the backdrop step of that place as a job. '
74
+ + 'For extra mist that is not in the picture, use weather_set fog.',
75
+ input: z.object({
76
+ slug: Slug,
77
+ place: z.string().min(1),
78
+ drift: z.enum(['auto', 'left', 'right', 'still']),
79
+ speed: z.number().min(0.25).max(3).optional().describe('How fast it drifts: 0.5 slow, 1 as by default, 2 rolling.'),
80
+ }),
81
+ job: true,
82
+ async run({ slug, place, drift, speed }, ctx) {
83
+ const dir = path.join(ctx.root, slug);
84
+ const blocked = agentBlocked(dir, ctx.by);
85
+ if (blocked) throw new ActionError(423, blocked);
86
+ const placeDir = path.join(dir, 'places', place);
87
+ if (!fs.existsSync(path.join(placeDir, 'geometry.json'))) throw new ActionError(404, `${slug}/${place} has no geometry yet: build it first`);
88
+ const file = path.join(placeDir, 'depth.json');
89
+ return ctx.jobs.start(slug, ['backdrop', slug, place], `fog ${place}: ${drift}${speed !== undefined ? ` x${speed}` : ''}`, ctx.by, () => {
90
+ const was = fs.existsSync(file) ? Depth.parse(JSON.parse(fs.readFileSync(file, 'utf8'))) : Depth.parse({});
91
+ const depth: Depth = { ...was, fog: drift, ...(speed !== undefined && { fogSpeed: speed }) };
92
+ fs.writeFileSync(file, `${JSON.stringify(depth, null, 2)}\n`);
93
+ });
94
+ },
95
+ });
@@ -0,0 +1,36 @@
1
+ // Add new workshop drawings to a built world without redesigning its existing places or script.
2
+ import fs from 'node:fs';
3
+ import { z } from 'zod';
4
+ import { readDrawn } from '../world/drawings.ts';
5
+ import { WorldFiles } from '../world/files.ts';
6
+ import { Slug, World } from '../world/schema.ts';
7
+ import { agentBlocked } from '../studio/locks.ts';
8
+ import { ActionError, defineAction } from './registry.ts';
9
+
10
+ export const DrawingFile = z.string().regex(/^drawings\/[a-z0-9-]+\.png$/);
11
+ defineAction({
12
+ name: 'world_extend',
13
+ description: 'Extend an existing playable world with drawings already saved by kids_add. files are the drawings/ paths from kids_status, wish is what the person wants. Keeps existing places and story; rebuilds the connecting place and new places, creates new characters and story, and checks playability. Builds in a private copy and installs only on success. Runs as a job; wait with job_wait, inspect the result and reload play only if it succeeds. Costs model calls; the connecting place is repainted. History can undo the job. A drawing already represented by an existing key cannot be added again.',
14
+ input: z.object({ slug: Slug, files: z.array(DrawingFile).min(1).max(8), wish: z.string().trim().min(5).max(2000) }),
15
+ job: true,
16
+ async run({ slug, files: selected, wish }, ctx) {
17
+ const files = new WorldFiles(slug, ctx.root);
18
+ if (!fs.existsSync(files.path('world.json')) || !fs.existsSync(files.path('story.json'))) throw new ActionError(404, 'build the world and story before extending it');
19
+ const blocked = agentBlocked(files.dir, ctx.by);
20
+ if (blocked) throw new ActionError(423, blocked);
21
+ const busy = ctx.jobs.busy(slug);
22
+ if (busy) throw new ActionError(409, `${busy.label} is ${busy.state}; wait before extending`);
23
+ if (new Set(selected).size !== selected.length) throw new ActionError(400, 'each drawing may be selected only once');
24
+ const drawn = readDrawn(files);
25
+ const world = World.parse(files.readJson(files.path('world.json')));
26
+ const known = new Set([world.hero.key, ...world.places.flatMap(p => [p.key, ...p.things.map(t => t.key)])]);
27
+ for (const file of selected) {
28
+ const d = drawn.find(x => `drawings/${x.file}` === file);
29
+ if (!d || !fs.existsSync(files.path(file))) throw new ActionError(404, `${file} is not a saved, read drawing; use kids_add first`);
30
+ if (d.kind === 'hero' || d.kind === 'player') throw new ActionError(400, `${file} is marked ${d.kind}; choose npc, place, item or prop with kids_kind before extending`);
31
+ if (known.has(d.key)) throw new ActionError(409, `${file} refers to ${d.key}, already in the world; it cannot be added again`);
32
+ }
33
+ if (new Set(selected.map(f => drawn.find(d => `drawings/${d.file}` === f)!.key)).size !== selected.length) throw new ActionError(400, 'selected drawings share a key; resolve their identity first');
34
+ return ctx.jobs.start(slug, ['extend', slug, JSON.stringify(selected), wish], 'extend world from drawings', ctx.by);
35
+ },
36
+ });