@forgeax/engine-runtime 0.1.27 → 0.1.29

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/README.md +98 -0
  2. package/dist/collect-scene-asset.d.ts.map +1 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.mjs +480 -15
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/scene-utils/mount-override-fold.d.ts +3 -1
  8. package/dist/scene-utils/mount-override-fold.d.ts.map +1 -1
  9. package/package.json +37 -30
  10. package/src/__tests__/animation-graph-collect-roundtrip.test.ts +4 -2
  11. package/src/__tests__/asset-registry-aabb.cases.unit.test.ts +5 -4
  12. package/src/__tests__/asset-registry-guid-reverse.test.ts +22 -27
  13. package/src/__tests__/asset-registry-mounts.test.ts +174 -734
  14. package/src/__tests__/asset-registry-scene.cases.unit.test.ts +9 -18
  15. package/src/__tests__/asset-registry.recursive.spec.ts +23 -29
  16. package/src/__tests__/camera-ortho.cases.unit.test.ts +12 -3
  17. package/src/__tests__/collect-scene-asset.test.ts +96 -113
  18. package/src/__tests__/collect-scene-mount-collapse.test.ts +64 -66
  19. package/src/__tests__/collect-transient-children-absent.test.ts +11 -17
  20. package/src/__tests__/collect-transient-roundtrip.test.ts +12 -13
  21. package/src/__tests__/collect-transient-skip.test.ts +20 -27
  22. package/src/__tests__/components.test-d.ts +17 -1
  23. package/src/__tests__/components.unit.test.ts +75 -27
  24. package/src/__tests__/composite-skybox-cross-world.browser.test.ts +4 -2
  25. package/src/__tests__/dawn/instances-per-instance-pbr.dawn.test.ts +8 -4
  26. package/src/__tests__/dawn/instances-shadow.dawn.test.ts +4 -1
  27. package/src/__tests__/dawn/instances-uniform-fallback.dawn.test.ts +66 -92
  28. package/src/__tests__/dawn/material-cooked-fixture.dawn.test.ts +200 -4
  29. package/src/__tests__/errors.unit.test.ts +13 -0
  30. package/src/__tests__/feature-depth-material.dawn.test.ts +23 -0
  31. package/src/__tests__/feature-depth-material.fixture.ts +473 -0
  32. package/src/__tests__/fullscreen-post-process-pass.dawn.test.ts +14 -14
  33. package/src/__tests__/geometry.unit.test.ts +42 -43
  34. package/src/__tests__/gpu-resource-release.cases.unit.test.ts +16 -9
  35. package/src/__tests__/handle-migration.test.ts +0 -1
  36. package/src/__tests__/instances-renderer-lifecycle.integration.test.ts +20 -14
  37. package/src/__tests__/instances.test-d.ts +13 -52
  38. package/src/__tests__/instantiate-mount-deferred-wiring.test.ts +42 -80
  39. package/src/__tests__/lazy-catalog.cases.unit.test.ts +17 -3
  40. package/src/__tests__/loader-registry.cases.unit.test.ts +4 -4
  41. package/src/__tests__/material-publication.browser.test.ts +29 -0
  42. package/src/__tests__/material-publication.commands.ts +23 -0
  43. package/src/__tests__/material-publication.dawn.test.ts +24 -0
  44. package/src/__tests__/material-publication.fixture.ts +349 -0
  45. package/src/__tests__/material-publication.server.ts +109 -0
  46. package/src/__tests__/parse-scene-payload-refs.cases.unit.test.ts +73 -96
  47. package/src/__tests__/pbr-pipeline.unit.test.ts +25 -11
  48. package/src/__tests__/point-light-shadow.browser.test.ts +8 -2
  49. package/src/__tests__/point-light-shadow.unit.test.ts +37 -8
  50. package/src/__tests__/record-all-topology.cases.unit.test.ts +52 -0
  51. package/src/__tests__/record-draw-branch.cases.unit.test.ts +1 -0
  52. package/src/__tests__/record-worldid-isolation.test.ts +47 -28
  53. package/src/__tests__/render-error-exhaustive.test-d.ts +24 -0
  54. package/src/__tests__/render-system-extract-dispatch-ordering.test.ts +15 -4
  55. package/src/__tests__/render-system-extract.test.ts +61 -0
  56. package/src/__tests__/render-system-extract.unit.test.ts +21 -7
  57. package/src/__tests__/render-system-fold-bucket.unit.test.ts +33 -1
  58. package/src/__tests__/render-system-fold-material.unit.test.ts +32 -6
  59. package/src/__tests__/render-system-fold-modegate.unit.test.ts +26 -4
  60. package/src/__tests__/render-system-fold.dawn.test.ts +4 -1
  61. package/src/__tests__/render-system-mega.test.ts +50 -62
  62. package/src/__tests__/renderer-device-loss-provider-feasibility.dawn.test.ts +0 -1
  63. package/src/__tests__/renderer-device-loss-recovery.dawn.test.ts +0 -3
  64. package/src/__tests__/renderer-draw-world.cases.unit.test.ts +16 -513
  65. package/src/__tests__/resolve-scene-guids.cases.unit.test.ts +48 -43
  66. package/src/__tests__/rhi-null-command-flow.unit.test.ts +9 -7
  67. package/src/__tests__/roots-to-scene-asset.test.ts +27 -28
  68. package/src/__tests__/roundtrip-primitive.test.ts +24 -23
  69. package/src/__tests__/scene-equirect-roundtrip.test.ts +5 -16
  70. package/src/__tests__/scene-instantiate-cycle-runtime.test.ts +13 -29
  71. package/src/__tests__/scene-mount-fixed-point.test.ts +104 -227
  72. package/src/__tests__/scene-mount-roundtrip.test.ts +95 -961
  73. package/src/__tests__/scene-mount-wrapper-roundtrip.test.ts +36 -182
  74. package/src/__tests__/scene-sugar-instantiate.test.ts +20 -15
  75. package/src/__tests__/serialize-mounts-roundtrip.test.ts +108 -492
  76. package/src/__tests__/serialize-scene-asset.test.ts +36 -50
  77. package/src/__tests__/shadow-caster-empty-schema.test.ts +3 -1
  78. package/src/__tests__/shadow-fields-observable.dawn.test.ts +34 -19
  79. package/src/__tests__/shadow-mesh-ssbo-order.test.ts +5 -12
  80. package/src/__tests__/skin-extract-getarrayview-count.unit.test.ts +6 -4
  81. package/src/__tests__/skin-m2-extract-happy.cases.unit.test.ts +50 -2
  82. package/src/__tests__/skin-m2-extract-joint-error.cases.unit.test.ts +50 -2
  83. package/src/__tests__/skin-m2-extract-null-assets.cases.unit.test.ts +50 -2
  84. package/src/__tests__/skin-m2-extract-skeleton-error.cases.unit.test.ts +50 -2
  85. package/src/__tests__/skin-palette-extract.cases.unit.test.ts +2 -0
  86. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +93 -48
  87. package/src/__tests__/skylight-pipeline-layout.cases.unit.test.ts +6 -2
  88. package/src/__tests__/sparse-tag-scene-roundtrip.test.ts +1 -1
  89. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +11 -3
  90. package/src/__tests__/template-game-default-api-contract.test.ts +21 -21
  91. package/src/__tests__/tonemap-hdr-target.cases.unit.test.ts +8 -5
  92. package/src/__tests__/tonemap-pipeline-split.cases.unit.test.ts +3 -0
  93. package/src/__tests__/transient-fields-collect.test.ts +4 -4
  94. package/src/__tests__/transient-runtime-behavior.test.ts +9 -9
  95. package/src/__tests__/vfx-depth-material.dawn.test.ts +42 -0
  96. package/src/__tests__/vfx-mesh-lighting.browser.test.ts +16 -0
  97. package/src/__tests__/vfx-mesh-lighting.commands.ts +14 -0
  98. package/src/__tests__/vfx-mesh-lighting.dawn.test.ts +16 -0
  99. package/src/__tests__/vfx-mesh-lighting.fixture.ts +883 -0
  100. package/src/__tests__/wave1-dynamic-geometry.browser.test.ts +326 -0
  101. package/src/__tests__/wave1-rendering-materials.browser.test.ts +617 -0
  102. package/src/__tests__/wave1-rendering-p0.browser.test.ts +751 -0
  103. package/src/__tests__/wave1-rendering-recovery.browser.test.ts +666 -0
  104. package/src/__tests__/wave1-shadow-diagnostic.browser.test.ts +582 -0
  105. package/src/__tests__/world-scene-despawn.test.ts +8 -8
  106. package/src/__tests__/world-scene-instantiate.test.ts +23 -29
  107. package/src/__tests__/world-scene-nested.test.ts +90 -163
  108. package/src/__tests__/world-scene-override.test.ts +17 -16
  109. package/src/__tests__/zero-compression-zero-load.integration.test.ts +1 -0
  110. package/src/collect-scene-asset.ts +637 -19
  111. package/src/components/__tests__/sprite-animation.test-d.ts +2 -2
  112. package/src/index.ts +1 -0
  113. package/src/scene-utils/mount-override-fold.ts +5 -14
  114. package/src/__tests__/node-shims.d.ts +0 -31
