incanto 0.29.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/bin/incanto-editor.mjs +101 -28
  2. package/dist/2d.d.ts +28 -6
  3. package/dist/2d.js +3 -3
  4. package/dist/3d.d.ts +81 -7
  5. package/dist/3d.js +4 -4
  6. package/dist/{audit-C6rMyict.js → audit-C4kmDK0o.js} +16 -0
  7. package/dist/{behavior-BAc0erXF.d.ts → behavior-D_jMpFh8.d.ts} +117 -55
  8. package/dist/{create-game-CNKXGfpr.js → create-game-DqOjMBUS.js} +61 -48
  9. package/dist/{create-game-CbuLWorm.js → create-game-z5XaB1p5.js} +27 -15
  10. package/dist/debug-draw-BM3DsvtT.js +18 -0
  11. package/dist/debug.d.ts +1 -1
  12. package/dist/debug.js +8 -3
  13. package/dist/{duplicate-BgtFrFo4.js → duplicate-KRPtUtzl.js} +1 -1
  14. package/dist/{editor-switch-B0wB_DSr.d.ts → editor-switch-BJb-CWfA.d.ts} +12 -1
  15. package/dist/editor.js +1346 -1231
  16. package/dist/{register-C44aSduO.js → environment-presets--DigHNg4.js} +42 -11
  17. package/dist/{gameplay-L05WgWd1.js → gameplay-BftxM_It.js} +14 -5
  18. package/dist/gameplay.d.ts +1 -1
  19. package/dist/gameplay.js +1 -1
  20. package/dist/index.d.ts +12 -3
  21. package/dist/index.js +6 -6
  22. package/dist/{loader-B242FF6N.d.ts → loader-BYBrqTxP.d.ts} +1 -1
  23. package/dist/{loader-BZqOKfI2.js → loader-BlaRQGaA.js} +482 -6
  24. package/dist/net.d.ts +1 -1
  25. package/dist/net.js +3 -3
  26. package/dist/{pathfinding-BwD974Ss.d.ts → pathfinding-BwhqPD3i.d.ts} +1 -1
  27. package/dist/{physics-2d-CCVTrKOd.js → physics-2d-D9wquBvK.js} +30 -4
  28. package/dist/{physics-3d-BZZLtwJu.js → physics-3d-CnPygVGo.js} +41 -5
  29. package/dist/react.d.ts +1 -1
  30. package/dist/react.js +1 -1
  31. package/dist/{register-MelqEdza.js → register-CUY284Is.js} +3 -3
  32. package/dist/{register-BTg0EM7s.js → register-Dzkd6-os.js} +56 -380
  33. package/dist/{register-DWcWq4QG.js → register-tkR_8tWg.js} +4 -4
  34. package/dist/{registry-BVJ2HbCn.js → registry-CJdGpT2V.js} +6 -2
  35. package/dist/{editor-switch-CAKlJMIY.js → teardown-ByzfDPyu.js} +83 -4
  36. package/dist/test.d.ts +2 -7
  37. package/dist/test.js +43 -14
  38. package/dist/vite.d.ts +87 -1
  39. package/dist/vite.js +262 -3
  40. package/editor/assets/{agent8-DEVkEa3d.js → agent8-o27_Y1xN.js} +1 -1
  41. package/editor/assets/{debug-zGAtpDF0.js → debug-DfcWX3uW.js} +3 -2
  42. package/editor/assets/index-C9fb5QcT.js +10696 -0
  43. package/editor/index.html +1 -1
  44. package/package.json +1 -1
  45. package/skills/incanto-editor.md +61 -8
  46. package/skills/incanto-physics-and-input.md +15 -0
  47. package/skills/incanto-verifying-your-game.md +68 -0
  48. package/templates-app/beacon-isle-3d/package.json +1 -1
  49. package/templates-app/beacon-isle-3d/vite.config.ts +7 -0
  50. package/templates-app/tps-3d/package.json +1 -1
  51. package/templates-app/tps-3d/vite.config.ts +7 -0
  52. package/templates-app/village-quest-3d/package.json +1 -1
  53. package/templates-app/village-quest-3d/vite.config.ts +7 -0
  54. package/dist/debug-draw-CZmOYjL2.js +0 -13
  55. package/editor/assets/index-Bx4UtWYY.js +0 -10586
@@ -69,6 +69,35 @@ function devServerLibrary(token) {
69
69
  };
70
70
  }
