@gtanczyk/diorama 0.0.0-stage → 0.1.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 +152 -0
- package/app/src/actions/build.ts +158 -0
- package/app/src/actions/cast.ts +248 -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 +31 -0
- package/app/src/actions/items.ts +94 -0
- package/app/src/actions/kids.ts +215 -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 +289 -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 +142 -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 +142 -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 +36 -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 +192 -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 +90 -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 +91 -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 +31 -0
- package/app/src/scene/cast.ts +192 -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 +55 -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 +375 -0
- package/app/src/scene/scale.ts +148 -0
- package/app/src/scene/shadows.ts +207 -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 +417 -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 +554 -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 +421 -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/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 +677 -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 +1328 -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 +3751 -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,198 @@
|
|
|
1
|
+
// Children's drawings as the world: photos of drawings on paper, each read by Spark into the hero, an NPC,
|
|
2
|
+
// an item, a prop or a place, kept with the world (drawings/) and used by world, paint, cast and icons.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import sharp from 'sharp';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import type { Memo } from '../meta/memo.ts';
|
|
8
|
+
import { askSparkJson } from '../meta/spark.ts';
|
|
9
|
+
import type { WorldFiles } from './files.ts';
|
|
10
|
+
import { Slug, type Place, type World } from './schema.ts';
|
|
11
|
+
|
|
12
|
+
export const PICTURE = /\.(png|jpe?g|webp|heic|heif|gif|tiff?)$/i;
|
|
13
|
+
|
|
14
|
+
export const Drawn = z.object({
|
|
15
|
+
file: z.string(),
|
|
16
|
+
/** player: another hero, for another gamepad (only the grown-up marks one, kinds.json); it walks beside the hero, in no place. */
|
|
17
|
+
kind: z.enum(['hero', 'npc', 'player', 'item', 'prop', 'place']),
|
|
18
|
+
key: Slug,
|
|
19
|
+
looks: z.string().min(3),
|
|
20
|
+
});
|
|
21
|
+
export type Drawn = z.infer<typeof Drawn>;
|
|
22
|
+
|
|
23
|
+
/** Upright, paper evened out, at most 1600 px on the long side: what Spark and Muse get. */
|
|
24
|
+
export function normaliseDrawing(photo: Buffer): Promise<Buffer> {
|
|
25
|
+
return sharp(photo).rotate().resize(1600, 1600, { fit: 'inside', withoutEnlargement: true }).normalise().png().toBuffer();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The drawings kept with a world, in name order (drawings/*.png). */
|
|
29
|
+
export function drawingFiles(files: WorldFiles): string[] {
|
|
30
|
+
const dir = files.path('drawings');
|
|
31
|
+
return fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith('.png')).sort() : [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A file's name as kept: lowercase kebab, short. */
|
|
35
|
+
export const keptName = (file: string) => path.basename(file).replace(/\.[^.]+$/, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 24) || 'x';
|
|
36
|
+
|
|
37
|
+
/** Copies photos of drawings from a folder into the world, normalised, numbered after the ones already there. */
|
|
38
|
+
export async function keepDrawings(files: WorldFiles, from: string): Promise<string[]> {
|
|
39
|
+
const sources = fs.statSync(from).isDirectory()
|
|
40
|
+
? fs.readdirSync(from).filter((f) => PICTURE.test(f)).sort().map((f) => path.join(from, f))
|
|
41
|
+
: [from];
|
|
42
|
+
const kept: string[] = [];
|
|
43
|
+
for (const source of sources) {
|
|
44
|
+
const stem = keptName(source);
|
|
45
|
+
// Building again with the same folder keeps each drawing once.
|
|
46
|
+
if (drawingFiles(files).some((f) => f.slice(3) === `${stem}.png`)) continue;
|
|
47
|
+
const name = `${String(drawingFiles(files).length + 1).padStart(2, '0')}-${stem}.png`;
|
|
48
|
+
files.write(files.path('drawings', name), await normaliseDrawing(fs.readFileSync(source)));
|
|
49
|
+
kept.push(name);
|
|
50
|
+
}
|
|
51
|
+
return kept;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What each drawing was made into, from drawings.json; empty for a world without drawings. */
|
|
55
|
+
export function readDrawn(files: WorldFiles): Drawn[] {
|
|
56
|
+
const file = files.path('drawings.json');
|
|
57
|
+
if (!fs.existsSync(file)) return [];
|
|
58
|
+
const parsed = z.array(Drawn).safeParse(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
59
|
+
return parsed.success ? parsed.data : [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The drawing a character, item or place was made from, if a child drew it. */
|
|
63
|
+
export function drawingFor(files: WorldFiles, key: string): Buffer | undefined {
|
|
64
|
+
const drawn = readDrawn(files).find((d) => d.key === key);
|
|
65
|
+
return drawn && fs.existsSync(files.path('drawings', drawn.file)) ? fs.readFileSync(files.path('drawings', drawn.file)) : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const Kind = Drawn.shape.kind;
|
|
69
|
+
export type Kind = z.infer<typeof Kind>;
|
|
70
|
+
|
|
71
|
+
/** What the grown-up said a drawing is (drawings/kinds.json, by file), over what Spark sees. */
|
|
72
|
+
export function readKinds(files: WorldFiles): Record<string, Kind> {
|
|
73
|
+
const file = files.path('drawings', 'kinds.json');
|
|
74
|
+
const parsed = z.record(z.string(), Kind).safeParse(fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {});
|
|
75
|
+
return parsed.success ? parsed.data : {};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A kids' world looks like this, so a character painted from a drawing before the world exists is the one the game uses. */
|
|
79
|
+
export const KIDS_STYLE = "the children's own drawings come alive: wax crayon and felt-tip texture, solidly coloured, bold simple rounded shapes with thick dark outlines, bright clean colours, soft sunny light, friendly and readable at a glance";
|
|
80
|
+
|
|
81
|
+
const KIND_TEXT = `- kind: "character" (someone: a person, an animal, a creature), "item" (a small thing to pick up and carry), "prop" (a big thing that stays in a place) or "place" (a whole scene: a landscape, a room, a house with its surroundings)
|
|
82
|
+
- key: a kebab-case English name for it ("green-dragon", "blue-hat", "mushroom-forest")
|
|
83
|
+
- looks: what the child drew, as a description a painter can follow: shape, every colour, clothes, face, what it holds. Describe the drawing kindly as the real thing it shows (a "green dragon with orange wings and a round light-green belly", not "scribbles"). For a character or a thing, only it, not the sun, sky or ground drawn around it. For a place: what it shows front to back.
|
|
84
|
+
Ignore any name written on the paper, the paper itself and the table.`;
|
|
85
|
+
|
|
86
|
+
/** Spark says what one drawing is, as soon as its photo arrives (no story needed yet). */
|
|
87
|
+
export async function readDrawing(memo: Memo, image: Buffer, file: string, taken: string[] = []): Promise<Drawn> {
|
|
88
|
+
const prompt = `A child of 5 or 6 drew this picture for a 2D point-and-click adventure game the class is making together. Say what it is in the game:
|
|
89
|
+
${KIND_TEXT}${taken.length ? `\nKeys already used by other drawings: ${taken.join(', ')}. Use one of them only when this drawing shows that same thing again.` : ''}
|
|
90
|
+
|
|
91
|
+
Reply with JSON only: {"kind": "...", "key": "...", "looks": "..."}`;
|
|
92
|
+
let ask = prompt;
|
|
93
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
94
|
+
const answer = await askSparkJson<{ kind?: string; key?: string; looks?: string }>(memo, { label: `drawing:${file}#${attempt}`, prompt: ask, images: [image], personal: true });
|
|
95
|
+
const parsed = Drawn.safeParse({ file, kind: answer?.kind === 'character' ? 'npc' : answer?.kind, key: answer?.key, looks: answer?.looks });
|
|
96
|
+
if (parsed.success) return parsed.data;
|
|
97
|
+
ask = `${prompt}\n\nYour last answer was:\n${JSON.stringify(answer)}\n\nFix: ${parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')}`;
|
|
98
|
+
}
|
|
99
|
+
throw new Error(`Spark could not read the drawing ${file} in 3 tries`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Every kept drawing read (each once, kept in drawings.json), with what the grown-up said over what Spark saw. */
|
|
103
|
+
export async function readDrawings(memo: Memo, files: WorldFiles): Promise<Drawn[]> {
|
|
104
|
+
const names = drawingFiles(files);
|
|
105
|
+
const known = new Map(readDrawn(files).map((d) => [d.file, d]));
|
|
106
|
+
// All at once (Spark's lane cap paces them): a class's 25 drawings read one after another took 6 minutes.
|
|
107
|
+
// A drawing read now keeps its own key, so two children's princesses are two princesses; ones read before keep theirs.
|
|
108
|
+
const read = await Promise.all(names.map((file) => known.get(file) ?? readDrawing(memo, fs.readFileSync(files.path('drawings', file)), file)));
|
|
109
|
+
const drawn = ownKeys(read, new Set(names.filter((f) => known.has(f))));
|
|
110
|
+
const kinds = readKinds(files);
|
|
111
|
+
for (const d of drawn) if (kinds[d.file]) d.kind = kinds[d.file];
|
|
112
|
+
return oneHero(drawn, drawn.find((d) => kinds[d.file] === 'hero')?.key);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A new drawing whose key another drawing has gets a number ("princess-2"); drawings in `kept` keep their keys as they are. */
|
|
116
|
+
export function ownKeys(drawn: Drawn[], kept = new Set<string>()): Drawn[] {
|
|
117
|
+
const taken = new Set(drawn.filter((d) => kept.has(d.file)).map((d) => d.key));
|
|
118
|
+
return drawn.map((d) => {
|
|
119
|
+
if (kept.has(d.file)) return d;
|
|
120
|
+
let key = d.key;
|
|
121
|
+
for (let n = 2; taken.has(key); n++) key = `${d.key}-${n}`;
|
|
122
|
+
taken.add(key);
|
|
123
|
+
return key === d.key ? d : { ...d, key };
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* At most `places` drawn places stay places, the first ones; a drawn place over that becomes a big prop inside one.
|
|
129
|
+
* Every drawn place was a place to paint, mask and check, so a class's 8 place drawings made an 8-place, 40-minute build.
|
|
130
|
+
*/
|
|
131
|
+
export function fitPlaces(drawn: Drawn[], places: number): Drawn[] {
|
|
132
|
+
let kept = 0;
|
|
133
|
+
return drawn.map((d) => {
|
|
134
|
+
if (d.kind !== 'place' || ++kept <= places) return d;
|
|
135
|
+
return { ...d, kind: 'prop', looks: `${d.looks} (a child drew it as a whole place; here it is one big thing in a place: a picture, a model, a view through a window)` };
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Exactly one character leads: the one asked for, else the one already leading, else the first drawn. */
|
|
140
|
+
export function oneHero(drawn: Drawn[], hero?: string): Drawn[] {
|
|
141
|
+
const characters = drawn.filter((d) => d.kind === 'hero' || d.kind === 'npc');
|
|
142
|
+
const lead = (hero && characters.find((d) => d.key === hero)) ?? characters.find((d) => d.kind === 'hero') ?? characters[0];
|
|
143
|
+
for (const d of characters) d.kind = lead && d.key === lead.key ? 'hero' : 'npc';
|
|
144
|
+
return drawn;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The other heroes, one per extra gamepad: characters a child drew and the grown-up marked "player". They are painted
|
|
149
|
+
* and rigged with the cast and walk beside the hero; they belong to no place and say nothing in the story.
|
|
150
|
+
*/
|
|
151
|
+
export function players(files: WorldFiles): Array<{ key: string; looks: string }> {
|
|
152
|
+
const world = files.world();
|
|
153
|
+
const taken = new Set([world.hero.key, ...world.places.flatMap((p) => p.things.map((t) => t.key))]);
|
|
154
|
+
return [...new Map(readDrawn(files).filter((d) => d.kind === 'player' && !taken.has(d.key)).map((d) => [d.key, { key: d.key, looks: d.looks }])).values()];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** What the world designer is told: every drawing is in the world, under its key, as drawn. */
|
|
158
|
+
export function drawingsBrief(drawn: Drawn[], style = KIDS_STYLE, chooseHero = false): string {
|
|
159
|
+
const once = [...new Map(drawn.map((d) => [d.key, d])).values()];
|
|
160
|
+
const line = (d: Drawn) => chooseHero && (d.kind === 'hero' || d.kind === 'npc')
|
|
161
|
+
? `- a character, key "${d.key}": ${d.looks} (hero or npc according to the creator's idea and clarifications)` : ({
|
|
162
|
+
hero: `- the hero, key "${d.key}": ${d.looks}`,
|
|
163
|
+
npc: `- an npc in one of the places, key "${d.key}", role "npc": ${d.looks}`,
|
|
164
|
+
item: `- an item to pick up in one of the places, key "${d.key}", role "item": ${d.looks}`,
|
|
165
|
+
prop: `- a prop in one of the places, key "${d.key}", role "prop": ${d.looks}`,
|
|
166
|
+
place: `- a place, key "${d.key}": ${d.looks}`,
|
|
167
|
+
player: '',
|
|
168
|
+
})[d.kind];
|
|
169
|
+
return `The children drew these, and every one of them must be in the world, under exactly this key, looking as drawn:
|
|
170
|
+
${once.filter((d) => d.kind !== 'player').map(line).join('\n')}
|
|
171
|
+
${chooseHero ? 'Choose exactly one of these drawn characters as the hero according to the creator\'s idea and answers; the other characters are NPCs. Do not choose the first drawing just because it came first.' : ''}
|
|
172
|
+
The style is exactly: "${style}".`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** What the designed world left out of the drawings, said so Spark can put it back. */
|
|
176
|
+
export function drawnIssues(world: World, drawn: Drawn[]): string[] {
|
|
177
|
+
const things = new Map(world.places.flatMap((p) => p.things.map((t) => [t.key, t.role] as const)));
|
|
178
|
+
const places = new Set(world.places.map((p) => p.key));
|
|
179
|
+
return [...new Map(drawn.map((d) => [d.key, d])).values()].flatMap((d) => {
|
|
180
|
+
if (d.kind === 'hero') return world.hero.key === d.key ? [] : [`the hero's key must be "${d.key}" (a child drew the hero)`];
|
|
181
|
+
if (d.kind === 'player') return [];
|
|
182
|
+
if (d.kind === 'place') return places.has(d.key) ? [] : [`a child drew the place "${d.key}": it must be one of the places, with that key`];
|
|
183
|
+
const role = things.get(d.key);
|
|
184
|
+
if (!role) return [`a child drew "${d.key}": it must be a thing with role "${d.kind}" in one of the places`];
|
|
185
|
+
// A drawn house or cave may well be the way into the next place.
|
|
186
|
+
if (role === d.kind || (d.kind === 'prop' && role === 'exit')) return [];
|
|
187
|
+
return [`"${d.key}" was drawn as ${d.kind === 'item' ? 'an item' : `a ${d.kind}`}: its role must be "${d.kind}"`];
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The drawings a place's painting follows: the place itself first, then what was drawn of the props and items in it (three at most). */
|
|
192
|
+
export function drawingsIn(files: WorldFiles, place: Place): Array<{ key: string; image: Buffer }> {
|
|
193
|
+
const keys = [place.key, ...place.things.filter((t) => t.role !== 'npc').map((t) => t.key)];
|
|
194
|
+
return keys.flatMap((key) => {
|
|
195
|
+
const image = drawingFor(files, key);
|
|
196
|
+
return image ? [{ key, image }] : [];
|
|
197
|
+
}).slice(0, 3);
|
|
198
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Build an extension in a private copy. A failed/cancelled job cannot leave half a game installed.
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { emit } from '../registry.ts';
|
|
7
|
+
|
|
8
|
+
function files(dir: string, rel = ''): Map<string, string> {
|
|
9
|
+
const out = new Map<string, string>();
|
|
10
|
+
if (!fs.existsSync(path.join(dir, rel))) return out;
|
|
11
|
+
for (const entry of fs.readdirSync(path.join(dir, rel), { withFileTypes: true })) {
|
|
12
|
+
if (!rel && (entry.name === 'studio' || entry.name === 'memo')) continue;
|
|
13
|
+
const name = rel ? `${rel}/${entry.name}` : entry.name;
|
|
14
|
+
if (entry.isDirectory()) for (const [key, value] of files(dir, name)) out.set(key, value);
|
|
15
|
+
else if (entry.isFile()) out.set(name, createHash('sha256').update(fs.readFileSync(path.join(dir, name))).digest('hex'));
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function retainMemo(staged: string, original: string, callBytes: number) {
|
|
21
|
+
const copy = (from: string, to: string) => {
|
|
22
|
+
if (!fs.existsSync(from)) return;
|
|
23
|
+
fs.mkdirSync(to, { recursive: true });
|
|
24
|
+
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
|
|
25
|
+
if (entry.name === 'calls.jsonl') continue;
|
|
26
|
+
const src = path.join(from, entry.name), dest = path.join(to, entry.name);
|
|
27
|
+
if (entry.isDirectory()) copy(src, dest);
|
|
28
|
+
else if (entry.isFile() && !fs.existsSync(dest)) fs.copyFileSync(src, dest);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
copy(path.join(staged, 'memo'), path.join(original, 'memo'));
|
|
32
|
+
const calls = path.join(staged, 'memo', 'calls.jsonl');
|
|
33
|
+
if (fs.existsSync(calls)) {
|
|
34
|
+
const log = fs.readFileSync(calls).subarray(callBytes);
|
|
35
|
+
if (log.length) fs.appendFileSync(path.join(original, 'memo', 'calls.jsonl'), log);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The callback runs in an isolated project cwd; commit checks for concurrent edits before changing anything. */
|
|
40
|
+
export async function stagedExtension<T>(slug: string, run: () => Promise<T>): Promise<T> {
|
|
41
|
+
const cwd = process.cwd();
|
|
42
|
+
const original = path.join(cwd, 'worlds', slug);
|
|
43
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'diorama-extend-'));
|
|
44
|
+
const staged = path.join(temp, 'worlds', slug);
|
|
45
|
+
let callBytes: number | undefined;
|
|
46
|
+
try {
|
|
47
|
+
fs.mkdirSync(path.dirname(staged), { recursive: true });
|
|
48
|
+
fs.cpSync(original, staged, { recursive: true, mode: fs.constants.COPYFILE_FICLONE, filter: source => source !== path.join(original, 'studio') });
|
|
49
|
+
const before = files(staged);
|
|
50
|
+
const calls = path.join(staged, 'memo', 'calls.jsonl');
|
|
51
|
+
callBytes = fs.existsSync(calls) ? fs.statSync(calls).size : 0;
|
|
52
|
+
process.chdir(temp);
|
|
53
|
+
const stagedFlag = process.env.DIORAMA_STAGED_BUILD;
|
|
54
|
+
process.env.DIORAMA_STAGED_BUILD = '1';
|
|
55
|
+
let result: T;
|
|
56
|
+
try { result = await run(); } finally {
|
|
57
|
+
process.chdir(cwd);
|
|
58
|
+
if (stagedFlag === undefined) delete process.env.DIORAMA_STAGED_BUILD;
|
|
59
|
+
else process.env.DIORAMA_STAGED_BUILD = stagedFlag;
|
|
60
|
+
}
|
|
61
|
+
const after = files(staged);
|
|
62
|
+
const changed = [...new Set([...before.keys(), ...after.keys()])].filter(key => before.get(key) !== after.get(key));
|
|
63
|
+
const current = files(original);
|
|
64
|
+
// Any source change can invalidate the staged build, even if that source file itself was not rewritten.
|
|
65
|
+
const conflicts = [...new Set([...before.keys(), ...current.keys()])].filter(key => current.get(key) !== before.get(key));
|
|
66
|
+
if (conflicts.length) throw new Error(`world changed during extension (${conflicts.slice(0, 5).join(', ')}); nothing was installed, try again`);
|
|
67
|
+
emit('extension.progress', { phase: 'install' });
|
|
68
|
+
// Place the world and script last, after their new visual assets.
|
|
69
|
+
changed.sort((a, b) => Number(a === 'world.json' || a === 'story.json') - Number(b === 'world.json' || b === 'story.json'));
|
|
70
|
+
const backup = path.join(temp, 'backup');
|
|
71
|
+
for (const key of changed) if (before.has(key)) {
|
|
72
|
+
const target = path.join(backup, key);
|
|
73
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
74
|
+
fs.copyFileSync(path.join(original, key), target, fs.constants.COPYFILE_FICLONE);
|
|
75
|
+
}
|
|
76
|
+
const installed: string[] = [];
|
|
77
|
+
try {
|
|
78
|
+
for (const key of changed) {
|
|
79
|
+
const target = path.join(original, key);
|
|
80
|
+
if (!after.has(key)) fs.rmSync(target, { force: true });
|
|
81
|
+
else {
|
|
82
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
83
|
+
const temporary = `${target}.extend-tmp`;
|
|
84
|
+
try {
|
|
85
|
+
fs.copyFileSync(path.join(staged, key), temporary, fs.constants.COPYFILE_FICLONE);
|
|
86
|
+
fs.renameSync(temporary, target);
|
|
87
|
+
} finally { fs.rmSync(temporary, { force: true }); }
|
|
88
|
+
}
|
|
89
|
+
installed.push(key);
|
|
90
|
+
}
|
|
91
|
+
} catch (error) {
|
|
92
|
+
for (const key of installed.reverse()) {
|
|
93
|
+
const target = path.join(original, key);
|
|
94
|
+
if (before.has(key)) fs.copyFileSync(path.join(backup, key), target);
|
|
95
|
+
else fs.rmSync(target, { force: true });
|
|
96
|
+
}
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
} finally {
|
|
101
|
+
process.chdir(cwd);
|
|
102
|
+
// Even a failed generation consumed calls; retain the immutable cache and its accounting.
|
|
103
|
+
if (callBytes !== undefined) {
|
|
104
|
+
try { retainMemo(staged, original, callBytes); }
|
|
105
|
+
catch (error) { console.error(`could not retain extension memo: ${error instanceof Error ? error.message : error}`); }
|
|
106
|
+
}
|
|
107
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Incremental world design. Spark proposes additions; the existing world and story are never model output.
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import type { Memo } from '../meta/memo.ts';
|
|
4
|
+
import { askSparkJson } from '../meta/spark.ts';
|
|
5
|
+
import { checkStory, questionIssues, type StoryReport } from '../story/check.ts';
|
|
6
|
+
import { Story } from '../story/schema.ts';
|
|
7
|
+
import { NPC_STORY_GUIDANCE } from '../story/npc-guidance.ts';
|
|
8
|
+
import { drawnIssues, type Drawn } from './drawings.ts';
|
|
9
|
+
import { checkWorld, Place, Slug, Thing, type World } from './schema.ts';
|
|
10
|
+
|
|
11
|
+
const Additions = z.object({
|
|
12
|
+
anchor: Slug,
|
|
13
|
+
things: z.array(Thing).min(1),
|
|
14
|
+
exits: z.array(z.object({ to: Slug, via: Slug })).default([]),
|
|
15
|
+
places: z.array(Place).default([]),
|
|
16
|
+
names: z.record(z.string(), z.string().min(1).max(40)).default({}),
|
|
17
|
+
});
|
|
18
|
+
export type Additions = z.infer<typeof Additions>;
|
|
19
|
+
|
|
20
|
+
/** Merge only additions; the model cannot rewrite a place, hero or prior exit. */
|
|
21
|
+
export function mergeWorld(world: World, raw: unknown, drawings: Drawn[]): { world?: World; issues: string[]; changed: string[] } {
|
|
22
|
+
const parsed = Additions.safeParse(raw);
|
|
23
|
+
if (!parsed.success) return { issues: parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`), changed: [] };
|
|
24
|
+
const a = parsed.data;
|
|
25
|
+
const anchor = world.places.find(p => p.key === a.anchor);
|
|
26
|
+
if (!anchor) return { issues: [`unknown anchor ${a.anchor}`], changed: [] };
|
|
27
|
+
const existingPlaces = new Set(world.places.map(p => p.key));
|
|
28
|
+
const existingThings = new Set([world.hero.key, ...world.places.flatMap(p => p.things.map(t => t.key))]);
|
|
29
|
+
const issues: string[] = [];
|
|
30
|
+
const allNew = [...a.things, ...a.places.flatMap(p => p.things)];
|
|
31
|
+
if (a.places.some(p => existingPlaces.has(p.key))) issues.push('new place keys must not reuse existing places');
|
|
32
|
+
if (allNew.some(t => existingThings.has(t.key))) issues.push('new thing keys must not reuse existing things or the hero');
|
|
33
|
+
if (new Set(allNew.map(t => t.key)).size !== allNew.length) issues.push('new thing keys must be unique across places');
|
|
34
|
+
if (a.exits.some(e => !a.things.some(t => t.key === e.via && t.role === 'exit'))) issues.push('each new anchor exit must use a newly added exit thing');
|
|
35
|
+
if (a.exits.some(e => existingPlaces.has(e.to))) issues.push('new anchor exits must lead to new places');
|
|
36
|
+
if (a.places.length && !a.exits.length) issues.push('connect new places to the anchor');
|
|
37
|
+
for (const key of Object.keys(a.names)) if (existingThings.has(key) || existingPlaces.has(key) || world.names?.[key] !== undefined) issues.push(`cannot replace the existing name ${key}`);
|
|
38
|
+
const candidate = { ...world, places: world.places.map(p => p.key === a.anchor ? { ...p, things: [...p.things, ...a.things], exits: [...p.exits, ...a.exits] } : p).concat(a.places), names: { ...world.names, ...a.names } };
|
|
39
|
+
const checked = checkWorld(candidate);
|
|
40
|
+
issues.push(...checked.issues);
|
|
41
|
+
if (checked.world) issues.push(...drawnIssues(checked.world, drawings));
|
|
42
|
+
return { ...(checked.world && !issues.length ? { world: checked.world } : {}), issues, changed: [a.anchor, ...a.places.map(p => p.key)] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const StoryAddition = Story.omit({ intro: true }).extend({ endings: z.array(Story.shape.endings.element).default([]) });
|
|
46
|
+
export type StoryAddition = z.infer<typeof StoryAddition>;
|
|
47
|
+
|
|
48
|
+
/** Existing script keeps its order, including conditional interactions and changes/plate indices. */
|
|
49
|
+
export function mergeStory(world: World, old: Story, raw: unknown, changed: string[], oldWorld: World): { story?: Story; issues: string[]; report?: StoryReport } {
|
|
50
|
+
const parsed = StoryAddition.safeParse(raw);
|
|
51
|
+
if (!parsed.success) return { issues: parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`) };
|
|
52
|
+
const a = parsed.data;
|
|
53
|
+
const oldKeys = new Set(oldWorld.places.flatMap(p => p.things.map(t => `${p.key}/${t.key}`)));
|
|
54
|
+
const newThings = world.places.filter(p => changed.includes(p.key)).flatMap(p => p.things.filter(t => !oldKeys.has(`${p.key}/${t.key}`)).map(t => ({ ...t, place: p.key })));
|
|
55
|
+
const issues: string[] = [];
|
|
56
|
+
if (a.interactions.some(i => oldKeys.has(`${i.place}/${i.thing}`)) || a.dialogues.some(d => oldKeys.has(`${d.place}/${d.npc}`)) || a.changes.some(c => oldKeys.has(`${c.place}/${c.thing}`)) || a.locks.some(l => oldKeys.has(`${l.place}/${l.via}`))) issues.push('add story only for new things; existing script must remain unchanged');
|
|
57
|
+
for (const t of newThings) {
|
|
58
|
+
if (!a.interactions.some(i => i.place === t.place && i.thing === t.key && i.verb === 'look')) issues.push(`${t.place}/${t.key} needs a look interaction`);
|
|
59
|
+
if (t.role === 'npc' && !a.dialogues.some(d => d.place === t.place && d.npc === t.key && d.lines.some(l => l.choices.length))) issues.push(`${t.place}/${t.key} needs a dialogue with choices`);
|
|
60
|
+
if (t.role === 'item' && (!a.items.some(i => i.key === t.key) || !a.interactions.some(i => i.place === t.place && i.thing === t.key && i.verb === 'take' && i.once && i.do.some(e => 'give' in e && e.give === t.key)))) issues.push(`${t.place}/${t.key} needs an item and a one-time take interaction`);
|
|
61
|
+
}
|
|
62
|
+
for (const field of ['items', 'endings'] as const) {
|
|
63
|
+
const keys = [...old[field], ...a[field]].map(x => x.key);
|
|
64
|
+
if (new Set(keys).size !== keys.length) issues.push(`${field} keys repeat`);
|
|
65
|
+
}
|
|
66
|
+
// New puzzles must not consume the old story's inventory or set/clear its flags.
|
|
67
|
+
const oldItems = new Set(old.items.map(i => i.key));
|
|
68
|
+
const oldFlags = new Set<string>();
|
|
69
|
+
const scan = (value: unknown) => {
|
|
70
|
+
if (typeof value === 'string' && /^!?[a-z][a-z0-9-]*$/.test(value)) oldFlags.add(value.replace(/^!/, ''));
|
|
71
|
+
else if (Array.isArray(value)) value.forEach(scan);
|
|
72
|
+
else if (value && typeof value === 'object') Object.values(value).forEach(scan);
|
|
73
|
+
};
|
|
74
|
+
for (const i of old.interactions) { scan(i.when); for (const e of i.do) if ('set' in e || 'clear' in e) scan('set' in e ? e.set : e.clear); }
|
|
75
|
+
for (const l of old.locks) scan(l.until);
|
|
76
|
+
for (const c of old.changes) scan(c.when);
|
|
77
|
+
for (const p of old.patrols ?? []) scan(p.when);
|
|
78
|
+
for (const d of old.dialogues) { scan(d.when); for (const l of d.lines) for (const c of l.choices) { scan(c.when); for (const e of c.do) if ('set' in e || 'clear' in e) scan('set' in e ? e.set : e.clear); } }
|
|
79
|
+
const effects = [...a.interactions.flatMap(i => i.do), ...a.dialogues.flatMap(d => d.lines.flatMap(l => l.choices.flatMap(c => c.do)))];
|
|
80
|
+
const oldNpcs = new Set(oldWorld.places.flatMap(p => p.things.filter(t => t.role === 'npc').map(t => t.key)));
|
|
81
|
+
if (effects.some(e => 'moveNpc' in e && oldNpcs.has(e.moveNpc.npc)) || a.patrols?.some(p => oldNpcs.has(p.npc))) issues.push('new motion must not change existing NPCs');
|
|
82
|
+
if (effects.some(e => ('set' in e && oldFlags.has(e.set)) || ('clear' in e && oldFlags.has(e.clear)) || ('take' in e && oldItems.has(e.take)) || ('give' in e && oldItems.has(e.give)))) issues.push('new effects must not change old flags or inventory items');
|
|
83
|
+
if (!a.dialogues.length && !a.interactions.some(i => i.verb !== 'look' && i.do.length)) issues.push('add a conversation or an interactive activity, not only look lines');
|
|
84
|
+
issues.push(...questionIssues(Story.parse({ intro: old.intro, ...a, endings: a.endings.length ? a.endings : old.endings })));
|
|
85
|
+
const story = Story.parse({ intro: old.intro, items: [...old.items, ...a.items], locks: [...old.locks, ...a.locks], interactions: [...old.interactions, ...a.interactions], dialogues: [...old.dialogues, ...a.dialogues], changes: [...old.changes, ...a.changes],
|
|
86
|
+
...((old.patrols || a.patrols) && { patrols: [...(old.patrols ?? []), ...(a.patrols ?? [])] }), endings: [...old.endings, ...a.endings] });
|
|
87
|
+
const report = checkStory(world, story);
|
|
88
|
+
issues.push(...report.issues);
|
|
89
|
+
const oldPlaceKeys = new Set(oldWorld.places.map(p => p.key));
|
|
90
|
+
const newPlaces = world.places.filter(p => !oldPlaceKeys.has(p.key));
|
|
91
|
+
const newEndings = new Set(a.endings.map(e => e.key));
|
|
92
|
+
for (const place of newPlaces) {
|
|
93
|
+
const incoming = world.places.flatMap(p => p.exits.filter(e => e.to === place.key).map(e => e.via));
|
|
94
|
+
const endsHere = new Set([
|
|
95
|
+
...a.interactions.filter(i => i.place === place.key).flatMap(i => i.do),
|
|
96
|
+
...a.dialogues.filter(d => d.place === place.key).flatMap(d => d.lines.flatMap(l => l.choices.flatMap(c => c.do))),
|
|
97
|
+
].flatMap(e => 'ending' in e ? [e.ending] : []));
|
|
98
|
+
if (!report.routes.some(r => newEndings.has(r.ending) && endsHere.has(r.ending) && r.actions.some(a => a.kind === 'go' && incoming.includes(a.via)))) issues.push(`a new ending must be reached by an activity in ${place.key}`);
|
|
99
|
+
}
|
|
100
|
+
return { ...(!issues.length ? { story } : {}), issues, report };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function designExtension(memo: Memo, oldWorld: World, oldStory: Story, drawings: Drawn[], wish: string): Promise<{ world: World; story: Story; changed: string[]; report: StoryReport }> {
|
|
104
|
+
const base = `Extend this existing point-and-click world from the new drawings and the person's wish. Do not rewrite existing places, people, exits, or script. Add a new place for each drawing whose kind is place, and include each new drawing under exactly its key and role. Add characters shown in scenes as NPCs where appropriate. Existing places have at most 10 things and the whole world at most 8 places. Connect each new place both ways with visible exit things. Use one existing anchor, adding visible things to it; its painting will be rebuilt. Keep descriptions in English, names in ${oldWorld.language ?? 'English'}. Wish: ${wish}\nDrawings: ${JSON.stringify(drawings)}\nExisting world: ${JSON.stringify(oldWorld)}\nReturn JSON only: {"anchor":"existing-place-key","things":[{"key":"new-key","role":"exit|npc|item|prop","looks":"..."}],"exits":[{"via":"new-exit-key","to":"new-place-key"}],"places":[{"key":"...","shows":"...","mood":"...","things":[...],"exits":[...]}],"names":{"new-key":"name"}}. No reused thing keys. For character/item-only drawings, add them to the anchor; places may be empty.`;
|
|
105
|
+
let prompt = base;
|
|
106
|
+
let world: World | undefined; let changed: string[] = [];
|
|
107
|
+
for (let i = 1; i <= 4; i++) {
|
|
108
|
+
const answer = await askSparkJson(memo, { label: `extend-world#${i}`, prompt });
|
|
109
|
+
const result = mergeWorld(oldWorld, answer, drawings);
|
|
110
|
+
if (result.world) { world = result.world; changed = result.changed; break; }
|
|
111
|
+
prompt = `${base}\nPrevious answer: ${JSON.stringify(answer)}\nFix: ${result.issues.join('; ')}`;
|
|
112
|
+
}
|
|
113
|
+
if (!world) throw new Error('Could not design a valid extension in 4 tries; the existing world was not changed');
|
|
114
|
+
const scriptBase = `Write ONLY additions to this point-and-click story. Preserve every existing line, ending and puzzle: do not repeat or edit them. Add look interactions for every new thing, take for new items (give their key, once true), dialogue with choices for new NPCs, and a small engaging activity involving the new material. Add a new ending reachable through each new place, triggered by an activity there. New interactions, dialogues, changes and locks may address only new things. Do not set or clear existing flags, or give or take existing items. Exit things may only have look interactions; they already move the player. Every ending must remain reachable from every non-ending state. A line with no choices must not ask a question.\n${NPC_STORY_GUIDANCE}\nExtension scope: apply these stages and optional motion only to newly added NPCs. Preserve existing NPC flags, patrol order, locations and script. Use fresh NPC-prefixed flags, including flags used only in negative conditions; new effects must not move old NPCs. Dialogue and interactions for a newly added travelling NPC may refer to its destination even though it is declared only at its original place. Use short friendly lines in ${world.language ?? 'English'}.\nWish: ${wish}\nDrawings: ${JSON.stringify(drawings)}\nExisting world: ${JSON.stringify(oldWorld)}\nExtended world: ${JSON.stringify(world)}\nExisting story: ${JSON.stringify(oldStory)}\nReturn JSON with arrays items, locks, interactions, dialogues, changes, endings and optional patrols (omit or [] when nobody needs a repeated walk). Same shapes and effects as existing story; empty arrays are fine. Do not include intro. Each new item and ending needs a unique key.`;
|
|
115
|
+
prompt = scriptBase;
|
|
116
|
+
for (let i = 1; i <= 4; i++) {
|
|
117
|
+
const answer = await askSparkJson(memo, { label: `extend-story#${i}`, prompt });
|
|
118
|
+
const result = mergeStory(world, oldStory, answer, changed, oldWorld);
|
|
119
|
+
if (result.story && result.report) return { world, story: result.story, changed, report: result.report };
|
|
120
|
+
prompt = `${scriptBase}\nPrevious answer: ${JSON.stringify(answer)}\nFix: ${result.issues.join('; ')}`;
|
|
121
|
+
}
|
|
122
|
+
throw new Error('Could not design a playable story extension in 4 tries; the existing world was not changed');
|
|
123
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Milestones for an extension job; a place build can repeat its steps several times. */
|
|
2
|
+
export type ExtensionProgress = {
|
|
3
|
+
phase: 'plan' | 'places' | 'audio' | 'install';
|
|
4
|
+
place?: string;
|
|
5
|
+
index?: number;
|
|
6
|
+
total?: number;
|
|
7
|
+
task?: 'characters';
|
|
8
|
+
/** New place keys are provisional until the staged extension is installed. */
|
|
9
|
+
newPlaces?: string[];
|
|
10
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Where a world lives on disk. Every step reads and writes plain files under worlds/<slug>/.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { Memo } from '../meta/memo.ts';
|
|
5
|
+
import { World } from './schema.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The world with each place's thing list as the person edited it (places/<key>/corrections.json): props taken
|
|
9
|
+
* off the list are gone, props added are there. world.json stays as Spark wrote it; every step reads this.
|
|
10
|
+
*/
|
|
11
|
+
export function withThingEdits(world: World, dir: string): World {
|
|
12
|
+
const places = world.places.map((place) => {
|
|
13
|
+
const file = path.join(dir, 'places', place.key, 'corrections.json');
|
|
14
|
+
if (!fs.existsSync(file)) return place;
|
|
15
|
+
let c: { things?: Record<string, { removed?: boolean }>; added?: Record<string, { looks?: string }> };
|
|
16
|
+
try {
|
|
17
|
+
c = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
18
|
+
} catch {
|
|
19
|
+
return place;
|
|
20
|
+
}
|
|
21
|
+
const removed = new Set(Object.entries(c.things ?? {}).filter(([, t]) => t?.removed).map(([k]) => k));
|
|
22
|
+
const added = Object.entries(c.added ?? {}).filter(([k, a]) => !place.things.some((t) => t.key === k) && typeof a?.looks === 'string');
|
|
23
|
+
if (!removed.size && !added.length) return place;
|
|
24
|
+
// Only props: an exit, an item or a person belongs to the story, which changes with story_redo.
|
|
25
|
+
const taken = place.things.filter((t) => t.role === 'prop' && removed.has(t.key)).map((t) => t.key);
|
|
26
|
+
const things = [...place.things.filter((t) => !taken.includes(t.key)), ...added.map(([key, a]) => ({ key, role: 'prop' as const, looks: a.looks! }))];
|
|
27
|
+
return things.length ? { ...place, things: things.slice(0, 10), ...(taken.length && { taken }) } : place;
|
|
28
|
+
});
|
|
29
|
+
return { ...world, places };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class WorldFiles {
|
|
33
|
+
readonly dir: string;
|
|
34
|
+
readonly memo: Memo;
|
|
35
|
+
|
|
36
|
+
constructor(readonly slug: string, root = 'worlds') {
|
|
37
|
+
this.dir = path.resolve(root, slug);
|
|
38
|
+
this.memo = new Memo(path.join(this.dir, 'memo'));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
path(...parts: string[]) {
|
|
42
|
+
return path.join(this.dir, ...parts);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
place(key: string, ...parts: string[]) {
|
|
46
|
+
return this.path('places', key, ...parts);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
write(file: string, data: Buffer | string | object) {
|
|
50
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
51
|
+
fs.writeFileSync(file, Buffer.isBuffer(data) || typeof data === 'string' ? data : `${JSON.stringify(data, null, 2)}\n`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
readJson(file: string): unknown {
|
|
55
|
+
if (!fs.existsSync(file)) throw new Error(`missing ${path.relative(process.cwd(), file)}: run the step before this one`);
|
|
56
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
world(): World {
|
|
60
|
+
return withThingEdits(World.parse(this.readJson(this.path('world.json'))), this.dir);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const IdeaReview = z.object({
|
|
4
|
+
understanding: z.string().min(1).max(700),
|
|
5
|
+
questions: z.array(z.object({
|
|
6
|
+
question: z.string().min(1).max(100),
|
|
7
|
+
why: z.string().max(300),
|
|
8
|
+
presentation: z.enum(['pictures', 'text']).optional(),
|
|
9
|
+
options: z.array(z.string().min(1).max(96)).max(3),
|
|
10
|
+
visuals: z.array(z.object({ scene: z.string().min(1).max(500), photo: z.number().int().nonnegative().optional() })).max(3).optional(),
|
|
11
|
+
photo: z.number().int().nonnegative().optional(),
|
|
12
|
+
video: z.object({ id: z.string(), seconds: z.number().nonnegative() }).optional(),
|
|
13
|
+
})).max(1),
|
|
14
|
+
styles: z.array(z.object({ title: z.string().min(1).max(80), description: z.string().min(10).max(500) })).max(3),
|
|
15
|
+
});
|
|
16
|
+
export type IdeaReview = z.infer<typeof IdeaReview>;
|
|
17
|
+
|
|
18
|
+
/** Only explicit creator decisions, never the reviewer's guesses or unanswered suggestions. */
|
|
19
|
+
export const CreativeChoices = z.object({
|
|
20
|
+
answers: z.array(z.object({ question: z.string().min(1).max(240), answer: z.string().trim().min(1).max(600) })).max(3),
|
|
21
|
+
style: z.string().trim().min(10).max(500).optional(),
|
|
22
|
+
styleReference: z.string().regex(/^[a-f0-9]{32}$/).optional(),
|
|
23
|
+
note: z.string().trim().max(1000).optional(),
|
|
24
|
+
}).refine(v => !v.styleReference || !!v.style, 'A style reference needs a chosen style.');
|
|
25
|
+
export type CreativeChoices = z.infer<typeof CreativeChoices>;
|
|
26
|
+
export function refinedIdea(idea: string, choices?: CreativeChoices): string {
|
|
27
|
+
if (!choices) return idea.trim();
|
|
28
|
+
const lines = choices.answers.map(a => `${a.question}\n${a.answer}`);
|
|
29
|
+
if (choices.style) lines.push(`Visual style chosen by the creator: ${choices.style}`);
|
|
30
|
+
if (choices.note) lines.push(choices.note);
|
|
31
|
+
return [idea.trim(), ...(lines.length ? [`Creator's clarifications (these override earlier ambiguity; keep the original story language unless explicitly changed):\n${lines.join('\n\n')}`] : [])].join('\n\n');
|
|
32
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Work that may run side by side, a few at a time: the cast's 3D bodies and faces, each character on its own.
|
|
2
|
+
|
|
3
|
+
/** Runs `work` on every item, at most `limit` at once; results keep the items' order. Errors are kept per item. */
|
|
4
|
+
export async function mapLimit<T, R>(items: readonly T[], limit: number, work: (item: T, index: number) => Promise<R>): Promise<PromiseSettledResult<R>[]> {
|
|
5
|
+
const results: PromiseSettledResult<R>[] = new Array(items.length);
|
|
6
|
+
let next = 0;
|
|
7
|
+
const runner = async () => {
|
|
8
|
+
while (next < items.length) {
|
|
9
|
+
const i = next++;
|
|
10
|
+
try {
|
|
11
|
+
results[i] = { status: 'fulfilled', value: await work(items[i], i) };
|
|
12
|
+
} catch (reason) {
|
|
13
|
+
results[i] = { status: 'rejected', reason };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, runner));
|
|
18
|
+
return results;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const open = new Map<string, number>();
|
|
22
|
+
const waiting = new Map<string, Array<() => void>>();
|
|
23
|
+
|
|
24
|
+
/** At most `limit` of `lane`'s work at once in this process (a Hugging Face Space queue, a local GPU). */
|
|
25
|
+
export async function inLane<T>(lane: string, limit: number, work: () => Promise<T>): Promise<T> {
|
|
26
|
+
if ((open.get(lane) ?? 0) >= limit) await new Promise<void>((go) => (waiting.get(lane) ?? waiting.set(lane, []).get(lane)!).push(go));
|
|
27
|
+
else open.set(lane, (open.get(lane) ?? 0) + 1);
|
|
28
|
+
try {
|
|
29
|
+
return await work();
|
|
30
|
+
} finally {
|
|
31
|
+
const go = waiting.get(lane)?.shift();
|
|
32
|
+
if (go) go();
|
|
33
|
+
else open.set(lane, (open.get(lane) ?? 1) - 1);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** How many characters are worked on at once: DIORAMA_CAST_LANES, default 3. */
|
|
38
|
+
export function castLanes(): number {
|
|
39
|
+
const n = Number(process.env.DIORAMA_CAST_LANES);
|
|
40
|
+
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : 3;
|
|
41
|
+
}
|