@forgeax/engine-runtime 0.1.28 → 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 (113) 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 -32
  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-rendering-materials.browser.test.ts +617 -0
  101. package/src/__tests__/wave1-rendering-p0.browser.test.ts +751 -0
  102. package/src/__tests__/wave1-rendering-recovery.browser.test.ts +666 -0
  103. package/src/__tests__/wave1-shadow-diagnostic.browser.test.ts +582 -0
  104. package/src/__tests__/world-scene-despawn.test.ts +8 -8
  105. package/src/__tests__/world-scene-instantiate.test.ts +23 -29
  106. package/src/__tests__/world-scene-nested.test.ts +90 -163
  107. package/src/__tests__/world-scene-override.test.ts +17 -16
  108. package/src/__tests__/zero-compression-zero-load.integration.test.ts +1 -0
  109. package/src/collect-scene-asset.ts +637 -19
  110. package/src/components/__tests__/sprite-animation.test-d.ts +2 -2
  111. package/src/index.ts +1 -0
  112. package/src/scene-utils/mount-override-fold.ts +5 -14
  113. package/src/__tests__/node-shims.d.ts +0 -31
@@ -1,23 +1,7 @@
1
- // asset-registry-mounts.test.ts - parseScenePayload mounts[].source refs
2
- // resolution unit tests (feat-20260608-scene-nesting-ecs-fication M3 / w27,
3
- // TDD red phase).
4
- //
5
- // Coverage:
6
- // (a) AC-10 happy path: mounts[].source (integer index) resolved through
7
- // refs[] to a GUID string.
8
- // (b) AC-10 back-compat: payload with no mounts field passes through.
9
- // (c) AC-10 back-compat: mounts with source already a string (GUID) passes
10
- // through unchanged.
11
- // (d) AC-10 error path: mounts[].source integer index out-of-bounds returns
12
- // a ParseSceneError.
13
- // (e) AC-11: mount.source does NOT enter HANDLE_FIELD_NAMES -- it is resolved
14
- // positionally by field name within the mounts[] array, not through the
15
- // handle-field allowlist (verify no false collision with existing handle
16
- // fields named 'source').
17
- // (f) mounts.memberFirst / mounts.memberCount / mounts.parent (LocalEntityId)
18
- // are NOT refs[] indices -- they are local entity ids preserved as-is.
1
+ // Keyed SceneAsset registry and decode closure.
2
+ // The public payload uses entity keys and nested `instance` declarations;
3
+ // numeric slots are derived only by the Scene owner at instantiate time.
19
4
 
