castle-web-cli 0.4.83 → 0.4.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/agent-failures.d.ts +17 -0
  2. package/dist/agent-failures.js +151 -0
  3. package/dist/agent.d.ts +26 -0
  4. package/dist/agent.js +317 -74
  5. package/dist/ide.js +35 -13
  6. package/dist/native/loop.js +35 -0
  7. package/dist/native/openrouter.d.ts +7 -0
  8. package/dist/native/openrouter.js +25 -1
  9. package/dist/native/types.d.ts +2 -0
  10. package/dist/native/types.js +0 -38
  11. package/dist/openrouter-catalog.d.ts +28 -0
  12. package/dist/openrouter-catalog.js +299 -0
  13. package/dist/shell/assets/{index-C9Zhmien.js → index-BJLaUTJE.js} +60 -58
  14. package/dist/shell/assets/{index-BMkQt27u.css → index-DonnH--m.css} +1 -1
  15. package/dist/shell/index.html +2 -2
  16. package/kits/physics-2d/.prettierrc +8 -0
  17. package/kits/physics-2d/CLAUDE.md +329 -0
  18. package/kits/physics-2d/behaviors/Camera.jsx +43 -0
  19. package/kits/physics-2d/behaviors/Collider.jsx +199 -0
  20. package/kits/physics-2d/behaviors/Goal.jsx +29 -0
  21. package/kits/physics-2d/behaviors/Layout.jsx +53 -0
  22. package/kits/physics-2d/behaviors/Sprite.jsx +352 -0
  23. package/kits/physics-2d/behaviors/tint.js +47 -0
  24. package/kits/physics-2d/blueprints/ball.scene +14 -0
  25. package/kits/physics-2d/blueprints/block.scene +12 -0
  26. package/kits/physics-2d/blueprints/cauldron.scene +18 -0
  27. package/kits/physics-2d/blueprints/crate.scene +14 -0
  28. package/kits/physics-2d/blueprints/goal.scene +12 -0
  29. package/kits/physics-2d/castle.json +13 -0
  30. package/kits/physics-2d/docs/pxart-format.md +377 -0
  31. package/kits/physics-2d/drawings/block.pxart +25 -0
  32. package/kits/physics-2d/drawings/cauldron.pxart +113 -0
  33. package/kits/physics-2d/editors/BlueprintLibrary.jsx +247 -0
  34. package/kits/physics-2d/editors/ErrorBoundary.jsx +59 -0
  35. package/kits/physics-2d/editors/PlayOnly.jsx +31 -0
  36. package/kits/physics-2d/editors/PxArtEditor.jsx +954 -0
  37. package/kits/physics-2d/editors/SceneEditor.jsx +1681 -0
  38. package/kits/physics-2d/editors/SelectionOverlay.jsx +909 -0
  39. package/kits/physics-2d/editors/SingleEditor.jsx +122 -0
  40. package/kits/physics-2d/editors/behaviorRegistry.js +30 -0
  41. package/kits/physics-2d/editors/editorHistory.js +157 -0
  42. package/kits/physics-2d/editors/inspectorSheet.js +13 -0
  43. package/kits/physics-2d/editors/pixelCanvas.js +11 -0
  44. package/kits/physics-2d/editors/pixelEditorChrome.jsx +74 -0
  45. package/kits/physics-2d/editors/pixelGeometry.js +140 -0
  46. package/kits/physics-2d/editors/pixelInspector.jsx +633 -0
  47. package/kits/physics-2d/editors/pxArtEditorModel.js +732 -0
  48. package/kits/physics-2d/editors/pxArtPlayback.js +92 -0
  49. package/kits/physics-2d/editors/pxArtTimeline.jsx +752 -0
  50. package/kits/physics-2d/editors/pxArtTimeline.module.css +506 -0
  51. package/kits/physics-2d/editors/pxArtTools.js +232 -0
  52. package/kits/physics-2d/editors/useArtboardFit.js +102 -0
  53. package/kits/physics-2d/engine/ScenePlayer.jsx +196 -0
  54. package/kits/physics-2d/engine/SceneUI.jsx +59 -0
  55. package/kits/physics-2d/engine/assets.js +15 -0
  56. package/kits/physics-2d/engine/autoInspector.jsx +70 -0
  57. package/kits/physics-2d/engine/blueprint.js +521 -0
  58. package/kits/physics-2d/engine/collider.js +196 -0
  59. package/kits/physics-2d/engine/files.js +117 -0
  60. package/kits/physics-2d/engine/liveReload.js +88 -0
  61. package/kits/physics-2d/engine/pxart.js +1032 -0
  62. package/kits/physics-2d/engine/pxartSmooth.js +222 -0
  63. package/kits/physics-2d/engine/scene.js +686 -0
  64. package/kits/physics-2d/engine/spriteGeometry.js +32 -0
  65. package/kits/physics-2d/engine/ui.jsx +688 -0
  66. package/kits/physics-2d/engine/ui.module.css +2287 -0
  67. package/kits/physics-2d/eslint.config.js +71 -0
  68. package/kits/physics-2d/index.html +24 -0
  69. package/kits/physics-2d/main.jsx +24 -0
  70. package/kits/physics-2d/package-lock.json +2706 -0
  71. package/kits/physics-2d/package.json +42 -0
  72. package/kits/physics-2d/physics/PhysicsSystem.js +290 -0
  73. package/kits/physics-2d/physics/behaviors/AnalogStick.jsx +101 -0
  74. package/kits/physics-2d/physics/behaviors/Draggable.jsx +79 -0
  75. package/kits/physics-2d/physics/behaviors/RigidBody.jsx +55 -0
  76. package/kits/physics-2d/physics/behaviors/Slingshot.jsx +118 -0
  77. package/kits/physics-2d/physics/controls.js +79 -0
  78. package/kits/physics-2d/physics/index.js +26 -0
  79. package/kits/physics-2d/physics/matterBridge.js +126 -0
  80. package/kits/physics-2d/pnpm-lock.yaml +1761 -0
  81. package/kits/physics-2d/scenes/main.scene +12 -0
  82. package/kits/physics-2d/scenes/sandbox.scene +13 -0
  83. package/kits/physics-2d/scripts/draw.mjs +121 -0
  84. package/kits/physics-2d/vite.config.js +1 -0
  85. package/package.json +1 -1
