@vgai/engine 0.2.0 → 0.4.0-canary.20260715.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 (123) hide show
  1. package/README.md +3 -1
  2. package/package.json +24 -4
  3. package/schemas/engine-api.json +124 -0
  4. package/schemas/engine-api.md +53 -0
  5. package/schemas/engine-capabilities.json +124 -0
  6. package/schemas/inputmap.schema.json +314 -0
  7. package/schemas/mat.schema.json +286 -0
  8. package/schemas/prefab.schema.json +10148 -0
  9. package/schemas/scn2d.schema.json +475 -0
  10. package/schemas/vgai-game.schema.json +383 -0
  11. package/schemas/vscn.schema.json +11007 -0
  12. package/src/adapter/{world-kind.ts → adapter-surface.ts} +6 -6
  13. package/src/adapter/authoring.ts +77 -0
  14. package/src/adapter/first-party-systems.ts +23 -34
  15. package/src/adapter/game-adapter.ts +8 -8
  16. package/src/adapter/host-context.ts +2 -4
  17. package/src/adapter/index.ts +4 -4
  18. package/src/adapter/system-adapter.ts +88 -22
  19. package/src/adapter/vgai-scene-game-adapter.ts +244 -194
  20. package/src/animation/anim-graph-types.ts +12 -43
  21. package/src/animation/animation-clock.ts +479 -0
  22. package/src/animation/camera-ownership.ts +467 -0
  23. package/src/animation/cinematic-cues.ts +451 -0
  24. package/src/animation/clip-map.ts +41 -0
  25. package/src/animation/gsap-registration.ts +184 -0
  26. package/src/animation/theatre-clock-binding.ts +111 -0
  27. package/src/animation/theatre-director.ts +347 -0
  28. package/src/animation/theatre-object-binding.ts +661 -0
  29. package/src/animation/xstate-animation-binding.ts +436 -0
  30. package/src/animation/xstate-animation-meta.ts +319 -0
  31. package/src/audio/index.ts +39 -7
  32. package/src/audio/tone-clock-binding.ts +98 -0
  33. package/src/audio/tone-context.ts +129 -0
  34. package/src/audio/tone-offline-render.ts +167 -0
  35. package/src/audio/wav-encode.ts +119 -0
  36. package/src/character/cloth-sim.ts +533 -0
  37. package/src/character/spring-chain.ts +307 -0
  38. package/src/core/game-loop.ts +57 -2
  39. package/src/core/seeded-random.ts +161 -0
  40. package/src/core/system-runner.ts +20 -3
  41. package/src/core/types.ts +50 -0
  42. package/src/data/data-asset.ts +167 -0
  43. package/src/data/data-check-core.ts +242 -0
  44. package/src/data/data-ref.ts +145 -0
  45. package/src/data/vite-plugin-data.ts +290 -0
  46. package/src/dev/performance-profiler.ts +213 -0
  47. package/src/dev/webgl-gpu-timer.ts +53 -0
  48. package/src/ecs/component-manager.ts +45 -12
  49. package/src/ecs/game-component.ts +95 -11
  50. package/src/humanoid/bake.operation.ts +326 -0
  51. package/src/humanoid/body.ts +663 -0
  52. package/src/humanoid/clips.ts +149 -0
  53. package/src/humanoid/compose.ts +209 -0
  54. package/src/humanoid/generate.ts +189 -0
  55. package/src/humanoid/index.ts +36 -0
  56. package/src/humanoid/schema.ts +108 -0
  57. package/src/humanoid/skeleton.ts +345 -0
  58. package/src/index.ts +48 -0
  59. package/src/input/input-manager.ts +1886 -33
  60. package/src/input/input-types.ts +158 -3
  61. package/src/input/prompt-labels.ts +122 -0
  62. package/src/input/rebind-controller.ts +105 -0
  63. package/src/input/schema.ts +206 -52
  64. package/src/manifest/index.ts +5 -5
  65. package/src/manifest/load.ts +125 -72
  66. package/src/manifest/schema.ts +362 -255
  67. package/src/react/game-state.tsx +135 -32
  68. package/src/react/root-adapter.tsx +49 -0
  69. package/src/react/unmanaged-root-detector.ts +66 -0
  70. package/src/react/use-data.ts +124 -0
  71. package/src/react/use-selection.tsx +135 -0
  72. package/src/runtime/create-runtime.ts +112 -273
  73. package/src/runtime/debug-bridge.ts +483 -0
  74. package/src/runtime/debug-registry.ts +856 -0
  75. package/src/runtime/game.ts +342 -93
  76. package/src/runtime/gameplay-rng-trap.ts +134 -0
  77. package/src/runtime/input-router.ts +7 -7
  78. package/src/runtime/mount-game.ts +40 -38
  79. package/src/runtime/mount-manifest.ts +169 -37
  80. package/src/runtime/render-audio-control.ts +168 -0
  81. package/src/runtime/render-control.ts +522 -0
  82. package/src/runtime/render-seed.ts +79 -0
  83. package/src/runtime/state-bridge.ts +24 -10
  84. package/src/runtime/types.ts +110 -33
  85. package/src/scene/asset-loaders.ts +10 -36
  86. package/src/scene/asset-paths.ts +0 -2
  87. package/src/scene/asset-ref-check.ts +248 -0
  88. package/src/scene/asset-registry.ts +22 -0
  89. package/src/scene/component-registry.ts +14 -3
  90. package/src/scene/defaults.ts +1 -0
  91. package/src/scene/light-camera-factory.ts +11 -3
  92. package/src/scene/parse.ts +133 -0
  93. package/src/scene/scene-apply.ts +55 -4
  94. package/src/scene/scene-loader.ts +91 -123
  95. package/src/scene/scene-types.ts +0 -1
  96. package/src/scene/schema/animation.ts +30 -79
  97. package/src/scene/schema/entity.ts +20 -0
  98. package/src/scene/schema/index.ts +2 -46
  99. package/src/scene/schema/light.ts +16 -1
  100. package/src/scene/schema/material.ts +96 -91
  101. package/src/scene/schema/scene-file.ts +1 -7
  102. package/src/scene/user-data.ts +22 -10
  103. package/src/setup/setup-renderer.ts +10 -3
  104. package/src/tools/define-tool.ts +191 -0
  105. package/src/world2d/authoring-2d.ts +17 -1
  106. package/src/world2d/collision-2d.ts +1 -1
  107. package/src/world2d/pixi-game-adapter.ts +19 -17
  108. package/src/world2d/scene2d-loader.ts +1 -0
  109. package/src/world2d/types.ts +8 -2
  110. package/src/animation/anim-graph.ts +0 -406
  111. package/src/animation/anim-system.ts +0 -28
  112. package/src/animation/property-track.ts +0 -178
  113. package/src/animation/schema.ts +0 -204
  114. package/src/audio/ambient.ts +0 -300
  115. package/src/audio/impacts.ts +0 -212
  116. package/src/audio/movement.ts +0 -140
  117. package/src/audio/musical.ts +0 -200
  118. package/src/audio/ui-sounds.ts +0 -171
  119. package/src/audio/vehicle.ts +0 -235
  120. package/src/audio/weapons.ts +0 -152
  121. package/src/runtime/scene-ui-bridge.ts +0 -86
  122. package/src/runtime/scene-ui-data.ts +0 -119
  123. package/src/scene/schema/ui.ts +0 -602
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Engine-PUBLISHED `GameProvider`/`useGame`/`useGameState`/`useWorldObservation`
3
- * (D-Z5, `docs/WAVE3-ADAPTER-PLUMBING-DESIGN.md` §D-Z5) the canonical,
4
- * opt-in react entry for the T7.4 state bridge (`docs/REACT-STATE-BRIDGE.md`
5
- * §3). This is a SEPARATE, react-value-importing module under
2
+ * Canonical `GameProvider`/`useGame`/`useGameState`/`useWorldObservation`
3
+ * entry for React adapter roots. Also home to `useDebugProvider` and
4
+ * `useDebugCommand`/`useDebugEmit`, the React-facing debug seam.
5
+ *
6
+ * This is a SEPARATE, react-value-importing module under
6
7
  * `packages/engine/src/react/` — colocated per REACT-STATE-BRIDGE §3's own