@@ -1,533 +1,149 @@
1
- import * as SceneOwner from '@forgeax/engine-scene';
2
- // serialize-mounts-roundtrip.test.ts M3 serialization breakpoint A/B + resolver
3
- // wiring TDD tests (feat-20260703-collect-nested-sceneinstance-to-mount-roundtrip).
4
- //
5
- // Coverage:
6
- // m3-t1: serialize<->parse mounts symmetry round-trip
7
- // m3-t2: registry.instantiate with mounts overload
8
- // m3-t3: cyclic mount fail-fast
1
+ // Keyed SceneAsset production and round-trip coverage.
2
+ // The filename remains for the historical test target, while the public
3
+ // contract is now `entities[key].instance`, with no authored mount windows.
9
4
 
10
5
  import type { Asset } from '@forgeax/engine-assets-runtime';
11
- import { AssetRegistry, resolveAssetHandle } from '@forgeax/engine-assets-runtime';
12
- import { type EntityHandle, World } from '@forgeax/engine-ecs';
6
+ import { AssetRegistry } from '@forgeax/engine-assets-runtime';
7
+ import { World } from '@forgeax/engine-ecs';
13
8
  import { AssetGuid } from '@forgeax/engine-pack/guid';
14
9
  import { SceneInstance } from '@forgeax/engine-render';
