@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,423 @@
|
|
|
1
|
+
// The Studio's local server. It listens on 127.0.0.1 only and trusts nobody by default: every API call
|
|
2
|
+
// carries the per-run token (handed to the browser in the URL fragment, so it never reaches a log or a
|
|
3
|
+
// Referer), the Host must be ours (no DNS rebinding), cross-site requests are refused (Origin and
|
|
4
|
+
// Sec-Fetch-Site), writes are JSON only and no CORS header is ever sent. World files are served to <img>
|
|
5
|
+
// with a SameSite=Strict cookie the page gets in exchange for the token.
|
|
6
|
+
import { callVia } from '../meta/memo.ts';
|
|
7
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import http from 'node:http';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { build } from 'esbuild';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import '../actions/index.ts';
|
|
15
|
+
import { ActionError, describeActions, runAction, type ActionContext } from '../actions/registry.ts';
|
|
16
|
+
import { redact } from '../secrets.ts';
|
|
17
|
+
import { Slug } from '../world/schema.ts';
|
|
18
|
+
import { Events, type StudioEvent } from './events.ts';
|
|
19
|
+
import { Jobs, type Job } from './jobs.ts';
|
|
20
|
+
import { repairPrompt, wantsRepair } from './repair.ts';
|
|
21
|
+
import { readBudget } from '../actions/budget.ts';
|
|
22
|
+
import { AgentHub, isAgentName } from './agent.ts';
|
|
23
|
+
import { bundle, WEB, worldData } from '../commands/play.ts';
|
|
24
|
+
import { WorldFiles } from '../world/files.ts';
|
|
25
|
+
import { snapshotBeforeJob } from './history.ts';
|
|
26
|
+
import { handleMcp } from './mcp.ts';
|
|
27
|
+
|
|
28
|
+
export const STUDIO_WEB = fileURLToPath(new URL('../../studio/', import.meta.url));
|
|
29
|
+
const CHAT_CSS = createRequire(import.meta.url).resolve('genaicode/react/styles.css');
|
|
30
|
+
const TYPES: Record<string, string> = { '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp', '.json': 'application/json', '.mp3': 'audio/mpeg', '.jsonl': 'text/plain; charset=utf-8', '.webm': 'audio/webm', '.m4a': 'audio/mp4', '.mp4': 'audio/mp4', '.ogg': 'audio/ogg', '.wav': 'audio/wav', '.txt': 'text/plain; charset=utf-8', '.glb': 'model/gltf-binary' };
|
|
31
|
+
const CSP = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' blob: data:; media-src 'self' blob:; connect-src 'self' blob:; font-src 'self'; frame-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'";
|
|
32
|
+
/** The game page: its own inline styles, framed only by the Studio. */
|
|
33
|
+
const PLAY_CSP = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; media-src 'self' blob:; connect-src 'self'; worker-src 'self' blob:; frame-ancestors 'self'; base-uri 'none'; form-action 'none'";
|
|
34
|
+
let playJs: Promise<string> | null = null;
|
|
35
|
+
const COOKIE = 'diorama_studio';
|
|
36
|
+
// Photos of drawings and recordings arrive as base64 JSON.
|
|
37
|
+
const MAX_BODY = 16 << 20;
|
|
38
|
+
|
|
39
|
+
/** token opens the page; agentToken is the agent's (MCP, `diorama mcp`), so its calls show as the agent's. */
|
|
40
|
+
export type Studio = { url: string; port: number; token: string; agentToken: string; root: string; events: Events; jobs: Jobs; close(): Promise<void> };
|
|
41
|
+
|
|
42
|
+
function same(a: string, b: string): boolean {
|
|
43
|
+
const x = Buffer.from(a);
|
|
44
|
+
const y = Buffer.from(b);
|
|
45
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function bundleApp(): Promise<string> {
|
|
49
|
+
const out = await build({
|
|
50
|
+
entryPoints: [path.join(STUDIO_WEB, 'main.tsx')],
|
|
51
|
+
bundle: true,
|
|
52
|
+
format: 'esm',
|
|
53
|
+
write: false,
|
|
54
|
+
target: 'es2022',
|
|
55
|
+
jsx: 'automatic',
|
|
56
|
+
jsxImportSource: 'preact',
|
|
57
|
+
// genaicode's chat is a React component; Preact's compat layer stands in for React.
|
|
58
|
+
alias: { react: 'preact/compat', 'react-dom': 'preact/compat', 'react/jsx-runtime': 'preact/jsx-runtime' },
|
|
59
|
+
sourcemap: 'inline',
|
|
60
|
+
// Aliases resolve from the working dir; Studio may be started from anywhere, so resolve from the package.
|
|
61
|
+
absWorkingDir: path.dirname(STUDIO_WEB),
|
|
62
|
+
});
|
|
63
|
+
return out.outputFiles[0].text;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Watches a world folder while someone looks at it: file changes and new model calls become events. */
|
|
67
|
+
class WorldWatch {
|
|
68
|
+
private watcher: fs.FSWatcher | null = null;
|
|
69
|
+
private readonly pending = new Map<string, NodeJS.Timeout>();
|
|
70
|
+
private callsAt = 0;
|
|
71
|
+
users = 0;
|
|
72
|
+
|
|
73
|
+
constructor(private readonly slug: string, private readonly dir: string, private readonly events: Events) {
|
|
74
|
+
const calls = path.join(dir, 'memo', 'calls.jsonl');
|
|
75
|
+
this.callsAt = fs.existsSync(calls) ? fs.statSync(calls).size : 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
start() {
|
|
79
|
+
if (this.watcher || this.users <= 0 || !fs.existsSync(this.dir)) return;
|
|
80
|
+
this.tailCalls();
|
|
81
|
+
try {
|
|
82
|
+
this.watcher = fs.watch(this.dir, { recursive: true }, (_, name) => name && this.changed(String(name)));
|
|
83
|
+
this.watcher.on('error', () => this.stop());
|
|
84
|
+
} catch {}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
stop() {
|
|
88
|
+
this.watcher?.close();
|
|
89
|
+
this.watcher = null;
|
|
90
|
+
for (const t of this.pending.values()) clearTimeout(t);
|
|
91
|
+
this.pending.clear();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private changed(name: string) {
|
|
95
|
+
const file = name.split(path.sep).join('/');
|
|
96
|
+
if (file === 'memo/calls.jsonl') return this.tailCalls();
|
|
97
|
+
if (file.startsWith('memo/') || file.startsWith('studio/') || file.endsWith('.tmp')) return;
|
|
98
|
+
clearTimeout(this.pending.get(file));
|
|
99
|
+
this.pending.set(file, setTimeout(() => {
|
|
100
|
+
this.pending.delete(file);
|
|
101
|
+
const at = path.join(this.dir, file);
|
|
102
|
+
const exists = fs.existsSync(at);
|
|
103
|
+
this.events.publish('file.changed', { world: this.slug, file, exists, v: exists ? Math.round(fs.statSync(at).mtimeMs) : null });
|
|
104
|
+
}, 150));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private tailCalls() {
|
|
108
|
+
const file = path.join(this.dir, 'memo', 'calls.jsonl');
|
|
109
|
+
if (!fs.existsSync(file)) return;
|
|
110
|
+
const size = fs.statSync(file).size;
|
|
111
|
+
if (size < this.callsAt) this.callsAt = 0;
|
|
112
|
+
if (size === this.callsAt) return;
|
|
113
|
+
const fd = fs.openSync(file, 'r');
|
|
114
|
+
const chunk = Buffer.alloc(size - this.callsAt);
|
|
115
|
+
fs.readSync(fd, chunk, 0, chunk.length, this.callsAt);
|
|
116
|
+
fs.closeSync(fd);
|
|
117
|
+
const text = chunk.toString('utf8');
|
|
118
|
+
const complete = text.lastIndexOf('\n') + 1;
|
|
119
|
+
this.callsAt += Buffer.byteLength(text.slice(0, complete));
|
|
120
|
+
for (const line of text.slice(0, complete).split('\n')) {
|
|
121
|
+
if (!line) continue;
|
|
122
|
+
try {
|
|
123
|
+
const call = JSON.parse(line) as Record<string, unknown>;
|
|
124
|
+
const { model, label, ms, hit } = call;
|
|
125
|
+
this.events.publish('call', { world: this.slug, model, label, ms, hit, via: callVia(this.dir, call as { model: string; hash?: string; via?: unknown }) });
|
|
126
|
+
} catch {}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function startStudio(opts: { port?: number; root?: string; cwd?: string; token?: string; bin?: string; muse?: string } = {}): Promise<Studio> {
|
|
132
|
+
const root = path.resolve(opts.root ?? 'worlds');
|
|
133
|
+
const token = opts.token ?? randomBytes(32).toString('base64url');
|
|
134
|
+
const agentToken = randomBytes(32).toString('base64url');
|
|
135
|
+
const cookie = randomBytes(32).toString('base64url');
|
|
136
|
+
const events = new Events();
|
|
137
|
+
const jobs = new Jobs(events, {
|
|
138
|
+
// Commands find worlds in ./worlds, so a Studio on another worlds folder runs them beside it.
|
|
139
|
+
cwd: opts.cwd ?? (path.basename(root) === 'worlds' ? path.dirname(root) : undefined),
|
|
140
|
+
bin: opts.bin,
|
|
141
|
+
beforeStart: snapshotBeforeJob(root, events),
|
|
142
|
+
});
|
|
143
|
+
const watches = new Map<string, WorldWatch>();
|
|
144
|
+
let app: Promise<string> | null = null;
|
|
145
|
+
let port = 0;
|
|
146
|
+
// Filled in once the port is known (the agent needs the MCP URL).
|
|
147
|
+
let agents: AgentHub = null as unknown as AgentHub;
|
|
148
|
+
|
|
149
|
+
const watch = (slug: string) => {
|
|
150
|
+
let w = watches.get(slug);
|
|
151
|
+
if (!w) watches.set(slug, (w = new WorldWatch(slug, path.join(root, slug), events)));
|
|
152
|
+
w.users++;
|
|
153
|
+
w.start();
|
|
154
|
+
return () => {
|
|
155
|
+
if (--w!.users <= 0) w!.stop();
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
// A job's world folder may appear only once the job runs; watch it from then on while anyone looks.
|
|
159
|
+
events.subscribe((e) => {
|
|
160
|
+
if (e.world && e.type !== 'file.changed' && e.type !== 'call') watches.get(e.world)?.start();
|
|
161
|
+
});
|
|
162
|
+
// A failed build wakes the agent to repair it: places left unplayable, or the step that broke (budget_set autoRepair turns it off).
|
|
163
|
+
events.subscribe((e) => {
|
|
164
|
+
if (e.type !== 'job.end' || !e.world) return;
|
|
165
|
+
const job = e.job as Job;
|
|
166
|
+
// A busy agent gets it after its turn: the Studio's messages wait as notes.
|
|
167
|
+
if (!agents || !wantsRepair(job, { enabled: readBudget().autoRepair })) return;
|
|
168
|
+
const places = (job.unplayable ?? []).map((u) => u.place);
|
|
169
|
+
const step = places.length ? undefined : job.step ?? (job.warnings?.faces?.length ? 'faces' : undefined);
|
|
170
|
+
// With no agent on this machine there is nobody to wake; the person is told what would help instead.
|
|
171
|
+
const using = agents.agents(e.world).find((a) => a.using);
|
|
172
|
+
if (!using?.installed) return events.publish('repair.agent', { world: e.world, job: job.id, places, step, missing: using?.label ?? 'an agent' });
|
|
173
|
+
events.publish('repair.agent', { world: e.world, job: job.id, places, step });
|
|
174
|
+
agents.say(e.world, repairPrompt(job), 'studio').catch((error) => events.publish('log', { world: e.world, stream: 'err', line: `could not wake the agent: ${error instanceof Error ? error.message : error}` }));
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Fix it on a place the build could not mend hands it to the agent, as a finished build does.
|
|
178
|
+
events.subscribe((e) => {
|
|
179
|
+
if (e.type !== 'repair.ask' || !e.world || !agents) return;
|
|
180
|
+
const using = agents.agents(e.world).find((a) => a.using);
|
|
181
|
+
if (!using?.installed) return events.publish('repair.agent', { world: e.world, places: [e.place], missing: using?.label ?? 'an agent' });
|
|
182
|
+
events.publish('repair.agent', { world: e.world, places: [e.place] });
|
|
183
|
+
agents.say(e.world, String(e.prompt), 'studio').catch((error) => events.publish('log', { world: e.world, stream: 'err', line: `could not wake the agent: ${error instanceof Error ? error.message : error}` }));
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// The game and pictures read the world through the Studio's session cookie; a browser that never opened the Studio has none.
|
|
187
|
+
const NO_SESSION = 'no session: open the Studio link from `diorama studio` in this browser first, or play the world on its own with `diorama play <slug>`';
|
|
188
|
+
const server = http.createServer(async (req, res) => {
|
|
189
|
+
const headers = { 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer', 'cross-origin-resource-policy': 'same-origin' };
|
|
190
|
+
const send = (status: number, type: string, body: string | Buffer, extra: Record<string, string> = {}) => {
|
|
191
|
+
res.writeHead(status, { ...headers, 'content-type': type, ...extra });
|
|
192
|
+
res.end(body);
|
|
193
|
+
};
|
|
194
|
+
const json = (status: number, body: unknown) => send(status, 'application/json', JSON.stringify(body));
|
|
195
|
+
try {
|
|
196
|
+
const host = req.headers.host ?? '';
|
|
197
|
+
if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) return send(421, 'text/plain', 'wrong host');
|
|
198
|
+
const site = req.headers['sec-fetch-site'];
|
|
199
|
+
if (site && site !== 'same-origin' && site !== 'none') return send(403, 'text/plain', 'cross-site request refused');
|
|
200
|
+
const origin = req.headers.origin;
|
|
201
|
+
if (origin && origin !== `http://${host}`) return send(403, 'text/plain', 'cross-origin request refused');
|
|
202
|
+
|
|
203
|
+
const url = new URL(req.url ?? '/', `http://${host}`);
|
|
204
|
+
const bearer = /^Bearer (.+)$/.exec(req.headers.authorization ?? '')?.[1] ?? '';
|
|
205
|
+
const asAgent = !!bearer && same(bearer, agentToken);
|
|
206
|
+
const authed = asAgent || (!!bearer && same(bearer, token));
|
|
207
|
+
const body = async (): Promise<{ value?: unknown; error?: [number, string] }> => {
|
|
208
|
+
let size = 0;
|
|
209
|
+
const chunks: Buffer[] = [];
|
|
210
|
+
for await (const chunk of req) {
|
|
211
|
+
size += (chunk as Buffer).length;
|
|
212
|
+
if (size > MAX_BODY) return { error: [413, 'too large'] };
|
|
213
|
+
chunks.push(chunk as Buffer);
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
return { value: chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {} };
|
|
217
|
+
} catch {
|
|
218
|
+
return { error: [400, 'body is not JSON'] };
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
const cookieOk = (req.headers.cookie ?? '').split(/;\s*/).some((c) => c.startsWith(`${COOKIE}=`) && same(c.slice(COOKIE.length + 1), cookie));
|
|
222
|
+
|
|
223
|
+
// MCP over streamable HTTP, for the agent: plain JSON answers to POSTed JSON-RPC, no server stream.
|
|
224
|
+
if (url.pathname === '/mcp') {
|
|
225
|
+
if (!asAgent) return json(401, { error: 'the agent token is required' });
|
|
226
|
+
if (req.method !== 'POST') return send(405, 'text/plain', 'POST JSON-RPC here', { allow: 'POST' });
|
|
227
|
+
if (!(req.headers['content-type'] ?? '').startsWith('application/json')) return json(415, { error: 'JSON only' });
|
|
228
|
+
const { value, error } = await body();
|
|
229
|
+
if (error) return json(error[0], { jsonrpc: '2.0', id: null, error: { code: -32700, message: error[1] } });
|
|
230
|
+
const batch = Array.isArray(value) ? value : [value];
|
|
231
|
+
const world = Slug.safeParse(url.searchParams.get('world'));
|
|
232
|
+
const ctx: ActionContext = { by: 'agent', root, jobs, events, ...(world.success && { onPersonSays: (listener: (text: string) => void) => agents.onPersonSays(world.data, listener) }) };
|
|
233
|
+
const replies = (await Promise.all(batch.map((m) => handleMcp(m as Parameters<typeof handleMcp>[0], ctx, world.success ? world.data : undefined, world.success ? (name, args) => agents.called(world.data, name, args) : undefined)))).filter(Boolean);
|
|
234
|
+
if (!replies.length) {
|
|
235
|
+
res.writeHead(202, headers);
|
|
236
|
+
return res.end();
|
|
237
|
+
}
|
|
238
|
+
return json(200, Array.isArray(value) ? replies : replies[0]);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (url.pathname.startsWith('/api/')) {
|
|
242
|
+
if (!authed) return json(401, { error: 'missing or wrong token: open the link diorama studio printed' });
|
|
243
|
+
if (req.method === 'POST' && !(req.headers['content-type'] ?? '').startsWith('application/json')) return json(415, { error: 'JSON only' });
|
|
244
|
+
|
|
245
|
+
if (url.pathname === '/api/session' && req.method === 'POST') {
|
|
246
|
+
return send(200, 'application/json', '{"ok":true}', { 'set-cookie': `${COOKIE}=${cookie}; HttpOnly; SameSite=Strict; Path=/` });
|
|
247
|
+
}
|
|
248
|
+
if (url.pathname === '/api/actions' && req.method === 'GET') return json(200, describeActions());
|
|
249
|
+
// Setup: which agent new worlds start with.
|
|
250
|
+
if (url.pathname === '/api/agent-default' && !asAgent) {
|
|
251
|
+
if (req.method === 'GET') return json(200, agents.defaults());
|
|
252
|
+
if (req.method === 'POST') {
|
|
253
|
+
const { value, error } = await body();
|
|
254
|
+
if (error) return json(error[0], { error: error[1] });
|
|
255
|
+
const name = String((value as { name?: unknown } | undefined)?.name ?? '');
|
|
256
|
+
if (!isAgentName(name)) return json(400, { error: 'no such agent' });
|
|
257
|
+
agents.setDefault(name);
|
|
258
|
+
return json(200, agents.defaults());
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// The agent conversation: the person's side only (the agent itself never posts here).
|
|
262
|
+
const talk = /^\/api\/agent\/([a-z][a-z0-9-]*)(?:\/(say|stop|approval|live|agents|use))?$/.exec(url.pathname);
|
|
263
|
+
if (talk && !asAgent) {
|
|
264
|
+
const [, slug, verb] = talk;
|
|
265
|
+
if (!verb && req.method === 'GET') return json(200, agents.state(slug));
|
|
266
|
+
if (verb === 'agents' && req.method === 'GET') return json(200, agents.agents(slug));
|
|
267
|
+
if (verb === 'live' && req.method === 'GET') {
|
|
268
|
+
// genaicode's session state, whole, on every change (at most ten a second while text streams).
|
|
269
|
+
res.writeHead(200, { ...headers, 'content-type': 'text/event-stream; charset=utf-8', connection: 'keep-alive' });
|
|
270
|
+
let timer: NodeJS.Timeout | undefined;
|
|
271
|
+
let latest: unknown = agents.state(slug);
|
|
272
|
+
const flush = () => {
|
|
273
|
+
timer = undefined;
|
|
274
|
+
res.write(`data: ${JSON.stringify(latest)}\n\n`);
|
|
275
|
+
};
|
|
276
|
+
flush();
|
|
277
|
+
const off = agents.watch(slug, (state) => {
|
|
278
|
+
latest = state;
|
|
279
|
+
timer ??= setTimeout(flush, 100);
|
|
280
|
+
});
|
|
281
|
+
const beat = setInterval(() => res.write(': beat\n\n'), 15_000);
|
|
282
|
+
req.on('close', () => {
|
|
283
|
+
off();
|
|
284
|
+
clearTimeout(timer);
|
|
285
|
+
clearInterval(beat);
|
|
286
|
+
});
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (verb && req.method === 'POST') {
|
|
290
|
+
const { value, error } = await body();
|
|
291
|
+
if (error) return json(error[0], { error: error[1] });
|
|
292
|
+
const input = (value ?? {}) as { text?: unknown; id?: unknown; decision?: unknown; name?: unknown };
|
|
293
|
+
try {
|
|
294
|
+
if (verb === 'say') {
|
|
295
|
+
if (typeof input.text !== 'string' || !input.text.trim() || input.text.length > 8000) return json(400, { error: 'say something (up to 8000 characters)' });
|
|
296
|
+
await agents.say(slug, input.text.trim());
|
|
297
|
+
} else if (verb === 'stop') agents.stop(slug);
|
|
298
|
+
else if (verb === 'use') {
|
|
299
|
+
if (typeof input.name !== 'string' || !isAgentName(input.name)) return json(400, { error: 'name one of the offered agents' });
|
|
300
|
+
agents.use(slug, input.name);
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
if (input.decision !== 'approve' && input.decision !== 'deny') return json(400, { error: 'decision is approve or deny' });
|
|
304
|
+
agents.answer(slug, String(input.id), input.decision);
|
|
305
|
+
}
|
|
306
|
+
return json(200, { ok: true });
|
|
307
|
+
} catch (e) {
|
|
308
|
+
return json(409, { error: redact(e instanceof Error ? e.message : String(e)) });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (url.pathname.startsWith('/api/actions/') && req.method === 'POST') {
|
|
313
|
+
const { value: input, error } = await body();
|
|
314
|
+
if (error) return json(error[0], { error: error[1] });
|
|
315
|
+
const ctx: ActionContext = { by: asAgent ? 'agent' : 'user', root, jobs, events };
|
|
316
|
+
try {
|
|
317
|
+
return json(200, { result: await runAction(url.pathname.slice('/api/actions/'.length), input, ctx) });
|
|
318
|
+
} catch (error) {
|
|
319
|
+
const status = error instanceof ActionError ? error.status : 500;
|
|
320
|
+
return json(status, { error: redact(error instanceof Error ? error.message : String(error)) });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (url.pathname === '/api/events' && req.method === 'GET') {
|
|
324
|
+
const world = url.searchParams.get('world') ?? undefined;
|
|
325
|
+
if (world && !Slug.safeParse(world).success) return json(400, { error: 'bad world' });
|
|
326
|
+
const since = Number(url.searchParams.get('since') ?? 0);
|
|
327
|
+
res.writeHead(200, { ...headers, 'content-type': 'text/event-stream; charset=utf-8', connection: 'keep-alive' });
|
|
328
|
+
const write = (e: StudioEvent) => {
|
|
329
|
+
if (!world || !e.world || e.world === world) res.write(`id: ${e.seq}\ndata: ${JSON.stringify(e)}\n\n`);
|
|
330
|
+
};
|
|
331
|
+
for (const e of events.since(since, world)) write(e);
|
|
332
|
+
res.write(': ready\n\n');
|
|
333
|
+
const off = events.subscribe(write);
|
|
334
|
+
const unwatch = world ? watch(world) : () => {};
|
|
335
|
+
const beat = setInterval(() => res.write(': beat\n\n'), 15_000);
|
|
336
|
+
req.on('close', () => {
|
|
337
|
+
off();
|
|
338
|
+
unwatch();
|
|
339
|
+
clearInterval(beat);
|
|
340
|
+
});
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
return json(404, { error: 'no such endpoint' });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return send(405, 'text/plain', 'method not allowed');
|
|
347
|
+
|
|
348
|
+
// The game itself, in a frame of the Workshop: the same page `diorama play` serves, at /play/<slug>/,
|
|
349
|
+
// reading the world through the session cookie like the pictures do.
|
|
350
|
+
const play = /^\/play\/([a-z][a-z0-9-]*)\/(.*)$/.exec(url.pathname);
|
|
351
|
+
if (play) {
|
|
352
|
+
if (!cookieOk) return send(401, 'text/plain', NO_SESSION);
|
|
353
|
+
const [, slug, rest] = play;
|
|
354
|
+
if (!fs.existsSync(path.join(root, slug, 'world.json'))) return send(404, 'text/plain', 'no such world yet');
|
|
355
|
+
const framed = { 'content-security-policy': PLAY_CSP, 'x-frame-options': 'SAMEORIGIN' };
|
|
356
|
+
if (rest === '') return send(200, 'text/html; charset=utf-8', fs.readFileSync(path.join(WEB, 'index.html')), framed);
|
|
357
|
+
if (rest === 'main.js') return send(200, 'text/javascript; charset=utf-8', await (playJs ??= bundle().catch((e) => ((playJs = null), Promise.reject(e)))));
|
|
358
|
+
if (rest === 'world-data.json') return json(200, worldData(new WorldFiles(slug, root)));
|
|
359
|
+
if (rest.startsWith('files/')) url.pathname = `/files/${slug}/${rest.slice('files/'.length)}`;
|
|
360
|
+
else return send(404, 'text/plain', 'not found');
|
|
361
|
+
}
|
|
362
|
+
if (url.pathname.startsWith('/files/')) {
|
|
363
|
+
if (!cookieOk) return send(401, 'text/plain', NO_SESSION);
|
|
364
|
+
const [slug, ...rest] = decodeURIComponent(url.pathname.slice('/files/'.length)).split('/');
|
|
365
|
+
if (!Slug.safeParse(slug).success) return send(404, 'text/plain', 'not found');
|
|
366
|
+
const dir = path.join(root, slug);
|
|
367
|
+
const file = path.resolve(dir, rest.join('/'));
|
|
368
|
+
// Only a world's own files, and never its memo (model answers) or anything outside the folder.
|
|
369
|
+
if (!file.startsWith(dir + path.sep) || file.startsWith(path.join(dir, 'memo') + path.sep) || !fs.existsSync(file) || !fs.statSync(file).isFile()) return send(404, 'text/plain', 'not found');
|
|
370
|
+
return send(200, TYPES[path.extname(file)] ?? 'application/octet-stream', fs.readFileSync(file));
|
|
371
|
+
}
|
|
372
|
+
// A world's exported zip, for the person to download: exports/ sits beside the worlds folder, where the jobs run.
|
|
373
|
+
const zip = /^\/exports\/([a-z][a-z0-9-]*)\.zip$/.exec(url.pathname);
|
|
374
|
+
if (zip) {
|
|
375
|
+
if (!cookieOk) return send(401, 'text/plain', NO_SESSION);
|
|
376
|
+
const file = path.join(path.dirname(root), 'exports', `${zip[1]}.zip`);
|
|
377
|
+
if (!fs.existsSync(file)) return send(404, 'text/plain', 'not exported yet');
|
|
378
|
+
return send(200, 'application/zip', fs.readFileSync(file), { 'content-disposition': `attachment; filename="${zip[1]}.zip"` });
|
|
379
|
+
}
|
|
380
|
+
if (url.pathname === '/app.js') {
|
|
381
|
+
if (!app || process.env.DIORAMA_STUDIO_DEV) app = bundleApp();
|
|
382
|
+
try {
|
|
383
|
+
return send(200, 'text/javascript; charset=utf-8', await app);
|
|
384
|
+
} catch (error) {
|
|
385
|
+
app = null;
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// The Studio's own fonts, then genaicode's chat styles, then the Studio's, which tune them to its look.
|
|
390
|
+
if (url.pathname === '/app.css') return send(200, 'text/css; charset=utf-8', Buffer.concat([fs.readFileSync(path.join(STUDIO_WEB, 'fonts', 'fonts.css')), fs.readFileSync(CHAT_CSS), Buffer.from('\n'), fs.readFileSync(path.join(STUDIO_WEB, 'style.css'))]));
|
|
391
|
+
if (url.pathname === '/favicon.svg' || url.pathname === '/favicon.ico') return send(200, 'image/svg+xml', fs.readFileSync(path.join(STUDIO_WEB, 'favicon.svg')), { 'cache-control': 'max-age=86400' });
|
|
392
|
+
const font = /^\/fonts\/([a-z-]+\.woff2)$/.exec(url.pathname);
|
|
393
|
+
if (font && fs.existsSync(path.join(STUDIO_WEB, 'fonts', font[1]))) return send(200, 'font/woff2', fs.readFileSync(path.join(STUDIO_WEB, 'fonts', font[1])), { 'cache-control': 'max-age=86400' });
|
|
394
|
+
// Every other path is a screen of the single-page app.
|
|
395
|
+
return send(200, 'text/html; charset=utf-8', fs.readFileSync(path.join(STUDIO_WEB, 'index.html')), { 'content-security-policy': CSP, 'x-frame-options': 'DENY' });
|
|
396
|
+
} catch (error) {
|
|
397
|
+
send(500, 'text/plain', redact(String(error instanceof Error ? error.message : error)));
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
await new Promise<void>((resolve, reject) => {
|
|
402
|
+
server.once('error', reject);
|
|
403
|
+
server.listen(opts.port ?? 0, '127.0.0.1', () => resolve());
|
|
404
|
+
});
|
|
405
|
+
port = (server.address() as { port: number }).port;
|
|
406
|
+
agents = new AgentHub(events, { root, mcpUrl: `http://127.0.0.1:${port}/mcp`, agentToken, command: opts.muse });
|
|
407
|
+
return {
|
|
408
|
+
url: `http://127.0.0.1:${port}/`,
|
|
409
|
+
port,
|
|
410
|
+
token,
|
|
411
|
+
agentToken,
|
|
412
|
+
root,
|
|
413
|
+
events,
|
|
414
|
+
jobs,
|
|
415
|
+
async close() {
|
|
416
|
+
jobs.stopAll();
|
|
417
|
+
agents.stopAll();
|
|
418
|
+
for (const w of watches.values()) w.stop();
|
|
419
|
+
server.closeAllConnections();
|
|
420
|
+
await new Promise((resolve) => server.close(resolve));
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// What is wrong with a place, in one line, and the step to rebuild it from: a step the last build set aside the
|
|
2
|
+
// place at (build.json), a step an earlier job never got it past (a missing file), or verify's first error.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { PLACE_STEPS, placeGap, STEPS } from '../commands/build.ts';
|
|
6
|
+
import type { Verdict } from '../scene/verify.ts';
|
|
7
|
+
import { currentVerdict } from '../scene/status.ts';
|
|
8
|
+
import { refusedByFilter } from '../scene/words.ts';
|
|
9
|
+
|
|
10
|
+
type Step = (typeof STEPS)[number];
|
|
11
|
+
export type Trouble = { why: string; from: string | null; errors: string[] };
|
|
12
|
+
|
|
13
|
+
const readJson = <T>(file: string): T | null => {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(fs.readFileSync(file, 'utf8')) as T;
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** The short reason a failed step gives, without the model API's JSON. */
|
|
22
|
+
function said(step: string, error: string): string {
|
|
23
|
+
if (refusedByFilter(new Error(error))) return `${step}: the image safety filter refused the painting`;
|
|
24
|
+
if (/never got past/.test(error)) return error.split(':')[0];
|
|
25
|
+
return `${step}: ${error.replace(/\s*\{.*$/s, '').slice(0, 140)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Null when the place is playable or not built far enough to tell. */
|
|
29
|
+
export function placeTrouble(worldDir: string, place: string): Trouble | null {
|
|
30
|
+
const dir = path.join(worldDir, 'places', place);
|
|
31
|
+
const log = readJson<{ steps?: Record<string, unknown>; places?: Array<{ place: string; from?: string; errors?: string[] }> }>(path.join(worldDir, 'build.json'));
|
|
32
|
+
const report = log?.places?.find((r) => r.place === place);
|
|
33
|
+
// A step an earlier job never got the place past, once the world's build went on beyond it (not while it builds).
|
|
34
|
+
// Only place steps count: the cast, voices and sound run alongside and finish while a place is still being cut out.
|
|
35
|
+
const gap = fs.existsSync(path.join(dir, 'blockin.png')) ? placeGap(dir, 'verify') : null;
|
|
36
|
+
const past = gap && [...Object.keys(log?.steps ?? {}), ...(report?.from ? [report.from] : [])].some((s) => PLACE_STEPS.includes(s as Step) && STEPS.indexOf(s as Step) > STEPS.indexOf(gap.step));
|
|
37
|
+
const gapFirst = gap && past && (!report?.from || STEPS.indexOf(gap.step) < STEPS.indexOf(report.from as Step));
|
|
38
|
+
if (gapFirst) return { why: `${gap.step} never finished here (no ${gap.file})`, from: gap.step, errors: report?.errors ?? [] };
|
|
39
|
+
if (report?.from) return { why: said(report.from, firstError(report.errors)), from: report.from, errors: report.errors ?? [] };
|
|
40
|
+
const verdict = currentVerdict(worldDir, place);
|
|
41
|
+
if (verdict && !verdict.ok) {
|
|
42
|
+
const lines = verdict.errors.map((x) => `${x.code}${x.key ? ` ${x.key}` : ''}: ${x.detail}`);
|
|
43
|
+
return { why: (lines[0] ?? 'not playable').slice(0, 160), from: null, errors: lines };
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const firstError = (errors?: string[]) => (errors?.[0] ?? '').replace(/^STEP_FAILED \S+: /, '');
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The world step: a prompt becomes world.json. Spark designs it; the gate sends problems back.
|
|
2
|
+
import type { Memo } from '../meta/memo.ts';
|
|
3
|
+
import { askSparkJson } from '../meta/spark.ts';
|
|
4
|
+
import { checkWorld, type Place, type World } from './schema.ts';
|
|
5
|
+
|
|
6
|
+
const EXAMPLE = `{
|
|
7
|
+
"title": "...",
|
|
8
|
+
"pitch": "two sentences for the back of the box",
|
|
9
|
+
"style": "one painting style for every place: medium, palette, light (no artist names)",
|
|
10
|
+
"start": "<place key>",
|
|
11
|
+
"hero": { "key": "kebab-key", "looks": "the hero head to toe" },
|
|
12
|
+
"places": [{
|
|
13
|
+
"key": "kebab-key",
|
|
14
|
+
"shows": "what the painting of this place shows, front to back",
|
|
15
|
+
"mood": "time of day, weather, feeling",
|
|
16
|
+
"things": [{ "key": "kebab-key", "role": "prop | exit | item | npc", "looks": "a few words" }],
|
|
17
|
+
"exits": [{ "to": "<place key>", "via": "<key of a thing with role exit here>" }]
|
|
18
|
+
}]
|
|
19
|
+
}`;
|
|
20
|
+
|
|
21
|
+
function brief(idea: string, places: number, fixed?: Place, extra = ''): string {
|
|
22
|
+
const first = fixed
|
|
23
|
+
? `\nThe first place is a real room from a photo and is fixed. It is the start; copy its key, shows, mood and things unchanged and only add exits through its exit things:\n${JSON.stringify(fixed)}\n`
|
|
24
|
+
: '';
|
|
25
|
+
return `You design worlds for a 2D point-and-click adventure. Each place is one painted background seen from a fixed camera, slightly from above, with open floor the hero walks on.
|
|
26
|
+
|
|
27
|
+
The idea:
|
|
28
|
+
"""
|
|
29
|
+
${idea}
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
${first}
|
|
33
|
+
${extra ? `${extra}
|
|
34
|
+
|
|
35
|
+
` : ''}Design ${places} places that tell this idea${fixed ? ', the room included' : ''}. Rules:
|
|
36
|
+
- 3 to 7 things per place, big enough to see and click; no tiny details.
|
|
37
|
+
- Exits are things you can see (door, path, ladder, boat) and every exit has one back.
|
|
38
|
+
- The world has at least one item to pick up and one npc to talk to.
|
|
39
|
+
- Everything is original: no characters, places or names from existing films, books, games or brands.
|
|
40
|
+
- A place's shows and its props' looks go to a painter whose safety filter refuses single words: calm, plain words (leafless trees, not bare; a swing seat that rests, not hangs on chains; no blood, weapons, graves or bodies).
|
|
41
|
+
|
|
42
|
+
Reply with JSON only, shaped like:
|
|
43
|
+
${EXAMPLE}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** More to ask of the world than the idea: a brief part, and what to check the answer for. */
|
|
47
|
+
export type Extra = { brief: string; issues: (world: World) => string[] };
|
|
48
|
+
|
|
49
|
+
/** Up to three tries: the second and third show Spark its last answer and what was wrong with it. */
|
|
50
|
+
export async function designWorld(memo: Memo, idea: string, places = 4, fixed?: Place, extra?: Extra): Promise<World> {
|
|
51
|
+
let prompt = brief(idea, places, fixed, extra?.brief);
|
|
52
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
53
|
+
const answer = await askSparkJson(memo, { label: `world#${attempt}`, prompt });
|
|
54
|
+
const checked = checkWorld(fixed ? keepFixed(answer, fixed) : answer);
|
|
55
|
+
const { world } = checked;
|
|
56
|
+
const issues = world && extra ? extra.issues(world) : checked.issues;
|
|
57
|
+
if (world && issues.length === 0) return world;
|
|
58
|
+
console.log(` world try ${attempt}: ${issues.length} issue(s): ${issues.slice(0, 4).join('; ')}`);
|
|
59
|
+
prompt = `${brief(idea, places, fixed, extra?.brief)}\n\nYour last answer was:\n${JSON.stringify(answer)}\n\nFix every one of these and reply with the whole JSON again:\n- ${issues.join('\n- ')}`;
|
|
60
|
+
}
|
|
61
|
+
throw new Error('Spark gave no valid world in 3 tries');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The photo room is what the photo shows, whatever Spark wrote: only its exits come from the answer. */
|
|
65
|
+
function keepFixed(answer: unknown, fixed: Place): unknown {
|
|
66
|
+
const world = answer as { start?: string; places?: { key?: string; exits?: unknown }[] };
|
|
67
|
+
if (!Array.isArray(world?.places)) return answer;
|
|
68
|
+
const at = world.places.findIndex((p) => p?.key === fixed.key);
|
|
69
|
+
const exits = at >= 0 ? world.places[at].exits : [];
|
|
70
|
+
const places = at >= 0 ? world.places.map((p, i) => (i === at ? { ...fixed, exits } : p)) : [{ ...fixed, exits }, ...world.places];
|
|
71
|
+
return { ...world, start: fixed.key, places };
|
|
72
|
+
}
|