@vgai/engine 0.5.42 → 0.5.44

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 (114) hide show
  1. package/dist/adapter/adapter-module.d.ts +28 -29
  2. package/dist/adapter/adapter-module.d.ts.map +1 -1
  3. package/dist/adapter/adapter-module.js +11 -34
  4. package/dist/adapter/authoring-seam-contract.d.ts +5 -0
  5. package/dist/adapter/authoring-seam-contract.d.ts.map +1 -1
  6. package/dist/adapter/authoring-seam-contract.js +1 -0
  7. package/dist/adapter/authoring.d.ts +16 -0
  8. package/dist/adapter/authoring.d.ts.map +1 -1
  9. package/dist/adapter/finders/finder-result.d.ts +2 -2
  10. package/dist/adapter/finders/finder-result.d.ts.map +1 -1
  11. package/dist/adapter/finders/index.d.ts +15 -2
  12. package/dist/adapter/finders/index.d.ts.map +1 -1
  13. package/dist/adapter/finders/index.js +38 -10
  14. package/dist/adapter/finders/prefabs-from-stories.d.ts.map +1 -1
  15. package/dist/adapter/finders/registry.d.ts +45 -0
  16. package/dist/adapter/finders/registry.d.ts.map +1 -0
  17. package/dist/adapter/finders/registry.js +56 -0
  18. package/dist/adapter/ingest/scene-capture.d.ts.map +1 -1
  19. package/dist/adapter/ingest/scene-capture.js +50 -20
  20. package/dist/adapter/system-adapter.d.ts +3 -1
  21. package/dist/adapter/system-adapter.d.ts.map +1 -1
  22. package/dist/canvas-react/pixi-react-root-factory.d.ts.map +1 -1
  23. package/dist/canvas-react/pixi-react-root-factory.js +21 -11
  24. package/dist/data/vite-plugin-data.d.ts +1 -1
  25. package/dist/data/vite-plugin-data.d.ts.map +1 -1
  26. package/dist/data/vite-plugin-data.js +2 -2
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +1 -1
  30. package/dist/input/host-pointer.d.ts +18 -0
  31. package/dist/input/host-pointer.d.ts.map +1 -1
  32. package/dist/input/host-pointer.js +54 -5
  33. package/dist/input/input-manager.d.ts +83 -1
  34. package/dist/input/input-manager.d.ts.map +1 -1
  35. package/dist/input/input-manager.js +357 -8
  36. package/dist/manifest/configuration-kinds.d.ts +97 -0
  37. package/dist/manifest/configuration-kinds.d.ts.map +1 -0
  38. package/dist/manifest/configuration-kinds.js +148 -0
  39. package/dist/manifest/index.d.ts +2 -1
  40. package/dist/manifest/index.d.ts.map +1 -1
  41. package/dist/manifest/index.js +4 -3
  42. package/dist/manifest/kind-modules.d.ts +18 -0
  43. package/dist/manifest/kind-modules.d.ts.map +1 -0
  44. package/dist/manifest/kind-modules.js +58 -0
  45. package/dist/manifest/load-file.d.ts +3 -3
  46. package/dist/manifest/load-file.d.ts.map +1 -1
  47. package/dist/manifest/load-file.js +4 -4
  48. package/dist/manifest/load.d.ts +17 -12
  49. package/dist/manifest/load.d.ts.map +1 -1
  50. package/dist/manifest/load.js +29 -19
  51. package/dist/manifest/runtime-environment.d.ts +11 -0
  52. package/dist/manifest/runtime-environment.d.ts.map +1 -0
  53. package/dist/manifest/runtime-environment.js +44 -0
  54. package/dist/manifest/schema.d.ts +10 -9
  55. package/dist/manifest/schema.d.ts.map +1 -1
  56. package/dist/manifest/schema.js +32 -27
  57. package/dist/react/use-data.d.ts +1 -1
  58. package/dist/react/use-data.js +1 -1
  59. package/dist/render/matcap-texture.d.ts +5 -0
  60. package/dist/render/matcap-texture.d.ts.map +1 -0
  61. package/dist/render/matcap-texture.js +82 -0
  62. package/dist/render/soft-particle-depth.d.ts +14 -8
  63. package/dist/render/soft-particle-depth.d.ts.map +1 -1
  64. package/dist/render/soft-particle-depth.js +37 -14
  65. package/dist/render/viewport-shading.d.ts +11 -1
  66. package/dist/render/viewport-shading.d.ts.map +1 -1
  67. package/dist/render/viewport-shading.js +25 -0
  68. package/dist/runtime/create-runtime.js +6 -5
  69. package/dist/runtime/mount-manifest.d.ts.map +1 -1
  70. package/dist/runtime/mount-manifest.js +11 -2
  71. package/dist/settings/schema.d.ts +99 -0
  72. package/dist/settings/schema.d.ts.map +1 -0
  73. package/dist/settings/schema.js +110 -0
  74. package/dist/world3d-react/r3f-root-factory.d.ts.map +1 -1
  75. package/dist/world3d-react/r3f-root-factory.js +34 -36
  76. package/dist/world3d-react/rapier-physics-bridge.d.ts.map +1 -1
  77. package/dist/world3d-react/rapier-physics-bridge.js +21 -6
  78. package/dist-config/config.js +1 -1
  79. package/dist-config/config.js.map +2 -2
  80. package/package.json +2 -1
  81. package/schemas/engine-capabilities.json +4 -4
  82. package/schemas/vgai-project.schema.json +41 -36
  83. package/schemas/vgai-settings.schema.json +112 -0
  84. package/src/adapter/adapter-module.ts +33 -51
  85. package/src/adapter/authoring-seam-contract.ts +1 -0
  86. package/src/adapter/authoring.ts +16 -0
  87. package/src/adapter/finders/finder-result.ts +2 -2
  88. package/src/adapter/finders/index.ts +68 -12
  89. package/src/adapter/finders/prefabs-from-stories.ts +2 -2
  90. package/src/adapter/finders/registry.ts +95 -0
  91. package/src/adapter/finders/scenes-from-entrypoint-selection.ts +4 -4
  92. package/src/adapter/ingest/scene-capture.ts +48 -19
  93. package/src/adapter/system-adapter.ts +3 -1
  94. package/src/canvas-react/pixi-react-root-factory.tsx +21 -11
  95. package/src/data/vite-plugin-data.ts +4 -2
  96. package/src/index.ts +1 -1
  97. package/src/input/host-pointer.ts +84 -5
  98. package/src/input/input-manager.ts +404 -7
  99. package/src/manifest/configuration-kinds.ts +241 -0
  100. package/src/manifest/index.ts +4 -3
  101. package/src/manifest/kind-modules.ts +58 -0
  102. package/src/manifest/load-file.ts +11 -5
  103. package/src/manifest/load.ts +53 -22
  104. package/src/manifest/runtime-environment.ts +48 -0
  105. package/src/manifest/schema.ts +39 -33
  106. package/src/react/use-data.ts +1 -1
  107. package/src/render/matcap-texture.ts +92 -0
  108. package/src/render/soft-particle-depth.ts +43 -14
  109. package/src/render/viewport-shading.ts +33 -1
  110. package/src/runtime/create-runtime.ts +6 -5
  111. package/src/runtime/mount-manifest.ts +13 -2
  112. package/src/settings/schema.ts +127 -0
  113. package/src/world3d-react/r3f-root-factory.tsx +45 -39
  114. package/src/world3d-react/rapier-physics-bridge.tsx +22 -8