15
- import type { Handle, SceneAsset, SceneInstanceMount } from '@forgeax/engine-types';
16
- import { err, ok } from '@forgeax/engine-types';
10
+ import type { Handle, SceneAsset } from '@forgeax/engine-types';
17
11
  import { describe, expect, it } from 'vitest';
18
- import { serializeSceneAssetToPack } from '../collect-scene-asset';
12
+ import { rootsToSceneAsset, serializeSceneAssetToPack } from '../collect-scene-asset';
19
13
  import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
20
14
  import { registerSceneComponents } from './helpers/register-scene-components';
21
15
 
22
- // ═══════════════════════════════════════════════════════════════════════════════
23
- // Helpers
24
- // ═══════════════════════════════════════════════════════════════════════════════
16
+ const CHILD_GUID = '11111111-1111-4111-8111-111111111111';
17
+ const PARENT_GUID = '22222222-2222-4222-8222-222222222222';
18
+ const OUT_GUID = '33333333-3333-4333-8333-333333333333';
25
19
 
26
- function mkReg(): AssetRegistry {
27
- return new AssetRegistry(makeMockShaderRegistry());
20
+ function parseGuid(value: string): AssetGuid {
21
+ const result = AssetGuid.parse(value);
22
+ if (!result.ok) throw new Error(`bad GUID: ${value}`);
23
+ return result.value;
28
24
  }
29
25
 
30
- function pg(s: string): AssetGuid {
31
- const r = AssetGuid.parse(s);
32
- if (!r.ok) throw new Error(`bad GUID: ${s}`);
33
- return r.value;
26
+ function register(world: World, asset: SceneAsset): Handle<'SceneAsset', 'shared'> {
27
+ registerSceneComponents(world);
28
+ return world.allocSharedRef('SceneAsset', asset);
34
29
  }
35
30
 
36
- function cat(reg: AssetRegistry, g: string, p: SceneAsset): void {
37
- reg.catalog(pg(g), p as Asset);
31
+ function catalog(registry: AssetRegistry, guid: string, asset: SceneAsset): void {
32
+ const result = registry.catalog(parseGuid(guid), asset as Asset);
33
+ if (!result.ok) throw new Error(result.error.code);
38
34
  }
39
35
 
40
- function rs(w: World, a: SceneAsset): Handle<'SceneAsset', 'shared'> {
41
- registerSceneComponents(w);
42
- return w.allocSharedRef('SceneAsset', a);
43
- }
44
-
45
- function swr(
46
- w: World,
47
- ph: Handle<'SceneAsset', 'shared'>,
48
- ch: Handle<'SceneAsset', 'shared'>,
49
- ): void {
50
- SceneOwner.worldSetSceneAssetResolver(w, (_s, pH) =>
51
- (pH as unknown as number) === (ph as unknown as number)
52
- ? ok(ch)
53
- : err({ code: 'asset-not-found' }),
54
- );
55
- }
56
-
57
- /** Access parseScenePayload via public parseAssetPayload -> scene kind cast. */
58
- function accessParseScenePayload(reg: AssetRegistry) {
59
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
60
- const internal = reg as unknown as {
61
- parseAssetPayload(kind: string, payload: Record<string, unknown>, refs?: string[]): unknown;
36
+ function childScene(): SceneAsset {
37
+ return {
38
+ kind: 'scene',
39
+ entities: {
40
+ door: { components: { Transform: { pos: [1, 0, 0] } } },
41
+ frame: { components: { Transform: { pos: [0, 0, 0] } } },
42
+ },
62
43
  };
63
- return (
64
- kind: string,
65
- payload: Record<string, unknown>,
66
- refs?: readonly string[] | undefined,
67
- ): SceneAsset | undefined => {
68
- const result = internal.parseAssetPayload(kind, payload, refs as string[] | undefined);
69
- if (result === undefined) return undefined;
70
- if (
71
- typeof result === 'object' &&
72
- result !== null &&
73
- 'kind' in result &&
74
- result.kind === 'scene'
75
- ) {
76
- return result as SceneAsset;
77
- }
78
- return undefined;
79
- };
80
- }
81
-
82
- /** Extract payload and refs from serializeSceneAssetToPack result. */
83
- function unpackSerialized(
84
- pack: Record<string, unknown>,
85
- ): { payload: Record<string, unknown>; refs: readonly string[] | undefined } | undefined {
86
- const assets = pack.assets as readonly Record<string, unknown>[] | undefined;
87
- if (!assets || assets.length === 0) return undefined;
88
- const a = assets[0];
89
- if (!a) return undefined;
90
- const payload = a.payload as Record<string, unknown> | undefined;
91
- if (!payload) return undefined;
92
- return { payload, refs: a.refs as readonly string[] | undefined };
93
44
  }
94
45
 
95
- /** Verify child SceneInstance is valid for an instantiated mount. */
96
- function getSceneInstanceSource(w: World, e: EntityHandle): string | undefined {
97
- const si = w.get(e, SceneInstance);
98
- if (!si.ok) return undefined;
99
- const src = (si.value as Record<string, unknown>).source as Handle<string, 'shared'>;
100
- const pr = resolveAssetHandle<SceneAsset>(w, src as unknown as Handle<string, 'shared'>);
101
- if (!pr.ok) return undefined;
102
- return (pr.value as SceneAsset).mounts !== undefined ? 'has-mounts' : 'no-mounts';
103
- }
104
-
105
- const G1 = 'cbe42beb-8975-5096-b3a1-3dda4cb4c077';
106
- const G2 = 'f6af7007-158f-4d92-9e47-93bf2f213e1f';
107
- const G3 = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
108
-
109
- // ═══════════════════════════════════════════════════════════════════════════════
110
- // m3-t1: serialize<->parse mounts symmetry round-trip
111
- // ═══════════════════════════════════════════════════════════════════════════════
112
-
113
- describe('m3-t1 — serialize<->parse mounts symmetry round-trip', () => {
114
- it('serialize mounts.source GUID -> refs index, parse refs index -> GUID', () => {
115
- const reg = mkReg();
116
- const fn = accessParseScenePayload(reg);
117
-
118
- // Build a SceneAsset with mounts (source as GUID string, post-collect shape).
119
- const mounts: SceneInstanceMount[] = [
120
- {
121
- localId: 1 as never,
122
- source: G1,
123
- memberFirst: 3 as never,
124
- memberCount: 5,
125
- parent: 0 as never,
126
- },
127
- {
128
- localId: 2 as never,
129
- source: G2,
130
- memberFirst: 8 as never,
131
- memberCount: 2,
132
- },
133
- ];
46
+ describe('keyed SceneAsset serialize and decode', () => {
47
+ it('serializes nested instance sources to refs and decodes them back to GUIDs', () => {
48
+ const registry = new AssetRegistry(makeMockShaderRegistry());
134
49
  const scene: SceneAsset = {
135
50
  kind: 'scene',
136
- entities: [{ localId: 0 as never, components: { Transform: { pos: [1, 0, 0] } } }],
137
- mounts,
138
- };
139
-
140
- // Serialize.
141
- const serRes = serializeSceneAssetToPack(scene, new Map(), G3);
142
- expect(serRes.ok).toBe(true);
143
- if (!serRes.ok) return;
144
-
145
- const unpacked = unpackSerialized(serRes.value);
146
- expect(unpacked).toBeDefined();
147
- if (!unpacked) return;
148
- const { payload: packPayload, refs } = unpacked;
149
-
150
- // mounts should be present in the serialized output.
151
- const serMounts = packPayload.mounts as ReadonlyArray<Record<string, unknown>> | undefined;
152
- expect(serMounts).toBeDefined();
153
- expect(serMounts?.length).toBe(2);
154
-
155
- // source should be refs index (number), not GUID string.
156
- const sm0 = serMounts?.[0];
157
- expect(sm0).toBeDefined();
158
- if (!sm0) return;
159
- expect(typeof sm0.source).toBe('number');
160
- expect(sm0.localId).toBe(1);
161
- expect(sm0.memberFirst).toBe(3);
162
- expect(sm0.memberCount).toBe(5);
163
- expect(sm0.parent).toBe(0);
164
-
165
- const sm1 = serMounts?.[1];
166
- expect(sm1).toBeDefined();
167
- if (!sm1) return;
168
- expect(typeof sm1.source).toBe('number');
169
- expect(sm1.localId).toBe(2);
170
- expect(sm1.memberFirst).toBe(8);
171
- expect(sm1.memberCount).toBe(2);
172
- expect(sm1.parent).toBeUndefined();
173
-
174
- // refs should contain both GUIDs.
175
- expect(refs).toBeDefined();
176
- expect(refs).toContain(G1);
177
- expect(refs).toContain(G2);
178
-
179
- // Verify source index -> GUID round-trip: refs[sourceIndex] === original GUID.
180
- const idx0 = sm0.source as number;
181
- const idx1 = sm1.source as number;
182
- expect(refs?.[idx0]).toBe(G1);
183
- expect(refs?.[idx1]).toBe(G2);
184
-
185
- // Parse back: source should be restored to GUID string.
186
- const parsed = fn('scene', packPayload, refs);
187
- expect(parsed).toBeDefined();
188
- if (!parsed) return;
189
-
190
- expect(parsed.mounts).toBeDefined();
191
- expect(parsed.mounts?.length).toBe(2);
192
-
193
- const pm0 = parsed.mounts?.[0];
194
- expect(pm0).toBeDefined();
195
- if (!pm0) return;
196
- expect(pm0.source).toBe(G1);
197
- expect(pm0.localId).toBe(1);
198
- expect(pm0.memberFirst).toBe(3);
199
- expect(pm0.memberCount).toBe(5);
200
- expect(pm0.parent).toBe(0);
201
-
202
- const pm1 = parsed.mounts?.[1];
203
- expect(pm1).toBeDefined();
204
- if (!pm1) return;
205
- expect(pm1.source).toBe(G2);
206
- expect(pm1.localId).toBe(2);
207
- expect(pm1.memberFirst).toBe(8);
208
- expect(pm1.memberCount).toBe(2);
209
- expect(pm1.parent).toBeUndefined();
210
- });
211
-
212
- it('scene without mounts serializes without mounts field (back-compat)', () => {
213
- const reg = mkReg();
214
- const fn = accessParseScenePayload(reg);
215
-
216
- const scene: SceneAsset = {
217
- kind: 'scene',
218
- entities: [{ localId: 0 as never, components: { Transform: { pos: [1, 0, 0] } } }],
219
- };
220
- const serResBackcompat = serializeSceneAssetToPack(scene, new Map(), G3);
221
- expect(serResBackcompat.ok).toBe(true);
222
- if (!serResBackcompat.ok) return;
223
-
224
- const unpackedBackcompat = unpackSerialized(serResBackcompat.value);
225
- expect(unpackedBackcompat).toBeDefined();
226
- if (!unpackedBackcompat) return;
227
- // Scene with no mounts: serialize should not add an empty mounts array
228
- // unless it carries semantic meaning. Parse back should still work.
229
- const parsedBackcompat = fn('scene', unpackedBackcompat.payload, unpackedBackcompat.refs);
230
- expect(parsedBackcompat).toBeDefined();
231
- expect(parsedBackcompat?.entities).toBeDefined();
232
- });
233
-
234
- it('numeric fields (memberFirst/memberCount/localId) preserved exactly', () => {
235
- const reg = mkReg();
236
- const fn = accessParseScenePayload(reg);
237
-
238
- const mounts: SceneInstanceMount[] = [
239
- {
240
- localId: 999 as never,
241
- source: G1,
242
- memberFirst: 1000 as never,
243
- memberCount: 42,
244
- parent: 500 as never,
245
- },
246
- ];
247
- const sceneNumeric: SceneAsset = {
248
- kind: 'scene',
249
- entities: [],
250
- mounts,
251
- };
252
-
253
- const serResNum = serializeSceneAssetToPack(sceneNumeric, new Map(), G3);
254
- expect(serResNum.ok).toBe(true);
255
- if (!serResNum.ok) return;
256
-
257
- const unpackedNum = unpackSerialized(serResNum.value);
258
- expect(unpackedNum).toBeDefined();
259
- if (!unpackedNum) return;
260
- const packPayloadNum = unpackedNum.payload;
261
-
262
- const serMountsNum = packPayloadNum.mounts as
263
- | ReadonlyArray<Record<string, unknown>>
264
- | undefined;
265
- expect(serMountsNum).toBeDefined();
266
- expect(serMountsNum?.[0]?.localId).toBe(999);
267
- expect(serMountsNum?.[0]?.memberFirst).toBe(1000);
268
- expect(serMountsNum?.[0]?.memberCount).toBe(42);
269
- expect(serMountsNum?.[0]?.parent).toBe(500);
270
-
271
- // Parse back and verify.
272
- const parsedNum = fn('scene', packPayloadNum, unpackedNum.refs);
273
- expect(parsedNum).toBeDefined();
274
- if (!parsedNum?.mounts?.[0]) return;
275
- expect(parsedNum.mounts[0].localId).toBe(999);
276
- expect(parsedNum.mounts[0].memberFirst).toBe(1000);
277
- expect(parsedNum.mounts[0].memberCount).toBe(42);
278
- expect(parsedNum.mounts[0].parent).toBe(500);
279
- });
280
-
281
- it('preserves the atomic publication fence on authored mounts', () => {
282
- const fence = {
283
- schemaVersion: 'scene-publication-fence/1' as const,
284
- sourcePath: 'assets/showcase.pack.ts',
285
- sourceRevision: 'sha256:source',
286
- publicationGeneration: 4,
287
- outputDigest: 'sha256:output',
288
- outputSetDigest: 'sha256:output-set',
289
- receiptIdentity: 'sha256:receipt',
290
- };
291
- const packed = serializeSceneAssetToPack(
292
- {
293
- kind: 'scene',
294
- entities: [{ localId: 0 as never, components: {} }],
295
- mounts: [
296
- {
297
- localId: 1 as never,
298
- source: G1,
299
- memberFirst: 2 as never,
300
- memberCount: 1,
301
- publicationFence: fence,
51
+ entities: {
52
+ root: { components: { Transform: { pos: [0, 0, 0] } } },
53
+ house: {
54
+ components: {},
55
+ instance: {
56
+ source: CHILD_GUID,
57
+ overrides: [{ target: ['door'], components: { Transform: { pos: [4, 0, 0] } } }],
302
58
  },
303
- ],
304
- },
305
- new Map(),
306
- G3,
307
- );
308
- expect(packed.ok).toBe(true);
309
- if (!packed.ok) return;
310
- const payload = (packed.value.assets as Array<{ payload: Record<string, unknown> }>)[0]
311
- ?.payload;
312
- expect((payload?.mounts as Array<Record<string, unknown>>)[0]?.publicationFence).toEqual(fence);
313
- });
314
- });
315
-
316
- // ═══════════════════════════════════════════════════════════════════════════════
317
- // m3-t2: registry.instantiate with mounts overload
318
- // ═══════════════════════════════════════════════════════════════════════════════
319
-
320
- describe('m3-t2 — registry.instantiate with mounts overload', () => {
321
- it('instantiate parent with mount spawns nested child SceneInstance', () => {
322
- const reg = mkReg();
323
- const w = new World();
324
-
325
- // Child scene: two entities with Transform.
326
- const child: SceneAsset = {
327
- kind: 'scene',
328
- entities: [
329
- { localId: 0 as never, components: { Transform: { pos: [1, 0, 0] } } },
330
- { localId: 1 as never, components: { Transform: { pos: [2, 0, 0] } } },
331
- ],
332
- };
333
- cat(reg, G1, child);
334
- const ch = rs(w, child);
335
-
336
- // Parent scene: one owned entity + mount referencing child.
337
- const parent: SceneAsset = {
338
- kind: 'scene',
339
- entities: [{ localId: 0 as never, components: { Transform: { pos: [10, 0, 0] } } }],
340
- mounts: [
341
- {
342
- localId: 1 as never,
343
- source: G1, // GUID string — post-parse / post-collect shape
344
- memberFirst: 2 as never,
345
- memberCount: 2,
346
59
  },
347
- ],
60
+ },
348
61
  };
349
- cat(reg, G2, parent);
350
-
351
- // Wire resolver: when source=G1 on parent handle, return child handle.
352
- // This is needed so world.instantiateScene can resolve the mount.
353
- const ph = rs(w, parent);
354
- swr(w, ph, ch);
355
-
356
- const inst = reg.instantiate(ph, w);
357
- expect(inst.ok).toBe(true);
358
- if (!inst.ok) return;
359
-
360
- // Verify instantiated root exists.
361
- const root = inst.value;
362
- expect(root).toBeDefined();
363
-
364
- // Walk descendants of root (excluding root) — should include mount entity and
365
- // nested SceneInstance root entities.
366
- let descendantCount = 0;
367
- let foundMountChild = false;
368
- for (const c of w.iterDescendants(root)) {
369
- if (c === root) continue;
370
- descendantCount++;
371
- // A child that carries SceneInstance = nested instance root.
372
- if (w.get(c, SceneInstance).ok) {
373
- foundMountChild = true;
62
+ const serialized = serializeSceneAssetToPack(scene, new Map(), OUT_GUID);
63
+ expect(serialized.ok).toBe(true);
64
+ if (!serialized.ok) return;
65
+ const packedAsset = (serialized.value.assets as Array<Record<string, unknown>>)[0];
66
+ expect(packedAsset).toBeDefined();
67
+ if (!packedAsset) return;
68
+ const payload = packedAsset.payload as Record<string, unknown>;
69
+ const entities = payload.entities as Record<string, Record<string, unknown>>;
70
+ expect((entities.house?.instance as Record<string, unknown>).source).toBe(0);
71
+ const parsed = (
72
+ registry as unknown as {
73
+ parseAssetPayload(
74
+ kind: string,
75
+ payload: Record<string, unknown>,
76
+ refs?: readonly string[],
77
+ ): unknown;
374
78
  }
375
- }
376
- // At least 2 descendants: the owned entity (localId=0) + mount entity + child SceneInstance root.
377
- expect(descendantCount).toBeGreaterThanOrEqual(2);
378
- expect(foundMountChild).toBe(true);
379
- });
380
-
381
- it('reload path through catalog+instantiate restores mount structure', () => {
382
- const reg = mkReg();
383
- const w = new World();
384
-
385
- // Build a complete round-trip fixture.
386
- // Step 1: Catalog child scene.
387
- const child: SceneAsset = {
79
+ ).parseAssetPayload('scene', payload, packedAsset.refs as readonly string[]);
80
+ expect(parsed).toMatchObject({
388
81
  kind: 'scene',
389
- entities: [{ localId: 0 as never, components: { Transform: { pos: [5, 0, 0] } } }],
390
- };
391
- cat(reg, G1, child);
392
- const ch = rs(w, child);
82
+ entities: { house: { instance: { source: CHILD_GUID } } },
83
+ });
84
+ });
393
85
 
394
- // Step 2: Build parent with mount -> catalog.
86
+ it('collects a live nested instance back to a keyed declaration', () => {
87
+ const registry = new AssetRegistry(makeMockShaderRegistry());
88
+ const world = new World();
89
+ const child = childScene();
90
+ catalog(registry, CHILD_GUID, child);
395
91
  const parent: SceneAsset = {
396
92
  kind: 'scene',
397
- entities: [{ localId: 0 as never, components: { Transform: { pos: [100, 0, 0] } } }],
398
- mounts: [
399
- {
400
- localId: 1 as never,
401
- source: G1,
402
- memberFirst: 2 as never,
403
- memberCount: 1,
404
- },
405
- ],
93
+ entities: {
94
+ root: { components: { Transform: { pos: [0, 0, 0] } } },
95
+ house: { components: {}, instance: { source: CHILD_GUID } },
96
+ },
406
97
  };
407
- cat(reg, G2, parent);
408
- const ph = rs(w, parent);
409
- swr(w, ph, ch);
410
-
411
- // Step 3: Instantiate parent via registry.
412
- const inst = reg.instantiate(ph, w);
413
- expect(inst.ok).toBe(true);
414
- if (!inst.ok) return;
415
-
416
- // Step 4: Verify root hierarchy has child SceneInstance entity.
417
- const root = inst.value;
418
- let hasSceneInstanceChild = false;
419
- for (const c of w.iterDescendants(root)) {
420
- if (c === root) continue;
421
- if (w.get(c, SceneInstance).ok) {
422
- hasSceneInstanceChild = true;
423
- // Verify the SceneInstance.source can be resolved.
424
- const si = getSceneInstanceSource(w, c);
425
- expect(si).toBeDefined();
426
- }
427
- }
428
- expect(hasSceneInstanceChild).toBe(true);
98
+ catalog(registry, PARENT_GUID, parent);
99
+ const result = registry.instantiate(register(world, parent), world);
100
+ expect(result.ok).toBe(true);
101
+ if (!result.ok) return;
102
+ expect(world.get(result.value, SceneInstance).ok).toBe(true);
103
+ const collected = rootsToSceneAsset(registry, world, [result.value]);
104
+ expect(collected.ok).toBe(true);
105
+ if (!collected.ok) return;
106
+ expect(Object.keys(collected.value.entities)).toContain('house');
107
+ expect(collected.value.entities.house?.instance?.source).toBe(CHILD_GUID);
108
+ expect(collected.value.entities.house?.instance?.overrides).toBeUndefined();
429
109
  });
430
- });
431
-
432
- // ═══════════════════════════════════════════════════════════════════════════════
433
- // m3-t3: cyclic mount fail-fast
434
- // ═══════════════════════════════════════════════════════════════════════════════
435
-
436
- describe('m3-t3 — cyclic mount fail-fast', () => {
437
- it('A mount B, B mount A surfaces pack-cyclic-reference / mount-asset', () => {
438
- const reg = mkReg();
439
- const w = new World();
440
-
441
- // Scene A: mounts scene B (source=G2).
442
- const sceneA: SceneAsset = {
443
- kind: 'scene',
444
- entities: [{ localId: 0 as never, components: { Transform: {} } }],
445
- mounts: [
446
- {
447
- localId: 1 as never,
448
- source: G2,
449
- memberFirst: 2 as never,
450
- memberCount: 1,
451
- },
452
- ],
453
- };
454
- cat(reg, G1, sceneA);
455
- const ha = rs(w, sceneA);
456
110
 
457
- // Scene B: mounts scene A (source=G1) cycle.
458
- const sceneB: SceneAsset = {
111
+ it('keeps nested declarations isolated across two instances', () => {
112
+ const registry = new AssetRegistry(makeMockShaderRegistry());
113
+ const world = new World();
114
+ catalog(registry, CHILD_GUID, childScene());
115
+ const parent: SceneAsset = {
459
116
  kind: 'scene',
460
- entities: [{ localId: 0 as never, components: { Transform: {} } }],
461
- mounts: [
462
- {
463
- localId: 1 as never,
464
- source: G1,
465
- memberFirst: 2 as never,
466
- memberCount: 1,
467
- },
468
- ],
469
- };
470
- cat(reg, G2, sceneB);
471
- const hb = rs(w, sceneB);
472
-
473
- // Wire resolver: A -> B, B -> A.
474
- swr(w, ha, hb);
475
- swr(w, hb, ha);
476
-
477
- // Attempt instantiate via registry — should fail with cycle detection.
478
- const inst = reg.instantiate(ha, w);
479
- expect(inst.ok).toBe(false);
480
- if (inst.ok) return;
481
-
482
- // Check error shape: must be pack-cyclic-reference with kind='mount-asset'.
483
- const e = inst.error as {
484
- code: string;
485
- detail?: { kind?: string; cycle?: readonly string[] };
117
+ entities: {
118
+ left: { components: {}, instance: { source: CHILD_GUID } },
119
+ right: { components: {}, instance: { source: CHILD_GUID } },
120
+ },
486
121
  };
487
- expect(e.code).toBe('pack-cyclic-reference');
122
+ const result = registry.instantiate(register(world, parent), world);
123
+ expect(result.ok).toBe(true);
124
+ if (!result.ok) return;
125
+ const nestedRoots = [...world.iterDescendants(result.value)].filter(
126
+ (entity) => world.get(entity, SceneInstance).ok,
127
+ );
128
+ expect(nestedRoots).toHaveLength(2);
488
129
  });
489
130
 
490
- it('A mount B, B mount C, C mount A (3-node cycle) fail-fast', () => {
491
- const reg = mkReg();
492
- const w = new World();
493
-
494
- const sceneA: SceneAsset = {
495
- kind: 'scene',
496
- entities: [],
497
- mounts: [{ localId: 0 as never, source: G2, memberFirst: 1 as never, memberCount: 0 }],
498
- };
499
- cat(reg, G1, sceneA);
500
- const ha = rs(w, sceneA);
501
-
502
- const sceneB: SceneAsset = {
131
+ it('rejects keyed recursive instances before a root is published', () => {
132
+ const registry = new AssetRegistry(makeMockShaderRegistry());
133
+ const world = new World();
134
+ const a: SceneAsset = {
503
135
  kind: 'scene',
504
- entities: [],
505
- mounts: [{ localId: 0 as never, source: G3, memberFirst: 1 as never, memberCount: 0 }],
136
+ entities: { b: { components: {}, instance: { source: CHILD_GUID } } },
506
137
  };
507
- cat(reg, G2, sceneB);
508
- const hb = rs(w, sceneB);
509
-
510
- const sceneC: SceneAsset = {
138
+ const b: SceneAsset = {
511
139
  kind: 'scene',
512
- entities: [],
513
- mounts: [{ localId: 0 as never, source: G1, memberFirst: 1 as never, memberCount: 0 }],
514
- };
515
- cat(reg, G3, sceneC);
516
- const hc = rs(w, sceneC);
517
-
518
- // Wire: A->B, B->C, C->A.
519
- swr(w, ha, hb);
520
- swr(w, hb, hc);
521
- swr(w, hc, ha);
522
-
523
- const inst = reg.instantiate(ha, w);
524
- expect(inst.ok).toBe(false);
525
- if (inst.ok) return;
526
-
527
- const e = inst.error as {
528
- code: string;
529
- detail?: { kind?: string; cycle?: readonly string[] };
140
+ entities: { a: { components: {}, instance: { source: PARENT_GUID } } },
530
141
  };
531
- expect(e.code).toBe('pack-cyclic-reference');
142
+ catalog(registry, CHILD_GUID, b);
143
+ catalog(registry, PARENT_GUID, a);
144
+ const before = world.inspect().entityCount;
145
+ const result = registry.instantiate(register(world, a), world);
146
+ expect(result.ok).toBe(false);
147
+ expect(world.inspect().entityCount).toBe(before);
532
148
  });
533
149
  });