71
71
  /**
72
+ * Does the page's own dev server serve the project's scenes?
73
+ *
74
+ * `incantoScenes()` from `incanto/vite` (the plugin that already validates
75
+ * scenes on save; `incanto-new` puts it in every scaffolded project) answers
76
+ * `GET /api/scenes`. When it does, the
77
+ * editor a game switches into is a PROJECT editor: it can list, open, create
78
+ * and save every scene in the project, and play whichever one you loaded.
79
+ * When it does not (a built game, someone else's server), the editor still
80
+ * edits the scene the game is running, exactly as before.
81
+ */
82
+ async function projectScenes() {
83
+ try {
84
+ const res = await fetch("/api/scenes");
85
+ if (!res.ok) return null;
86
+ const body = await res.json();
87
+ return Array.isArray(body) ? body : null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+ /** What to say when the dev server has no idea what scenes this project has. */
93
+ const NO_SCENE_ROUTES = "this dev server does not serve the project's scenes yet. Add incantoScenes() to vite.config.ts and restart:\n\n import { incantoScenes } from 'incanto/vite';\n export default defineConfig({ plugins: [incantoScenes()] });\n\nIt also validates every scene you save, before the page reloads into a broken one.";
94
+ async function readProjectScene(file) {
95
+ const res = await fetch(`/api/scene?file=${encodeURIComponent(file)}`);
96
+ if (res.ok) return await res.json();
97
+ const body = await res.json().catch(() => null);
98
+ throw new Error(body?.error ?? `could not open ${file}: HTTP ${res.status}`);
99
+ }
100
+ /**
72
101
  * The default opener: pull `incanto/editor` in on demand and mount it.
73
102
  *
74
103
  * It is a dynamic import so the editor — an IDE's worth of UI — never enters a
@@ -83,18 +112,44 @@ async function openBundledEditor(opts) {
83
112
  throw new Error(`createGame3D editor: could not load 'incanto/editor'. If this app aliases incanto to the engine SOURCE (the examples and playground do), add an alias for 'incanto/editor' → packages/editor/src/main.ts — or pass editor: { open } and load it yourself. (${error instanceof Error ? error.message : String(error)})`);
84
113
  }
85
114
  let handle = null;
115
+ const project = await projectScenes();
116
+ const format = mod.formatSceneJson ?? ((json) => JSON.stringify(json, null, 2));
117
+ const writeProjectScene = async (file, json) => {
118
+ const res = await fetch(`/api/scene?file=${encodeURIComponent(file)}`, {
119
+ method: "PUT",
120
+ body: format(json)
121
+ });
122
+ if (!res.ok) throw new Error(`could not save ${file}: HTTP ${res.status} ${await res.text()}`);
123
+ };
86
124
  handle = await mod.mountEditor({
87
125
  ...opts.camera ? { camera: opts.camera } : {},
88
126
  api: {
89
127
  meta: async () => ({
90
128
  version: "live",
91
- mode: "single",
129
+ mode: "project",
92
130
  root: "",
93
131
  input: null,
94
132
  output: null
95
133
  }),
96
- loadScene: async () => opts.scene,
97
- ...opts.save ? { saveScene: async (json) => void opts.save?.(json) } : {},
134
+ loadScene: async (file) => file ? await readProjectScene(file) : opts.scene,
135
+ ...opts.save || project ? { saveScene: async (json, file) => {
136
+ if (file) return writeProjectScene(file, json);
137
+ await opts.save?.(json);
138
+ } } : {},
139
+ scenes: async () => {
140
+ const found = await projectScenes();
141
+ if (!found) throw new Error(NO_SCENE_ROUTES);
142
+ return found;
143
+ },
144
+ createScene: async (path) => {
145
+ const res = await fetch("/api/scenes", {
146
+ method: "POST",
147
+ body: JSON.stringify({ path })
148
+ });
149
+ if (res.status === 404) throw new Error(NO_SCENE_ROUTES);
150
+ if (!res.ok) throw new Error(`could not create ${path}: ${await res.text()}`);
151
+ return await res.json();
152
+ },
98
153
  ...opts.library ? { library: opts.library } : {}
99
154
  },
100
155
  onPlay: (choice, working) => {
@@ -158,4 +213,28 @@ function poseFromRenderer(renderer, fallbackDistance = 8) {
158
213
  };
159
214
  }
160
215
  //#endregion
161
- export { poseFromRenderer as i, devServerLibrary as n, openBundledEditor as r, crossFade as t };
216
+ //#region src/core/teardown.ts
217
+ function teardown(steps, onError = defaultReport) {
218
+ const failures = [];
219
+ for (const step of steps) {
220
+ if (!step) continue;
221
+ const [what, run] = step;
222
+ try {
223
+ run();
224
+ } catch (error) {
225
+ failures.push({
226
+ what,
227
+ error
228
+ });
229
+ try {
230
+ onError(what, error);
231
+ } catch {}
232
+ }
233
+ }
234
+ return { failures };
235
+ }
236
+ function defaultReport(what, error) {
237
+ console.error(`[incanto] teardown: ${what} failed (continuing)`, error);
238
+ }
239
+ //#endregion
240
+ export { poseFromRenderer as a, openBundledEditor as i, crossFade as n, devServerLibrary as r, teardown as t };
package/dist/test.d.ts CHANGED
@@ -1,6 +1,6 @@
1
+ import { $ as LogEntry, X as Node, n as BehaviorCtor, v as Engine, w as Scene } from "./behavior-D_jMpFh8.js";
1
2
  import { c as JsonValue, i as SceneJson } from "./schema-CcoWb32N.js";
2
- import { $ as Node, T as LogEntry, n as BehaviorCtor, v as Engine, w as Scene } from "./behavior-BAc0erXF.js";
3
- import { t as LoadSceneOptions } from "./loader-B242FF6N.js";
3
+ import { t as LoadSceneOptions } from "./loader-BYBrqTxP.js";
4
4
  import { i as auditScene, t as IncantoError } from "./errors-1dXlIwoR.js";
5
5
 
6
6
  //#region src/test/index.d.ts
@@ -74,11 +74,6 @@ interface ValidationResult {
74
74
  /** The hard load error, when ok is false — `details` is machine-readable. */
75
75
  error?: IncantoError;
76
76
  }
77
- /**
78
- * Run every hard load-time check headlessly. Unregistered behaviors are
79
- * stubbed by default (structure-only validation, no TypeScript needed) —
80
- * pass the real classes via `behaviors` to validate script props too.
81
- */
82
77
  declare function validateScene(json: unknown, opts?: ValidateSceneOptions): ValidationResult;
83
78
  interface RunContext {
84
79
  engine: Engine;
package/dist/test.js CHANGED
@@ -1,13 +1,13 @@
1
- import { _ as registerBehavior, n as loadScene } from "./loader-BZqOKfI2.js";
2
- import { u as Engine } from "./register-BTg0EM7s.js";
1
+ import { n as loadScene, y as registerBehavior } from "./loader-BlaRQGaA.js";
2
+ import { u as Engine } from "./register-Dzkd6-os.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
- import { t as auditScene } from "./audit-C6rMyict.js";
4
+ import { t as auditScene } from "./audit-C4kmDK0o.js";
5
5
  import { n as jsonEquals, t as jsonClone } from "./json-BLk7H2Qa.js";
6
- import { i as getNodeSchema, s as mergeStaticProps } from "./registry-BVJ2HbCn.js";
7
- import { n as registerGameplayBehaviors } from "./gameplay-L05WgWd1.js";
8
- import { t as registerNodes2D } from "./register-DWcWq4QG.js";
9
- import { t as registerNodes3D } from "./register-C44aSduO.js";
10
- import { t as registerNodesNet } from "./register-MelqEdza.js";
6
+ import { i as getNodeSchema, s as mergeStaticProps } from "./registry-CJdGpT2V.js";
7
+ import { n as registerGameplayBehaviors } from "./gameplay-BftxM_It.js";
8
+ import { t as registerNodes2D } from "./register-tkR_8tWg.js";
9
+ import { n as registerNodes3D, t as resolveEnvironmentHdri } from "./environment-presets--DigHNg4.js";
10
+ import { t as registerNodesNet } from "./register-CUY284Is.js";
11
11
  //#region src/test/index.ts
12
12
  /**
13
13
  * incanto/test — the browserless verification harness.
@@ -184,10 +184,28 @@ function defaultsFor(typeName) {
184
184
  * stubbed by default (structure-only validation, no TypeScript needed) —
185
185
  * pass the real classes via `behaviors` to validate script props too.
186
186
  */
187
+ /**
188
+ * `environment.preset` is resolved deep inside Renderer3D, which no headless
189
+ * tool reaches — so a typo there passed `incanto-check` and hard-failed at boot.
190
+ * It cannot live in the core loader (core must never import 3d), but it belongs
191
+ * exactly here: the test entry already registers the 3D nodes.
192
+ */
193
+ function checkEnvironment(json) {
194
+ const env = json?.environment;
195
+ if (!env || typeof env !== "object") return;
196
+ if (env.preset === void 0) return;
197
+ if (typeof env.preset !== "string") throw new IncantoError("BAD_FORMAT", `Scene "environment.preset" must be a string, got ${JSON.stringify(env.preset)}.`);
198
+ try {
199
+ resolveEnvironmentHdri(env);
200
+ } catch (error) {
201
+ throw new IncantoError("BAD_FORMAT", error instanceof Error ? error.message : String(error));
202
+ }
203
+ }
187
204
  function validateScene(json, opts = {}) {
188
205
  registerAllNodes();
189
206
  for (const [name, ctor] of Object.entries(opts.behaviors ?? {})) registerBehavior(name, ctor, { replace: true });
190
207
  try {
208
+ checkEnvironment(json);
191
209
  loadScene(json, {
192
210
  resolveScene: opts.resolveScene,
193
211
  stubMissingBehaviors: !opts.strictBehaviors
@@ -218,10 +236,10 @@ async function runScript(json, opts) {
218
236
  engine.setScene(scene);
219
237
  const physics = opts.physics ?? "auto";
220
238
  if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
221
- const { enablePhysics2D } = await import("./physics-2d-CCVTrKOd.js").then((n) => n.r);
239
+ const { enablePhysics2D } = await import("./physics-2d-D9wquBvK.js").then((n) => n.r);
222
240
  await enablePhysics2D(engine);
223
241
  } else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
224
- const { enablePhysics3D } = await import("./physics-3d-BZZLtwJu.js").then((n) => n.r);
242
+ const { enablePhysics3D } = await import("./physics-3d-CnPygVGo.js").then((n) => n.r);
225
243
  await enablePhysics3D(engine);
226
244
  }
227
245
  const failures = [];
@@ -284,6 +302,12 @@ async function runScript(json, opts) {
284
302
  }
285
303
  const finalCapture = captureScene(scene);
286
304
  const logs = [...engine.log.entries()];
305
+ const swallowed = engine.stats().errors;
306
+ if (swallowed > 0) failures.push({
307
+ atMs: opts.durationMs,
308
+ label: "engine",
309
+ message: `${swallowed} error(s) were swallowed to keep the loop alive — the logs above name the node, the behavior and the phase`
310
+ });
287
311
  engine.dispose();
288
312
  const ok = failures.length === 0;
289
313
  return {
@@ -293,9 +317,14 @@ async function runScript(json, opts) {
293
317
  logs,
294
318
  finalCapture,
295
319
  describe: () => {
320
+ const engineWarnings = logs.filter((l) => l.level === "warn" || l.level === "error");
321
+ const head = `run ${ok ? "OK" : "FAILED"} — ${opts.durationMs}ms simulated, ${failures.length} failure(s), ${logs.length} log(s)`;
322
+ const fails = failures.map((f) => ` ✗ at ${f.atMs}ms${f.label ? ` [${f.label}]` : ""}: ${f.message}`);
323
+ const noisy = engineWarnings.map((l) => ` ! ${l.level}: ${l.parts.map((p) => typeof p === "string" ? p : String(p)).join(" ")}`);
296
324
  return [
297
- `run ${ok ? "OK" : "FAILED"} — ${opts.durationMs}ms simulated, ${failures.length} failure(s), ${logs.length} log(s)`,
298
- ...failures.map((f) => ` ✗ at ${f.atMs}ms${f.label ? ` [${f.label}]` : ""}: ${f.message}`),
325
+ head,
326
+ ...fails,
327
+ ...noisy,
299
328
  describeCapture(finalCapture)
300
329
  ].join("\n");
301
330
  }
@@ -323,10 +352,10 @@ async function createPlaySession(json, opts = {}) {
323
352
  engine.setScene(scene);
324
353
  const physics = opts.physics ?? "auto";
325
354
  if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
326
- const { enablePhysics2D } = await import("./physics-2d-CCVTrKOd.js").then((n) => n.r);
355
+ const { enablePhysics2D } = await import("./physics-2d-D9wquBvK.js").then((n) => n.r);
327
356
  await enablePhysics2D(engine);
328
357
  } else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
329
- const { enablePhysics3D } = await import("./physics-3d-BZZLtwJu.js").then((n) => n.r);
358
+ const { enablePhysics3D } = await import("./physics-3d-CnPygVGo.js").then((n) => n.r);
330
359
  await enablePhysics3D(engine);
331
360
  }
332
361
  const stepMs = 1e3 / (opts.fixedHz ?? 60);
package/dist/vite.d.ts CHANGED
@@ -17,11 +17,97 @@ interface HotUpdateContext {
17
17
  interface IncantoScenesOptions {
18
18
  /** Hard-fail on unregistered behaviors too (default: structure-only). */
19
19
  strictBehaviors?: boolean;
20
+ /**
21
+ * Where scenes are found and written. Default: vite's `root` (your project).
22
+ * Nothing outside it is readable or writable, whatever a request asks for.
23
+ */
24
+ root?: string;
20
25
  }
21
26
  declare function incantoScenes(opts?: IncantoScenesOptions): {
22
27
  name: string;
23
28
  handleHotUpdate(ctx: HotUpdateContext): Promise<undefined | never[]>;
29
+ configureServer(server: SceneDevServer): void;
30
+ };
31
+ /**
32
+ * `GET /api/scenes` → every `*.scene.json` under the project root
33
+ * `POST /api/scenes` `{path}` → create a minimal scene there
34
+ * `GET /api/scene?file=<rel>` → that scene's JSON
35
+ * `PUT /api/scene?file=<rel>` → write it back (bytes verbatim)
36
+ *
37
+ * These are the routes `incanto-editor` serves, on YOUR dev server, so the
38
+ * editor a game switches into is a project editor rather than a single-file
39
+ * one: it can list, open, create and save any scene in the project, and play
40
+ * whichever one you loaded.
41
+ *
42
+ * Everything is bounded to the root and to `*.scene.json`. A dev server is
43
+ * still a server: `?file=../../.ssh/id_rsa` has to be a 400, not a read.
44
+ */
45
+ interface SceneDevServer {
46
+ config?: {
47
+ root?: string;
48
+ };
49
+ middlewares: {
50
+ use(path: string, handler: (req: ApiReq, res: LibraryRes) => void): void;
51
+ };
52
+ }
53
+ interface ApiReq extends LibraryReq {
54
+ on(event: "data" | "end" | "error", cb: (chunk?: unknown) => void): void;
55
+ }
56
+ /**
57
+ * One JSON file in the project, and whether it is a scene.
58
+ *
59
+ * A file browser that only shows `*.scene.json` cannot show you the scene you
60
+ * saved as `level.json` — and, worse, gives no reason for its absence. So the
61
+ * list is every `.json` under the project (a scene is JSON by definition, so
62
+ * nothing else can be one), and `scene` says which of them actually are.
63
+ */
64
+ interface ProjectFileEntry {
65
+ rel: string;
66
+ abs: string;
67
+ size: number;
68
+ modified: number;
69
+ /** Present only when the file IS a scene — read from the file, not the name. */
70
+ scene?: {
71
+ name: string;
72
+ dimension?: "2d" | "3d";
73
+ nodes: number;
74
+ };
75
+ /** Why it is not one, in a form a person can act on. */
76
+ notScene?: string;
77
+ }
78
+ /**
79
+ * Is this JSON an incanto scene? The file itself answers.
80
+ *
81
+ * A scene declares what it is — `{"format": 1, "type": "scene", "name", "root"}`
82
+ * — and those are exactly the four keys the loader demands before it will build
83
+ * anything (`validateHeader`). So "is this a scene" is not a guess from the file
84
+ * name; it is a fact readable from the first object in the file. That is what
85
+ * lets the browser mark a row ✓ instead of hoping.
86
+ */
87
+ declare function sceneFacts(json: unknown): {
88
+ ok: true;
89
+ name: string;
90
+ dimension?: "2d" | "3d";
91
+ nodes: number;
92
+ } | {
93
+ ok: false;
94
+ why: string;
24
95
  };
96
+ /** @internal Exported for tests. Root-bounded walk, deepest 6 levels. */
97
+ declare function discoverScenes(root: string): ProjectFileEntry[];
98
+ /**
99
+ * @internal The one place a request's path becomes a file path.
100
+ *
101
+ * `.json` under the project, nothing else — the file browser offers every JSON
102
+ * and the loader decides, but a dev server still must not be a way to read
103
+ * `.env` or somebody's key file. Content is gated separately: a JSON that is
104
+ * not a scene is never sent to the page, only the reason it is not one.
105
+ */
106
+ declare function resolveSceneFile(root: string, rel: unknown): string | null;
107
+ /** @internal Exported for tests — `GET`/`POST /api/scenes`. */
108
+ declare function serveSceneList(req: ApiReq, res: LibraryRes, root: string): Promise<void>;
109
+ /** @internal Exported for tests — `GET`/`PUT /api/scene?file=`. */
110
+ declare function serveSceneFile(req: ApiReq, res: LibraryRes, root: string): Promise<void>;
25
111
  interface DevServer {
26
112
  middlewares: {
27
113
  use(path: string, handler: (req: LibraryReq, res: LibraryRes) => void): void;
@@ -75,4 +161,4 @@ declare function incantoLibrary(opts?: IncantoLibraryOptions): {
75
161
  /** @internal Exported for tests — the whole request/response behaviour. */
76
162
  declare function serveLibrary(req: LibraryReq, res: LibraryRes, opts?: IncantoLibraryOptions, doFetch?: typeof fetch): Promise<void>;
77
163
  //#endregion
78
- export { IncantoLibraryOptions, IncantoScenesOptions, incantoLibrary, incantoScenes, serveLibrary };
164
+ export { IncantoLibraryOptions, IncantoScenesOptions, ProjectFileEntry, discoverScenes, incantoLibrary, incantoScenes, resolveSceneFile, sceneFacts, serveLibrary, serveSceneFile, serveSceneList };
package/dist/vite.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { validateScene } from "./test.js";
2
- import { readFileSync } from "node:fs";
3
- import { dirname, resolve } from "node:path";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, join, normalize, relative, resolve, sep } from "node:path";
4
4
  //#region src/vite/index.ts
5
5
  /**
6
6
  * incanto/vite — dev-server integration: validate every `*.scene.json` edit
@@ -24,6 +24,20 @@ import { dirname, resolve } from "node:path";
24
24
  function incantoScenes(opts = {}) {
25
25
  return {
26
26
  name: "incanto-scenes",
27
+ /**
28
+ * The project's scenes, over HTTP, so the editor opened from a running
29
+ * game can browse and load them the same way the standalone
30
+ * `incanto-editor` does. Dev-only by construction (`configureServer`).
31
+ */
32
+ configureServer(server) {
33
+ const root = opts.root ?? server.config?.root ?? process.cwd();
34
+ server.middlewares.use("/api/scenes", (req, res) => {
35
+ serveSceneList(req, res, root);
36
+ });
37
+ server.middlewares.use("/api/scene", (req, res) => {
38
+ serveSceneFile(req, res, root);
39
+ });
40
+ },
27
41
  async handleHotUpdate(ctx) {
28
42
  if (!ctx.file.endsWith(".scene.json")) return void 0;
29
43
  let json;
@@ -58,6 +72,251 @@ function report(ctx, message) {
58
72
  }
59
73
  });
60
74
  }
75
+ const SKIP_DIRS = new Set([
76
+ "node_modules",
77
+ "dist",
78
+ "build",
79
+ "out",
80
+ ".git",
81
+ "coverage",
82
+ ".vite",
83
+ ".next"
84
+ ]);
85
+ /**
86
+ * Is this JSON an incanto scene? The file itself answers.
87
+ *
88
+ * A scene declares what it is — `{"format": 1, "type": "scene", "name", "root"}`
89
+ * — and those are exactly the four keys the loader demands before it will build
90
+ * anything (`validateHeader`). So "is this a scene" is not a guess from the file
91
+ * name; it is a fact readable from the first object in the file. That is what
92
+ * lets the browser mark a row ✓ instead of hoping.
93
+ */
94
+ function sceneFacts(json) {
95
+ if (json === null || typeof json !== "object" || Array.isArray(json)) return {
96
+ ok: false,
97
+ why: "the file is JSON, but not a JSON object"
98
+ };
99
+ const j = json;
100
+ if (j.type !== "scene") return {
101
+ ok: false,
102
+ why: `"type" must be "scene"${j.type === void 0 ? ", and this file has no \"type\"" : `, not ${JSON.stringify(j.type)}`}`
103
+ };
104
+ if (j.format !== 1) return {
105
+ ok: false,
106
+ why: `"format" must be 1, not ${JSON.stringify(j.format)}`
107
+ };
108
+ if (typeof j.root !== "object" || j.root === null) return {
109
+ ok: false,
110
+ why: "\"root\" must be a node object"
111
+ };
112
+ return {
113
+ ok: true,
114
+ name: typeof j.name === "string" ? j.name : "",
115
+ ...j.dimension === "2d" || j.dimension === "3d" ? { dimension: j.dimension } : {},
116
+ nodes: countNodes(j.root)
117
+ };
118
+ }
119
+ function countNodes(node) {
120
+ const children = node.children;
121
+ if (!Array.isArray(children)) return 1;
122
+ let n = 1;
123
+ for (const child of children) n += countNodes(child);
124
+ return n;
125
+ }
126
+ /** Reading a 300 MB "json" to label a row is not worth it — say so instead. */
127
+ const SNIFF_LIMIT = 16e6;
128
+ function inspect(abs, size) {
129
+ if (size > SNIFF_LIMIT) return { notScene: "too large to check without opening it" };
130
+ let parsed;
131
+ try {
132
+ parsed = JSON.parse(readFileSync(abs, "utf-8"));
133
+ } catch (error) {
134
+ return { notScene: `not valid JSON: ${message(error)}` };
135
+ }
136
+ const facts = sceneFacts(parsed);
137
+ return facts.ok ? { scene: {
138
+ name: facts.name,
139
+ ...facts.dimension ? { dimension: facts.dimension } : {},
140
+ nodes: facts.nodes
141
+ } } : { notScene: facts.why };
142
+ }
143
+ /** @internal Exported for tests. Root-bounded walk, deepest 6 levels. */
144
+ function discoverScenes(root) {
145
+ const found = [];
146
+ const walk = (dir, depth) => {
147
+ if (depth > 6) return;
148
+ let entries;
149
+ try {
150
+ entries = readdirSync(dir, { withFileTypes: true });
151
+ } catch {
152
+ return;
153
+ }
154
+ for (const entry of entries) if (entry.isDirectory()) {
155
+ if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) walk(join(dir, entry.name), depth + 1);
156
+ } else if (entry.name.endsWith(".json")) {
157
+ const abs = join(dir, entry.name);
158
+ let size = 0;
159
+ let modified = 0;
160
+ try {
161
+ const stat = statSync(abs);
162
+ size = stat.size;
163
+ modified = stat.mtimeMs;
164
+ } catch {}
165
+ found.push({
166
+ rel: relative(root, abs).split(sep).join("/"),
167
+ abs,
168
+ size,
169
+ modified,
170
+ ...inspect(abs, size)
171
+ });
172
+ }
173
+ };
174
+ walk(root, 0);
175
+ return found.sort((a, b) => a.rel.localeCompare(b.rel));
176
+ }
177
+ /**
178
+ * @internal The one place a request's path becomes a file path.
179
+ *
180
+ * `.json` under the project, nothing else — the file browser offers every JSON
181
+ * and the loader decides, but a dev server still must not be a way to read
182
+ * `.env` or somebody's key file. Content is gated separately: a JSON that is
183
+ * not a scene is never sent to the page, only the reason it is not one.
184
+ */
185
+ function resolveSceneFile(root, rel) {
186
+ if (typeof rel !== "string" || !rel.endsWith(".json")) return null;
187
+ const abs = normalize(resolve(root, rel));
188
+ return abs.startsWith(root.endsWith(sep) ? root : root + sep) ? abs : null;
189
+ }
190
+ /**
191
+ * Same-origin assertion for state-changing requests.
192
+ *
193
+ * A dev server is reachable from any page the developer happens to have open:
194
+ * `fetch('http://localhost:5173/api/scenes', {method:'POST', ...})` from
195
+ * evil.example needs no CORS permission to be SENT — only to be read — so
196
+ * without this a visited page can write `*.scene.json` files into the repo.
197
+ * Browsers attach `Origin` to every POST/PUT (including cross-site form posts,
198
+ * the one path that used to omit it), so requiring it to match the host we were
199
+ * addressed as costs a real editor nothing. A request with no `Origin` at all is
200
+ * not a browser, and a non-browser client has no CSRF exposure to exploit —
201
+ * same rule the `incanto-editor` server uses.
202
+ */
203
+ function originAllowed(req) {
204
+ const raw = req.headers.origin;
205
+ const origin = Array.isArray(raw) ? raw[0] : raw;
206
+ if (!origin) return true;
207
+ const rawHost = req.headers.host;
208
+ try {
209
+ return new URL(origin).host === (Array.isArray(rawHost) ? rawHost[0] : rawHost ?? "");
210
+ } catch {
211
+ return false;
212
+ }
213
+ }
214
+ function json(res, status, body) {
215
+ res.statusCode = status;
216
+ res.setHeader("content-type", "application/json; charset=utf-8");
217
+ res.end(typeof body === "string" ? body : JSON.stringify(body));
218
+ }
219
+ function readBody(req) {
220
+ return new Promise((done, fail) => {
221
+ let raw = "";
222
+ req.on("data", (chunk) => {
223
+ raw += String(chunk);
224
+ if (raw.length > 64e6) fail(/* @__PURE__ */ new Error("scene too large"));
225
+ });
226
+ req.on("end", () => done(raw));
227
+ req.on("error", () => fail(/* @__PURE__ */ new Error("request failed")));
228
+ });
229
+ }
230
+ /** @internal Exported for tests — `GET`/`POST /api/scenes`. */
231
+ async function serveSceneList(req, res, root) {
232
+ const base = normalize(resolve(root));
233
+ if (req.method === void 0 || req.method === "GET") return json(res, 200, discoverScenes(base));
234
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
235
+ if (!originAllowed(req)) return json(res, 403, { error: "cross-origin write refused" });
236
+ let body;
237
+ try {
238
+ body = JSON.parse(await readBody(req));
239
+ } catch (error) {
240
+ return json(res, 400, { error: `bad request body: ${message(error)}` });
241
+ }
242
+ const wanted = typeof body.path === "string" && body.path && !body.path.endsWith(".json") ? `${body.path}.scene.json` : body.path;
243
+ const abs = resolveSceneFile(base, wanted);
244
+ if (!abs) return json(res, 400, { error: "path must be a .json file inside the project" });
245
+ if (existsSync(abs)) return json(res, 409, { error: `already exists: ${String(wanted)}` });
246
+ const name = basename(abs).replace(/\.scene\.json$|\.json$/, "") || "Scene";
247
+ try {
248
+ mkdirSync(dirname(abs), { recursive: true });
249
+ writeFileSync(abs, `${JSON.stringify({
250
+ format: 1,
251
+ type: "scene",
252
+ dimension: "3d",
253
+ name,
254
+ root: {
255
+ name,
256
+ type: "Node3D"
257
+ }
258
+ }, null, 2)}\n`);
259
+ } catch (error) {
260
+ return json(res, 400, { error: message(error) });
261
+ }
262
+ return json(res, 201, {
263
+ rel: relative(base, abs).split(sep).join("/"),
264
+ abs,
265
+ size: 0,
266
+ modified: 0
267
+ });
268
+ }
269
+ /** @internal Exported for tests — `GET`/`PUT /api/scene?file=`. */
270
+ async function serveSceneFile(req, res, root) {
271
+ const base = normalize(resolve(root));
272
+ const file = new URL(req.url ?? "/", "http://localhost").searchParams.get("file");
273
+ const abs = resolveSceneFile(base, file);
274
+ if (!abs) return json(res, 400, { error: "?file= must be a .json file inside the project" });
275
+ if (req.method === void 0 || req.method === "GET") {
276
+ let text;
277
+ let parsed;
278
+ try {
279
+ text = readFileSync(abs, "utf-8");
280
+ parsed = JSON.parse(text);
281
+ } catch (error) {
282
+ return json(res, 422, { error: `${file}: ${message(error)}` });
283
+ }
284
+ const facts = sceneFacts(parsed);
285
+ if (!facts.ok) return json(res, 422, {
286
+ error: `${file} is not an incanto scene — ${facts.why}.`,
287
+ notScene: true
288
+ });
289
+ return json(res, 200, text);
290
+ }
291
+ if (req.method !== "PUT") return json(res, 405, { error: "method not allowed" });
292
+ if (!originAllowed(req)) return json(res, 403, { error: "cross-origin write refused" });
293
+ let raw;
294
+ try {
295
+ raw = await readBody(req);
296
+ } catch (error) {
297
+ return json(res, 400, { error: message(error) });
298
+ }
299
+ let parsed;
300
+ try {
301
+ parsed = JSON.parse(raw);
302
+ } catch (error) {
303
+ return json(res, 422, { error: `not JSON — the file was NOT written: ${message(error)}` });
304
+ }
305
+ const shape = sceneFacts(parsed);
306
+ if (!shape.ok) return json(res, 422, { error: `not an incanto scene — ${shape.why}. The file was NOT written.` });
307
+ try {
308
+ writeFileSync(abs, raw.endsWith("\n") ? raw : `${raw}\n`);
309
+ } catch (error) {
310
+ return json(res, 400, { error: message(error) });
311
+ }
312
+ return json(res, 200, {
313
+ ok: true,
314
+ output: abs
315
+ });
316
+ }
317
+ function message(error) {
318
+ return error instanceof Error ? error.message : String(error);
319
+ }
61
320
  /**
62
321
  * Serve the agent8 asset catalog at `/api/library` on the dev server.
63
322
  *
@@ -146,4 +405,4 @@ function libraryItem(row) {
146
405
  };
147
406
  }
148
407
  //#endregion
149
- export { incantoLibrary, incantoScenes, serveLibrary };
408
+ export { discoverScenes, incantoLibrary, incantoScenes, resolveSceneFile, sceneFacts, serveLibrary, serveSceneFile, serveSceneList };
@@ -1 +1 @@
1
- import{n as e}from"./index-Bx4UtWYY.js";async function t(t){return new n((await e(()=>import(`./GameServer-C56iOUgF.js`),[],import.meta.url)).GameServer,t)}var n=class{raw;active=new Map;reconnecting=!1;disposed=!1;constructor(e,t){this.raw=new e({...t})}get account(){return this.raw.account}get connected(){return this.raw.connected}connect(){return this.raw.connected?Promise.resolve(!0):(this.disposed=!1,this.rawConnect())}rawConnect(){return this.raw.connect({onDisconnect:()=>void this.reconnect()})}disconnect(){this.disposed=!0;for(let e of this.active.values())e.off();return this.active.clear(),this.raw.disconnect()}remoteFunction(e,t,n){return this.raw.remoteFunction(e,t,n)}track(e){let t=Symbol(`sub`),n={make:e,off:e()};return this.active.set(t,n),()=>{n.off(),this.active.delete(t)}}async reconnect(){if(!this.disposed&&!this.reconnecting){this.reconnecting=!0;try{await this.rawConnect();for(let e of this.active.values())e.off(),e.off=e.make()}finally{this.reconnecting=!1}}}subscribeRoomState(e,t){return this.track(()=>this.raw.subscribeRoomState(e,t))}subscribeRoomMyState(e,t){return this.track(()=>this.raw.subscribeRoomMyState(e,t))}subscribeRoomAllUserStates(e,t){return this.track(()=>this.raw.subscribeRoomAllUserStates(e,e=>{let n={};for(let t of e??[]){if(!t||typeof t.account!=`string`||t.__leaved)continue;let{account:e,__updated:r,__leaved:i,...a}=t;n[e]=a}t(n)}))}subscribeRoomCollection(e,t,n){return this.track(()=>this.raw.subscribeRoomCollection(e,t,({items:e})=>{let t={};for(let n of e??[])n&&typeof n.__id==`string`&&(t[n.__id]=n);n(t)}))}onRoomMessage(e,t,n){return this.track(()=>this.raw.onRoomMessage(e,t,n))}onRoomUserJoin(e,t){return this.track(()=>this.raw.onRoomUserJoin(e,t))}onRoomUserLeave(e,t){return this.track(()=>this.raw.onRoomUserLeave(e,t))}subscribeGlobalState(e){return this.track(()=>this.raw.subscribeGlobalState(e))}subscribeGlobalMyState(e){return this.track(()=>this.raw.subscribeGlobalMyState(e))}subscribeGlobalUserState(e,t){return this.track(()=>this.raw.subscribeGlobalUserState(e,t))}subscribeGlobalCollection(e,t){return this.track(()=>this.raw.subscribeGlobalCollection(e,({items:e})=>{let n={};for(let t of e??[])t&&typeof t.__id==`string`&&(n[t.__id]=t);t(n)}))}subscribeAsset(e,t){return this.track(()=>this.raw.subscribeAsset(e,t))}onGlobalMessage(e,t){return this.track(()=>this.raw.onGlobalMessage(e,t))}};export{t as createAgent8Server};
1
+ import{n as e}from"./index-C9fb5QcT.js";async function t(t){return new n((await e(()=>import(`./GameServer-C56iOUgF.js`),[],import.meta.url)).GameServer,t)}var n=class{raw;active=new Map;reconnecting=!1;disposed=!1;constructor(e,t){this.raw=new e({...t})}get account(){return this.raw.account}get connected(){return this.raw.connected}connect(){return this.raw.connected?Promise.resolve(!0):(this.disposed=!1,this.rawConnect())}rawConnect(){return this.raw.connect({onDisconnect:()=>void this.reconnect()})}disconnect(){this.disposed=!0;for(let e of this.active.values())e.off();return this.active.clear(),this.raw.disconnect()}remoteFunction(e,t,n){return this.raw.remoteFunction(e,t,n)}track(e){let t=Symbol(`sub`),n={make:e,off:e()};return this.active.set(t,n),()=>{n.off(),this.active.delete(t)}}async reconnect(){if(!this.disposed&&!this.reconnecting){this.reconnecting=!0;try{await this.rawConnect();for(let e of this.active.values())e.off(),e.off=e.make()}finally{this.reconnecting=!1}}}subscribeRoomState(e,t){return this.track(()=>this.raw.subscribeRoomState(e,t))}subscribeRoomMyState(e,t){return this.track(()=>this.raw.subscribeRoomMyState(e,t))}subscribeRoomAllUserStates(e,t){return this.track(()=>this.raw.subscribeRoomAllUserStates(e,e=>{let n={};for(let t of e??[]){if(!t||typeof t.account!=`string`||t.__leaved)continue;let{account:e,__updated:r,__leaved:i,...a}=t;n[e]=a}t(n)}))}subscribeRoomCollection(e,t,n){return this.track(()=>this.raw.subscribeRoomCollection(e,t,({items:e})=>{let t={};for(let n of e??[])n&&typeof n.__id==`string`&&(t[n.__id]=n);n(t)}))}onRoomMessage(e,t,n){return this.track(()=>this.raw.onRoomMessage(e,t,n))}onRoomUserJoin(e,t){return this.track(()=>this.raw.onRoomUserJoin(e,t))}onRoomUserLeave(e,t){return this.track(()=>this.raw.onRoomUserLeave(e,t))}subscribeGlobalState(e){return this.track(()=>this.raw.subscribeGlobalState(e))}subscribeGlobalMyState(e){return this.track(()=>this.raw.subscribeGlobalMyState(e))}subscribeGlobalUserState(e,t){return this.track(()=>this.raw.subscribeGlobalUserState(e,t))}subscribeGlobalCollection(e,t){return this.track(()=>this.raw.subscribeGlobalCollection(e,({items:e})=>{let n={};for(let t of e??[])t&&typeof t.__id==`string`&&(n[t.__id]=t);t(n)}))}subscribeAsset(e,t){return this.track(()=>this.raw.subscribeAsset(e,t))}onGlobalMessage(e,t){return this.track(()=>this.raw.onGlobalMessage(e,t))}};export{t as createAgent8Server};