7
8
  * "colocate the react-facing hooks with the HUD seam, not the react-free
8
9
  * core" rule, just now living IN the engine package rather than only in the
@@ -11,34 +12,26 @@
11
12
  * invariant is proved by `packages/engine/test/react-core-import-ban.test.ts`
12
13
  * (AC-F1).
13
14
  *
14
- * This module is the SPEC others port faithfully: it was originally authored
15
- * as `packages/editor/template/src/ui/game-state.tsx` (T7.4); that file is
16
- * now a thin re-export of this one (D-Z5 step 3) so every NEW scaffolded
17
- * project gets ONE canonical context, while existing projects with their own
18
- * full copy (their own `createContext`) keep working untouched — adoption of
19
- * THIS module is an option, never a forced migration (D-Z5).
20
- *
21
- * Context-identity landmine (unchanged from the template file's own history,
22
- * still the reason `loadProjectGameProvider`/the virtual-module plugin exist
23
- * in `packages/editor/src/adapter-resolver.ts` /
24
- * `packages/editor/vite-plugin-react-world-provider.ts`): `<GameProvider>`
25
- * and `useGame()` must resolve against the SAME `createContext()` call. A
26
- * project that ships its OWN `src/ui/game-state.tsx` (its own
27
- * `createContext`) must be wrapped by ITS OWN `GameProvider` — wrapping it
28
- * with THIS module's `GameProvider` instead would split context and
29
- * `useGame()` would throw "no Game in context" even though a `<GameProvider>`
30
- * genuinely wraps the tree (proved by
31
- * `packages/editor/test/game-state-context-split.test.tsx`, AC-F1). This is
32
- * exactly why the engine-published fallback (`resolveIngestReactAdapter`'s
33
- * D-Y3 upgrade, `default-react`'s optional fallback) only ever fires when the
34
- * project's OWN `src/ui/game-state.tsx` is ABSENT — an unmodified foreign
35
- * entry that imports no vgai hooks has no context to split.
15
+ * Every host and game imports this one module, so provider and hooks share
16
+ * one `createContext()` identity. The engine core outside `src/react/`
17
+ * remains React-free, enforced by `react-core-import-ban.test.ts`.
36
18
  */
37
19
 
38
- import { createContext, type ReactNode, useContext, useRef, useSyncExternalStore } from 'react';
20
+ import {
21
+ createContext,
22
+ type PropsWithChildren,
23
+ useCallback,
24
+ useContext,
25
+ useEffect,
26
+ useRef,
27
+ useSyncExternalStore,
28
+ } from 'react';
29
+ import type { z } from 'zod';
39
30
  import type { WorldStateObserver } from '../adapter';
31
+ import { getDebugRegistry } from '../runtime/debug-registry';
40
32
  import { createFrameSelectorCache, type Equals, shallow } from '../runtime/frame-selector-cache';
41
33
  import type { Game } from '../runtime/game';
34
+ import type { DebugCommandArgs } from '../runtime/types';
42
35
 
43
36
  /** Re-exported for convenience — the opt-in equality for selectors that
44
37
  * return a fresh object/array/tuple every call (default is `Object.is`). */
@@ -47,10 +40,21 @@ export { shallow };
47
40
  const GameContext = createContext<Game | null>(null);
48
41
 
49
42
  /** Provide the `Game` to `useGameState`/`useGame` for everything mounted
50
- * beneath it. Compose it around whatever renders your tree (`mountUI`, a
51
- * react world's `createRoot(...).render(...)`, etc.) see the module doc
52
- * comment above for the context-identity rule this hook family depends on. */
53
- export function GameProvider({ game, children }: { game: Game; children: ReactNode }) {
43
+ * beneath it. Adapter-root hosts install this provider automatically see the module doc
44
+ * comment above for the context-identity rule this hook family depends on.
45
+ *
46
+ * Props use `PropsWithChildren` (children optional in the TYPE, not
47
+ * runtime-optional in practice — you always want children mounted under
48
+ * the provider) rather than a bare `{ children: ReactNode }` field: a
49
+ * required `children` in `P` makes TS's `createElement<P>(type, props?:
50
+ * Attributes & P, ...children)` overload reject the plain 3-arg call
51
+ * `React.createElement(GameProvider, { game }, child)` (no JSX transform,
52
+ * e.g. a `.ts` main composing a sibling `.tsx` HUD) with "Property
53
+ * 'children' is missing" even though the 3rd arg supplies it — issue #97.
54
+ * Making `children` optional in the type lets that overload resolve; the
55
+ * rest-arg children are still wired through to `children` at runtime by
56
+ * React itself, unchanged. See `test/game-state-create-element-types.test.tsx`. */
57
+ export function GameProvider({ game, children }: PropsWithChildren<{ game: Game }>) {
54
58
  return <GameContext.Provider value={game}>{children}</GameContext.Provider>;
55
59
  }
56
60
 
@@ -109,7 +113,7 @@ export function requireWorldObserver(game: Game, worldId: string): WorldStateObs
109
113
  const world = game.world(worldId);
110
114
  if (!world) {
111
115
  throw new Error(
112
- `useWorldObservation: no world registered with id "${worldId}" — check game.worlds for the ` +
116
+ `useWorldObservation: no world registered with id "${worldId}" — check game.roots for the ` +
113
117
  'ids actually registered.',
114
118
  );
115
119
  }
@@ -170,3 +174,102 @@ export function useWorldObservation<T>(
170
174
  () => cacheRef.current!.get(versionRef.current, () => selector(observer.snapshot())),
171
175
  );
172
176
  }
177
+
178
+ /**
179
+ * The debug seam's react-world registration door (Task 1.5,
180
+ * `docs/SYNTHETIC-PLAYER-SPEC.md` §3.1): react roots have no `setup()`/
181
+ * `ctx.debug` of their own, so this hook registers a named state provider
182
+ * into the SAME game-scoped registry every `GameComponent`'s `ctx.debug`
183
+ * feeds (`getDebugRegistry(game)`, `runtime/debug-registry.ts`), readable
184
+ * through `game.systemAdapters.debug.state(name)` exactly like a
185
+ * `ctx.debug.registerStateProvider` call.
186
+ *
187
+ * Registers ONCE per mount (keyed on `name`) and unregisters on unmount —
188
+ * silently, like hot-reload's `strip()`. Re-renders never re-register or
189
+ * re-warn: `fn` is kept in a ref updated every render, so the registered
190
+ * closure always calls the CURRENT `fn` without touching the registry.
191
+ * Outside a `<GameProvider>` (or against a bare test `Game` built without a
192
+ * registry) this is an inert no-op — it never throws, matching the "no seam
193
+ * without a consumer" degrade every optional `ctx.*` surface follows.
194
+ */
195
+ export function useDebugProvider(
196
+ name: string,
197
+ fn: () => unknown,
198
+ opts?: { tier?: 'observable' | 'assisted' },
199
+ ): void {
200
+ const game = useContext(GameContext);
201
+ const fnRef = useRef(fn);
202
+ fnRef.current = fn;
203
+ const tier = opts?.tier;
204
+
205
+ useEffect(() => {
206
+ const registry = game && getDebugRegistry(game);
207
+ if (!registry) return;
208
+ return registry.registerReactProvider(name, () => fnRef.current(), { tier });
209
+ // `tier` is deliberately NOT a dep (same reasoning as `useDebugCommand`'s
210
+ // `spec`) — captured once at mount, so it never forces re-registration.
211
+ }, [game, name]);
212
+ }
213
+
214
+ /**
215
+ * The command-side counterpart of {@link useDebugProvider} — registers an
216
+ * invokable debug command into the same game-scoped registry, readable
217
+ * through `game.systemAdapters.debug.commands()`/`invoke(name, args)`.
218
+ *
219
+ * Same mount/unmount/ref-latest contract as {@link useDebugProvider}: `spec`
220
+ * (description/args/locus) is captured at registration time, `fn` always
221
+ * calls through to the latest render's closure via a ref, and the hook is an
222
+ * inert no-op with no `<GameProvider>` in scope.
223
+ *
224
+ * Generic over the declared `args` Zod tuple, same as
225
+ * `DebugCtxSurface.registerCommand` ({@link DebugCommandArgs}, dry-run
226
+ * finding — `docs/ACCEPTANCE-DRIVER-BUILD-PLAN.md` Wave 6 ledger): declaring
227
+ * `args: z.tuple([z.number(), z.string()])` types `fn`'s parameters as
228
+ * `(n: number, s: string) => ...` with no `unknown[]` cast; omitting `args`
229
+ * keeps `fn` typed `(...args: unknown[]) => ...` as before.
230
+ */
231
+ export function useDebugCommand<T extends z.ZodTuple | undefined = undefined>(
232
+ name: string,
233
+ spec: { description?: string; args?: T; locus?: 'client' | 'server' },
234
+ fn: (...args: DebugCommandArgs<T>) => unknown | Promise<unknown>,
235
+ ): void {
236
+ const game = useContext(GameContext);
237
+ const fnRef = useRef(fn);
238
+ fnRef.current = fn;
239
+
240
+ useEffect(() => {
241
+ const registry = game && getDebugRegistry(game);
242
+ if (!registry) return;
243
+ return registry.registerReactCommand(name, spec, (...args: DebugCommandArgs<T>) =>
244
+ fnRef.current(...args),
245
+ );
246
+ // `spec` is deliberately NOT a dep (like `equals` above) — captured once
247
+ // at mount, so a fresh inline object literal every render never forces
248
+ // re-registration.
249
+ }, [game, name]);
250
+ }
251
+
252
+ /**
253
+ * The event-side counterpart of {@link useDebugProvider} and
254
+ * {@link useDebugCommand}. Returns a stable callback that writes a
255
+ * tick-stamped event to the game-scoped debug flight recorder:
256
+ *
257
+ * ```tsx
258
+ * const emit = useDebugEmit();
259
+ * emit('score-changed', { score });
260
+ * ```
261
+ *
262
+ * It is an inert no-op outside a `<GameProvider>`, matching the optional
263
+ * behavior of the other React debug hooks. Games never need to import the
264
+ * debug registry or know its internal React provenance id.
265
+ */
266
+ export function useDebugEmit(): (event: string, detail?: unknown) => void {
267
+ const game = useContext(GameContext);
268
+ return useCallback(
269
+ (event: string, detail?: unknown) => {
270
+ if (!game) return;
271
+ getDebugRegistry(game)?.emitReact(event, detail);
272
+ },
273
+ [game],
274
+ );
275
+ }
@@ -0,0 +1,49 @@
1
+ import { type ComponentType, createElement } from 'react';
2
+ import { flushSync } from 'react-dom';
3
+ import { createRoot } from 'react-dom/client';
4
+ import type { MountedReactGame, ReactRootAdapter, ReactWorldHost } from '../runtime/create-runtime';
5
+ import { isAdapterRegistered, registerAdapter } from '../runtime/mount-game';
6
+ import { GameProvider } from './game-state';
7
+
8
+ interface ReactEntryModule {
9
+ readonly default?: ComponentType;
10
+ }
11
+
12
+ /** Construct one canonical React root adapter for direct host composition. */
13
+ export function createReactRootAdapter(id: string, Entry: ComponentType): ReactRootAdapter {
14
+ return {
15
+ id,
16
+ async mount(host: ReactWorldHost): Promise<MountedReactGame> {
17
+ if (!host.game) throw new Error(`React root "${id}" requires a Game host.`);
18
+ const reactRoot = createRoot(host.container);
19
+ flushSync(() => {
20
+ reactRoot.render(createElement(GameProvider, { game: host.game! }, createElement(Entry)));
21
+ });
22
+ return {
23
+ kind: 'react',
24
+ container: host.container,
25
+ drivesOwnLoop: false,
26
+ dispose: () => reactRoot.unmount(),
27
+ };
28
+ },
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Register the engine's React adapter-root factory. This module is an
34
+ * explicit optional React entry point: engine core never imports it, while a
35
+ * React game host imports it once and receives the canonical root lifecycle.
36
+ */
37
+ export function registerReactAdapter(): void {
38
+ if (isAdapterRegistered('react')) return;
39
+ registerAdapter('react', (root, { entryModule }) => {
40
+ const Entry = (entryModule as ReactEntryModule | undefined)?.default;
41
+ if (!Entry) {
42
+ throw new Error(
43
+ `React root "${root.id}" entry "${root.entry ?? '(missing)'}" must default-export a component.`,
44
+ );
45
+ }
46
+
47
+ return { kind: 'react', adapter: createReactRootAdapter(root.id, Entry) };
48
+ });
49
+ }
@@ -0,0 +1,66 @@
1
+ export interface UnmanagedReactRootFinding {
2
+ readonly file: string;
3
+ readonly line: number;
4
+ readonly api: 'createRoot' | 'hydrateRoot' | 'ReactDOM.render';
5
+ }
6
+
7
+ function lineAt(source: string, offset: number): number {
8
+ return source.slice(0, offset).split('\n').length;
9
+ }
10
+
11
+ /**
12
+ * Find project-owned React DOM root creation. VGAI hosts React through
13
+ * manifest adapter roots, so any of these calls in game source creates an
14
+ * unmanaged second lifecycle and is a hard validation error.
15
+ */
16
+ export function detectUnmanagedReactRoots(
17
+ source: string,
18
+ file: string,
19
+ ): UnmanagedReactRootFinding[] {
20
+ const findings: UnmanagedReactRootFinding[] = [];
21
+ const aliases = new Map<string, 'createRoot' | 'hydrateRoot'>();
22
+
23
+ for (const match of source.matchAll(/import\s*\{([^}]*)\}\s*from\s*['"]react-dom\/client['"]/g)) {
24
+ for (const member of (match[1] ?? '').split(',')) {
25
+ const parsed = member.trim().match(/^(createRoot|hydrateRoot)(?:\s+as\s+([\w$]+))?$/);
26
+ if (parsed) aliases.set(parsed[2] ?? parsed[1]!, parsed[1] as 'createRoot' | 'hydrateRoot');
27
+ }
28
+ }
29
+
30
+ for (const [alias, api] of aliases) {
31
+ const call = new RegExp(`\\b${alias.replace(/[$]/g, '\\$&')}\\s*\\(`, 'g');
32
+ for (const match of source.matchAll(call)) {
33
+ findings.push({ file, line: lineAt(source, match.index ?? 0), api });
34
+ }
35
+ }
36
+
37
+ const namespaceNames = new Set<string>();
38
+ for (const match of source.matchAll(
39
+ /import\s+(?:\*\s+as\s+|)([\w$]+)\s+from\s*['"]react-dom(?:\/client)?['"]/g,
40
+ )) {
41
+ namespaceNames.add(match[1]!);
42
+ }
43
+ namespaceNames.add('ReactDOM');
44
+ for (const name of namespaceNames) {
45
+ for (const api of ['createRoot', 'hydrateRoot', 'render'] as const) {
46
+ const call = new RegExp(`\\b${name}\\.${api}\\s*\\(`, 'g');
47
+ for (const match of source.matchAll(call)) {
48
+ findings.push({
49
+ file,
50
+ line: lineAt(source, match.index ?? 0),
51
+ api: api === 'render' ? 'ReactDOM.render' : api,
52
+ });
53
+ }
54
+ }
55
+ }
56
+
57
+ return findings.filter(
58
+ (finding, index) =>
59
+ findings.findIndex(
60
+ (candidate) =>
61
+ candidate.file === finding.file &&
62
+ candidate.line === finding.line &&
63
+ candidate.api === finding.api,
64
+ ) === index,
65
+ );
66
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * React hooks for data assets — the tool-hooks half of
3
+ * `docs/DATA-TOOLS-DESIGN.md` §3.3, shipped with W4 (dock tools).
4
+ *
5
+ * Lives under `packages/engine/src/react/` deliberately: this is the ONE
6
+ * directory of the engine allowed to value-import react (colocated with
7
+ * `game-state.tsx` per the react-free-core rule proved by
8
+ * `test/react-core-import-ban.test.ts`). Game roots that aren't react keep
9
+ * using `DataHandle.get()`/`subscribe()` directly (§2.3).
10
+ *
11
+ * - {@link useData} — subscribe a component to any W1 `DataHandle`; works in
12
+ * project tools, react roots, and HUD overlays alike.
13
+ * - {@link writeData} — EDITOR-TOOL plumbing: write a whole `.data.json`
14
+ * back through the editor dev server, closing the live-tuning loop
15
+ * (widget → file write → Vite HMR → `hotSwap` → {@link useData} re-render
16
+ * → the running game reads the new value). Not for game code.
17
+ */
18
+
19
+ import { useSyncExternalStore } from 'react';
20
+ import type { DataHandle } from '../data/data-asset';
21
+
22
+ /**
23
+ * Read a data asset's current values and re-render whenever a hot edit lands
24
+ * (§3.3). A thin `useSyncExternalStore` over the handle's
25
+ * `subscribe`/`get` — any `defineData` handle works, no provider needed:
26
+ *
27
+ * ```tsx
28
+ * import { tuning } from '../data/tuning';
29
+ * const values = useData(tuning); // fresh parsed object after every hotSwap
30
+ * ```
31
+ *
32
+ * `get()` returns the same reference between successful hot swaps, so this
33
+ * re-renders exactly once per accepted edit and never in between.
34
+ */
35
+ export function useData<T>(handle: DataHandle<T>): T {
36
+ return useSyncExternalStore(
37
+ (onStoreChange) => handle.subscribe(() => onStoreChange()),
38
+ () => handle.get(),
39
+ );
40
+ }
41
+
42
+ /**
43
+ * Write a data asset's ENTIRE new value back to its `.data.json` — the write
44
+ * half of a project tool's live-tuning loop (§3.3, D1: writes are file
45
+ * writes, always). POSTs to the editor dev server's `/__editor/data-file`
46
+ * route (see its doc comment in `packages/editor/server/editor-server.ts`),
47
+ * which accepts only `src/data/**\/*.data.json` and writes immediately; the
48
+ * running game then receives the change through the normal `.data.json` HMR
49
+ * path, exactly as if VS Code had saved the file.
50
+ *
51
+ * ```tsx
52
+ * // inside a tool's onChange — spread the live values, replace one field:
53
+ * void writeData('src/data/tuning.data.json', { ...tuning.get(), gravity: v });
54
+ * ```
55
+ *
56
+ * FOR TOOLS, NOT GAME CODE: data assets are immutable at runtime (§2.1) —
57
+ * runtime state belongs in your game's own store, never written back into
58
+ * data files. Outside a running editor there is no `/__editor` server, so
59
+ * this rejects with a teaching error (a shipped game could never reach the
60
+ * route anyway — builds strip `src/tools/` entirely, §4).
61
+ *
62
+ * Serialization matches the editor Data panel's diff-minimal contract:
63
+ * `"$schema"` first, 2-space indent, trailing newline. When `value` carries
64
+ * no `"$schema"` key (the common case — `DataHandle.get()` strips it), the
65
+ * conventional sibling reference (`./<name>.schema.json`, §2.1) is restored
66
+ * so a tool write never silently drops VS Code validation from the file.
67
+ */
68
+ export async function writeData(
69
+ projectRelativePath: string,
70
+ value: Record<string, unknown>,
71
+ ): Promise<void> {
72
+ if (!projectRelativePath.startsWith('src/data/') || !projectRelativePath.endsWith('.data.json')) {
73
+ throw new Error(
74
+ `writeData: invalid path ${JSON.stringify(projectRelativePath)} — data assets live at ` +
75
+ 'src/data/**/*.data.json, addressed project-relative (docs/DATA-TOOLS-DESIGN.md §2.1), ' +
76
+ "e.g. writeData('src/data/tuning.data.json', next).",
77
+ );
78
+ }
79
+
80
+ // "$schema" first for minimal diffs; restore the conventional sibling
81
+ // reference when the caller's object (typically `{ ...handle.get() }`,
82
+ // which never carries it) omits the line.
83
+ const { $schema, ...rest } = value;
84
+ const schemaRef =
85
+ $schema ??
86
+ `./${projectRelativePath
87
+ .split('/')
88
+ .pop()
89
+ ?.replace(/\.data\.json$/, '')}.schema.json`;
90
+ const content = `${JSON.stringify({ $schema: schemaRef, ...rest }, null, 2)}\n`;
91
+
92
+ const transactionalWriter = (
93
+ globalThis as typeof globalThis & {
94
+ __vgaiHistoryWriteData?: (path: string, content: string) => Promise<void>;
95
+ }
96
+ ).__vgaiHistoryWriteData;
97
+ if (transactionalWriter) {
98
+ await transactionalWriter(projectRelativePath, content);
99
+ return;
100
+ }
101
+
102
+ let res: Response;
103
+ try {
104
+ res = await fetch('/__editor/data-file', {
105
+ method: 'POST',
106
+ headers: { 'Content-Type': 'application/json' },
107
+ body: JSON.stringify({ path: projectRelativePath, content }),
108
+ });
109
+ } catch (err) {
110
+ throw new Error(
111
+ 'writeData: could not reach the editor dev server (/__editor/data-file) — this helper is ' +
112
+ 'editor-tool plumbing (docs/DATA-TOOLS-DESIGN.md §3.3) and only works inside the running ' +
113
+ 'editor. Game code must not write data assets: they are immutable at runtime (§2.1) — ' +
114
+ `keep runtime state in your game's own store. (${String(err)})`,
115
+ );
116
+ }
117
+ if (!res.ok) {
118
+ const detail = await res.text().catch(() => '');
119
+ throw new Error(
120
+ `writeData: the editor rejected the write to "${projectRelativePath}" ` +
121
+ `(HTTP ${res.status}${detail ? `: ${detail}` : ''}).`,
122
+ );
123
+ }
124
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * W3.1 of docs/DATA-TOOLS-DESIGN.md §3.3 — `useSelection()`, the third
3
+ * tool-hooks contract member (alongside `useData`/`writeData` from
4
+ * `use-data.ts`). Where those two hooks are about DATA ASSETS, this one is
5
+ * about the currently-selected ENTITY and the components authored on it —
6
+ * the gap a real consumer (`critter-types-w3b`'s `critter-type.tool.tsx`,
7
+ * §9.4) hit hard: with no way to read a component's own authored config, it
8
+ * had to recover species from `node.label` (a display string, not data).
9
+ *
10
+ * Scope, v1:
11
+ * - INSPECTOR-placement tools only (`placement: 'inspector'`,
12
+ * `define-tool.ts`). `EditorSelectionProvider` is host plumbing —
13
+ * `InspectorToolSection` wraps every tool render in it; dock tools
14
+ * (`ToolHost`/`BottomPanel`) have no adapter/selection in scope and never
15
+ * get a provider. Calling `useSelection()` there throws a teaching error,
16
+ * contained by the tool's `ToolErrorBoundary` (the editor survives).
17
+ * - READ-only. `config` is a shallow copy of the authored record; write
18
+ * through `InspectorProvider.set('components.<Type>.<field>', v)` /
19
+ * `writeData` (the existing paths), never by mutating what this hook
20
+ * returns. Per-entity WRITE-from-tool is deferred with `useTunable` (§10).
21
+ * - Honest degrades, not errors, when the adapter is thinner than the vgai
22
+ * first-party one: no `components` provider at all ⇒ `components: []`;
23
+ * a `components` provider present but with no optional `config()`
24
+ * accessor ⇒ each entry still appears (from `list()`) but with
25
+ * `config: null`. Both mirror the `AuthoringAdapter` optional-provider
26
+ * doctrine (§4) — an ingest/UI adapter that never implements `components`
27
+ * was already legal, and stays legal.
28
+ *
29
+ * Liveness — two existing paths reused, no new mechanism (§4's "reuse that
30
+ * path" rule):
31
+ * 1. Selection changes: `Inspector.tsx`'s own
32
+ * `useSyncExternalStore(store.subscribe, store.getSnapshot)` re-renders
33
+ * the panel, each matched `Section` gets the new `nodeId` prop, and
34
+ * `InspectorToolSection` re-creates this provider's context value.
35
+ * 2. Config edits while the SAME node stays selected: `adapter.subscribe`
36
+ * fires (the vgai adapter's store notifies on every `updateEntity`,
37
+ * which is what `InspectorProvider.set` goes through) — this hook keeps
38
+ * a local version counter bumped per notification (exactly
39
+ * `game-state.tsx`'s `useWorldObservation` pattern: the adapter has no
40
+ * snapshot/version of its own, `subscribe` is fire-only) and recomputes.
41
+ */
42
+
43
+ import {
44
+ createContext,
45
+ type ReactNode,
46
+ useContext,
47
+ useMemo,
48
+ useRef,
49
+ useSyncExternalStore,
50
+ } from 'react';
51
+ import type { AuthoringAdapter, EditorNode } from '../adapter';
52
+
53
+ interface SelectionContextValue {
54
+ adapter: AuthoringAdapter;
55
+ nodeId: string | null;
56
+ }
57
+
58
+ const SelectionContext = createContext<SelectionContextValue | null>(null);
59
+
60
+ /**
61
+ * EDITOR-side host plumbing: `InspectorToolSection` wraps a tool's rendered
62
+ * `<Component/>` in this, passing the adapter + nodeId it already holds.
63
+ * Project tools never render this themselves — it exists so `useSelection()`
64
+ * has something to read.
65
+ */
66
+ export function EditorSelectionProvider({
67
+ adapter,
68
+ nodeId,
69
+ children,
70
+ }: {
71
+ adapter: AuthoringAdapter;
72
+ nodeId: string | null;
73
+ children: ReactNode;
74
+ }) {
75
+ const value = useMemo(() => ({ adapter, nodeId }), [adapter, nodeId]);
76
+ return <SelectionContext.Provider value={value}>{children}</SelectionContext.Provider>;
77
+ }
78
+
79
+ /** One component attached to the selected entity, with its authored config
80
+ * if the adapter can produce one (see the module doc's degrade rules). */
81
+ export interface SelectedComponent {
82
+ type: string;
83
+ config: Record<string, unknown> | null;
84
+ }
85
+
86
+ export interface EditorSelection {
87
+ nodeId: string | null;
88
+ node: EditorNode | null;
89
+ components: SelectedComponent[];
90
+ }
91
+
92
+ /**
93
+ * The selected entity, live: its node, and every component attached to it
94
+ * with that component's own authored config (read-only — see module doc).
95
+ *
96
+ * Throws outside an `EditorSelectionProvider` — inspector tools always render
97
+ * inside one; a dock tool or game-code caller does not, and the error names
98
+ * the fix rather than failing silently.
99
+ */
100
+ export function useSelection(): EditorSelection {
101
+ const ctx = useContext(SelectionContext);
102
+ if (!ctx) {
103
+ throw new Error(
104
+ 'useSelection: no editor selection in context — this hook only works inside an editor-hosted ' +
105
+ "INSPECTOR tool (placement: 'inspector', docs/DATA-TOOLS-DESIGN.md §3.3/§3.2). Dock tools and " +
106
+ 'game code have no selection to read.',
107
+ );
108
+ }
109
+ const { adapter, nodeId } = ctx;
110
+
111
+ // Local version counter bumped per adapter notification — the adapter has
112
+ // no snapshot/version of its own, `subscribe` is fire-only (same pattern
113
+ // as `useWorldObservation`'s `versionRef`, game-state.tsx).
114
+ const versionRef = useRef(0);
115
+ const version = useSyncExternalStore(
116
+ (onStoreChange) =>
117
+ adapter.subscribe?.(() => {
118
+ versionRef.current++;
119
+ onStoreChange();
120
+ }) ?? (() => {}),
121
+ () => versionRef.current,
122
+ );
123
+
124
+ return useMemo(() => {
125
+ const node = nodeId ? adapter.hierarchy.node(nodeId) : null;
126
+ const types = nodeId ? (adapter.components?.list(nodeId) ?? []) : [];
127
+ const components: SelectedComponent[] = types.map(({ type }) => ({
128
+ type,
129
+ config: nodeId ? (adapter.components?.config?.(nodeId, type) ?? null) : null,
130
+ }));
131
+ return { nodeId, node, components };
132
+ // `version` has no value of its own — only its CHANGE matters, as the
133
+ // liveness signal described above.
134
+ }, [adapter, nodeId, version]);
135
+ }