@@ -491,42 +491,22 @@ export interface PrefabsFromStoriesParams {
491
491
  readonly finder: 'prefabsFromStories';
492
492
  }
493
493
 
494
- /** Every finder this engine ships, as the adapter's concrete SELECTION. */
495
- export type FinderSelection = ScenesFromEntrypointSelectionParams | PrefabsFromStoriesParams;
496
-
497
- /** The finder names, as a value — what the loader's dispatcher switches on. */
498
- export const FINDER_NAMES = ['scenesFromEntrypointSelection', 'prefabsFromStories'] as const;
499
- export type FinderName = (typeof FINDER_NAMES)[number];
500
-
501
- const ScenesFromEntrypointSelectionSchema = z
502
- .object({
503
- finder: z.literal('scenesFromEntrypointSelection'),
504
- regions: z
505
- .union([z.array(z.string().min(1)).min(1), z.literal(EXPORTED_COMPOSITION_REGIONS)])
506
- .describe('Region ids whose entrypoints carry the selection, or the rule form'),
507
- selection: z
508
- .string()
509
- .min(1)
510
- .optional()
511
- .describe(
512
- "Identifier of the entrypoint's module-level scene selection table; omit for a " +
513
- 'single-composition entrypoint',
514
- ),
515
- })
516
- .strict()
517
- .refine((value) => value.selection === undefined || Array.isArray(value.regions), {
518
- message:
519
- 'scenesFromEntrypointSelection: `selection` names an identifier in ONE entrypoint’s ' +
520
- `source, so it cannot be paired with the \`${EXPORTED_COMPOSITION_REGIONS}\` rule — ` +
521
- 'name the region explicitly (`regions: ["<rootId>"]`).',
522
- });
523
-
524
- const PrefabsFromStoriesSchema = z.object({ finder: z.literal('prefabsFromStories') }).strict();
494
+ /**
495
+ * A finder selection: the finder's registered NAME plus its own parameters.
496
+ * The set of finders is OPEN (ARCHITECTURE-CORE §The editor protocol,
497
+ * "Documents, not scenes"): the engine's own register in
498
+ * `adapter/finders/registry.ts`, a contribution registers more, and this
499
+ * module validates only the envelope — each finder's parameters are checked
500
+ * against ITS schema the moment the loader runs it, loudly and by name.
501
+ */
502
+ export type FinderSelection = {
503
+ readonly finder: string;
504
+ readonly [param: string]: unknown;
505
+ };
525
506
 
526
- const FinderSelectionSchema = z.union([
527
- ScenesFromEntrypointSelectionSchema,
528
- PrefabsFromStoriesSchema,
529
- ]);
507
+ const FinderSelectionSchema = z
508
+ .object({ finder: z.string().min(1).describe('Registered finder name') })
509
+ .passthrough();
530
510
 
531
511
  // ---------------------------------------------------------------------------
