@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.
- package/LICENSE +202 -0
- package/README.md +157 -2
- package/app/bin/diorama.mjs +13 -0
- package/app/src/actions/budget.ts +160 -0
- package/app/src/actions/build.ts +158 -0
- package/app/src/actions/cast.ts +267 -0
- package/app/src/actions/depth.ts +95 -0
- package/app/src/actions/extend.ts +36 -0
- package/app/src/actions/faces.ts +160 -0
- package/app/src/actions/feedback.ts +87 -0
- package/app/src/actions/fix.ts +382 -0
- package/app/src/actions/history.ts +93 -0
- package/app/src/actions/idea-preview.ts +65 -0
- package/app/src/actions/idea-review.ts +102 -0
- package/app/src/actions/idea-sketch.ts +172 -0
- package/app/src/actions/index.ts +32 -0
- package/app/src/actions/items.ts +94 -0
- package/app/src/actions/kids.ts +215 -0
- package/app/src/actions/language.ts +52 -0
- package/app/src/actions/models.ts +64 -0
- package/app/src/actions/motion.ts +119 -0
- package/app/src/actions/pictures.ts +49 -0
- package/app/src/actions/reactions.ts +36 -0
- package/app/src/actions/registry.ts +101 -0
- package/app/src/actions/share.ts +32 -0
- package/app/src/actions/sound.ts +73 -0
- package/app/src/actions/start.ts +154 -0
- package/app/src/actions/step-work.ts +150 -0
- package/app/src/actions/story.ts +101 -0
- package/app/src/actions/studio.ts +133 -0
- package/app/src/actions/style.ts +72 -0
- package/app/src/actions/video.ts +46 -0
- package/app/src/actions/view.ts +109 -0
- package/app/src/actions/weather.ts +38 -0
- package/app/src/actions/world.ts +290 -0
- package/app/src/commands/action.ts +39 -0
- package/app/src/commands/backdrop.ts +188 -0
- package/app/src/commands/browsers.ts +21 -0
- package/app/src/commands/build.ts +503 -0
- package/app/src/commands/cast.ts +161 -0
- package/app/src/commands/clip.ts +431 -0
- package/app/src/commands/derive.ts +167 -0
- package/app/src/commands/doctor.ts +74 -0
- package/app/src/commands/explore.ts +123 -0
- package/app/src/commands/export.ts +158 -0
- package/app/src/commands/extend.ts +75 -0
- package/app/src/commands/faces.ts +80 -0
- package/app/src/commands/icons.ts +69 -0
- package/app/src/commands/index.ts +37 -0
- package/app/src/commands/language.ts +87 -0
- package/app/src/commands/layout.ts +31 -0
- package/app/src/commands/masks.ts +117 -0
- package/app/src/commands/mcp.ts +52 -0
- package/app/src/commands/model.ts +284 -0
- package/app/src/commands/paint.ts +182 -0
- package/app/src/commands/plates.ts +97 -0
- package/app/src/commands/play.ts +195 -0
- package/app/src/commands/playthrough.ts +111 -0
- package/app/src/commands/publish.ts +78 -0
- package/app/src/commands/reactions.ts +40 -0
- package/app/src/commands/reel.ts +46 -0
- package/app/src/commands/setup.ts +85 -0
- package/app/src/commands/sound.ts +100 -0
- package/app/src/commands/sprites.ts +232 -0
- package/app/src/commands/story.ts +37 -0
- package/app/src/commands/studio.ts +65 -0
- package/app/src/commands/style.ts +87 -0
- package/app/src/commands/verify.ts +45 -0
- package/app/src/commands/voice-retry.ts +14 -0
- package/app/src/commands/voices.ts +145 -0
- package/app/src/commands/weather.ts +52 -0
- package/app/src/commands/world.ts +104 -0
- package/app/src/film/clip.ts +386 -0
- package/app/src/film/reel.ts +126 -0
- package/app/src/film/screencast.ts +123 -0
- package/app/src/image/mask.ts +29 -0
- package/app/src/image/overlay.ts +38 -0
- package/app/src/image/raster.ts +224 -0
- package/app/src/main.ts +28 -0
- package/app/src/meta/choice.ts +27 -0
- package/app/src/meta/http.ts +99 -0
- package/app/src/meta/memo.ts +105 -0
- package/app/src/meta/muse.ts +70 -0
- package/app/src/meta/openrouter.ts +93 -0
- package/app/src/meta/prices.ts +25 -0
- package/app/src/meta/sam.ts +83 -0
- package/app/src/meta/schema.ts +63 -0
- package/app/src/meta/spark.ts +220 -0
- package/app/src/meta/step.ts +14 -0
- package/app/src/meta/voice-file.ts +78 -0
- package/app/src/meta/voice.ts +103 -0
- package/app/src/model/animation.ts +38 -0
- package/app/src/model/blender/relax.py +284 -0
- package/app/src/model/blender.ts +115 -0
- package/app/src/model/build.ts +50 -0
- package/app/src/model/face.ts +21 -0
- package/app/src/model/fit.ts +181 -0
- package/app/src/model/glb.ts +241 -0
- package/app/src/model/plan.ts +151 -0
- package/app/src/model/quality.ts +48 -0
- package/app/src/model/space.ts +168 -0
- package/app/src/model/sprites.ts +293 -0
- package/app/src/model/views.ts +163 -0
- package/app/src/play/creature.ts +43 -0
- package/app/src/play/depth-occlusion.ts +150 -0
- package/app/src/play/exit-outline.ts +48 -0
- package/app/src/play/face3d.ts +34 -0
- package/app/src/play/focus.ts +78 -0
- package/app/src/play/geometry.ts +272 -0
- package/app/src/play/gestures.ts +23 -0
- package/app/src/play/glow.ts +187 -0
- package/app/src/play/lipsync.ts +69 -0
- package/app/src/play/living.ts +83 -0
- package/app/src/play/outline-path.ts +130 -0
- package/app/src/play/parallax.ts +200 -0
- package/app/src/play/path.ts +262 -0
- package/app/src/play/preview.ts +71 -0
- package/app/src/play/reactions.ts +92 -0
- package/app/src/play/rig.ts +172 -0
- package/app/src/play/shadow.ts +253 -0
- package/app/src/play/sound.ts +51 -0
- package/app/src/play/weather.ts +58 -0
- package/app/src/qa/explore.ts +136 -0
- package/app/src/qa/probe.ts +139 -0
- package/app/src/qa/route.ts +118 -0
- package/app/src/registry.ts +23 -0
- package/app/src/scene/animation-source.ts +36 -0
- package/app/src/scene/assets.ts +37 -0
- package/app/src/scene/autofix.ts +133 -0
- package/app/src/scene/backdrop.ts +326 -0
- package/app/src/scene/blockin.ts +189 -0
- package/app/src/scene/brief.ts +74 -0
- package/app/src/scene/carry.ts +177 -0
- package/app/src/scene/cast-fixes.ts +33 -0
- package/app/src/scene/cast.ts +199 -0
- package/app/src/scene/corrections.ts +164 -0
- package/app/src/scene/critic.ts +169 -0
- package/app/src/scene/depthmap.ts +149 -0
- package/app/src/scene/derive.ts +475 -0
- package/app/src/scene/diagnose.ts +132 -0
- package/app/src/scene/face-calibration.ts +65 -0
- package/app/src/scene/face-registration.ts +21 -0
- package/app/src/scene/face-work.ts +71 -0
- package/app/src/scene/faces-data.ts +48 -0
- package/app/src/scene/faces-settings.ts +20 -0
- package/app/src/scene/faces.ts +435 -0
- package/app/src/scene/glows.ts +153 -0
- package/app/src/scene/held.ts +109 -0
- package/app/src/scene/icons.ts +185 -0
- package/app/src/scene/layers.ts +822 -0
- package/app/src/scene/layout.ts +216 -0
- package/app/src/scene/locate.ts +118 -0
- package/app/src/scene/maskcheck.ts +43 -0
- package/app/src/scene/masks.ts +392 -0
- package/app/src/scene/occluders.ts +123 -0
- package/app/src/scene/occlusion-check.ts +138 -0
- package/app/src/scene/paint-layout.ts +135 -0
- package/app/src/scene/paint.ts +81 -0
- package/app/src/scene/photo.ts +123 -0
- package/app/src/scene/plates.ts +256 -0
- package/app/src/scene/playbook.ts +121 -0
- package/app/src/scene/preview.ts +23 -0
- package/app/src/scene/reactions.ts +118 -0
- package/app/src/scene/restyle.ts +53 -0
- package/app/src/scene/rig-check.ts +68 -0
- package/app/src/scene/rig.ts +379 -0
- package/app/src/scene/scale.ts +148 -0
- package/app/src/scene/shadows.ts +215 -0
- package/app/src/scene/stairs.ts +183 -0
- package/app/src/scene/status.ts +32 -0
- package/app/src/scene/verify.ts +156 -0
- package/app/src/scene/walk.ts +326 -0
- package/app/src/scene/warp.ts +419 -0
- package/app/src/scene/weather.ts +118 -0
- package/app/src/scene/words.ts +65 -0
- package/app/src/secrets.ts +219 -0
- package/app/src/setup/checks.ts +110 -0
- package/app/src/sound/cost.ts +74 -0
- package/app/src/sound/data.ts +89 -0
- package/app/src/sound/fixes.ts +23 -0
- package/app/src/sound/issues.ts +21 -0
- package/app/src/sound/language.ts +11 -0
- package/app/src/sound/lipsync-make.ts +98 -0
- package/app/src/sound/lipsync.ts +24 -0
- package/app/src/sound/make.ts +229 -0
- package/app/src/sound/musicgen.py +20 -0
- package/app/src/sound/pcm.ts +280 -0
- package/app/src/sound/plan.ts +154 -0
- package/app/src/sound/retry.ts +43 -0
- package/app/src/sound/settings.ts +28 -0
- package/app/src/sound/sources.ts +81 -0
- package/app/src/sound/spoken.ts +70 -0
- package/app/src/sound/voices.ts +579 -0
- package/app/src/speech/transcribe.ts +38 -0
- package/app/src/speech/types.ts +18 -0
- package/app/src/story/check.ts +230 -0
- package/app/src/story/design.ts +108 -0
- package/app/src/story/engine.ts +233 -0
- package/app/src/story/fixes.ts +54 -0
- package/app/src/story/npc-guidance.ts +7 -0
- package/app/src/story/phrases.ts +4 -0
- package/app/src/story/schema.ts +92 -0
- package/app/src/studio/agent.ts +555 -0
- package/app/src/studio/discovery.ts +28 -0
- package/app/src/studio/events.ts +48 -0
- package/app/src/studio/history.ts +124 -0
- package/app/src/studio/jobs.ts +291 -0
- package/app/src/studio/locks.ts +33 -0
- package/app/src/studio/mcp.ts +86 -0
- package/app/src/studio/operation.ts +18 -0
- package/app/src/studio/repair.ts +31 -0
- package/app/src/studio/server.ts +423 -0
- package/app/src/studio/trouble.ts +48 -0
- package/app/src/world/design.ts +72 -0
- package/app/src/world/drawings.ts +198 -0
- package/app/src/world/extend-stage.ts +109 -0
- package/app/src/world/extend.ts +123 -0
- package/app/src/world/extension-progress.ts +10 -0
- package/app/src/world/files.ts +62 -0
- package/app/src/world/idea-review.ts +32 -0
- package/app/src/world/language.ts +116 -0
- package/app/src/world/photo-limits.ts +2 -0
- package/app/src/world/pool.ts +41 -0
- package/app/src/world/schedule.ts +64 -0
- package/app/src/world/schema.ts +74 -0
- package/app/src/world/sketch.ts +81 -0
- package/app/src/world/style-plan.ts +90 -0
- package/app/src/world/style-reference.ts +16 -0
- package/app/src/world/styled.ts +51 -0
- package/app/src/world/told.ts +105 -0
- package/app/src/world/video-limits.ts +3 -0
- package/app/src/world/video.ts +98 -0
- package/app/studio/activity.tsx +124 -0
- package/app/studio/agent.tsx +384 -0
- package/app/studio/api.ts +111 -0
- package/app/studio/atelier.tsx +234 -0
- package/app/studio/audio-missing.tsx +25 -0
- package/app/studio/audio-setup.tsx +33 -0
- package/app/studio/blender-setup.tsx +30 -0
- package/app/studio/build.tsx +679 -0
- package/app/studio/camera.tsx +59 -0
- package/app/studio/cast-work.tsx +207 -0
- package/app/studio/cast.tsx +178 -0
- package/app/studio/concept-edit.tsx +47 -0
- package/app/studio/creator-idea.tsx +46 -0
- package/app/studio/custom-look.tsx +36 -0
- package/app/studio/faces-setup.tsx +28 -0
- package/app/studio/favicon.svg +11 -0
- package/app/studio/figure.tsx +314 -0
- package/app/studio/fonts/OFL.txt +93 -0
- package/app/studio/fonts/fonts.css +7 -0
- package/app/studio/fonts/fraunces-italic-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-italic-latin.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin.woff2 +0 -0
- package/app/studio/icons.tsx +42 -0
- package/app/studio/idea-card.tsx +42 -0
- package/app/studio/idea-review.tsx +181 -0
- package/app/studio/idea-storyboard.tsx +134 -0
- package/app/studio/index.html +15 -0
- package/app/studio/job-words.ts +33 -0
- package/app/studio/lightbox.tsx +49 -0
- package/app/studio/main.tsx +1329 -0
- package/app/studio/nav.ts +14 -0
- package/app/studio/place.tsx +264 -0
- package/app/studio/review-sketch.tsx +47 -0
- package/app/studio/say-it.tsx +49 -0
- package/app/studio/step-work.tsx +134 -0
- package/app/studio/story.tsx +394 -0
- package/app/studio/style-menu.tsx +81 -0
- package/app/studio/style.css +1390 -0
- package/app/studio/sure.tsx +54 -0
- package/app/studio/tip.tsx +74 -0
- package/app/studio/tsconfig.json +5 -0
- package/app/studio/video.tsx +73 -0
- package/app/studio/world.tsx +188 -0
- package/app/tsconfig.json +13 -0
- package/app/web/audio.ts +315 -0
- package/app/web/face.ts +131 -0
- package/app/web/face3d.ts +35 -0
- package/app/web/glows.ts +387 -0
- package/app/web/hero3d.ts +728 -0
- package/app/web/index.html +118 -0
- package/app/web/living.ts +404 -0
- package/app/web/main.ts +3769 -0
- package/app/web/pad.ts +174 -0
- package/app/web/reactions.ts +79 -0
- package/app/web/sheet.ts +36 -0
- package/app/web/synth.ts +140 -0
- package/app/web/tsconfig.json +5 -0
- package/app/web/ui.ts +263 -0
- package/app/web/weather.ts +306 -0
- package/app/web/words.ts +12 -0
- package/docs/characters.md +58 -0
- package/docs/npc-motion.md +69 -0
- package/docs/scenes.md +75 -0
- package/docs/setup.md +60 -0
- package/docs/sound.md +23 -0
- package/docs/studio.md +62 -0
- package/package.json +70 -4
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Sharing a world: a zip to download (itch.io, e-mail) or the public link on GitHub Pages, as jobs.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { Slug } from '../world/schema.ts';
|
|
6
|
+
import { ActionError, defineAction } from './registry.ts';
|
|
7
|
+
|
|
8
|
+
const playable = (root: string, slug: string) => {
|
|
9
|
+
if (!fs.existsSync(path.join(root, slug, 'world.json'))) throw new ActionError(404, `no world ${slug} yet`);
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
defineAction({
|
|
13
|
+
name: 'world_export',
|
|
14
|
+
description: 'Pack a playable copy of the world as a zip the person can download from the Studio (for itch.io or e-mail). Runs as a job.',
|
|
15
|
+
input: z.object({ slug: Slug }),
|
|
16
|
+
job: true,
|
|
17
|
+
async run({ slug }, ctx) {
|
|
18
|
+
playable(ctx.root, slug);
|
|
19
|
+
return ctx.jobs.start(slug, ['export', slug, '--zip'], 'export a zip', ctx.by);
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
defineAction({
|
|
24
|
+
name: 'world_publish',
|
|
25
|
+
description: 'Put the world on the public web (GitHub Pages of this repository) and get its link. Anyone can play it there: ask the person before you call this. Runs as a job.',
|
|
26
|
+
input: z.object({ slug: Slug }),
|
|
27
|
+
job: true,
|
|
28
|
+
async run({ slug }, ctx) {
|
|
29
|
+
playable(ctx.root, slug);
|
|
30
|
+
return ctx.jobs.start(slug, ['publish', slug], 'publish to GitHub Pages', ctx.by);
|
|
31
|
+
},
|
|
32
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The world's sounds one by one (ambience beds, loops, footsteps, one-shots, music): what each is asked for, whether
|
|
2
|
+
// it was made or rejected and why, and a redo for a single sound with a hint, kept in sound/fixes.json.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { jobsOf, SoundManifest } from '../sound/make.ts';
|
|
7
|
+
import { readSoundFixes, SoundFixes, soundFixesFile } from '../sound/fixes.ts';
|
|
8
|
+
import { SoundPlan } from '../sound/plan.ts';
|
|
9
|
+
import { agentBlocked } from '../studio/locks.ts';
|
|
10
|
+
import { WorldFiles } from '../world/files.ts';
|
|
11
|
+
import { Slug } from '../world/schema.ts';
|
|
12
|
+
import { ActionError, defineAction } from './registry.ts';
|
|
13
|
+
|
|
14
|
+
function soundFiles(slug: string, root: string) {
|
|
15
|
+
const files = new WorldFiles(slug, root);
|
|
16
|
+
if (!fs.existsSync(files.path('sound.json'))) throw new ActionError(404, `${slug} has no sound plan yet: run_step sound`);
|
|
17
|
+
const plan = SoundPlan.parse(files.readJson(files.path('sound.json')));
|
|
18
|
+
const made = fs.existsSync(files.path('sound', 'sounds.json')) ? SoundManifest.parse(files.readJson(files.path('sound', 'sounds.json'))) : { sounds: {}, failures: {} };
|
|
19
|
+
return { files, plan, made };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
defineAction({
|
|
23
|
+
name: 'sound_status',
|
|
24
|
+
reads: true,
|
|
25
|
+
description: 'Every sound of a world (music, each place\'s ambience bed, loops of things, footsteps per surface, one-shots): the prompt it is asked for '
|
|
26
|
+
+ '(with the person\'s hint), whether it was made (file, seconds, flags) or rejected and why (rejected ones stay silent in the game), and the hint kept for it. '
|
|
27
|
+
+ 'Free. Use it before sound_redo.',
|
|
28
|
+
input: z.object({ slug: Slug }),
|
|
29
|
+
async run({ slug }, ctx) {
|
|
30
|
+
const { files, plan, made } = soundFiles(slug, ctx.root);
|
|
31
|
+
const fixes = readSoundFixes(files);
|
|
32
|
+
return jobsOf(plan, fixes).map((j) => {
|
|
33
|
+
const m = made.sounds[j.id];
|
|
34
|
+
const current = m && m.prompt === j.prompt && fs.existsSync(files.path(m.file));
|
|
35
|
+
return {
|
|
36
|
+
id: j.id, kind: j.kind, prompt: j.prompt,
|
|
37
|
+
state: current ? 'made' : made.failures[j.id] ? 'rejected' : m ? 'outdated' : 'not made',
|
|
38
|
+
...(current && { file: m.file, seconds: m.seconds, flags: m.flags }),
|
|
39
|
+
...(made.failures[j.id] && { rejected: made.failures[j.id] }),
|
|
40
|
+
fix: fixes[j.id] ?? null,
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
defineAction({
|
|
47
|
+
name: 'sound_redo',
|
|
48
|
+
description: 'Make one or more sounds again (ids from sound_status), e.g. an ambience rejected as "mostly hiss" or "nearly silent", or one the person says sounds wrong. '
|
|
49
|
+
+ 'hint (English, a few words: "a clear ticking clock and soft room tone, audible") is added to its prompt and kept across rebuilds and new sound plans; '
|
|
50
|
+
+ 'without a hint it simply asks ElevenLabs for new takes. Only these sounds are paid for (up to 3 takes each). Runs as a job; then play_reload.',
|
|
51
|
+
input: z.object({ slug: Slug, ids: z.array(z.string().min(1)).min(1), hint: z.string().min(3).optional() }),
|
|
52
|
+
job: true,
|
|
53
|
+
async run({ slug, ids, hint }, ctx) {
|
|
54
|
+
const { files, plan } = soundFiles(slug, ctx.root);
|
|
55
|
+
const known = new Set(jobsOf(plan).map((j) => j.id));
|
|
56
|
+
const unknown = ids.filter((id) => !known.has(id));
|
|
57
|
+
if (unknown.length) throw new ActionError(404, `${slug} has no sound ${unknown.join(', ')}: see sound_status`);
|
|
58
|
+
const blocked = agentBlocked(files.dir, ctx.by);
|
|
59
|
+
if (blocked) throw new ActionError(423, blocked);
|
|
60
|
+
const prepare = () => {
|
|
61
|
+
const fixes = readSoundFixes(files);
|
|
62
|
+
for (const id of ids) {
|
|
63
|
+
const fix = (fixes[id] ??= {});
|
|
64
|
+
if (hint) fix.hint = hint;
|
|
65
|
+
// A new round of takes: the cached takes of the last round are not asked for again.
|
|
66
|
+
fix.take = (fix.take ?? 0) + 1;
|
|
67
|
+
}
|
|
68
|
+
fs.mkdirSync(path.dirname(soundFixesFile(files)), { recursive: true });
|
|
69
|
+
files.write(soundFixesFile(files), SoundFixes.parse(fixes));
|
|
70
|
+
};
|
|
71
|
+
return ctx.jobs.start(slug, ['sound', slug, '--redo', ids.join(',')], `sound ${ids.length === 1 ? ids[0] : `${ids.length} sounds`}`, ctx.by, prepare);
|
|
72
|
+
},
|
|
73
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { styleReferencePath } from '../world/style-reference.ts';
|
|
2
|
+
// A new world from one idea: Spark reads it (and any photos) and picks the world's name, how many places the
|
|
3
|
+
// story needs and the language it is told in, so the person writes the idea and nothing else. A spoken idea is heard here too.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { Memo } from '../meta/memo.ts';
|
|
8
|
+
import { askSparkJson } from '../meta/spark.ts';
|
|
9
|
+
import { transcribe } from '../meta/voice.ts';
|
|
10
|
+
import { normalisePhoto } from '../scene/photo.ts';
|
|
11
|
+
import { decode } from '../sound/pcm.ts';
|
|
12
|
+
import { pcm24k } from '../sound/voices.ts';
|
|
13
|
+
import { PICTURE } from '../world/drawings.ts';
|
|
14
|
+
import { pieces, SOUND } from '../world/told.ts';
|
|
15
|
+
import { Slug } from '../world/schema.ts';
|
|
16
|
+
import { MAX_WORLD_PHOTOS } from '../world/photo-limits.ts';
|
|
17
|
+
import { CreativeChoices, refinedIdea } from '../world/idea-review.ts';
|
|
18
|
+
import { VideoId, videoDir, videoState, VIDEO_STEP } from '../world/video.ts';
|
|
19
|
+
import { ActionError, defineAction } from './registry.ts';
|
|
20
|
+
import { SketchApproval, publishSketch } from '../world/sketch.ts';
|
|
21
|
+
|
|
22
|
+
/** Calls made before a world has a folder are remembered here (a dot folder, so it is never listed as a world). */
|
|
23
|
+
const startMemo = (root: string) => new Memo(path.join(root, '.start', 'memo'));
|
|
24
|
+
|
|
25
|
+
const Shape = z.object({
|
|
26
|
+
title: z.string().min(1).max(80),
|
|
27
|
+
slug: z.string().min(2).max(40),
|
|
28
|
+
places: z.number().int().min(2).max(5),
|
|
29
|
+
language: z.string().min(2).max(30),
|
|
30
|
+
pictures: z.array(z.enum(['place', 'hero', 'drawing', 'none'])).optional(),
|
|
31
|
+
});
|
|
32
|
+
type Shape = z.infer<typeof Shape>;
|
|
33
|
+
|
|
34
|
+
/** Read the idea and account for every photo before saving material or starting a build. */
|
|
35
|
+
export async function shapeIdea(memo: Memo, idea: string, photos: Buffer[]): Promise<Shape> {
|
|
36
|
+
const prompt = `Someone wants a short 2D point-and-click adventure game made from this idea:
|
|
37
|
+
"""${idea}"""
|
|
38
|
+
${photos.length ? `\nThey added ${photos.length} photo${photos.length > 1 ? 's' : ''} (in order). For each say what it is for: "place" when it is a photo of a real room or spot that should become the first place of the game, "hero" when it is a photo of a real person who should be the hero, "drawing" when it is a drawing (a child's or anyone's, on paper or a screen) of a character, a thing or a place for the game, "none" otherwise. At most one "place" and one "hero"; any number of drawings.\n` : ''}
|
|
39
|
+
Decide:
|
|
40
|
+
- title: a short, evocative title for the game, in the language the idea is written in.
|
|
41
|
+
- slug: a short name for its folder: 1 to 3 English words in lowercase-kebab, like "last-foghorn" or "lantern-market".
|
|
42
|
+
- places: how many places the story needs, 2 to 5. Make only what the story needs: one scene or one room with a small task is 2; a usual small story with a goal is 3; a journey or a story that names more places is 4 or 5. If the idea names a number of places, use it.
|
|
43
|
+
- language: the language the idea is written in, in English ("English", "Polish").
|
|
44
|
+
|
|
45
|
+
Reply with JSON only: {"title": "...", "slug": "...", "places": 3, "language": "English"${photos.length ? `, "pictures": ${JSON.stringify(photos.map(() => 'drawing'))}` : ''}}
|
|
46
|
+
${photos.length ? `The pictures array must contain exactly ${photos.length} classifications, one for each photo in input order. The example uses drawing for every entry; classify each actual photo. Never omit an entry.` : ''}`;
|
|
47
|
+
const schema = Shape.superRefine((shape, ctx) => {
|
|
48
|
+
if ((shape.pictures?.length ?? 0) !== photos.length) {
|
|
49
|
+
ctx.addIssue({ code: 'custom', path: ['pictures'], message: `expected exactly ${photos.length} classifications in photo order; received ${shape.pictures?.length ?? 0}` });
|
|
50
|
+
}
|
|
51
|
+
for (const role of ['place', 'hero']) {
|
|
52
|
+
if ((shape.pictures ?? []).filter((p) => p === role).length > 1) {
|
|
53
|
+
ctx.addIssue({ code: 'custom', path: ['pictures'], message: `at most one ${role} photo is supported; do not silently discard the others` });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
let ask = prompt, problem = '';
|
|
58
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
59
|
+
const answer = await askSparkJson(memo, { label: `idea-shape#${attempt}`, prompt: ask, images: photos, personal: true });
|
|
60
|
+
const parsed = schema.safeParse(answer);
|
|
61
|
+
if (parsed.success) return parsed.data;
|
|
62
|
+
problem = parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
|
|
63
|
+
ask = `${prompt}\n\nCorrection attempt ${attempt + 1}. Your last answer was:\n${JSON.stringify(answer)}\n\nFix: ${problem}`;
|
|
64
|
+
}
|
|
65
|
+
throw new ActionError(502, `Spark could not classify all ${photos.length} photos and read the idea after 3 attempts. No world build was started. ${problem}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The slug cleaned to a folder name, numbered when a world has it already. */
|
|
69
|
+
function freeSlug(root: string, wanted: string): string {
|
|
70
|
+
const base = wanted.toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^[^a-z]+|-+$/g, '').slice(0, 40).replace(/-+$/, '') || 'world';
|
|
71
|
+
let slug = base, n = 2;
|
|
72
|
+
while (fs.existsSync(path.join(root, slug))) slug = `${base}-${n++}`;
|
|
73
|
+
return slug;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const bytesOf = (data: string) => Buffer.from(data.replace(/^data:[^,]*,/, ''), 'base64');
|
|
77
|
+
|
|
78
|
+
/** Seed the same source material for a preview and a direct build. Videos are copied only on publication. */
|
|
79
|
+
export function seedIdea(dir: string, idea: string, choices: CreativeChoices | undefined, photos: Array<{ name: string; data: string }>, images: Buffer[], shape: Shape, sourceRoot: string) {
|
|
80
|
+
const styleImage = choices?.styleReference ? fs.readFileSync(styleReferencePath(sourceRoot, choices.styleReference)) : undefined;
|
|
81
|
+
if (choices) {
|
|
82
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
83
|
+
fs.writeFileSync(path.join(dir, 'creative-brief.json'), JSON.stringify({ idea, choices }, null, 2));
|
|
84
|
+
if (styleImage) fs.writeFileSync(path.join(dir, 'style-reference.png'), styleImage);
|
|
85
|
+
}
|
|
86
|
+
const photoFiles: { photoFile?: string; heroFile?: string } = {};
|
|
87
|
+
for (const role of ['place', 'hero'] as const) {
|
|
88
|
+
const at = (shape.pictures ?? []).indexOf(role);
|
|
89
|
+
if (at < 0 || !images[at]) continue;
|
|
90
|
+
const file = path.join(dir, 'start', `${role}.png`);
|
|
91
|
+
fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, images[at]);
|
|
92
|
+
photoFiles[role === 'place' ? 'photoFile' : 'heroFile'] = file;
|
|
93
|
+
}
|
|
94
|
+
const drawings = (shape.pictures ?? []).flatMap((role, i) => role === 'drawing' && images[i] ? [i] : []);
|
|
95
|
+
drawings.forEach((i, n) => {
|
|
96
|
+
const file = path.join(dir, 'drawings', `${String(n + 1).padStart(2, '0')}-${photos[i].name.replace(/\.[^.]+$/, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 30) || 'drawing'}.png`);
|
|
97
|
+
fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, images[i]);
|
|
98
|
+
});
|
|
99
|
+
return { ...photoFiles, drawings: drawings.length };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
defineAction({
|
|
103
|
+
name: 'world_new',
|
|
104
|
+
description: 'Make a new world from an idea and build it: Spark picks its title, folder name, how many places the story needs (2 to 5) and its language, '
|
|
105
|
+
+ 'unless given; photos (optional) of a real room become the first place, of a person the hero, and drawings the characters, things and places they show. Starts the world_build job and returns the slug.',
|
|
106
|
+
input: z.object({
|
|
107
|
+
idea: z.string().min(10).max(4000),
|
|
108
|
+
choices: CreativeChoices.optional().describe('Only answers, style and notes explicitly chosen by the creator in the optional idea review'),
|
|
109
|
+
sketch: SketchApproval.optional().describe('Build this exact reviewed world plan and selected visual references'),
|
|
110
|
+
slug: Slug.optional().describe('the folder name; picked from the idea when left out'),
|
|
111
|
+
places: z.number().int().min(1).max(8).optional().describe('picked from the idea when left out'),
|
|
112
|
+
language: z.string().min(2).max(30).optional().describe('the game\'s language ("Polish"); read from the idea when left out'),
|
|
113
|
+
videos: z.array(VideoId).max(8).optional(),
|
|
114
|
+
photos: z.array(z.object({ name: z.string().min(1).max(120), data: z.string().min(10) })).max(MAX_WORLD_PHOTOS).optional().describe('photos, base64: a real room becomes the first place, a person the hero, drawings the characters, things and places they show'),
|
|
115
|
+
}),
|
|
116
|
+
job: true,
|
|
117
|
+
async run({ idea, choices, sketch, slug, places, language, photos = [], videos = [] }, ctx) {
|
|
118
|
+
for (const id of videos) {
|
|
119
|
+
const dir = videoDir(ctx.root, id), state = videoState(dir);
|
|
120
|
+
if (!state.duration || !fs.existsSync(path.join(dir, `part-${Math.ceil(state.duration / VIDEO_STEP) - 1}.json`))) throw new ActionError(409, 'Finish interpreting the video before building.');
|
|
121
|
+
}
|
|
122
|
+
if (sketch) return publishSketch(ctx, { idea, choices, slug, places, language, photos, videos }, sketch);
|
|
123
|
+
for (const p of photos) if (!PICTURE.test(p.name)) throw new ActionError(400, `${p.name}: a photo is png, jpg, webp or heic`);
|
|
124
|
+
const images = await Promise.all(photos.map((p) => normalisePhoto(bytesOf(p.data))));
|
|
125
|
+
const brief = refinedIdea(idea, choices);
|
|
126
|
+
const shape = await shapeIdea(startMemo(ctx.root), brief, images);
|
|
127
|
+
if (slug && fs.existsSync(path.join(ctx.root, slug, 'world.json'))) throw new ActionError(409, `${slug} is built already: pick another name`);
|
|
128
|
+
const name = slug ?? freeSlug(ctx.root, shape.slug);
|
|
129
|
+
const dir = path.join(ctx.root, name);
|
|
130
|
+
const seeded = seedIdea(dir, idea, choices, photos, images, shape, ctx.root);
|
|
131
|
+
for (const id of videos) {
|
|
132
|
+
const dest = path.join(dir, 'videos', id);
|
|
133
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
134
|
+
fs.cpSync(videoDir(ctx.root, id), dest, { recursive: true });
|
|
135
|
+
}
|
|
136
|
+
const lang = language ?? shape.language;
|
|
137
|
+
const args = ['build', name, brief, '--from', 'world', '--places', String(places ?? shape.places), ...(/^english$/i.test(lang) ? [] : ['--language', lang]), ...(seeded.photoFile ? ['--photo', seeded.photoFile] : []), ...(seeded.heroFile ? ['--hero-photo', seeded.heroFile] : [])];
|
|
138
|
+
const job = await ctx.jobs.start(name, args, 'build world > verify', ctx.by);
|
|
139
|
+
return { slug: name, title: shape.title, places: places ?? shape.places, language: lang, drawings: seeded.drawings, job };
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
defineAction({
|
|
144
|
+
name: 'idea_hear',
|
|
145
|
+
description: 'Hear a spoken idea (a recording, base64) and return it as text, in the language it was said (Muse Voice Transcribe).',
|
|
146
|
+
input: z.object({ name: z.string().min(1).max(120), data: z.string().min(10) }),
|
|
147
|
+
async run({ name, data }, ctx) {
|
|
148
|
+
if (!SOUND.test(name)) throw new ActionError(400, `${name}: a recording is webm, m4a, mp3, wav or ogg`);
|
|
149
|
+
const audio = decode(bytesOf(data));
|
|
150
|
+
const said: string[] = [];
|
|
151
|
+
for (const [i, piece] of pieces(pcm24k(audio)).entries()) said.push(await transcribe(startMemo(ctx.root), `idea:${i}`, piece, 8));
|
|
152
|
+
return { text: said.join(' ').replace(/\s+/g, ' ').trim() };
|
|
153
|
+
},
|
|
154
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// What a world-wide build step works from and what it made so far, card by card, for the Studio's live step view
|
|
2
|
+
// (a click on that step's segment of the build bar): item icons (where SAM cuts each one from, the icon), plates
|
|
3
|
+
// (what goes, the place without it), weather and reactions (each place, what was read for it). Free, reads files only.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { maskFromPng } from '../image/mask.ts';
|
|
7
|
+
import { maskBox } from '../image/raster.ts';
|
|
8
|
+
import { Reactions } from '../play/reactions.ts';
|
|
9
|
+
import { Weather } from '../play/weather.ts';
|
|
10
|
+
import { itemSources, ItemsInfo } from '../scene/icons.ts';
|
|
11
|
+
import { removableThings } from '../story/engine.ts';
|
|
12
|
+
import { Story } from '../story/schema.ts';
|
|
13
|
+
import { WorldFiles } from '../world/files.ts';
|
|
14
|
+
import { Slug } from '../world/schema.ts';
|
|
15
|
+
import { defineAction } from './registry.ts';
|
|
16
|
+
|
|
17
|
+
export type Pic = { file: string; v: number | null };
|
|
18
|
+
/** A crop of a place's painting: the box as fractions of the picture, the SAM mask that was cut, and what lies over it. */
|
|
19
|
+
export type Shot = { painting: Pic; box?: [number, number, number, number]; mask?: Pic; over?: Pic };
|
|
20
|
+
export type WorkCard = {
|
|
21
|
+
key: string; title: string; place?: string;
|
|
22
|
+
/** What the step works from, and what it made (null: not yet). */
|
|
23
|
+
from: Shot | null; made: Shot | Pic | null;
|
|
24
|
+
/** How it is made or what was read, in a few words each. */
|
|
25
|
+
words: string[];
|
|
26
|
+
/** When its result was written (ms), to tell this run's result from the earlier build's. */
|
|
27
|
+
at: number | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const WORK_STEPS = ['icons', 'plates', 'weather', 'reactions'] as const;
|
|
31
|
+
|
|
32
|
+
const stamp = (file: string) => (fs.existsSync(file) ? Math.round(fs.statSync(file).mtimeMs) : null);
|
|
33
|
+
const boxes = new Map<string, [number, number, number, number] | undefined>();
|
|
34
|
+
|
|
35
|
+
/** The thing's box in a mask PNG as fractions, grown so there is some of the place around it; cached by file time. */
|
|
36
|
+
async function boxOf(file: string): Promise<[number, number, number, number] | undefined> {
|
|
37
|
+
const at = `${file}@${stamp(file)}`;
|
|
38
|
+
if (boxes.has(at)) return boxes.get(at);
|
|
39
|
+
let out: [number, number, number, number] | undefined;
|
|
40
|
+
if (fs.existsSync(file)) {
|
|
41
|
+
const m = await maskFromPng(fs.readFileSync(file));
|
|
42
|
+
const b = maskBox(m);
|
|
43
|
+
if (b) {
|
|
44
|
+
// A square-ish view at least a fifth of the picture wide, so a small thing is still seen where it stands.
|
|
45
|
+
const side = Math.max(b[2] - b[0], b[3] - b[1], m.width / 5) * 1.5;
|
|
46
|
+
const [cx, cy] = [(b[0] + b[2]) / 2, (b[1] + b[3]) / 2];
|
|
47
|
+
const w = Math.min(m.width, side), h = Math.min(m.height, side);
|
|
48
|
+
const x0 = Math.max(0, Math.min(m.width - w, cx - w / 2)), y0 = Math.max(0, Math.min(m.height - h, cy - h / 2));
|
|
49
|
+
out = [x0 / m.width, y0 / m.height, (x0 + w) / m.width, (y0 + h) / m.height];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
boxes.set(at, out);
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const pic = (files: WorldFiles, rel: string): Pic | null => (fs.existsSync(files.path(rel)) ? { file: rel, v: stamp(files.path(rel)) } : null);
|
|
57
|
+
const readJson = <T,>(files: WorldFiles, rel: string, parse: (x: unknown) => T): T | null => {
|
|
58
|
+
try {
|
|
59
|
+
return fs.existsSync(files.path(rel)) ? parse(files.readJson(files.path(rel))) : null;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** The painting where a thing stands, cropped to it, with the SAM mask that cut it out. */
|
|
66
|
+
async function thingShot(files: WorldFiles, place: string, thing: string): Promise<Shot | null> {
|
|
67
|
+
const painting = pic(files, `places/${place}/painting.png`);
|
|
68
|
+
if (!painting) return null;
|
|
69
|
+
const mask = pic(files, `places/${place}/masks/${thing}.png`);
|
|
70
|
+
return { painting, ...(mask && { mask, box: await boxOf(files.path(mask.file)) }) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function stepWork(files: WorldFiles, step: string): Promise<WorkCard[]> {
|
|
74
|
+
const story = readJson(files, 'story.json', (x) => Story.parse(x));
|
|
75
|
+
if (step === 'icons') {
|
|
76
|
+
if (!story) return [];
|
|
77
|
+
const sources = itemSources(story);
|
|
78
|
+
const info = readJson(files, 'items/items.json', (x) => ItemsInfo.parse(x)) ?? {};
|
|
79
|
+
return Promise.all(story.items.map(async (item) => {
|
|
80
|
+
const from = sources[item.key];
|
|
81
|
+
const icon = pic(files, `items/${item.key}.png`);
|
|
82
|
+
const how = info[item.key]?.how;
|
|
83
|
+
return {
|
|
84
|
+
key: item.key, title: item.name, place: from?.place,
|
|
85
|
+
from: from ? await thingShot(files, from.place, from.thing) : null,
|
|
86
|
+
made: icon,
|
|
87
|
+
words: [from ? `picked up in ${from.place} (${from.thing})` : 'handed over: Muse Image paints it',
|
|
88
|
+
...(how ? [how === 'cut' ? 'cut from the painting with SAM\'s mask' : how === 'redraw' ? 'too small: Muse Image redrew the cut-out, SAM cut it' : 'painted by Muse Image, cut out by SAM'] : [])],
|
|
89
|
+
at: icon?.v ?? null,
|
|
90
|
+
};
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
if (step === 'plates') {
|
|
94
|
+
if (!story) return [];
|
|
95
|
+
const takes = removableThings(story).map(async ({ place, thing }) => {
|
|
96
|
+
const from = await thingShot(files, place, thing);
|
|
97
|
+
const plate = pic(files, `places/${place}/plates/${thing}.png`);
|
|
98
|
+
return { key: `${place}/${thing}`, title: `${thing} taken`, place, from,
|
|
99
|
+
made: plate && from ? { ...from, mask: undefined, over: plate } : null,
|
|
100
|
+
words: [`${place}: the place once the hero takes the ${thing}`, 'Muse Image paints over where it stood'], at: plate?.v ?? null };
|
|
101
|
+
});
|
|
102
|
+
const changes = story.changes.map(async (c, i) => {
|
|
103
|
+
const from = await thingShot(files, c.place, c.thing);
|
|
104
|
+
const plate = pic(files, `places/${c.place}/changes/${i}.png`);
|
|
105
|
+
return { key: `${c.place}/change-${i}`, title: `${c.thing} changes`, place: c.place, from,
|
|
106
|
+
made: plate && from ? { ...from, mask: undefined, over: plate } : null,
|
|
107
|
+
words: [`${c.place}: ${c.looks}`, `once ${c.when.join(', ')}`], at: plate?.v ?? null };
|
|
108
|
+
});
|
|
109
|
+
return Promise.all([...takes, ...changes]);
|
|
110
|
+
}
|
|
111
|
+
if (step === 'weather' || step === 'reactions') {
|
|
112
|
+
return files.world().places.map((p) => {
|
|
113
|
+
const painting = pic(files, `places/${p.key}/painting.png`);
|
|
114
|
+
const from = painting && { painting };
|
|
115
|
+
if (step === 'weather') {
|
|
116
|
+
const rel = `places/${p.key}/weather.json`;
|
|
117
|
+
const w = readJson(files, rel, (x) => Weather.parse(x));
|
|
118
|
+
const ripples = pic(files, `places/${p.key}/weather/ripples.png`);
|
|
119
|
+
const words = w ? [
|
|
120
|
+
w.fall === 'none' ? 'nothing falls' : `${w.fall}, ${Math.round(w.strength * 100)}%${w.wind ? `, wind ${w.wind > 0 ? 'right' : 'left'}` : ''}`,
|
|
121
|
+
...(w.fog ? [`fog ${Math.round(w.fog * 100)}%`] : []),
|
|
122
|
+
...(ripples ? ['rings on water and puddles'] : []),
|
|
123
|
+
...w.drips.map((d) => `drips from ${d.phrase}`),
|
|
124
|
+
] : [];
|
|
125
|
+
return { key: p.key, title: p.key, place: p.key, from, made: w && painting ? { painting, ...(ripples && { mask: ripples }) } : null, words, at: stamp(files.path(rel)) };
|
|
126
|
+
}
|
|
127
|
+
const rel = `places/${p.key}/reactions.json`;
|
|
128
|
+
const r = readJson(files, rel, (x) => Reactions.parse(x));
|
|
129
|
+
const words = r ? [
|
|
130
|
+
...r.areas.map((a) => `${a.key}: ${[a.step && `${a.step.kind} under each step`, a.enter && `${a.enter.kind} on entering`].filter(Boolean).join(', ')}`),
|
|
131
|
+
...r.interactions.map((i) => `${i.thing} (${i.action}): ${i.burst.kind}`),
|
|
132
|
+
] : [];
|
|
133
|
+
if (r && !words.length) words.push('nothing reacts here (a clean, still place)');
|
|
134
|
+
return { key: p.key, title: p.key, place: p.key, from, made: r && painting ? { painting } : null, words, at: stamp(files.path(rel)) };
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
defineAction({
|
|
141
|
+
name: 'step_work',
|
|
142
|
+
reads: true,
|
|
143
|
+
description: 'What a world-wide build step (icons, plates, weather, reactions) works from and what it made so far, card by card: '
|
|
144
|
+
+ 'for icons each item with the painting crop and SAM mask it is cut from and its icon; for plates each taken or changed thing and its plate; '
|
|
145
|
+
+ 'for weather and reactions each place and what was read for it. Free.',
|
|
146
|
+
input: z.object({ slug: Slug, step: z.enum(WORK_STEPS) }),
|
|
147
|
+
async run({ slug, step }, ctx) {
|
|
148
|
+
return stepWork(new WorldFiles(slug, ctx.root), step);
|
|
149
|
+
},
|
|
150
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// The story: places and how exits join them, locks, items, conversations, changes and endings, with the
|
|
2
|
+
// checker's verdict and a shortest route to each ending. Lines are reworded in place (story_text); anything
|
|
3
|
+
// that changes what the player can do goes back to Spark as a wish (story_redo), so the checker always runs.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { storyLines, Voices } from '../sound/voices.ts';
|
|
7
|
+
import { brevityIssues, checkStory, paintingIssues, questionIssues } from '../story/check.ts';
|
|
8
|
+
import { applyTexts, readStoryFixes, StoryFixes, storyFixesFile, textAt } from '../story/fixes.ts';
|
|
9
|
+
import { Story } from '../story/schema.ts';
|
|
10
|
+
import { History } from '../studio/history.ts';
|
|
11
|
+
import { agentBlocked } from '../studio/locks.ts';
|
|
12
|
+
import { WorldFiles } from '../world/files.ts';
|
|
13
|
+
import { Slug } from '../world/schema.ts';
|
|
14
|
+
import { ActionError, defineAction, type ActionContext } from './registry.ts';
|
|
15
|
+
|
|
16
|
+
function open(slug: string, ctx: ActionContext) {
|
|
17
|
+
const files = new WorldFiles(slug, ctx.root);
|
|
18
|
+
if (!fs.existsSync(files.path('world.json'))) throw new ActionError(404, `no world ${slug} yet`);
|
|
19
|
+
return files;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const readStory = (files: WorldFiles) => (fs.existsSync(files.path('story.json')) ? Story.parse(files.readJson(files.path('story.json'))) : null);
|
|
23
|
+
|
|
24
|
+
defineAction({
|
|
25
|
+
name: 'story_status',
|
|
26
|
+
reads: true,
|
|
27
|
+
description: 'The story of a world: places with their exits and things, locks, items, interactions, conversations, changes, optional NPC patrols and endings (story). NPC stages use flags/when; moveNpc effects relocate them after events. Includes '
|
|
28
|
+
+ 'the checker\'s issues and a shortest route to each ending, lines over the word limits (long), the person\'s wish and reworded lines (fixes), '
|
|
29
|
+
+ 'spoken lines not voiced yet (unvoiced: run_step voices) and changes whose plate is not painted for the current wording (unpainted: run_step plates). '
|
|
30
|
+
+ 'Text paths for story_text are dotted, like "dialogues.0.lines.2.says".',
|
|
31
|
+
input: z.object({ slug: Slug }),
|
|
32
|
+
async run({ slug }, ctx) {
|
|
33
|
+
const files = open(slug, ctx);
|
|
34
|
+
const world = files.world();
|
|
35
|
+
const places = world.places.map((p) => ({ key: p.key, shows: p.shows, exits: p.exits, things: p.things.map((t) => ({ key: t.key, role: t.role })) }));
|
|
36
|
+
const story = readStory(files);
|
|
37
|
+
const fixes = readStoryFixes(files);
|
|
38
|
+
if (!story) return { start: world.start, hero: world.hero.key, places, story: null, fixes, issues: [], routes: [], states: 0, long: [], unvoiced: [], unpainted: [] };
|
|
39
|
+
const report = checkStory(world, story);
|
|
40
|
+
const voices = fs.existsSync(files.path('voices.json')) ? Voices.safeParse(files.readJson(files.path('voices.json'))) : null;
|
|
41
|
+
const said = new Set(voices?.success ? voices.data.lines.filter((l) => !l.flags.some((f) => f.startsWith('heard')) && fs.existsSync(files.path(l.file))).map((l) => JSON.stringify([l.speaker, l.text])) : []);
|
|
42
|
+
const unvoiced = storyLines(world, story, world.audience === 'kids').filter((l) => !said.has(JSON.stringify([l.speaker, l.text]))).map((l) => l.text);
|
|
43
|
+
const unpainted = story.changes.flatMap((c, i) => {
|
|
44
|
+
const plate = files.path('places', c.place, 'changes', `${i}.png`);
|
|
45
|
+
const worded = fixes.texts[`changes.${i}.looks`]?.at;
|
|
46
|
+
const painted = fs.existsSync(plate) ? fs.statSync(plate).mtimeMs : 0;
|
|
47
|
+
return fs.existsSync(files.path('places', c.place, 'painting.png')) && (!painted || (worded && Date.parse(worded) > painted)) ? [i] : [];
|
|
48
|
+
});
|
|
49
|
+
return {
|
|
50
|
+
start: world.start, hero: world.hero.key, places, story, fixes,
|
|
51
|
+
issues: report.issues, states: report.states, routes: report.routes.map((r) => ({ ending: r.ending, steps: r.steps })),
|
|
52
|
+
long: brevityIssues(story), unanswerable: questionIssues(story), unseen: paintingIssues(world, story), unvoiced, unpainted,
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
defineAction({
|
|
58
|
+
name: 'story_text',
|
|
59
|
+
description: 'Reword one line of the story: the intro, what the hero says at a thing or a lock, an NPC line, an answer, an item name, an ending, or how a change looks. '
|
|
60
|
+
+ 'Path is dotted into story.json ("intro", "interactions.3.say", "dialogues.0.lines.1.choices.0.text", "endings.1.text", "changes.0.looks"). '
|
|
61
|
+
+ 'Only wording: what the player can do stays the same. Kept across rebuilds. Spoken lines need run_step voices afterwards, a change\'s looks run_step plates.',
|
|
62
|
+
input: z.object({ slug: Slug, path: z.string().min(1), text: z.string().trim().min(1).max(400) }),
|
|
63
|
+
async run({ slug, path, text }, ctx) {
|
|
64
|
+
const files = open(slug, ctx);
|
|
65
|
+
const blocked = agentBlocked(files.dir, ctx.by);
|
|
66
|
+
if (blocked) throw new ActionError(423, blocked);
|
|
67
|
+
const story = readStory(files);
|
|
68
|
+
if (!story) throw new ActionError(409, `${slug} has no story yet: run_step story first`);
|
|
69
|
+
const before = textAt(story, path);
|
|
70
|
+
if (before === null) throw new ActionError(400, `"${path}" is not a line of the story; story_status lists them`);
|
|
71
|
+
const busy = ctx.jobs.busy(slug);
|
|
72
|
+
if (busy) throw new ActionError(409, `${busy.label} is ${busy.state}; wait before editing story text`);
|
|
73
|
+
new History(files.dir).snapshot(`story text ${path}`, ctx.by);
|
|
74
|
+
const fixes = readStoryFixes(files);
|
|
75
|
+
fixes.texts[path] = { text, at: new Date().toISOString() };
|
|
76
|
+
files.write(storyFixesFile(files), StoryFixes.parse(fixes));
|
|
77
|
+
const now = applyTexts(story, { texts: { [path]: fixes.texts[path] } }).story;
|
|
78
|
+
files.write(files.path('story.json'), now);
|
|
79
|
+
const spoken = storyLines(files.world(), now).some((l) => l.text === text);
|
|
80
|
+
const [kind, i] = path.split('.');
|
|
81
|
+
return { path, before, text, spoken, plate: kind === 'changes' ? now.changes[Number(i)].place : null };
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
defineAction({
|
|
86
|
+
name: 'story_redo',
|
|
87
|
+
description: 'Have Spark write the story again to a wish ("the ferrywoman should want something in return", "after receiving the key she goes to the garden and thanks me there"); '
|
|
88
|
+
+ 'NPC stages use ordinary flags and conditional dialogue; patrols are optional, and moveNpc effects trigger travel through existing exits. '
|
|
89
|
+
+ 'the checker plays every state, so it stays winnable. Reworded lines are dropped, since the lines change. Without a wish, goes back to Spark\'s first story. '
|
|
90
|
+
+ 'Runs as a job; afterwards inspect story_status/cast_status. New walkers may need model/sprites; new lines need voices/faces and visual changes need plates.',
|
|
91
|
+
input: z.object({ slug: Slug, wish: z.string().trim().min(5).max(600).optional() }),
|
|
92
|
+
job: true,
|
|
93
|
+
async run({ slug, wish }, ctx) {
|
|
94
|
+
const files = open(slug, ctx);
|
|
95
|
+
const blocked = agentBlocked(files.dir, ctx.by);
|
|
96
|
+
if (blocked) throw new ActionError(423, blocked);
|
|
97
|
+
return ctx.jobs.start(slug, ['story', slug], wish ? 'rewrite story' : 'first story', ctx.by, () => {
|
|
98
|
+
files.write(storyFixesFile(files), StoryFixes.parse({ ...(wish && { wish }), texts: {} }));
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Setup checks and moving the view. Storing a key is deliberately not an action: keys are typed only
|
|
2
|
+
// into `diorama setup` in a terminal, never into the GUI or through the agent.
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { runChecks } from '../setup/checks.ts';
|
|
5
|
+
import { Slug } from '../world/schema.ts';
|
|
6
|
+
import { defineAction } from './registry.ts';
|
|
7
|
+
import { soundQuality } from '../sound/cost.ts';
|
|
8
|
+
import { writeSoundQuality } from '../sound/settings.ts';
|
|
9
|
+
import { blenderEnv, blenderState, writeBlenderSettings } from '../model/blender.ts';
|
|
10
|
+
import { facesState, writeFacesSettings } from '../scene/faces-settings.ts';
|
|
11
|
+
|
|
12
|
+
const audioState = () => ({ quality: soundQuality(), env: process.env.DIORAMA_SOUND ?? null });
|
|
13
|
+
|
|
14
|
+
defineAction({
|
|
15
|
+
name: 'audio_get',
|
|
16
|
+
reads: true,
|
|
17
|
+
description: 'Audio quality for new generations: final or draft, and any DIORAMA_SOUND override.',
|
|
18
|
+
input: z.object({}),
|
|
19
|
+
async run() { return audioState(); },
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
defineAction({
|
|
23
|
+
name: 'audio_set',
|
|
24
|
+
description: 'Save the machine-wide audio quality for future sound and voices generations. Existing recordings are unchanged.',
|
|
25
|
+
input: z.object({ quality: z.enum(['final', 'draft']) }),
|
|
26
|
+
async run({ quality }) {
|
|
27
|
+
if (process.env.DIORAMA_SOUND !== undefined) throw new Error('DIORAMA_SOUND overrides Setup. Unset it and restart Studio to choose audio quality here.');
|
|
28
|
+
writeSoundQuality(quality);
|
|
29
|
+
return audioState();
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
defineAction({
|
|
34
|
+
name: 'blender_get',
|
|
35
|
+
reads: true,
|
|
36
|
+
description: 'Whether a local Blender relaxes 3D figures\' rest pose (arms down, feet under the hips) before they walk: found or not, on or off.',
|
|
37
|
+
input: z.object({}),
|
|
38
|
+
async run() { return blenderState(); },
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
defineAction({
|
|
42
|
+
name: 'blender_set',
|
|
43
|
+
description: 'Turn the local Blender step for 3D figures on or off for this machine. On: new models get a calm rest pose; cast_relax fixes existing ones.',
|
|
44
|
+
input: z.object({ enabled: z.boolean() }),
|
|
45
|
+
async run({ enabled }) {
|
|
46
|
+
if (blenderEnv() !== undefined) throw new Error('DIORAMA_BLENDER overrides Setup. Unset it and restart Studio to switch Blender here.');
|
|
47
|
+
writeBlenderSettings({ enabled });
|
|
48
|
+
return blenderState();
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
defineAction({
|
|
53
|
+
name: 'faces_get',
|
|
54
|
+
reads: true,
|
|
55
|
+
description: 'Whether builds prepare talking mouths (the faces step) on this machine. Off by default.',
|
|
56
|
+
input: z.object({}),
|
|
57
|
+
async run() { return facesState(); },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
defineAction({
|
|
61
|
+
name: 'faces_set',
|
|
62
|
+
description: 'Turn talking mouths on or off for builds on this machine (the person decides this in Setup; ask before changing it).',
|
|
63
|
+
input: z.object({ enabled: z.boolean() }),
|
|
64
|
+
async run({ enabled }) {
|
|
65
|
+
if (facesState().env !== null) throw new Error('DIORAMA_FACES overrides Setup. Unset it and restart Studio to switch it here.');
|
|
66
|
+
writeFacesSettings(enabled);
|
|
67
|
+
return facesState();
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
defineAction({
|
|
72
|
+
name: 'setup_status',
|
|
73
|
+
reads: true,
|
|
74
|
+
description: 'What this machine has and still needs (Node, Meta key, Muse Code, Chromium, ElevenLabs, Python, publishing). Never returns a key, only where it comes from.',
|
|
75
|
+
input: z.object({}),
|
|
76
|
+
async run() {
|
|
77
|
+
return runChecks();
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
defineAction({
|
|
82
|
+
name: 'setup_check',
|
|
83
|
+
description: 'Like setup_status, plus one live call with the Meta key to prove it works.',
|
|
84
|
+
input: z.object({}),
|
|
85
|
+
async run() {
|
|
86
|
+
return runChecks({ live: true });
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
defineAction({
|
|
91
|
+
name: 'ui_focus',
|
|
92
|
+
description: 'Point the Studio view at a world, a screen or a place, so the person sees what the agent is talking about (when they follow the agent).',
|
|
93
|
+
input: z.object({
|
|
94
|
+
slug: Slug.optional(),
|
|
95
|
+
view: z.enum(['worlds', 'start', 'setup', 'workshop', 'story', 'cast', 'play']).optional()
|
|
96
|
+
.describe('worlds: leave the world for the list of all worlds (start is the same); setup: keys and tools; workshop: the world\'s places; story, cast; play: the game'),
|
|
97
|
+
place: Slug.optional(),
|
|
98
|
+
}),
|
|
99
|
+
async run(input, ctx) {
|
|
100
|
+
// Pointing at a world or a place never means leaving it: "start" there reads as "begin at this place".
|
|
101
|
+
const view = (input.view === 'start' || input.view === 'worlds') && (input.slug || input.place) ? 'workshop' : input.view === 'worlds' ? 'start' : input.view;
|
|
102
|
+
ctx.events.publish('ui.focus', { ...input, ...(view && { view }), world: input.slug, by: ctx.by });
|
|
103
|
+
return { ok: true };
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
defineAction({
|
|
108
|
+
name: 'play_reload',
|
|
109
|
+
description: 'Reload the game the person is playing in the Studio, so it shows your change (opens it if they follow you and it is closed). '
|
|
110
|
+
+ 'Call it after a change they should see in play, instead of asking them to refresh.',
|
|
111
|
+
input: z.object({ slug: Slug, place: Slug.optional() }),
|
|
112
|
+
async run({ slug, place }, ctx) {
|
|
113
|
+
ctx.events.publish('ui.focus', { slug, world: slug, view: 'play', reload: true, ...(place && { place }), by: ctx.by });
|
|
114
|
+
return { ok: true };
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
defineAction({
|
|
119
|
+
name: 'ask_person',
|
|
120
|
+
reads: true,
|
|
121
|
+
description: 'Ask the person a question with a few answers to tap (yes/no, which of two fixes, go ahead or not), shown in the Studio as a card with buttons. '
|
|
122
|
+
+ 'Put the question here and not again in your text, then end your turn: their tap arrives as their next message, the answer\'s words exactly.',
|
|
123
|
+
input: z.object({
|
|
124
|
+
slug: Slug,
|
|
125
|
+
question: z.string().min(3).max(300).describe('One short question, in the language of the person\'s newest message'),
|
|
126
|
+
options: z.array(z.string().min(1).max(60)).min(2).max(4).describe('2 to 4 short answers, in that language: "Yes, rebuild it" and "No, leave it" rather than bare yes/no'),
|
|
127
|
+
recommended: z.number().int().min(0).max(3).optional().describe('Index of the answer you advise'),
|
|
128
|
+
}),
|
|
129
|
+
async run(input, ctx) {
|
|
130
|
+
ctx.events.publish('ui.ask', { ...input, world: input.slug, by: ctx.by });
|
|
131
|
+
return { asked: true, next: 'End your turn now. The person\'s tap comes back as their next message.' };
|
|
132
|
+
},
|
|
133
|
+
});
|