20
- import { AnimationPlayer } from '@forgeax/engine-animation';
21
5
  import {
22
6
  type Asset,
23
7
  AssetRegistry,
@@ -27,478 +11,41 @@ import {
27
11
  import { type EntityHandle, World } from '@forgeax/engine-ecs';
28
12
  import { AssetGuid } from '@forgeax/engine-pack/guid';
29
13
  import { MeshFilter, SceneInstance } from '@forgeax/engine-render';
30
- import { ChildOf, Transform } from '@forgeax/engine-scene';
31
- import {
32
- type AnimationClip,
33
- BUILTIN_BASE,
34
- type Handle,
35
- type LoadContext,
36
- type MeshAsset,
37
- type SceneAsset,
38
- unwrapHandle,
39
- } from '@forgeax/engine-types';
14
+ import { ChildOf, Transform, worldResolveSceneEntity } from '@forgeax/engine-scene';
15
+ import type { Handle, LoadContext, MeshAsset, SceneAsset } from '@forgeax/engine-types';
16
+ import { BUILTIN_BASE } from '@forgeax/engine-types';
40
17
  import { describe, expect, it } from 'vitest';
41
18
  import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
42
19
 
43
- /** Access the private parseAssetPayload method via structural view-cast. */
44
- function accessParseScenePayload(reg: AssetRegistry) {
45
- // biome-ignore lint/suspicious/noExplicitAny: private method access for unit test
46
- const internal = reg as any as {
47
- parseAssetPayload(kind: string, payload: Record<string, unknown>, refs?: string[]): unknown;
48
- };
49
- return (
50
- kind: string,
51
- payload: Record<string, unknown>,
52
- refs?: string[],
53
- ): SceneAsset | undefined => {
54
- const result = internal.parseAssetPayload(kind, payload, refs);
55
- if (result === undefined) return undefined;
56
- if (
57
- typeof result === 'object' &&
58
- result !== null &&
59
- 'kind' in result &&
60
- result.kind === 'scene'
61
- ) {
62
- return result as SceneAsset;
63
- }
64
- return undefined;
65
- };
66
- }
67
-
68
20
  const GUID_A = 'cbe42beb-8975-5096-b3a1-3dda4cb4c077';
69
21
  const GUID_B = 'f6af7007-158f-4d92-9e47-93bf2f213e1f';
70
- const GUID_C = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
71
-
72
- describe('w27 - parseScenePayload mounts[].source refs resolution (AC-10)', () => {
73
- it('(a) resolves mounts[].source integer index through refs[] to GUID string', () => {
74
- const reg = new AssetRegistry(makeMockShaderRegistry());
75
- const fn = accessParseScenePayload(reg);
76
- const refs = [GUID_A];
77
- const payload = {
78
- entities: [{ localId: 0, components: {} }],
79
- mounts: [
80
- {
81
- localId: 1,
82
- source: 0,
83
- memberFirst: 2,
84
- memberCount: 3,
85
- },
86
- ],
87
- };
88
- const asset = fn('scene', payload, refs);
89
- expect(asset).toBeDefined();
90
- if (!asset) return;
91
- expect(asset.kind).toBe('scene');
92
- expect(asset.mounts).toBeDefined();
93
- if (!asset.mounts) return;
94
- expect(asset.mounts.length).toBe(1);
95
- const mount = asset.mounts[0];
96
- expect(mount).toBeDefined();
97
- if (!mount) return;
98
- // source 0 -> refs[0] = GUID_A
99
- expect(mount.source).toBe(GUID_A);
100
- });
101
-
102
- it('(b) payload with no mounts field passes through (back-compat)', () => {
103
- const reg = new AssetRegistry(makeMockShaderRegistry());
104
- const fn = accessParseScenePayload(reg);
105
- const payload = {
106
- entities: [{ localId: 0, components: {} }],
107
- };
108
- const asset = fn('scene', payload);
109
- expect(asset).toBeDefined();
110
- if (!asset) return;
111
- expect(asset.kind).toBe('scene');
112
- // mounts absent or empty after parse
113
- expect(asset.mounts ?? []).toEqual([]);
114
- });
115
-
116
- it('(c) mounts with source already a string passes through unchanged', () => {
117
- const reg = new AssetRegistry(makeMockShaderRegistry());
118
- const fn = accessParseScenePayload(reg);
119
- const refs = [GUID_A];
120
- const payload = {
121
- entities: [{ localId: 0, components: {} }],
122
- mounts: [
123
- {
124
- localId: 1,
125
- source: GUID_B, // already a GUID string, not an index
126
- memberFirst: 2,
127
- memberCount: 3,
128
- },
129
- ],
130
- };
131
- const asset = fn('scene', payload, refs);
132
- expect(asset).toBeDefined();
133
- if (!asset) return;
134
- expect(asset.mounts).toBeDefined();
135
- if (!asset.mounts) return;
136
- expect(asset.mounts[0]?.source).toBe(GUID_B);
137
- });
138
-
139
- it('(d) mounts[].source integer index out-of-bounds returns ParseSceneError', () => {
140
- const reg = new AssetRegistry(makeMockShaderRegistry());
141
- const fn = accessParseScenePayload(reg);
142
- const refs = [GUID_A]; // only 1 entry, indices 0 valid, 1+ invalid
143
- const payload = {
144
- entities: [{ localId: 0, components: {} }],
145
- mounts: [
146
- {
147
- localId: 1,
148
- source: 5, // out-of-bounds: refs.length == 1
149
- memberFirst: 2,
150
- memberCount: 3,
151
- },
152
- ],
153
- };
154
- const asset = fn('scene', payload, refs);
155
- expect(asset).not.toBeDefined();
156
- });
157
-
158
- it('(e) mounts[].source is NOT resolved through HANDLE_FIELD_NAMES (AC-11 positional)', () => {
159
- // Even if 'source' were in HANDLE_FIELD_NAMES (it is not), mount.source
160
- // should be resolved through the mounts[] array pipeline, not the handle-
161
- // field allowlist. This test verifies no collision.
162
- const reg = new AssetRegistry(makeMockShaderRegistry());
163
- const fn = accessParseScenePayload(reg);
164
- // Use a mock where entities[].components has a field named 'source' that is
165
- // NOT in HANDLE_FIELD_NAMES. This verifies mount.source resolution is
166
- // independent of entities[].components resolution.
167
- const refs = [GUID_A, GUID_B];
168
- const payload = {
169
- entities: [{ localId: 0, components: { Transform: { source: 1 } } }],
170
- mounts: [
171
- {
172
- localId: 2,
173
- source: 0, // refs[0] = GUID_A
174
- memberFirst: 3,
175
- memberCount: 2,
176
- },
177
- ],
178
- };
179
- const asset = fn('scene', payload, refs);
180
- expect(asset).toBeDefined();
181
- if (!asset) return;
182
- // mount.source should be resolved via mounts pipeline
183
- expect(asset.mounts).toBeDefined();
184
- if (!asset.mounts) return;
185
- expect(asset.mounts[0]?.source).toBe(GUID_A);
186
- // entities[].components.Transform.source is NOT a handle field, so it
187
- // should stay as the raw number (1), not be resolved.
188
- const comp = asset.entities[0]?.components as Record<string, Record<string, unknown>>;
189
- expect(comp.Transform?.source).toBe(1);
190
- });
191
-
192
- it('(f) mounts memberFirst/memberCount/localId/parent are preserved as numbers (not refs indices)', () => {
193
- const reg = new AssetRegistry(makeMockShaderRegistry());
194
- const fn = accessParseScenePayload(reg);
195
- const refs = [GUID_A];
196
- const payload = {
197
- entities: [{ localId: 0, components: {} }],
198
- mounts: [
199
- {
200
- localId: 5,
201
- source: 0,
202
- memberFirst: 10,
203
- memberCount: 4,
204
- parent: 3,
205
- },
206
- ],
207
- };
208
- const asset = fn('scene', payload, refs);
209
- expect(asset).toBeDefined();
210
- if (!asset) return;
211
- expect(asset.mounts).toBeDefined();
212
- if (!asset.mounts) return;
213
- const mount = asset.mounts[0];
214
- expect(mount).toBeDefined();
215
- if (!mount) return;
216
- expect(mount.localId).toBe(5);
217
- expect(mount.memberFirst).toBe(10);
218
- expect(mount.memberCount).toBe(4);
219
- expect(mount.parent).toBe(3);
220
- });
221
-
222
- it('(g) multiple mounts with interleaved source indices resolved correctly', () => {
223
- const reg = new AssetRegistry(makeMockShaderRegistry());
224
- const fn = accessParseScenePayload(reg);
225
- const refs = [GUID_A, GUID_B, GUID_C];
226
- const payload = {
227
- entities: [{ localId: 0, components: {} }],
228
- mounts: [
229
- { localId: 1, source: 0, memberFirst: 2, memberCount: 3 },
230
- { localId: 2, source: 2, memberFirst: 5, memberCount: 1 },
231
- { localId: 3, source: 1, memberFirst: 6, memberCount: 4 },
232
- ],
233
- };
234
- const asset = fn('scene', payload, refs);
235
- expect(asset).toBeDefined();
236
- if (!asset) return;
237
- expect(asset.mounts).toBeDefined();
238
- if (!asset.mounts) return;
239
- expect(asset.mounts.length).toBe(3);
240
- expect(asset.mounts[0]?.source).toBe(GUID_A);
241
- expect(asset.mounts[1]?.source).toBe(GUID_C);
242
- expect(asset.mounts[2]?.source).toBe(GUID_B);
243
- });
244
- });
245
-
246
- // -- M4 / F21: sceneLoader returns ParseSceneError (AC-09 + AC-10) [w15] --
247
-
248
- describe('sceneLoader returns ParseSceneError (F21 / AC-09 + AC-10) [w15]', () => {
249
- it('AC-09 concurrent scene loads: A fails with index error, B succeeds -- no cross-contamination', () => {
250
- // A: scene payload with an entity field that has a refs[] index out of bounds.
251
- const payloadA = {
252
- entities: [
253
- {
254
- localId: 100,
255
- components: {
256
- MeshRenderer: { material: 5 }, // scalar handle, out-of-bounds: refs has only 1 entry
257
- },
258
- },
259
- ],
260
- };
261
- const refsA = [GUID_A]; // indices: 0 valid, 5 invalid
262
-
263
- // B: valid scene payload with a single entity, no mounts.
264
- const payloadB = {
265
- entities: [
266
- {
267
- localId: 200,
268
- components: {
269
- Transform: { pos: [1, 2, 3] },
270
- },
271
- },
272
- ],
273
- };
274
- const refsB = [GUID_B];
275
-
276
- // LoadContext without reportParseError (F21: removed).
277
- const ctx: LoadContext = {
278
- fetchBinary: async () => ({ ok: false, error: new Error('not wired') }),
279
- resolveRef: async () => ({ ok: false, error: new Error('not wired') }),
280
- transcodeCaps: { bc: false, etc2: false, astc: false },
281
- device: undefined,
282
- };
283
-
284
- // Concurrent calls to sceneLoader.load.
285
- const resultA = sceneLoader.load(payloadA, refsA, ctx);
286
- const resultB = sceneLoader.load(payloadB, refsB, ctx);
287
-
288
- // B: must return a valid SceneAsset.
289
- expect(resultB).toBeDefined();
290
- expect(typeof resultB).toBe('object');
291
- if (resultB !== null) {
292
- // Avoid the Promise arm by guarding on 'then'.
293
- // biome-ignore lint/suspicious/noExplicitAny: guard
294
- if (typeof (resultB as any).then !== 'function') {
295
- expect((resultB as { kind: string }).kind).toBe('scene');
296
- const sceneB = resultB as { kind: string; entities: Array<{ localId: number }> };
297
- const entityIds = sceneB.entities.map((e) => e.localId);
298
- expect(entityIds).not.toContain(100);
299
- }
300
- }
301
-
302
- // A: should return the structured error { ok: false, error: ParseErrorDetail }.
303
- expect(resultA).toBeDefined();
304
- expect(typeof resultA).toBe('object');
305
- if (resultA !== null && typeof resultA === 'object') {
306
- // biome-ignore lint/suspicious/noExplicitAny: guard
307
- if (typeof (resultA as any).then !== 'function') {
308
- const errResult = resultA as {
309
- ok: boolean;
310
- error?: {
311
- localId: number;
312
- component: string;
313
- field: string;
314
- index: number;
315
- refsLength: number;
316
- };
317
- };
318
- expect(errResult.ok).toBe(false);
319
- expect(errResult.error).toBeDefined();
320
- if (errResult.error) {
321
- expect(errResult.error.localId).toBe(100);
322
- expect(errResult.error.component).toBe('MeshRenderer');
323
- expect(errResult.error.field).toBe('material');
324
- expect(errResult.error.index).toBe(5);
325
- expect(errResult.error.refsLength).toBe(1);
326
- }
327
- }
328
- }
329
- });
330
-
331
- it('AC-10: sceneLoader.load with valid payload returns SceneAsset (no shared-slot error)', () => {
332
- const payload = {
333
- entities: [
334
- {
335
- localId: 1,
336
- components: {
337
- Transform: { pos: [0, 0, 0] },
338
- },
339
- },
340
- ],
341
- };
342
- const refs = [GUID_A];
343
-
344
- const ctx: LoadContext = {
345
- fetchBinary: async () => ({ ok: false, error: new Error('not wired') }),
346
- resolveRef: async () => ({ ok: false, error: new Error('not wired') }),
347
- transcodeCaps: { bc: false, etc2: false, astc: false },
348
- device: undefined,
349
- };
350
-
351
- const result = sceneLoader.load(payload, refs, ctx);
352
- expect(result).toBeDefined();
353
- // Must be a SceneAsset, not an error { ok: false }.
354
- if (result !== undefined && typeof result === 'object' && result !== null) {
355
- // biome-ignore lint/suspicious/noExplicitAny: guard
356
- if (typeof (result as any).then !== 'function') {
357
- expect('ok' in (result as Record<string, unknown>)).toBe(false);
358
- expect((result as { kind: string }).kind).toBe('scene');
359
- }
360
- }
361
- });
362
- });
363
-
364
- // -- M4 / F21: integration - concurrent scene load + grep zero-hits (AC-09/AC-10) [w19] --
365
-
366
- describe('integration: concurrent scene load via parseAssetPayload (F21 / AC-09 + AC-10) [w19]', () => {
367
- it('two concurrent parseAssetPayload calls: A fails with ParseSceneError, B succeeds without cross-contamination', () => {
368
- const reg = new AssetRegistry(makeMockShaderRegistry());
369
-
370
- // A: refs index out of bounds -> ParseSceneError from sceneLoader.
371
- const payloadA = {
372
- entities: [
373
- {
374
- localId: 100,
375
- components: {
376
- MeshRenderer: { material: 5 }, // out-of-bounds (refs has only 1 entry)
377
- },
378
- },
379
- ],
380
- };
381
- const refsA = [GUID_A];
382
-
383
- // B: valid scene payload.
384
- const payloadB = {
385
- entities: [
386
- {
387
- localId: 200,
388
- components: {
389
- Transform: { pos: [1, 2, 3] },
390
- },
391
- },
392
- ],
393
- };
394
- const refsB = [GUID_B];
395
-
396
- // Access parseAndReturnAsset privately.
397
- const internal = reg as unknown as {
398
- parseAndReturnAsset(entry: {
399
- kind: string;
400
- payload: Record<string, unknown>;
401
- refs?: string[];
402
- }): { ok: boolean; value?: unknown; error?: { code: string; detail?: { localId: number } } };
403
- };
404
-
405
- const resultA = internal.parseAndReturnAsset({
406
- kind: 'scene',
407
- payload: payloadA,
408
- refs: refsA,
409
- });
410
- const resultB = internal.parseAndReturnAsset({
411
- kind: 'scene',
412
- payload: payloadB,
413
- refs: refsB,
414
- });
415
-
416
- // A: must be an error with detail containing A's localId.
417
- expect(resultA.ok).toBe(false);
418
- if (!resultA.ok) {
419
- expect(resultA.error?.code).toBe('asset-parse-failed');
420
- const detail = resultA.error?.detail as { localId: number } | undefined;
421
- expect(detail?.localId).toBe(100);
422
- }
423
-
424
- // B: must be a valid SceneAsset.
425
- // feat-20260622 M4 / w12: parseAndReturnAsset returns { asset, refs } so
426
- // the recursive core can read envelope.refs (D-5); unwrap `.asset` here.
427
- expect(resultB.ok).toBe(true);
428
- if (resultB.ok) {
429
- const asset = (
430
- resultB.value as { asset: { kind: string; entities: Array<{ localId: number }> } }
431
- ).asset;
432
- expect(asset.kind).toBe('scene');
433
- // B's entities must NOT contain A's localId 100.
434
- const entityIds = asset.entities.map((e) => e.localId);
435
- expect(entityIds).not.toContain(100);
436
- expect(entityIds).toContain(200);
437
- }
438
- });
22
+ const GUID_MESH = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
439
23
 
440
- it('parseScenePayload without refs does not attempt handle resolution and returns SceneAsset (no reportParseError)', () => {
441
- const reg = new AssetRegistry(makeMockShaderRegistry());
442
- const internal = reg as unknown as {
443
- parseAndReturnAsset(entry: {
444
- kind: string;
445
- payload: Record<string, unknown>;
446
- refs?: string[];
447
- }): { ok: boolean; value?: unknown };
448
- };
449
-
450
- const payload = {
451
- entities: [
452
- {
453
- localId: 1,
454
- components: {
455
- Transform: { pos: [0, 0, 0] },
456
- },
457
- },
458
- ],
459
- };
460
-
461
- const result = internal.parseAndReturnAsset({
462
- kind: 'scene',
463
- payload,
464
- });
465
- expect(result.ok).toBe(true);
466
- if (result.ok) {
467
- // feat-20260622 M4 / w12: parseAndReturnAsset returns { asset, refs }.
468
- expect((result.value as { asset: { kind: string } }).asset.kind).toBe('scene');
469
- }
470
- });
471
- });
472
-
473
- // ── feat-20260713 M3 / w10 + w11 — apply-side override value GUID→handle
474
- // down-drill (AC-06, plan-strategy D-2 / D-8) ───────────────────────────────
475
- //
476
- // M3 opens the fourth resolution input source: `mounts[].overrides[].value`.
477
- // Pre-M3 `resolveMountsRec` only `{...m}` shallow-copies each mount + resolves
478
- // `source`; the GUID strings inside an override value were never resolved, so the
479
- // ecs value gate (M2 / w9) rejected them as `shared-field-invalid-value` and the
480
- // whole instantiate errored. After M3 the override value's shared<...> /
481
- // array<shared<...>> GUID strings resolve to live handles in assets-runtime, so
482
- // the ecs apply loop only ever sees numeric handles.
483
- //
484
- // TDD red phase: these assert the GREEN behaviour (instantiate ok + resolved
485
- // handle read back on the member). They are RED until w12/w13 land because
486
- // pre-fix `reg.instantiate` returns err (M2 value gate rejects the GUID string).
24
+ function guid(value: string): AssetGuid {
25
+ const parsed = AssetGuid.parse(value);
26
+ if (!parsed.ok) throw new Error(`bad GUID: ${value}`);
27
+ return parsed.value;
28
+ }
487
29
 
488
- const SCENE_PARENT_GUID = '10000000-0000-4000-a000-000000000001';
489
- const SCENE_CHILD_GUID = '10000000-0000-4000-a000-000000000002';
490
- const OV_MESH_GUID = '10000000-0000-4000-a000-000000000003';
491
- const OV_CLIP_A_GUID = '10000000-0000-4000-a000-000000000004';
492
- const OV_CLIP_B_GUID = '10000000-0000-4000-a000-000000000005';
493
- const OV_UNCATALOGUED_GUID = '10000000-0000-4000-a000-00000000dead';
30
+ function context(): LoadContext {
31
+ return {
32
+ fetchBinary: async () => ({ ok: false, error: new Error('not wired') }),
33
+ resolveRef: async () => ({ ok: false, error: new Error('not wired') }),
34
+ transcodeCaps: { bc: false, etc2: false, astc: false },
35
+ device: undefined,
36
+ };
37
+ }
494
38
 
495
- function pgOv(s: string): AssetGuid {
496
- const r = AssetGuid.parse(s);
497
- if (!r.ok) throw new Error(`bad test GUID: ${s}`);
498
- return r.value;
39
+ function childScene(): SceneAsset {
40
+ return {
41
+ kind: 'scene',
42
+ entities: {
43
+ 'entity-0': { components: { Transform: {}, MeshFilter: { assetHandle: 0 } } },
44
+ },
45
+ };
499
46
  }
500
47
 
501
- function mkMeshAsset(): MeshAsset {
48
+ function meshAsset(): MeshAsset {
502
49
  return {
503
50
  kind: 'mesh',
504
51
  vertices: new Float32Array([
@@ -510,297 +57,190 @@ function mkMeshAsset(): MeshAsset {
510
57
  submeshes: [
511
58
  { indexOffset: 0, indexCount: 3, vertexCount: 3, topology: 'triangle-list', materialSlot: 0 },
512
59
  ],
513
-
514
60
  materialSlots: [{ slotName: 'Default' }],
515
61
  };
516
62
  }
517
63
 
518
- function mkClipAsset(duration: number): AnimationClip {
519
- return { kind: 'animation-clip', duration, channels: [] };
520
- }
521
-
522
- function makeMountWorld(): World {
64
+ function worldWithSceneComponents(): World {
523
65
  const world = new World();
524
- for (const component of [Transform, ChildOf, MeshFilter, AnimationPlayer, SceneInstance]) {
66
+ for (const component of [Transform, ChildOf, MeshFilter, SceneInstance]) {
525
67
  world.components.register(component).unwrap();
526
68
  }
527
69
  return world;
528
70
  }
529
71
 
530
- /** First descendant (excluding `root`) that carries `token`. */
531
- function firstMemberWith(
532
- world: World,
533
- root: EntityHandle,
534
- token: typeof MeshFilter | typeof AnimationPlayer,
535
- ): Record<string, unknown> | undefined {
536
- for (const e of world.iterDescendants(root)) {
537
- if (e === root) continue;
538
- const r = world.get(e, token as never);
539
- if (r.ok) return r.value as unknown as Record<string, unknown>;
540
- }
541
- return undefined;
542
- }
543
-
544
- describe('feat-20260713 M3 / w10 override value GUID→handle resolution (AC-06)', () => {
545
- function mkReg(): AssetRegistry {
546
- return new AssetRegistry(makeMockShaderRegistry());
547
- }
548
- function childScene(): SceneAsset {
549
- // One member entity carrying Transform only (localId 0 in child namespace).
550
- return { kind: 'scene', entities: [{ localId: 0 as never, components: { Transform: {} } }] };
551
- }
552
-
553
- it('scalar shared<> override value GUID resolves to a live handle on the member', () => {
554
- const reg = mkReg();
555
- const world = makeMountWorld();
556
- reg.catalog(pgOv(OV_MESH_GUID), mkMeshAsset());
557
- reg.catalog(pgOv(SCENE_CHILD_GUID), childScene() as Asset);
558
-
559
- // Parent: no owned entities; a mount of the child whose single member (parent
560
- // namespace localId 2) gets a MeshFilter ADDED with a GUID-string assetHandle.
561
- const parent: SceneAsset = {
562
- kind: 'scene',
563
- entities: [],
564
- mounts: [
565
- {
566
- localId: 1 as never,
567
- source: SCENE_CHILD_GUID,
568
- memberFirst: 2 as never,
569
- memberCount: 1,
570
- overrides: [
571
- { localId: 2 as never, comp: 'MeshFilter', value: { assetHandle: OV_MESH_GUID } },
572
- ],
573
- },
574
- ],
72
+ describe('keyed SceneAsset decode', () => {
73
+ it('resolves only wire refs declared by instance.source and preserves ordinary fields', () => {
74
+ const registry = new AssetRegistry(makeMockShaderRegistry());
75
+ const parse = (
76
+ registry as unknown as {
77
+ parseAssetPayload(
78
+ kind: string,
79
+ payload: Record<string, unknown>,
80
+ refs?: readonly string[],
81
+ ): unknown;
82
+ }
83
+ ).parseAssetPayload.bind(registry);
84
+ const payload = {
85
+ entities: {
86
+ root: { components: { Transform: { source: 1 } } },
87
+ child: { components: {}, instance: { source: 0 } },
88
+ },
575
89
  };
576
- reg.catalog(pgOv(SCENE_PARENT_GUID), parent as Asset);
577
-
578
- const handle = world.allocSharedRef('SceneAsset', parent);
579
- const r = reg.instantiate(handle, world);
580
- expect(r.ok).toBe(true);
581
- if (!r.ok) return;
582
-
583
- const mf = firstMemberWith(world, r.value, MeshFilter);
584
- expect(mf).toBeDefined();
585
- const resolved = mf?.assetHandle as number;
586
- // Not the sentinel 0, not a GUID string — a live user-tier handle.
587
- expect(typeof resolved).toBe('number');
588
- expect(resolved).toBeGreaterThanOrEqual(BUILTIN_BASE);
589
- // And it resolves back to the catalogued mesh payload.
590
- const payload = resolveAssetHandle<MeshAsset>(
591
- world,
592
- resolved as unknown as Handle<string, 'shared'>,
593
- );
594
- expect(payload.ok).toBe(true);
595
- if (payload.ok) expect(payload.value.kind).toBe('mesh');
90
+ const asset = parse('scene', payload, [GUID_A]);
91
+ expect(asset).toMatchObject({
92
+ kind: 'scene',
93
+ entities: {
94
+ root: { components: { Transform: { source: 1 } } },
95
+ child: { instance: { source: GUID_A } },
96
+ },
97
+ });
596
98
  });
597
99
 
598
- it('array<shared<>> override value GUID array resolves to a handle array; number elements pass through (D-8)', () => {
599
- const reg = mkReg();
600
- const world = makeMountWorld();
601
- reg.catalog(pgOv(OV_CLIP_A_GUID), mkClipAsset(1.5));
602
- reg.catalog(pgOv(SCENE_CHILD_GUID), childScene() as Asset);
603
-
604
- // A pre-resolved clip handle number that must pass through untouched (D-8).
605
- const preHandle = unwrapHandle(world.allocSharedRef('AnimationClip', mkClipAsset(2.0)));
100
+ it('rejects an instance source refs index outside the envelope', () => {
101
+ const registry = new AssetRegistry(makeMockShaderRegistry());
102
+ const parse = (
103
+ registry as unknown as {
104
+ parseAssetPayload(
105
+ kind: string,
106
+ payload: Record<string, unknown>,
107
+ refs?: readonly string[],
108
+ ): unknown;
109
+ }
110
+ ).parseAssetPayload.bind(registry);
111
+ expect(
112
+ parse('scene', { entities: { child: { components: {}, instance: { source: 7 } } } }, [
113
+ GUID_A,
114
+ ]),
115
+ ).toBeUndefined();
116
+ });
606
117
 
607
- const parent: SceneAsset = {
608
- kind: 'scene',
609
- entities: [],
610
- mounts: [
611
- {
612
- localId: 1 as never,
613
- source: SCENE_CHILD_GUID,
614
- memberFirst: 2 as never,
615
- memberCount: 1,
616
- // clips[0] = GUID string (resolve); clips[1] = number (pass through).
617
- overrides: [
618
- {
619
- localId: 2 as never,
620
- comp: 'AnimationPlayer',
621
- value: { clips: [OV_CLIP_A_GUID, preHandle] },
622
- },
623
- ],
624
- },
625
- ],
118
+ it('loads keyed payloads concurrently without cross contaminating entity keys', () => {
119
+ const payloadA = {
120
+ entities: { 'entity-a': { components: { Transform: { pos: [0, 0, 0] } } } },
626
121
  };
627
- reg.catalog(pgOv(SCENE_PARENT_GUID), parent as Asset);
628
-
629
- const handle = world.allocSharedRef('SceneAsset', parent);
630
- const r = reg.instantiate(handle, world);
631
- expect(r.ok).toBe(true);
632
- if (!r.ok) return;
633
-
634
- const ap = firstMemberWith(world, r.value, AnimationPlayer);
635
- expect(ap).toBeDefined();
636
- const clips = ap?.clips as ArrayLike<number>;
637
- // slot 0: resolved handle; slot 1: number passthrough.
638
- expect(typeof clips[0]).toBe('number');
639
- expect(clips[0]).toBeGreaterThanOrEqual(BUILTIN_BASE);
640
- expect(clips[1]).toBe(preHandle);
641
- const payload = resolveAssetHandle<AnimationClip>(
642
- world,
643
- clips[0] as unknown as Handle<string, 'shared'>,
644
- );
645
- expect(payload.ok).toBe(true);
646
- if (payload.ok) expect(payload.value.duration).toBe(1.5);
122
+ const payloadB = {
123
+ entities: { 'entity-b': { components: { Transform: { pos: [1, 0, 0] } } } },
124
+ };
125
+ const a = sceneLoader.load(payloadA, [GUID_A], context());
126
+ const b = sceneLoader.load(payloadB, [GUID_B], context());
127
+ expect(a).toMatchObject({ kind: 'scene', entities: { 'entity-a': {} } });
128
+ expect(b).toMatchObject({ kind: 'scene', entities: { 'entity-b': {} } });
647
129
  });
130
+ });
648
131
 
649
- it('unresolvable override value GUID → AssetError fail-fast, no half-initialized member', () => {
650
- const reg = mkReg();
651
- const world = makeMountWorld();
652
- reg.catalog(pgOv(SCENE_CHILD_GUID), childScene() as Asset);
653
-
654
- const parent: SceneAsset = {
132
+ describe('keyed nested registry instantiate', () => {
133
+ it('projects the authored source key from the Catalog into scene bindings', () => {
134
+ const registry = new AssetRegistry(makeMockShaderRegistry());
135
+ const world = worldWithSceneComponents();
136
+ const scene: SceneAsset = {
655
137
  kind: 'scene',
656
- entities: [],
657
- mounts: [
138
+ entities: { player: { components: { Transform: {} } } },
139
+ };
140
+ expect(registry.catalog(guid(GUID_A), scene as Asset).ok).toBe(true);
141
+ registry.packIndexCache = new Map([
142
+ [
143
+ GUID_A,
658
144
  {
659
- localId: 1 as never,
660
- source: SCENE_CHILD_GUID,
661
- memberFirst: 2 as never,
662
- memberCount: 1,
663
- overrides: [
664
- {
665
- localId: 2 as never,
666
- comp: 'MeshFilter',
667
- value: { assetHandle: OV_UNCATALOGUED_GUID },
668
- },
669
- ],
145
+ packageUrl: '/assets/scene.pack.json',
146
+ kind: 'scene',
147
+ sourceKey: 'scene/showcase',
670
148
  },
671
149
  ],
672
- };
673
- reg.catalog(pgOv(SCENE_PARENT_GUID), parent as Asset);
150
+ ]);
674
151
 
675
- const before = world.inspect().entityCount;
676
- const handle = world.allocSharedRef('SceneAsset', parent);
677
- const r = reg.instantiate(handle, world);
678
- expect(r.ok).toBe(false);
679
- if (r.ok) return;
680
- expect(r.error.code).toBe('asset-not-found');
681
- // Fail-fast happens during _resolveSceneGuids (before any spawn): no entities
682
- // were materialised.
683
- expect(world.inspect().entityCount).toBe(before);
152
+ const result = registry.instantiate(world.allocSharedRef('SceneAsset', scene), world);
153
+ expect(result.ok).toBe(true);
154
+ if (!result.ok) return;
155
+ const resolved = worldResolveSceneEntity(world, result.value, {
156
+ sceneSourceKey: 'scene/showcase',
157
+ address: 'player',
158
+ });
159
+ expect(resolved.ok).toBe(true);
684
160
  });
685
161
 
686
- it('patch-form (field present) scalar shared<> override value GUID resolves too', () => {
687
- const reg = mkReg();
688
- const world = makeMountWorld();
689
- reg.catalog(pgOv(OV_MESH_GUID), mkMeshAsset());
690
- // Child member already carries MeshFilter (patch replaces its assetHandle).
691
- const child: SceneAsset = {
692
- kind: 'scene',
693
- entities: [
694
- { localId: 0 as never, components: { Transform: {}, MeshFilter: { assetHandle: 0 } } },
695
- ],
696
- };
697
- reg.catalog(pgOv(SCENE_CHILD_GUID), child as Asset);
698
-
162
+ it('resolves direct and nested authored addresses from one instance', () => {
163
+ const registry = new AssetRegistry(makeMockShaderRegistry());
164
+ const world = worldWithSceneComponents();
165
+ expect(registry.catalog(guid(GUID_B), childScene() as Asset).ok).toBe(true);
699
166
  const parent: SceneAsset = {
700
167
  kind: 'scene',
701
- entities: [],
702
- mounts: [
703
- {
704
- localId: 1 as never,
705
- source: SCENE_CHILD_GUID,
706
- memberFirst: 2 as never,
707
- memberCount: 1,
708
- // field present -> PATCH one field, value is that single field's value.
709
- overrides: [
710
- { localId: 2 as never, comp: 'MeshFilter', field: 'assetHandle', value: OV_MESH_GUID },
711
- ],
712
- },
713
- ],
168
+ entities: {
169
+ house: { components: {}, instance: { source: GUID_B } },
170
+ },
714
171
  };
715
- reg.catalog(pgOv(SCENE_PARENT_GUID), parent as Asset);
716
-
717
- const handle = world.allocSharedRef('SceneAsset', parent);
718
- const r = reg.instantiate(handle, world);
719
- expect(r.ok).toBe(true);
720
- if (!r.ok) return;
721
-
722
- const mf = firstMemberWith(world, r.value, MeshFilter);
723
- expect(mf).toBeDefined();
724
- const resolved = mf?.assetHandle as number;
725
- expect(typeof resolved).toBe('number');
726
- expect(resolved).toBeGreaterThanOrEqual(BUILTIN_BASE);
172
+ const result = registry.instantiate(world.allocSharedRef('SceneAsset', parent), world);
173
+ expect(result.ok).toBe(true);
174
+ if (!result.ok) return;
175
+ const direct = worldResolveSceneEntity(world, result.value, {
176
+ sceneSourceKey: '',
177
+ address: 'house',
178
+ });
179
+ const nested = worldResolveSceneEntity(world, result.value, {
180
+ sceneSourceKey: '',
181
+ address: ['house', 'entity-0'],
182
+ });
183
+ expect(direct.ok).toBe(true);
184
+ expect(nested.ok).toBe(true);
185
+ if (direct.ok && nested.ok) expect(direct.value).not.toBe(nested.value);
727
186
  });
728
- });
729
-
730
- describe('feat-20260713 M3 / w11 — envelope-less scene override value resolution', () => {
731
- function mkReg(): AssetRegistry {
732
- return new AssetRegistry(makeMockShaderRegistry());
733
- }
734
-
735
- it('override value GUID resolves even when the PARENT scene has no catalogued envelope', () => {
736
- const reg = mkReg();
737
- const world = makeMountWorld();
738
- // Sub-asset + child scene are catalogued; the PARENT scene is NOT (built +
739
- // allocSharedRef'd directly, no reg.catalog). sceneGuidKey is undefined so
740
- // _resolveSceneGuids takes the entity-walk fallback branch; the override
741
- // value must still resolve through resolveMountsRec's down-drill.
742
- reg.catalog(pgOv(OV_CLIP_B_GUID), mkClipAsset(3.0));
743
- reg.catalog(pgOv(SCENE_CHILD_GUID), {
744
- kind: 'scene',
745
- entities: [{ localId: 0 as never, components: { Transform: {} } }],
746
- } as Asset);
747
187
 
188
+ it('resolves nested instance source and override asset GUID before spawning', () => {
189
+ const registry = new AssetRegistry(makeMockShaderRegistry());
190
+ const world = worldWithSceneComponents();
191
+ expect(registry.catalog(guid(GUID_MESH), meshAsset()).ok).toBe(true);
192
+ expect(registry.catalog(guid(GUID_B), childScene() as Asset).ok).toBe(true);
748
193
  const parent: SceneAsset = {
749
194
  kind: 'scene',
750
- entities: [],
751
- mounts: [
752
- {
753
- localId: 1 as never,
754
- source: SCENE_CHILD_GUID,
755
- memberFirst: 2 as never,
756
- memberCount: 1,
757
- overrides: [
758
- { localId: 2 as never, comp: 'AnimationPlayer', value: { clips: [OV_CLIP_B_GUID] } },
759
- ],
195
+ entities: {
196
+ mount: {
197
+ components: {},
198
+ instance: {
199
+ source: GUID_B,
200
+ overrides: [
201
+ { target: ['entity-0'], components: { MeshFilter: { assetHandle: GUID_MESH } } },
202
+ ],
203
+ },
760
204
  },
761
- ],
205
+ },
762
206
  };
763
-
764
- // Confirm the parent really is envelope-less (no _guidForAsset hit).
765
- const guidForAsset = (
766
- reg as unknown as { _guidForAsset(a: Asset): string | undefined }
767
- )._guidForAsset(parent as Asset);
768
- expect(guidForAsset).toBeUndefined();
769
-
770
207
  const handle = world.allocSharedRef('SceneAsset', parent);
771
- const r = reg.instantiate(handle, world);
772
- expect(r.ok).toBe(true);
773
- if (!r.ok) return;
774
-
775
- const ap = firstMemberWith(world, r.value, AnimationPlayer);
776
- expect(ap).toBeDefined();
777
- const clips = ap?.clips as ArrayLike<number>;
778
- expect(typeof clips[0]).toBe('number');
779
- expect(clips[0]).toBeGreaterThanOrEqual(BUILTIN_BASE);
780
- const payload = resolveAssetHandle<AnimationClip>(
208
+ const result = registry.instantiate(handle, world);
209
+ expect(result.ok).toBe(true);
210
+ if (!result.ok) return;
211
+ const instance = world.get(result.value, SceneInstance);
212
+ expect(instance.ok).toBe(true);
213
+ if (!instance.ok) return;
214
+ const member = instance.value.mapping[1] as unknown as EntityHandle;
215
+ const mesh = world.get(member, MeshFilter);
216
+ expect(mesh.ok).toBe(true);
217
+ if (!mesh.ok) return;
218
+ expect(mesh.value.assetHandle as unknown as number).toBeGreaterThanOrEqual(BUILTIN_BASE);
219
+ const payload = resolveAssetHandle<MeshAsset>(
781
220
  world,
782
- clips[0] as unknown as Handle<string, 'shared'>,
221
+ mesh.value.assetHandle as unknown as Handle<string, 'shared'>,
783
222
  );
784
223
  expect(payload.ok).toBe(true);
785
- if (payload.ok) expect(payload.value.duration).toBe(3.0);
786
- });
787
- });
788
-
789
- // -- M4 / F21: static assertions (grep zero hits) [w19] --
790
-
791
- describe('static assertions: lastParseSceneError / reportParseError zero hits (F21 / AC-10) [w19]', () => {
792
- it('lastParseSceneError is absent from asset-registry.ts', () => {
793
- // This is a documentation assertion: grep lastParseSceneError
794
- // in packages/runtime/src/asset-registry.ts must return zero hits.
795
- // Verified at the CI sweep step; this test exists to document
796
- // the requirement and provides a placeholder that always passes.
797
- expect(true).toBe(true);
224
+ if (payload.ok) expect(payload.value.kind).toBe('mesh');
798
225
  });
799
226
 
800
- it('reportParseError is absent from asset-registry.ts and types', () => {
801
- // grep reportParseError in packages/runtime/src/ and
802
- // packages/types/src/ must return zero hits.
803
- // Verified at the CI sweep step.
804
- expect(true).toBe(true);
227
+ it('rejects recursive keyed instances before creating a usable root', () => {
228
+ const registry = new AssetRegistry(makeMockShaderRegistry());
229
+ const world = worldWithSceneComponents();
230
+ const a: SceneAsset = {
231
+ kind: 'scene',
232
+ entities: { b: { components: {}, instance: { source: GUID_B } } },
233
+ };
234
+ const b: SceneAsset = {
235
+ kind: 'scene',
236
+ entities: { a: { components: {}, instance: { source: GUID_A } } },
237
+ };
238
+ expect(registry.catalog(guid(GUID_A), a as Asset).ok).toBe(true);
239
+ expect(registry.catalog(guid(GUID_B), b as Asset).ok).toBe(true);
240
+ const handle = world.allocSharedRef('SceneAsset', a);
241
+ const before = world.inspect().entityCount;
242
+ const result = registry.instantiate(handle, world);
243
+ expect(result.ok).toBe(false);
244
+ expect(world.inspect().entityCount).toBe(before);
805
245
  });
806
246
  });