@@ -0,0 +1,521 @@
1
+ // Blueprint system: every actor in a scene is an instance of a blueprint (a
2
+ // `.scene` file under `blueprints/` whose `actors[0]` is the template, with no
3
+ // `id`). An instance stores only sparse per-property overrides in its own
4
+ // `components`; blueprint template values are the base, merged live from the
5
+ // current `files` map (never a load-time snapshot) so a blueprint edit in one
6
+ // panel propagates to every scene that places it. Shared by the runtime
7
+ // (`scene.js`, so play mode and `PlayOnly` resolve blueprints too) and the
8
+ // editors (which also mint/fork/migrate/cascade-delete blueprint files).
9
+ import { formatJson } from './files';
10
+ import { DEFAULT_RESOLUTION, TRANSPARENT, serializeCompact } from './pxart';
11
+ import { cardSize, dedupeActorId } from './scene';
12
+
13
+ export const BLUEPRINTS_DIR = 'blueprints';
14
+ export const DRAWINGS_DIR = 'drawings';
15
+
16
+ export function isBlueprintPath(path) {
17
+ return typeof path === 'string' && path.startsWith(`${BLUEPRINTS_DIR}/`) && path.endsWith('.scene');
18
+ }
19
+
20
+ // Per-property inherit metadata for a behavior prop. Behaviors opt a prop out
21
+ // of inheritance via `static propertyMeta = { propName: { inherit: false } }`
22
+ // (see behaviors/Layout.jsx for x/y/rotation); anything absent defaults to
23
+ // inherited. A non-inherited prop is always written as an instance value at
24
+ // placement time -- the blueprint's value for it is only a default for newly
25
+ // placed instances, and future blueprint edits never move an existing
26
+ // instance's copy of that prop.
27
+ export function getPropertyMeta(Behavior, prop) {
28
+ return Behavior?.propertyMeta?.[prop] ?? { inherit: true };
29
+ }
30
+
31
+ export function isInheritedProp(Behavior, prop) {
32
+ return getPropertyMeta(Behavior, prop).inherit !== false;
33
+ }
34
+
35
+ // Parse a blueprint file's text into { name, components } (the template
36
+ // actor's components), or null if the text isn't a valid blueprint (used to
37
+ // treat a tombstoned/deleted blueprint file as absent).
38
+ function parseBlueprintText(text) {
39
+ if (typeof text !== 'string') return null;
40
+ let data;
41
+ try {
42
+ data = JSON.parse(text);
43
+ } catch {
44
+ return null;
45
+ }
46
+ const actor = data?.actors?.[0];
47
+ if (!actor || typeof actor !== 'object' || !actor.components) return null;
48
+ const name = typeof data.name === 'string' && data.name.trim() ? data.name : 'Blueprint';
49
+ return { name, components: actor.components };
50
+ }
51
+
52
+ // Resolve a blueprint's template from the LIVE files map -- the load-bearing
53
+ // call for live propagation. Returns null when the file is missing, deleted,
54
+ // or malformed (a dangling `blueprint` ref degrades to "no template" rather
55
+ // than throwing).
56
+ export function getBlueprintTemplate(files, blueprintPath) {
57
+ return parseBlueprintText(files?.[blueprintPath]);
58
+ }
59
+
60
+ // Every blueprint currently in the deck, for the library/hotbar. Skips
61
+ // unparseable / tombstoned files (see `cascadeDeleteBlueprint`).
62
+ export function listBlueprints(files) {
63
+ const out = [];
64
+ for (const path of Object.keys(files ?? {})) {
65
+ if (!isBlueprintPath(path)) continue;
66
+ const template = parseBlueprintText(files[path]);
67
+ if (!template) continue;
68
+ out.push({ path, name: template.name, components: template.components });
69
+ }
70
+ out.sort((a, b) => a.path.localeCompare(b.path));
71
+ return out;
72
+ }
73
+
74
+ // Per-property merge: the blueprint template is the base, instance overrides
75
+ // win per-property within each component (not whole-component replacement). A
76
+ // component present on only one side appears whole.
77
+ export function mergeComponents(templateComponents, instanceComponents) {
78
+ const merged = {};
79
+ const names = new Set([
80
+ ...Object.keys(templateComponents ?? {}),
81
+ ...Object.keys(instanceComponents ?? {}),
82
+ ]);
83
+ for (const name of names) {
84
+ const templateProps = templateComponents?.[name];
85
+ const instanceProps = instanceComponents?.[name];
86
+ if (templateProps === undefined) merged[name] = { ...instanceProps };
87
+ else if (instanceProps === undefined) merged[name] = { ...templateProps };
88
+ else merged[name] = { ...templateProps, ...instanceProps };
89
+ }
90
+ return merged;
91
+ }
92
+
93
+ // The fully resolved components for one actor: its blueprint's template
94
+ // merged with its own sparse overrides, or its raw components when it carries
95
+ // no `blueprint` ref (the blueprint's own template actor, or a not-yet
96
+ // migrated legacy actor).
97
+ export function resolveActorComponents(files, actor) {
98
+ if (!actor?.blueprint) return actor?.components ?? {};
99
+ const template = getBlueprintTemplate(files, actor.blueprint);
100
+ return mergeComponents(template?.components, actor.components);
101
+ }
102
+
103
+ function countMatches(text, regex) {
104
+ let max = 0;
105
+ const re = new RegExp(regex);
106
+ const m = re.exec(text ?? '');
107
+ return m ? Number(m[1]) : max;
108
+ }
109
+
110
+ // "Blueprint N" -- N is one more than the highest existing "Blueprint <n>"
111
+ // name (not just existing.length + 1, so a gap left by a deleted blueprint
112
+ // doesn't get reused and collide with intent elsewhere).
113
+ function mintBlueprintName(existingNames) {
114
+ let max = 0;
115
+ for (const name of existingNames) max = Math.max(max, countMatches(name, /^Blueprint (\d+)$/));
116
+ return `Blueprint ${max + 1}`;
117
+ }
118
+
119
+ // `blueprints/blueprint-N.scene` -- the filename is a stable slug minted once
120
+ // at creation; nothing ever renames it afterward (renaming edits only `name`).
121
+ function mintBlueprintPath(existingPaths) {
122
+ let max = 0;
123
+ for (const path of existingPaths) {
124
+ max = Math.max(max, countMatches(path, new RegExp(`^${BLUEPRINTS_DIR}/blueprint-(\\d+)\\.scene$`)));
125
+ }
126
+ return `${BLUEPRINTS_DIR}/blueprint-${max + 1}.scene`;
127
+ }
128
+
129
+ // "auto-tetris" -> "Auto Tetris". Splits on non-alphanumeric runs (covers the
130
+ // hyphen/underscore ids actors typically use) and title-cases each word.
131
+ function humanizeActorId(id) {
132
+ return id
133
+ .split(/[^a-zA-Z0-9]+/)
134
+ .filter(Boolean)
135
+ .map((word) => word[0].toUpperCase() + word.slice(1))
136
+ .join(' ');
137
+ }
138
+
139
+ // "Auto Tetris" -> "auto-tetris" for the filename slug. Kept independent of
140
+ // the actor id's own formatting (an id could already be any casing/shape).
141
+ function slugifyActorId(id) {
142
+ return id
143
+ .trim()
144
+ .toLowerCase()
145
+ .replace(/[^a-z0-9]+/g, '-')
146
+ .replace(/^-+|-+$/g, '');
147
+ }
148
+
149
+ // Mint a readable, unique actor id from a display name (a blueprint's `name`):
150
+ // slugified and slug-suffix-deduped against `existing` (`cauldron`,
151
+ // `cauldron-2`, ...). This is the editor's placement/creation default, so a
152
+ // freshly placed actor reads as `cauldron` rather than an opaque hex id.
153
+ // `mintActorId` (hex) still backs runtime spawns and the dedup fallback.
154
+ export function mintActorIdFromName(existing, name) {
155
+ const base = slugifyActorId(typeof name === 'string' ? name : '');
156
+ return dedupeActorId(existing, base);
157
+ }
158
+
159
+ // Derive a migration blueprint's { name, path } from the orphan actor's own
160
+ // `id` -- the one meaningful signal available at migration time (vs. the
161
+ // anonymous "Blueprint N" counter), so e.g. an actor `id: "logo"` mints
162
+ // `blueprints/logo.scene` named "Logo" instead of "Blueprint 1". Returns null
163
+ // when the id doesn't yield a usable name/slug, so the caller falls back to
164
+ // the counter-based mint functions.
165
+ //
166
+ // Collision safety is load-bearing: `knownBlueprints` includes every
167
+ // already-migrated blueprint in this same run (see the caller's loop), so two
168
+ // orphan actors that humanize to the same name, or that collide with a
169
+ // pre-existing blueprint, must never mint the same path or duplicate name.
170
+ // Deterministically disambiguate with a numeric suffix instead.
171
+ function deriveBlueprintName(actorId, knownBlueprints) {
172
+ if (typeof actorId !== 'string') return null;
173
+ const baseSlug = slugifyActorId(actorId);
174
+ const baseName = humanizeActorId(actorId.trim());
175
+ if (!baseSlug || !baseName) return null;
176
+ const existingNames = new Set(knownBlueprints.map((blueprint) => blueprint.name));
177
+ const existingPaths = new Set(knownBlueprints.map((blueprint) => blueprint.path));
178
+ const MAX_ATTEMPTS = 1000;
179
+ for (let suffix = 1; suffix <= MAX_ATTEMPTS; suffix += 1) {
180
+ const name = suffix === 1 ? baseName : `${baseName} ${suffix}`;
181
+ const path = suffix === 1 ? `${BLUEPRINTS_DIR}/${baseSlug}.scene` : `${BLUEPRINTS_DIR}/${baseSlug}-${suffix}.scene`;
182
+ if (!existingNames.has(name) && !existingPaths.has(path)) return { name, path };
183
+ }
184
+ // Pathological: 1000 colliding suffixes. Let the caller fall back to the
185
+ // counter-based mint functions rather than looping forever.
186
+ return null;
187
+ }
188
+
189
+ // { name, path } for a freshly minted migration blueprint: the actor id's
190
+ // derived name/slug when usable and collision-free, else the anonymous
191
+ // counter (see `deriveBlueprintName`). Factored out so the branching here
192
+ // doesn't add to `migrateOrphanActors`'s own complexity.
193
+ function mintMigrationBlueprintName(actorId, knownBlueprints) {
194
+ const derived = deriveBlueprintName(actorId, knownBlueprints);
195
+ if (derived) return derived;
196
+ return {
197
+ name: mintBlueprintName(knownBlueprints.map((blueprint) => blueprint.name)),
198
+ path: mintBlueprintPath(knownBlueprints.map((blueprint) => blueprint.path)),
199
+ };
200
+ }
201
+
202
+ export function formatBlueprintFileText(name, components) {
203
+ return formatJson({ name, actors: [{ components }] });
204
+ }
205
+
206
+ // Rename a blueprint: replace its top-level `name`, preserving actors and any
207
+ // hand-added extras. Returns the next file text, or null when the file is
208
+ // missing/unparseable (caller should treat that as "blueprint gone" and skip
209
+ // the write). Empty/whitespace names are stored as-is; readers fall back to
210
+ // "Blueprint" for display (see getBlueprintTemplate).
211
+ export function setBlueprintName(files, blueprintPath, name) {
212
+ let data;
213
+ try {
214
+ data = JSON.parse(files?.[blueprintPath] ?? '');
215
+ } catch {
216
+ return null;
217
+ }
218
+ if (!data || typeof data !== 'object') return null;
219
+ data.name = name;
220
+ return formatJson(data);
221
+ }
222
+
223
+ // Replace a blueprint file's template components, preserving every other
224
+ // top-level field (name, hand-added extras). Returns the next file text, or
225
+ // null when the file is missing/unparseable/tombstoned (caller should treat
226
+ // that as "blueprint gone" and skip the write).
227
+ export function setBlueprintTemplateComponents(files, blueprintPath, components) {
228
+ let data;
229
+ try {
230
+ data = JSON.parse(files?.[blueprintPath] ?? '');
231
+ } catch {
232
+ return null;
233
+ }
234
+ if (!data?.actors?.[0]) return null;
235
+ data.actors[0].components = components;
236
+ return formatJson(data);
237
+ }
238
+
239
+ // Mint a brand new blueprint file descriptor ({ path, name, text }) for the
240
+ // given template components. Does not write the file -- callers write it
241
+ // (via the SDK's `writeFile`) and, for the current scene, place an instance
242
+ // referencing `path`.
243
+ export function mintBlueprintFile(files, components) {
244
+ const existing = listBlueprints(files);
245
+ const name = mintBlueprintName(existing.map((blueprint) => blueprint.name));
246
+ const path = mintBlueprintPath(existing.map((blueprint) => blueprint.path));
247
+ return { path, name, text: formatBlueprintFileText(name, components) };
248
+ }
249
+
250
+ // Split a resolved component's props into { inherited, overridden } by each
251
+ // prop's propertyMeta. Used by migration and fork to decide what belongs in a
252
+ // new blueprint template vs. what must stay an explicit instance value.
253
+ function splitByInherit(Behavior, props) {
254
+ const inherited = {};
255
+ const overridden = {};
256
+ for (const [prop, value] of Object.entries(props ?? {})) {
257
+ if (isInheritedProp(Behavior, prop)) inherited[prop] = value;
258
+ else overridden[prop] = value;
259
+ }
260
+ return { inherited, overridden };
261
+ }
262
+
263
+ function findBehavior(behaviors, name) {
264
+ return behaviors.find((candidate) => candidate.behaviorName === name);
265
+ }
266
+
267
+ // Apply an inspector-style partial patch (`{ propName: value, ... }`) to one
268
+ // actor's component, IN PLACE. For a blueprint's own template actor (no
269
+ // `blueprint` ref) this is a plain merge -- there's no baseline to diff
270
+ // against. For an instance, each patched prop is diffed against the
271
+ // blueprint's current value (falling back to the behavior's defaultProps):
272
+ // an inherited prop that becomes equal to the blueprint again drops its
273
+ // override (so it goes back to tracking blueprint edits); anything else is
274
+ // recorded as an override. Never writes a full component copy.
275
+ export function applyComponentPatch(files, behaviors, actor, behaviorName, nextProps) {
276
+ actor.components ??= {};
277
+ if (!actor.blueprint) {
278
+ actor.components[behaviorName] = { ...(actor.components[behaviorName] ?? {}), ...nextProps };
279
+ return;
280
+ }
281
+ const template = getBlueprintTemplate(files, actor.blueprint);
282
+ const templateProps = template?.components?.[behaviorName] ?? {};
283
+ const Behavior = findBehavior(behaviors, behaviorName);
284
+ const defaultProps = Behavior?.defaultProps ?? {};
285
+ const overrides = { ...(actor.components[behaviorName] ?? {}) };
286
+ for (const [prop, value] of Object.entries(nextProps)) {
287
+ const baseline = prop in templateProps ? templateProps[prop] : defaultProps[prop];
288
+ if (isInheritedProp(Behavior, prop) && Object.is(value, baseline)) delete overrides[prop];
289
+ else overrides[prop] = value;
290
+ }
291
+ if (Object.keys(overrides).length === 0) delete actor.components[behaviorName];
292
+ else actor.components[behaviorName] = overrides;
293
+ }
294
+
295
+ // Single-actor convenience wrapper around `applyComponentPatch` for the
296
+ // inspector: clones the scene, patches the one actor's component, returns the
297
+ // next scene (or the original when the actor is missing).
298
+ export function setInstanceComponent(files, behaviors, sceneData, actorId, behaviorName, nextProps) {
299
+ const next = structuredClone(sceneData);
300
+ const actor = next.actors?.find((candidate) => candidate.id === actorId);
301
+ if (!actor) return sceneData;
302
+ applyComponentPatch(files, behaviors, actor, behaviorName, nextProps);
303
+ return next;
304
+ }
305
+
306
+ function snapValue(value, snap) {
307
+ if (!snap?.enabled) return Math.round(value);
308
+ return Math.round(value / snap.gridSize) * snap.gridSize;
309
+ }
310
+
311
+ // "Add actor" = mint a new blueprint AND place its first instance in one
312
+ // gesture. Returns the next scene, the new instance id, and the new
313
+ // blueprint's { path, name, text } for the caller to write to disk.
314
+ // A blank 16x16 (DEFAULT_RESOLUTION) compact `.pxart`: all-transparent grid,
315
+ // empty palette. This is what a brand-new blueprint's sprite points at, so the
316
+ // "add actor" button always yields a fresh empty canvas to draw into rather
317
+ // than reusing whatever art happened to be first in drawings/.
318
+ export function blankPxArtText() {
319
+ const { width, height } = DEFAULT_RESOLUTION;
320
+ const grid = Array.from({ length: height }, () => TRANSPARENT.repeat(width));
321
+ return serializeCompact({ palette: {}, grid });
322
+ }
323
+
324
+ // `drawings/drawing-N.pxart` -- next free numbered slug, so repeated adds don't
325
+ // collide and a deleted drawing's number isn't reused.
326
+ function mintDrawingPath(files) {
327
+ const re = new RegExp(`^${DRAWINGS_DIR}/drawing-(\\d+)\\.pxart$`);
328
+ let max = 0;
329
+ for (const path of Object.keys(files ?? {})) {
330
+ const m = path.match(re);
331
+ if (m) max = Math.max(max, Number(m[1]));
332
+ }
333
+ return `${DRAWINGS_DIR}/drawing-${max + 1}.pxart`;
334
+ }
335
+
336
+ // The z that draws on top of every current actor: max effective z + 1 (0 for
337
+ // an empty scene). An actor's effective z resolves its own Layout override
338
+ // first, then its blueprint template, then 0 -- so this beats instances that
339
+ // only inherit z from their blueprint.
340
+ function topZ(actors, files) {
341
+ let max = -Infinity;
342
+ for (const actor of actors ?? []) {
343
+ const template = actor.blueprint ? getBlueprintTemplate(files, actor.blueprint) : null;
344
+ const z = actor.components?.Layout?.z ?? template?.components?.Layout?.z ?? 0;
345
+ if (z > max) max = z;
346
+ }
347
+ return max === -Infinity ? 0 : max + 1;
348
+ }
349
+
350
+ export function addActorWithBlueprint(sceneData, files) {
351
+ const width = 50;
352
+ const height = 50;
353
+ const drawingFile = { path: mintDrawingPath(files), text: blankPxArtText() };
354
+ const components = {
355
+ Layout: { width, height, z: 0 },
356
+ Sprite: { file: drawingFile.path },
357
+ };
358
+ const blueprintFile = mintBlueprintFile(files, components);
359
+ const next = structuredClone(sceneData);
360
+ const existingIds = new Set(next.actors.map((actor) => actor.id));
361
+ const newId = mintActorIdFromName(existingIds, blueprintFile.name);
362
+ const x = Math.round((cardSize.width - width) / 2);
363
+ const y = Math.round((cardSize.height - height) / 2);
364
+ // Guarantee the new actor renders on top of whatever's already there.
365
+ const z = topZ(next.actors, files);
366
+ next.actors.push({ id: newId, blueprint: blueprintFile.path, components: { Layout: { x, y, z } } });
367
+ return { sceneData: next, newId, blueprintFile, drawingFile };
368
+ }
369
+
370
+ // Centered, grid-snapped Layout x/y for a blueprint's template at a
371
+ // card-space point. Shared by initial drag-in placement and the subsequent
372
+ // moves while the drag is still in progress, so both snap identically.
373
+ export function blueprintDropXY(files, blueprintPath, position, snap) {
374
+ const template = getBlueprintTemplate(files, blueprintPath);
375
+ const layout = template?.components?.Layout ?? {};
376
+ const width = layout.width ?? 50;
377
+ const height = layout.height ?? 50;
378
+ const dropX = position?.x ?? cardSize.width / 2;
379
+ const dropY = position?.y ?? cardSize.height / 2;
380
+ return {
381
+ x: snapValue(dropX - width / 2, snap),
382
+ y: snapValue(dropY - height / 2, snap),
383
+ };
384
+ }
385
+
386
+ // Place a new instance of an existing blueprint at a drop point (card units,
387
+ // pre-snap), grid-snapped and centered on the point using the template's
388
+ // Layout size. Only Layout x/y are written on the instance -- everything else
389
+ // inherits from the blueprint.
390
+ export function placeBlueprintInstance(sceneData, files, blueprintPath, position, snap) {
391
+ const next = structuredClone(sceneData);
392
+ const existingIds = new Set(next.actors.map((actor) => actor.id));
393
+ const template = getBlueprintTemplate(files, blueprintPath);
394
+ const newId = mintActorIdFromName(existingIds, template?.name);
395
+ const { x, y } = blueprintDropXY(files, blueprintPath, position, snap);
396
+ next.actors.push({ id: newId, blueprint: blueprintPath, components: { Layout: { x, y } } });
397
+ return { sceneData: next, newId };
398
+ }
399
+
400
+ // "New blueprint from this actor": mint a blueprint whose template is the
401
+ // instance's fully merged components, with non-inherited props reset to their
402
+ // behavior defaults in the TEMPLATE (a fresh blueprint has no instances yet to
403
+ // carry position, so its own template position is a neutral default).
404
+ // Reparent the instance to the new blueprint, keeping only its non-inherited
405
+ // prop values (Layout position et al) as instance overrides -- everything
406
+ // else now matches the new template exactly, so no override is needed for it.
407
+ export function forkActorToBlueprint(files, behaviors, sceneData, actorId) {
408
+ const next = structuredClone(sceneData);
409
+ const actor = next.actors?.find((candidate) => candidate.id === actorId);
410
+ if (!actor) return null;
411
+ const merged = resolveActorComponents(files, actor);
412
+ const templateComponents = {};
413
+ const instanceOverrides = {};
414
+ for (const [behaviorName, props] of Object.entries(merged)) {
415
+ if (!props) continue;
416
+ const Behavior = findBehavior(behaviors, behaviorName);
417
+ const { inherited, overridden } = splitByInherit(Behavior, props);
418
+ if (Object.keys(overridden).length > 0) instanceOverrides[behaviorName] = overridden;
419
+ // The new template's non-inherited props reset to the behavior's default
420
+ // (a fresh blueprint has no instances yet to carry a real position); its
421
+ // inherited props keep the actor's current (merged) values verbatim.
422
+ const defaultProps = Behavior?.defaultProps ?? {};
423
+ const resetOverridden = Object.fromEntries(
424
+ Object.keys(overridden).map((prop) => [prop, prop in defaultProps ? defaultProps[prop] : overridden[prop]])
425
+ );
426
+ if (Object.keys(inherited).length > 0 || Object.keys(resetOverridden).length > 0) {
427
+ templateComponents[behaviorName] = { ...inherited, ...resetOverridden };
428
+ }
429
+ }
430
+ const blueprintFile = mintBlueprintFile(files, templateComponents);
431
+ actor.blueprint = blueprintFile.path;
432
+ actor.components = instanceOverrides;
433
+ return { sceneData: next, blueprintFile };
434
+ }
435
+
436
+ // Every `scenes/*.scene` file's parsed data, for callers that need to scan
437
+ // every scene in the deck (cascade delete, the instance-count badge/confirm).
438
+ // Skips unparseable files rather than throwing -- a mid-edit scene with
439
+ // invalid JSON just doesn't count towards these scans.
440
+ function parseSceneFiles(files) {
441
+ const out = [];
442
+ for (const [path, text] of Object.entries(files ?? {})) {
443
+ if (!path.startsWith('scenes/') || !path.endsWith('.scene')) continue;
444
+ try {
445
+ out.push({ path, data: JSON.parse(text) });
446
+ } catch {
447
+ // skip
448
+ }
449
+ }
450
+ return out;
451
+ }
452
+
453
+ // Every `scenes/*.scene` file's actor count referencing a blueprint, for the
454
+ // delete-confirm dialog and the library's per-blueprint badge.
455
+ export function countBlueprintInstances(files, blueprintPath) {
456
+ let count = 0;
457
+ for (const { data } of parseSceneFiles(files)) {
458
+ for (const actor of data?.actors ?? []) {
459
+ if (actor?.blueprint === blueprintPath) count += 1;
460
+ }
461
+ }
462
+ return count;
463
+ }
464
+
465
+ // Cascade-delete a blueprint: every instance in every scene is removed, and
466
+ // the blueprint file itself is tombstoned (overwritten with an empty scene --
467
+ // `listBlueprints` skips it and it stops resolving). Returns the list of
468
+ // { path, text } writes for the caller to apply (the currently open scene
469
+ // should go through that editor's own history/onChange so undo still works
470
+ // for it; every other file is a direct `writeFile`).
471
+ export function cascadeDeleteBlueprint(files, blueprintPath) {
472
+ const writes = [];
473
+ for (const { path, data } of parseSceneFiles(files)) {
474
+ const actors = data.actors ?? [];
475
+ const filtered = actors.filter((actor) => actor?.blueprint !== blueprintPath);
476
+ if (filtered.length !== actors.length) {
477
+ writes.push({ path, text: formatJson({ ...data, actors: filtered }) });
478
+ }
479
+ }
480
+ writes.push({ path: blueprintPath, text: formatJson({}) });
481
+ return writes;
482
+ }
483
+
484
+ // Auto-migrate a scene's orphan actors (no `blueprint` field -- the pre-v1
485
+ // shape) into blueprint instances: mint one blueprint per orphan (its
486
+ // authored components split by propertyMeta, same as fork), rewrite the actor
487
+ // to reference it with only the non-inherited props as overrides. Mechanical
488
+ // and safe: never touches an actor that already has a `blueprint`.
489
+ export function migrateOrphanActors(files, behaviors, sceneData) {
490
+ const next = structuredClone(sceneData);
491
+ const newBlueprintFiles = [];
492
+ let knownBlueprints = listBlueprints(files);
493
+ for (const actor of next.actors ?? []) {
494
+ if (actor.blueprint) continue;
495
+ const templateComponents = {};
496
+ const instanceOverrides = {};
497
+ for (const [behaviorName, props] of Object.entries(actor.components ?? {})) {
498
+ if (!props) continue;
499
+ const Behavior = findBehavior(behaviors, behaviorName);
500
+ const { inherited, overridden } = splitByInherit(Behavior, props);
501
+ // A behavior attached with no props at all (e.g. `AutoTetris: {}`) has
502
+ // both `inherited` and `overridden` empty. It must still land somewhere,
503
+ // or the attachment itself -- not just some prop of it -- silently
504
+ // vanishes from the resolved actor. Fall back to the template side (as
505
+ // `{}`) so the instance keeps referencing the behavior exactly once.
506
+ if (Object.keys(inherited).length > 0 || Object.keys(overridden).length === 0) {
507
+ templateComponents[behaviorName] = inherited;
508
+ }
509
+ if (Object.keys(overridden).length > 0) instanceOverrides[behaviorName] = overridden;
510
+ }
511
+ // The actor's own id is the one meaningful signal available here (vs. an
512
+ // anonymous "Blueprint N") -- see `mintMigrationBlueprintName`.
513
+ const { name, path } = mintMigrationBlueprintName(actor.id, knownBlueprints);
514
+ const text = formatBlueprintFileText(name, templateComponents);
515
+ newBlueprintFiles.push({ path, text });
516
+ knownBlueprints = [...knownBlueprints, { path, name, components: templateComponents }];
517
+ actor.blueprint = path;
518
+ actor.components = instanceOverrides;
519
+ }
520
+ return { sceneData: next, newBlueprintFiles };
521
+ }