532
512
  // The scene table
@@ -614,11 +594,13 @@ const SceneSourceSchema = z
614
594
  * verb, differing only in instance site (ARCHITECTURE-CORE §Roots: "A scene is
615
595
  * a ROLE, not a kind … the same kind of thing as a prefab").
616
596
  */
617
- export interface SceneEntry {
597
+ export interface DocumentEntry {
618
598
  readonly id: string;
619
599
  readonly label: string;
620
600
  /** `scene` (swaps at the entrypoint's slot) or `prefab` (placed by a designer). */
621
- readonly kind: 'scene' | 'prefab';
601
+ /** OPEN: `scene` and `prefab` are the first two kinds; a page, a component,
602
+ * a model, a bake are kinds the same way. */
603
+ readonly kind: string;
622
604
  /** Owning region id; `null` for a project-scoped entry bound to no one root. */
623
605
  readonly region: string | null;
624
606
  /**
@@ -633,20 +615,20 @@ export interface SceneEntry {
633
615
  * infers setup from neighboring filenames or game globals. */
634
616
  readonly isolationSetup?: SceneSource;
635
617
  /** Which finder produced it; absent = the adapter stated it outright. */
636
- readonly finder?: FinderName;
618
+ readonly finder?: string;
637
619
  }
638
620
 
639
- const SceneEntrySchema = z
621
+ const DocumentEntrySchema = z
640
622
  .object({
641
623
  id: z.string().min(1),
642
624
  label: z.string().min(1),
643
- kind: z.enum(['scene', 'prefab']),
625
+ kind: z.string().min(1),
644
626
  region: z.string().min(1).nullable(),
645
627
  authorable: z.boolean(),
646
628
  reach: SceneReachSchema,
647
629
  source: SceneSourceSchema.optional(),
648
630
  isolationSetup: SceneSourceSchema.optional(),
649
- finder: z.enum(FINDER_NAMES).optional(),
631
+ finder: z.string().min(1).optional(),
650
632
  })
651
633
  .strict();
652
634
 
@@ -654,8 +636,8 @@ const SceneEntrySchema = z
654
636
  * The adapter's scene table: entries stated outright, finder selections the
655
637
  * host runs to produce the rest, and the entry open by default.
656
638
  */
657
- export interface AdapterSceneTable {
658
- readonly entries?: readonly SceneEntry[];
639
+ export interface AdapterDocumentTable {
640
+ readonly entries?: readonly DocumentEntry[];
659
641
  readonly find?: readonly FinderSelection[];
660
642
  /**
661
643
  * Id of the entry open by default. Omit to let the finder's own answer
@@ -665,9 +647,9 @@ export interface AdapterSceneTable {
665
647
  readonly default?: string;
666
648
  }
667
649
 
668
- const AdapterSceneTableSchema = z
650
+ const AdapterDocumentTableSchema = z
669
651
  .object({
670
- entries: z.array(SceneEntrySchema).optional(),
652
+ entries: z.array(DocumentEntrySchema).optional(),
671
653
  find: z.array(FinderSelectionSchema).optional(),
672
654
  default: z.string().min(1).optional(),
673
655
  })
@@ -815,7 +797,7 @@ export interface AdapterDefinition {
815
797
  * an explicit `regions` LIST carries its own and rejects these by name.
816
798
  */
817
799
  readonly regionIncludes: Readonly<Record<string, AdapterRegionOverlay>>;
818
- readonly scenes: AdapterSceneTable;
800
+ readonly documents: AdapterDocumentTable;
819
801
  readonly observation: readonly ObservationDeclaration[];
820
802
  readonly input?: AdapterInputBinding | undefined;
821
803
  }
@@ -826,7 +808,7 @@ export interface AdapterDefinition {
826
808
  export interface AdapterDefinitionInput {
827
809
  readonly regions?: RegionBinding;
828
810
  readonly regionIncludes?: Readonly<Record<string, AdapterRegionOverlay>>;
829
- readonly scenes?: AdapterSceneTable;
811
+ readonly documents?: AdapterDocumentTable;
830
812
  readonly observation?: readonly ObservationDeclaration[];
831
813
  readonly input?: AdapterInputBinding | undefined;
832
814
  }
@@ -847,7 +829,7 @@ export const AdapterDefinitionSchema = z
847
829
  .object({
848
830
  regions: RegionBindingSchema.optional(),
849
831
  regionIncludes: RegionIncludesSchema.optional(),
850
- scenes: AdapterSceneTableSchema.optional(),
832
+ documents: AdapterDocumentTableSchema.optional(),
851
833
  observation: z.array(ObservationDeclarationSchema).optional(),
852
834
  input: AdapterInputBindingSchema.optional(),
853
835
  })
@@ -885,7 +867,7 @@ export function defineAdapter(input: AdapterDefinitionInput = {}): AdapterDefini
885
867
  return Object.freeze({
886
868
  regions: input.regions ?? 'manifest-roots',
887
869
  regionIncludes: Object.freeze({ ...(input.regionIncludes ?? {}) }),
888
- scenes: Object.freeze({ ...(input.scenes ?? {}) }),
870
+ documents: Object.freeze({ ...(input.documents ?? {}) }),
889
871
  observation: Object.freeze([...(input.observation ?? [])]),
890
872
  ...(input.input ? { input: input.input } : {}),
891
873
  });
@@ -894,7 +876,7 @@ export function defineAdapter(input: AdapterDefinitionInput = {}): AdapterDefini
894
876
  /** Extra bindings a near-native game adds without restating the native ones. */
895
877
  export interface NativeAdapterOptions {
896
878
  /** Scene-table entries this game states outright, beside what the finders find. */
897
- readonly scenes?: readonly SceneEntry[];
879
+ readonly scenes?: readonly DocumentEntry[];
898
880
  /** Per-region `include`/`mounts` parameters for the derivation — see
899
881
  * {@link AdapterDefinition.regionIncludes}. */
900
882
  readonly regionIncludes?: Readonly<Record<string, AdapterRegionOverlay>>;
@@ -911,7 +893,7 @@ export function nativeAdapter(options: NativeAdapterOptions = {}): AdapterDefini
911
893
  return defineAdapter({
912
894
  regions: 'manifest-roots',
913
895
  ...(options.regionIncludes ? { regionIncludes: options.regionIncludes } : {}),
914
- scenes: {
896
+ documents: {
915
897
  ...(options.scenes ? { entries: options.scenes } : {}),
916
898
  find: [
917
899
  { finder: 'scenesFromEntrypointSelection', regions: EXPORTED_COMPOSITION_REGIONS },
@@ -83,6 +83,7 @@ export const INSPECTOR_PROVIDER_SHAPE = defineSeamShape<InspectorProvider>()({
83
83
  properties: { optional: false, kind: 'function', required: 'operation' },
84
84
  get: { optional: false, kind: 'function', required: 'operation' },
85
85
  set: { optional: false, kind: 'function', required: 'effect' },
86
+ preview: { optional: true, kind: 'function', required: 'effect' },
86
87
  editability: { optional: true, kind: 'function', required: 'operation' },
87
88
  remove: { optional: true, kind: 'function', required: 'round-trip' },
88
89
  });
@@ -346,6 +346,22 @@ export interface InspectorProvider {
346
346
  * performed by this provider, and the shell reports the live-only floor.
347
347
  */
348
348
  set(id: string, path: string, value: unknown): void | WriteAck | Promise<void | WriteAck>;
349
+ /**
350
+ * Optional — SHOW a value without writing it, for a control the user drags.
351
+ *
352
+ * The gizmo already has this pair ({@link TransformProvider.writeTransform}
353
+ * live, {@link TransformProvider.endEdit} persisting), and a colour picker
354
+ * needs the same shape: it streams values continuously while the pointer
355
+ * moves, and persisting each one is a write burst the absorb cycle cannot
356
+ * follow. So the control previews here on every change and calls
357
+ * {@link set} ONCE when the gesture ends.
358
+ *
359
+ * A preview NEVER persists and is never the truth: whatever the adapter
360
+ * mutates is discarded by the next remount unless a `set` follows. An
361
+ * adapter that cannot show a value without writing it simply omits this,
362
+ * and the control's preview is a no-op — the value still lands on commit.
363
+ */
364
+ preview?(id: string, path: string, value: unknown): void;
349
365
  /** Optional per-subject write preflight for shell affordances outside the
350
366
  * Inspector field grid (notably hierarchy eye/lock controls). The property
351
367
  * descriptor remains the Inspector's source of `readonly`; this gives other
@@ -8,11 +8,11 @@
8
8
  * different facts, and only the second is a defect.
9
9
  */
10
10
 
11
- import type { SceneEntry } from '../adapter-module';
11
+ import type { DocumentEntry } from '../adapter-module';
12
12
 
13
13
  export interface FinderResult {
14
14
  /** Scene-table entries this finder produced, in discovery order. */
15
- readonly entries: readonly SceneEntry[];
15
+ readonly entries: readonly DocumentEntry[];
16
16
  /**
17
17
  * The entry this finder's own answer makes the default, when its answer
18
18
  * settles it UNAMBIGUOUSLY (one candidate). A finder never picks between
@@ -19,9 +19,16 @@
19
19
  * is what keeps a game's `vgai.adapter.ts` free of finder implementations.
20
20
  */
21
21
 
22
- import type { FinderSelection } from '../adapter-module';
22
+ import { type ZodType, z } from 'zod';
23
+ import {
24
+ EXPORTED_COMPOSITION_REGIONS,
25
+ type FinderSelection,
26
+ type PrefabsFromStoriesParams,
27
+ type ScenesFromEntrypointSelectionParams,
28
+ } from '../adapter-module';
23
29
  import type { FinderResult } from './finder-result';
24
30
  import { type PrefabsFromStoriesInput, prefabsFromStories } from './prefabs-from-stories';
31
+ import { registerFinder, runRegisteredFinder } from './registry';
25
32
  import {
26
33
  type ScenesFromEntrypointSelectionInput,
27
34
  scenesFromEntrypointSelection,
@@ -34,6 +41,13 @@ export type {
34
41
  StoryRegistration,
35
42
  } from './prefabs-from-stories';
36
43
  export { prefabsFromStories } from './prefabs-from-stories';
44
+ export type { FinderContribution, FinderRegistration } from './registry';
45
+ export {
46
+ registerContributedFinder,
47
+ registeredFinderNames,
48
+ registerFinder,
49
+ runRegisteredFinder,
50
+ } from './registry';
37
51
  export type {
38
52
  EntrypointSource,
39
53
  ScenesFromEntrypointSelectionInput,
@@ -41,7 +55,20 @@ export type {
41
55
  export { scenesFromEntrypointSelection } from './scenes-from-entrypoint-selection';
42
56
 
43
57
  /** Everything any shipped finder can be handed, gathered once by the loader. */
44
- export type FinderInput = ScenesFromEntrypointSelectionInput & PrefabsFromStoriesInput;
58
+ /** A project source the host read for a selection's `include` globs. */
59
+ export interface ProjectSourceFile {
60
+ /** Project-relative path. */
61
+ readonly path: string;
62
+ readonly source: string;
63
+ }
64
+
65
+ export type FinderInput = ScenesFromEntrypointSelectionInput &
66
+ PrefabsFromStoriesInput & {
67
+ /** The sources matching every selection's `include` globs — empty when
68
+ * no selection declares any. A contributed finder reads the project
69
+ * through this and nothing else. */
70
+ readonly sources?: readonly ProjectSourceFile[];
71
+ };
45
72
 
46
73
  /**
47
74
  * Run one adapter-declared selection. The switch is exhaustive over
@@ -49,14 +76,43 @@ export type FinderInput = ScenesFromEntrypointSelectionInput & PrefabsFromStorie
49
76
  * here rather than silently contributing nothing.
50
77
  */
51
78
  export function runFinderSelection(selection: FinderSelection, input: FinderInput): FinderResult {
52
- switch (selection.finder) {
53
- case 'scenesFromEntrypointSelection':
54
- return scenesFromEntrypointSelection(selection, input);
55
- case 'prefabsFromStories':
56
- return prefabsFromStories(selection, input);
57
- default: {
58
- const unreachable: never = selection;
59
- throw new Error(`Unknown finder selection: ${JSON.stringify(unreachable)}`);
60
- }
61
- }
79
+ return runRegisteredFinder(selection, input);
62
80
  }
81
+
82
+ // The engine's own finders register here, schemas beside them: what an
83
+ // adapter may select by name is exactly what this registry holds.
84
+ const ScenesFromEntrypointSelectionSchema = z
85
+ .object({
86
+ finder: z.literal('scenesFromEntrypointSelection'),
87
+ regions: z
88
+ .union([z.array(z.string().min(1)).min(1), z.literal(EXPORTED_COMPOSITION_REGIONS)])
89
+ .describe('Region ids whose entrypoints carry the selection, or the rule form'),
90
+ selection: z
91
+ .string()
92
+ .min(1)
93
+ .optional()
94
+ .describe(
95
+ "Identifier of the entrypoint's module-level scene selection table; omit for a " +
96
+ 'single-composition entrypoint',
97
+ ),
98
+ })
99
+ .strict()
100
+ .refine((value) => value.selection === undefined || Array.isArray(value.regions), {
101
+ message:
102
+ 'scenesFromEntrypointSelection: `selection` names an identifier in ONE entrypoint’s ' +
103
+ `source, so it cannot be paired with the \`${EXPORTED_COMPOSITION_REGIONS}\` rule — ` +
104
+ 'name the region explicitly (`regions: ["<rootId>"]`).',
105
+ });
106
+ registerFinder<ScenesFromEntrypointSelectionParams, FinderInput>({
107
+ name: 'scenesFromEntrypointSelection',
108
+ schema:
109
+ ScenesFromEntrypointSelectionSchema as unknown as ZodType<ScenesFromEntrypointSelectionParams>,
110
+ run: scenesFromEntrypointSelection,
111
+ });
112
+ registerFinder<PrefabsFromStoriesParams, FinderInput>({
113
+ name: 'prefabsFromStories',
114
+ schema: z
115
+ .object({ finder: z.literal('prefabsFromStories') })
116
+ .strict() as unknown as ZodType<PrefabsFromStoriesParams>,
117
+ run: prefabsFromStories,
118
+ });
@@ -26,7 +26,7 @@
26
26
  * already produced and the component index it already has.
27
27
  */
28
28
 
29
- import type { PrefabsFromStoriesParams, SceneEntry } from '../adapter-module';
29
+ import type { DocumentEntry, PrefabsFromStoriesParams } from '../adapter-module';
30
30
  import type { FinderResult } from './finder-result';
31
31
 
32
32
  /** One composed portable-CSF story, as the host's story registry reports it. */
@@ -112,7 +112,7 @@ export function prefabsFromStories(
112
112
  _params: PrefabsFromStoriesParams,
113
113
  input: PrefabsFromStoriesInput,
114
114
  ): FinderResult {
115
- const entries: SceneEntry[] = [];
115
+ const entries: DocumentEntry[] = [];
116
116
  const notes: string[] = [];
117
117
  for (const [componentName, stories] of storiesByComponent(input.stories)) {
118
118
  const component = input.components?.find((candidate) => candidate.name === componentName);
@@ -0,0 +1,95 @@
1
+ /**
2
+ * THE FINDER REGISTRY — the open set of finders an adapter may SELECT by
3
+ * name (ARCHITECTURE-CORE §The editor protocol, "Documents, not scenes").
4
+ *
5
+ * A finder is a pure function from the project's gathered source to document
6
+ * entries, plus the Zod schema of its own parameters. The engine registers
7
+ * its two here (`./index.ts`); a contribution registers more through the
8
+ * same door. `adapter-module.ts` validates only the selection ENVELOPE; the
9
+ * parameters are checked against the finder's schema when the loader runs
10
+ * it — an unregistered name or a bad parameter refuses loudly, naming both.
11
+ */
12
+
13
+ import type { ZodType } from 'zod';
14
+ import type { FinderSelection } from '../adapter-module';
15
+ import type { FinderResult } from './finder-result';
16
+
17
+ /** What a registration's selection type must carry: the name. A finder's own
18
+ * params interface satisfies this without an index signature. */
19
+ export type FinderSelectionEnvelope = { readonly finder: string };
20
+
21
+ export interface FinderRegistration<
22
+ S extends FinderSelectionEnvelope = FinderSelection,
23
+ I = unknown,
24
+ > {
25
+ readonly name: S['finder'];
26
+ /** The selection's full shape, `finder` included; `.strict()` by the
27
+ * same rule as every other adapter table. */
28
+ readonly schema: ZodType<S>;
29
+ readonly run: (selection: S, input: I) => FinderResult;
30
+ }
31
+
32
+ const registry = new Map<string, FinderRegistration>();
33
+
34
+ /** Register a finder. A second registration under one name replaces the
35
+ * first (a module re-evaluated on save), never throws. */
36
+ export function registerFinder<S extends FinderSelectionEnvelope, I>(
37
+ registration: FinderRegistration<S, I>,
38
+ ): () => void {
39
+ const entry = registration as unknown as FinderRegistration;
40
+ registry.set(registration.name, entry);
41
+ return () => {
42
+ if (registry.get(registration.name) === entry) registry.delete(registration.name);
43
+ };
44
+ }
45
+
46
+ /**
47
+ * What a CONTRIBUTION exports to register a finder (ARCHITECTURE-CORE §The
48
+ * project model): a plain object a project module `src/contributions/<name>.finder.ts`
49
+ * exports as `finder`, handed by the HOST to {@link registerContributedFinder}.
50
+ * A selection may carry `include` globs; the host then supplies the matching
51
+ * project sources on `input.sources`, which is how a finder reads the
52
+ * project without a file system.
53
+ */
54
+ export type FinderContribution = FinderRegistration<FinderSelection, unknown>;
55
+
56
+ /** Register a contribution's finder after checking its shape by name. */
57
+ export function registerContributedFinder(value: unknown, source: string): () => void {
58
+ const record = (value ?? {}) as Partial<FinderContribution>;
59
+ const problems: string[] = [];
60
+ if (typeof record.name !== 'string' || record.name.length === 0)
61
+ problems.push('`name` must be a non-empty string');
62
+ const schema = record.schema as { safeParse?: unknown } | undefined;
63
+ if (!schema || typeof schema.safeParse !== 'function')
64
+ problems.push('`schema` must be a Zod schema');
65
+ if (typeof record.run !== 'function') problems.push('`run(selection, input)` must be a function');
66
+ if (problems.length > 0) throw new Error(`${source}: not a finder — ${problems.join('; ')}`);
67
+ return registerFinder(record as FinderContribution);
68
+ }
69
+
70
+ /** Every registered finder name, in registration order. */
71
+ export function registeredFinderNames(): readonly string[] {
72
+ return [...registry.keys()];
73
+ }
74
+
75
+ /** Validate a selection against its finder's schema and run it. */
76
+ export function runRegisteredFinder<I>(selection: FinderSelection, input: I): FinderResult {
77
+ const registration = registry.get(selection.finder);
78
+ if (!registration) {
79
+ const known = registeredFinderNames();
80
+ throw new Error(
81
+ `Unknown finder ${JSON.stringify(selection.finder)} — registered: ${
82
+ known.length > 0 ? known.map((n) => `"${n}"`).join(', ') : 'none'
83
+ }`,
84
+ );
85
+ }
86
+ const parsed = registration.schema.safeParse(selection);
87
+ if (!parsed.success) {
88
+ throw new Error(
89
+ `Finder ${JSON.stringify(selection.finder)}: ${parsed.error.issues
90
+ .map((issue) => `${issue.path.join('.') || '(selection)'}: ${issue.message}`)
91
+ .join('; ')}`,
92
+ );
93
+ }
94
+ return registration.run(parsed.data, input);
95
+ }
@@ -38,7 +38,7 @@
38
38
 
39
39
  import ts from 'typescript';
40
40
  import type {
41
- SceneEntry,
41
+ DocumentEntry,
42
42
  SceneSource,
43
43
  ScenesFromEntrypointSelectionParams,
44
44
  } from '../adapter-module';
@@ -263,7 +263,7 @@ function targetedEntrypoints(
263
263
  /** DEGENERATE: the entrypoint mounts one composition, and mounting the region
264
264
  * IS opening it. Labelled by the module's own default export — the game's own
265
265
  * word for this composition. */
266
- function degenerateEntry(entrypoint: EntrypointSource, sf: ts.SourceFile): SceneEntry {
266
+ function degenerateEntry(entrypoint: EntrypointSource, sf: ts.SourceFile): DocumentEntry {
267
267
  return {
268
268
  id: entrypoint.regionId,
269
269
  label: defaultExportName(sf) ?? basename(entrypoint.path),
@@ -286,7 +286,7 @@ function selectionEntries(
286
286
  selection: string,
287
287
  resolveModule: ScenesFromEntrypointSelectionInput['resolveModule'],
288
288
  notes: string[],
289
- ): { entries: SceneEntry[]; active: string | undefined } {
289
+ ): { entries: DocumentEntry[]; active: string | undefined } {
290
290
  const table = selectionTable(sf, selection);
291
291
  if (!table) {
292
292
  notes.push(
@@ -352,7 +352,7 @@ export function scenesFromEntrypointSelection(
352
352
  input: ScenesFromEntrypointSelectionInput,
353
353
  ): FinderResult {
354
354
  const notes: string[] = [];
355
- const entries: SceneEntry[] = [];
355
+ const entries: DocumentEntry[] = [];
356
356
  const active: string[] = [];
357
357
 
358
358
  for (const entrypoint of targetedEntrypoints(params, input.entrypoints, notes)) {
@@ -649,30 +649,59 @@ export function installSceneCapture(
649
649
  recordLoop: recordAnimationLoop,
650
650
  });
651
651
 
652
- // DOM-backed Three renderers (CSS3DRenderer is the first) use a normal
653
- // prototype method rather than WebGLRenderer's constructor assignment. Trap
654
- // only classes explicitly supplied by the host, and restore each byte-for-byte.
652
+ // DOM-backed Three renderers (CSS3DRenderer is the first): trap only classes
653
+ // explicitly supplied by the host, and restore each byte-for-byte. Two
654
+ // shapes exist across three revisions and both are met: an ordinary
655
+ // prototype `render` is wrapped in place; a CONSTRUCTOR-ASSIGNED `render`
656
+ // (r180's CSS3DRenderer does `this.render = function …` exactly like
657
+ // WebGLRenderer, so the prototype carries none) gets the same
658
+ // getter/setter trap the WebGLRenderer path uses — the constructor's
659
+ // assignment hits the setter, the getter hands back the observing wrapper.
660
+ // Skipping that shape silently was measured as "the game never rendered"
661
+ // on css3d_periodictable while 118 of its elements sat in the DOM.
655
662
  const additionalRendererRestores: Array<() => void> = [];
656
663
  for (const candidate of opts?.additionalRendererCtors ?? []) {
657
664
  const ctor = candidate as PrototypeRendererCtorLike;
658
665
  const rendererProto = ctor?.prototype;
659
- const priorRender = rendererProto?.render;
660
- if (!rendererProto || typeof priorRender !== 'function' || rendererProto === proto) continue;
661
- rendererProto.render = function (this: unknown, ...args: unknown[]) {
662
- observeRendererRender(this, args[0], args[1]);
663
- const hooks = renderPassHooks;
664
- if (hooks === null || !captured || this !== captured.renderer) {
665
- return priorRender.apply(this, args);
666
- }
667
- hooks.before();
668
- try {
669
- return priorRender.apply(this, args);
670
- } finally {
671
- hooks.after();
672
- }
673
- };
666
+ if (!rendererProto || rendererProto === proto) continue;
667
+ const priorRender = rendererProto.render;
668
+ if (typeof priorRender === 'function') {
669
+ rendererProto.render = function (this: unknown, ...args: unknown[]) {
670
+ observeRendererRender(this, args[0], args[1]);
671
+ const hooks = renderPassHooks;
672
+ if (hooks === null || !captured || this !== captured.renderer) {
673
+ return priorRender.apply(this, args);
674
+ }
675
+ hooks.before();
676
+ try {
677
+ return priorRender.apply(this, args);
678
+ } finally {
679
+ hooks.after();
680
+ }
681
+ };
682
+ additionalRendererRestores.push(() => {
683
+ rendererProto.render = priorRender;
684
+ });
685
+ continue;
686
+ }
687
+ const priorAddonDescriptor = Object.getOwnPropertyDescriptor(rendererProto, 'render');
688
+ Object.defineProperty(rendererProto, 'render', {
689
+ configurable: true,
690
+ set(this: Record<symbol, unknown>, fn: unknown) {
691
+ this[REAL] = fn;
692
+ },
693
+ get(this: Record<symbol, unknown>) {
694
+ const self = this;
695
+ return function render(this: unknown, ...args: unknown[]) {
696
+ observeRendererRender(self, args[0], args[1]);
697
+ return forwardRender(self, args);
698
+ };
699
+ },
700
+ });
674
701
  additionalRendererRestores.push(() => {
675
- rendererProto.render = priorRender;
702
+ if (priorAddonDescriptor)
703
+ Object.defineProperty(rendererProto, 'render', priorAddonDescriptor);
704
+ else delete (rendererProto as { render?: unknown }).render;
676
705
  });
677
706
  }
678
707
 
@@ -168,7 +168,9 @@ export interface NetPeer {
168
168
  * running. `null` from `getServerConfig` means no server is declared. */
169
169
  export interface NetServerConfig {
170
170
  endpoint: string;
171
- roomName: string;
171
+ /** Known once the game's own networking adapter reports it; in edit mode
172
+ * the declared `process` gives only the endpoint. */
173
+ roomName?: string;
172
174
  }
173
175
 
174
176
  /** The signed-in player's authored identity, used as the connect default. */
@@ -150,19 +150,19 @@ function canvasWorldAdapter(id: string, component: ComponentType): RootAdapter<'
150
150
  const unmounted = new Promise<void>((resolve) => {
151
151
  resolveUnmounted = resolve;
152
152
  });
153
+ let resolveEffectsUnmounted!: () => void;
154
+ const effectsUnmounted = new Promise<void>((resolve) => {
155
+ resolveEffectsUnmounted = resolve;
156
+ });
153
157
  /**
154
- * Signals the tree's FIRST COMMIT and its LAST. A layout effect,
155
- * deliberately, and rendered as the LAST child, deliberately:
158
+ * Signals the tree's FIRST COMMIT and both unmount cleanup phases. Rendered as the LAST
159
+ * child deliberately:
156
160
  *
157
161
  * - mount: layout effects run synchronously inside the commit, before
158
162
  * any passive effect and before any tick can be scheduled;
159
- * - unmount: React commits deletion effects in tree order, each node
160
- * before its own children, so the LAST sibling's cleanup is the last
161
- * to run. That is what makes this a reliable "every component has
162
- * finished tearing down" signal — and it is load-bearing, because
163
- * `useTick`'s own cleanup calls `ticker.remove()`. Destroying the
164
- * Application before that cleanup lands throws inside React's commit
165
- * on every teardown of any world with a tick hook.
163
+ * - unmount: the layout cleanup fences `useTick` and the passive cleanup fences ordinary
164
+ * project effects. Native destruction waits for BOTH; resolving only from layout left
165
+ * passive listener/resource cleanup racing an Application whose renderer was null.
166
166
  */
167
167
  function Lifecycle(): null {
168
168
  useLayoutEffect(() => {
@@ -172,7 +172,10 @@ function canvasWorldAdapter(id: string, component: ComponentType): RootAdapter<'
172
172
  // This component is the last sibling. Reaching its passive effect
173
173
  // means the initial tree's provider/command effects have registered,
174
174
  // so mount() can truthfully back Play's ack.
175
- useEffect(resolveEffectsReady, []);
175
+ useEffect(() => {
176
+ resolveEffectsReady();
177
+ return resolveEffectsUnmounted;
178
+ }, []);
176
179
  return null;
177
180
  }
178
181
 
@@ -282,7 +285,14 @@ function canvasWorldAdapter(id: string, component: ComponentType): RootAdapter<'
282
285
  // Adapter surface: the host's shared debug registry. The game's own
283
286
  // capabilities arrive as the entry module's declared `systems`.
284
287
  const systemAdapters: SystemAdapters = registry ? { debug: registry.adapter } : {};
285
- const disposeComplete = unmounted.then(() => {
288
+ const disposeComplete = Promise.all([unmounted, effectsUnmounted]).then(() => {
289
+ // React can tear the root down itself after a fatal layout/effect error. In that path the
290
+ // host has not called dispose() yet, but Application.destroy() still clears Pixi's ticker
291
+ // and renderer. Wait for both layout and passive cleanup first: project effects may own
292
+ // window/document listeners and can still read the Pixi Application while React drains
293
+ // passive deletion effects. Fence the mounted root before clearing the native slots so
294
+ // subsequent host frames and the eventual host teardown cannot re-enter them.
295
+ disposed = true;
286
296
  mountedApp.destroy(false, { children: true });
287
297
  });
288
298
 
@@ -27,7 +27,7 @@
27
27
  * target file" hole, closed for declared refs — see
28
28
  * `collectDeclaredRefFields`/`findMissingRefTargets` in `data-check-core.ts`).
29
29
  * 3. **Tools never ship** (`generateBundle`): no emitted chunk may contain a
30
- * module from `src/tools/` or a `*.tool.*` file (§4: tester serves and
30
+ * module from `src/contributions/`, `src/tools/` or a `*.tool.*` file (§4: tester serves and
31
31
  * standalone builds strip tools entirely; W4 made this true by
32
32
  * construction — nothing imports tools — and this check PINS it against
33
33
  * the day some game module imports a tool "just for a helper").
@@ -268,7 +268,9 @@ export async function collectDataCheckProblems(options: VgaiDataCheckOptions): P
268
268
  /** True iff a bundled module id is tool code that must never ship (§4). Pure, unit-tested. */
269
269
  export function isToolModuleId(id: string): boolean {
270
270
  const normalized = id.split('\\').join('/');
271
- return /\/src\/tools\//.test(normalized) || /\.tool\.[tj]sx?(\?|$)/.test(normalized);
271
+ return (
272
+ /\/src\/(?:tools|contributions)\//.test(normalized) || /\.tool\.[tj]sx?(\?|$)/.test(normalized)
273
+ );
272
274
  }
273
275
 
274
276
  /**