@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,19 +1,9 @@
1
1
  import * as SceneOwner from '@forgeax/engine-scene';
2
- // feat-20260608-scene-nesting-ecs-fication M2 / w21 (red phase) runtime
3
- // cycle fail-fast (AC-13). Constructs a hand-authored SceneAsset graph
4
- // A -> B -> C -> A via mount.source resolved through a test-only
5
- // SceneAssetResolver, bypassing the build-time scanner. World's recursive
6
- // _instantiateSceneRec is expected to detect the cycle via its private
7
- // stack (Set<Guid>) and fail-fast pack-cyclic-reference + detail.kind:
8
- // 'mount-asset' + detail.cycle: GUID[] (plan-strategy §D-3).
2
+ // Keyed runtime cycle gate: A -> B -> C -> A through nested `instance` declarations.
9
3
 
10
4
  import { World } from '@forgeax/engine-ecs';
11
- import { err, ok } from '@forgeax/engine-types';
12
- // Side-effect import: register Transform / ChildOf etc. via the runtime
13
- // component barrel so the implementation's resolveComponent('ChildOf') call
14
- // in _spawnMountEntity sees a defined token.
15
- import '@forgeax/engine-runtime';
16
5
  import type { Handle, SceneAsset } from '@forgeax/engine-types';
6
+ import { err, ok } from '@forgeax/engine-types';
17
7
  import { describe, expect, it } from 'vitest';
18
8
  import { registerSceneComponents } from './helpers/register-scene-components';
19
9
 
@@ -22,36 +12,30 @@ function registerSceneAsset(world: World, asset: SceneAsset): Handle<'SceneAsset
22
12
  return world.allocSharedRef('SceneAsset', asset);
23
13
  }
24
14
 
25
- describe('runtime cycle fail-fast (w21)', () => {
26
- it('A -> B -> C -> A surfaces pack-cyclic-reference / mount-asset', () => {
15
+ describe('runtime cycle fail-fast (keyed SceneAsset)', () => {
16
+ it('A -> B -> C -> A surfaces pack-cyclic-reference', () => {
27
17
  const world = new World();
28
- // Build mount entries that name source=0 (parent's refs[0]) — the
29
- // resolver maps each parent handle to a different child handle so the
30
- // 3-asset chain forms A -> B -> C -> A.
31
18
  const assetA: SceneAsset = {
32
19
  kind: 'scene',
33
- entities: [],
34
- mounts: [{ localId: 0 as never, source: 0, memberFirst: 1 as never, memberCount: 0 }],
20
+ entities: { a: { components: {}, instance: { source: 'B' } } },
35
21
  };
36
22
  const assetB: SceneAsset = {
37
23
  kind: 'scene',
38
- entities: [],
39
- mounts: [{ localId: 0 as never, source: 0, memberFirst: 1 as never, memberCount: 0 }],
24
+ entities: { b: { components: {}, instance: { source: 'C' } } },
40
25
  };
41
26
  const assetC: SceneAsset = {
42
27
  kind: 'scene',
43
- entities: [],
44
- mounts: [{ localId: 0 as never, source: 0, memberFirst: 1 as never, memberCount: 0 }],
28
+ entities: { c: { components: {}, instance: { source: 'A' } } },
45
29
  };
46
30
  const handleA = registerSceneAsset(world, assetA);
47
31
  const handleB = registerSceneAsset(world, assetB);
48
32
  const handleC = registerSceneAsset(world, assetC);
49
- SceneOwner.worldSetSceneAssetResolver(world, (sourceIdx, parentHandle) => {
50
- void sourceIdx;
51
- const raw = parentHandle as unknown as number;
52
- if (raw === (handleA as unknown as number)) return ok(handleB);
53
- if (raw === (handleB as unknown as number)) return ok(handleC);
54
- if (raw === (handleC as unknown as number)) return ok(handleA);
33
+ SceneOwner.worldSetSceneAssetResolver(world, (source, parentHandle) => {
34
+ if (typeof source === 'number') return ok(source as Handle<'SceneAsset', 'shared'>);
35
+ const parent = Number(parentHandle);
36
+ if (parent === Number(handleA) && source === 'B') return ok(handleB);
37
+ if (parent === Number(handleB) && source === 'C') return ok(handleC);
38
+ if (parent === Number(handleC) && source === 'A') return ok(handleA);
55
39
  return err({ code: 'asset-not-found' });
56
40
  });
57
41
  const r = SceneOwner.worldInstantiateScene(world, handleA);
@@ -1,257 +1,134 @@
1
- import * as SceneOwner from '@forgeax/engine-scene';
2
-
3
- // scene-mount-fixed-point.test.ts — AC-09 structural-equivalence fixed-point
4
- // tests (feat-20260707-engine-world-clone-transient-for-editor-ssot,
5
- // plan-strategy D-10 + §3.2 fixed-point argument).
6
- //
7
- // AC-09: mount-collapse no longer depends on serialized `Children`. After the
8
- // M2 rewrite, a second rootsToSceneAsset on the reloaded world produces a
9
- // mounts[] structurally equivalent to the first collect's mounts[] — source
10
- // GUID / memberCount / parent per-mount equal, owned-entity set cardinality
11
- // equal, NO Children key, and NO carrier-ghost accumulation across cycles.
12
- //
13
- // The scene shape here is the editor spawnGlbSceneAsMount on-disk form: an
14
- // OWNED wrapper entity W plus a mount whose `parent` is W (NOT the synthetic
15
- // root). This is the exact shape that exercises the D-8 deferred-wiring hole:
16
- // on reload the mount carrier must re-acquire its ChildOf-to-W edge, or collect
17
- // cannot reach the anchor and mounts come back undefined.
18
- //
19
- // D-10 semantics: structural equivalence, NOT per-position Children order. The
20
- // first save->reload cycle may normalize order once; from the second collect
21
- // onward the output is a strict fixed-point (collect3 === collect2).
22
- //
23
- // RED pre-fix (falsification anchor, strategy §5.4 third pillar): before
24
- // m2t4/m2t5, collect2.mounts is undefined (anchor unreachable) so the
25
- // equivalence and three-cycle assertions fail. GREEN after the fix.
26
-
27
1
  import type { Asset } from '@forgeax/engine-assets-runtime';
28
2
  import { AssetRegistry } from '@forgeax/engine-assets-runtime';
29
3
  import { World } from '@forgeax/engine-ecs';
30
4
  import { AssetGuid } from '@forgeax/engine-pack/guid';
5
+ import { SceneInstance } from '@forgeax/engine-render';
31
6
  import type { Handle, SceneAsset } from '@forgeax/engine-types';
32
7
  import { describe, expect, it } from 'vitest';
33
8
  import { rootsToSceneAsset, serializeSceneAssetToPack } from '../collect-scene-asset';
34
9
  import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
35
10
  import { registerSceneComponents } from './helpers/register-scene-components';
36
11
 
37
- const G_CHILD = '11111111-1111-4111-8111-111111111111';
38
- const G_TOP = '22222222-2222-4222-8222-222222222222';
12
+ const CHILD = '11111111-1111-4111-8111-111111111111';
13
+ const SAVED = '22222222-2222-4222-8222-222222222222';
39
14
 
40
- function mkReg(): AssetRegistry {
41
- return new AssetRegistry(makeMockShaderRegistry());
42
- }
43
- function pg(s: string): AssetGuid {
44
- const r = AssetGuid.parse(s);
45
- if (!r.ok) throw new Error(`bad GUID: ${s}`);
46
- return r.value;
47
- }
48
- function cat(reg: AssetRegistry, g: string, p: SceneAsset): void {
49
- reg.catalog(pg(g), p as Asset);
50
- }
51
- function rs(w: World, a: SceneAsset): Handle<'SceneAsset', 'shared'> {
52
- registerSceneComponents(w);
53
- return w.allocSharedRef('SceneAsset', a);
54
- }
55
- function accessParse(reg: AssetRegistry) {
56
- const internal = reg as unknown as {
57
- parseAssetPayload(kind: string, payload: Record<string, unknown>, refs?: string[]): unknown;
58
- };
59
- return (payload: Record<string, unknown>, refs?: readonly string[]): SceneAsset =>
60
- internal.parseAssetPayload('scene', payload, refs as string[] | undefined) as SceneAsset;
61
- }
62
- function unpack(pack: Record<string, unknown>): {
63
- payload: Record<string, unknown>;
64
- refs: readonly string[] | undefined;
65
- } {
66
- const a = (pack.assets as Record<string, unknown>[])[0] as Record<string, unknown>;
67
- return { payload: a.payload as Record<string, unknown>, refs: a.refs as string[] | undefined };
15
+ function guid(value: string): AssetGuid {
16
+ const result = AssetGuid.parse(value);
17
+ if (!result.ok) throw new Error(`bad GUID: ${value}`);
18
+ return result.value;
68
19
  }
69
- /**
70
- * Collect from the top-level OWNED roots (SceneInstanceState.rootEntities), not
71
- * the synthetic root — mirrors the editor's entRootHandles set, which excludes
72
- * the synthetic root and mount carriers. Collecting from the synthetic root
73
- * would fold the synthetic root itself in as an extra owned entity.
74
- */
75
- function ownedRoots(w: World, syntheticRoot: import('@forgeax/engine-ecs').EntityHandle): number[] {
76
- const si = SceneOwner.worldGetSceneInstanceState(w, syntheticRoot);
77
- if (!si.ok) throw new Error('no SceneInstance state on synthetic root');
78
- return si.value.rootEntities.map((e) => e as unknown as number);
79
- }
80
-
81
- /**
82
- * D-10 structural equivalence of two SceneAssets: mounts (source, memberCount,
83
- * parent — memberFirst is derived from the owned-set cardinality and re-derived
84
- * each collect, so it is NOT part of the equivalence contract) + owned entity
85
- * set cardinality + per-entity component-type key set. Does NOT assert Children
86
- * per-position order (mirror is derived from spawn order).
87
- */
88
- function structurallyEquivalent(a: SceneAsset, b: SceneAsset): boolean {
89
- const am = a.mounts ?? [];
90
- const bm = b.mounts ?? [];
91
- if (am.length !== bm.length) return false;
92
- // Compare mounts as a multiset keyed by (source, memberCount, parent) so a
93
- // one-time order normalization does not fail equivalence.
94
- const key = (m: (typeof am)[number]): string =>
95
- `${String(m.source)}|${m.memberCount}|${String(m.parent)}`;
96
- const aKeys = am.map(key).sort();
97
- const bKeys = bm.map(key).sort();
98
- for (let i = 0; i < aKeys.length; i++) if (aKeys[i] !== bKeys[i]) return false;
99
20
 
100
- if (a.entities.length !== b.entities.length) return false;
101
- const compKeySet = (s: SceneAsset): string[] =>
102
- s.entities
103
- .map((e) =>
104
- Object.keys(e.components as Record<string, unknown>)
105
- .sort()
106
- .join(','),
107
- )
108
- .sort();
109
- const aC = compKeySet(a);
110
- const bC = compKeySet(b);
111
- for (let i = 0; i < aC.length; i++) if (aC[i] !== bC[i]) return false;
112
- return true;
21
+ function register(world: World, asset: SceneAsset): Handle<'SceneAsset', 'shared'> {
22
+ registerSceneComponents(world);
23
+ return world.allocSharedRef('SceneAsset', asset);
113
24
  }
114
25
 
115
- /** No entity in the SceneAsset carries a Children component (AC-04 holds through round-trip). */
116
- function noChildrenKey(s: SceneAsset): boolean {
117
- for (const e of s.entities) {
118
- if ((e.components as Record<string, unknown>).Children !== undefined) return false;
119
- }
120
- return true;
26
+ function catalog(registry: AssetRegistry, id: string, asset: SceneAsset): void {
27
+ const result = registry.catalog(guid(id), asset as Asset);
28
+ if (!result.ok) throw new Error(result.error.code);
121
29
  }
122
30
 
123
- describe('AC-09 mount-collapse fixed-point (structural equivalence, D-10)', () => {
124
- it('owned-parent mount: collect1 ~ collect2, three cycles no ghost, Children absent', () => {
125
- const reg = mkReg();
126
- const w = new World();
31
+ function parse(
32
+ registry: AssetRegistry,
33
+ payload: Record<string, unknown>,
34
+ refs: readonly string[],
35
+ ): SceneAsset {
36
+ const result = (
37
+ registry as unknown as {
38
+ parseAssetPayload(
39
+ kind: string,
40
+ payload: Record<string, unknown>,
41
+ refs?: readonly string[],
42
+ ): unknown;
43
+ }
44
+ ).parseAssetPayload('scene', payload, refs);
45
+ if (result === undefined) throw new Error('scene payload parse failed');
46
+ return result as SceneAsset;
47
+ }
127
48
 
128
- // Child scene = one owned mesh entity.
49
+ describe('keyed SceneAsset collect fixed point', () => {
50
+ it('collect -> serialize -> reload -> collect preserves nested declaration and keys', () => {
51
+ const registry = new AssetRegistry(makeMockShaderRegistry());
52
+ const world = new World();
129
53
  const child: SceneAsset = {
130
54
  kind: 'scene',
131
- entities: [{ localId: 0 as never, components: { Transform: { pos: [3, 0, 0] } } }],
55
+ entities: { door: { components: { Transform: { pos: [2, 0, 0] } } } },
132
56
  };
133
- cat(reg, G_CHILD, child);
134
-
135
- // Top scene: owned wrapper W (localId 0) + mount whose parent is W. Exact
136
- // spawnGlbSceneAsMount on-disk shape (an owned wrapper with a nested mount).
137
- const top: SceneAsset = {
57
+ catalog(registry, CHILD, child);
58
+ const parent: SceneAsset = {
138
59
  kind: 'scene',
139
- entities: [{ localId: 0 as never, components: { Transform: { pos: [1, 0, 0] } } }],
140
- mounts: [
141
- {
142
- localId: 1 as never,
143
- source: G_CHILD,
144
- memberFirst: 2 as never,
145
- memberCount: 1,
146
- parent: 0 as never,
147
- },
148
- ],
60
+ entities: {
61
+ root: { components: { Transform: {} } },
62
+ house: { components: {}, instance: { source: CHILD } },
63
+ },
149
64
  };
150
- cat(reg, G_TOP, top);
151
-
152
- const parse = accessParse(reg);
153
-
154
- // Cycle 1: instantiate the authored top, then collect.
155
- const i0 = reg.instantiate(rs(w, top), w);
156
- expect(i0.ok).toBe(true);
157
- if (!i0.ok) return;
158
- const c1 = rootsToSceneAsset(reg, w, ownedRoots(w, i0.value) as never);
159
- expect(c1.ok).toBe(true);
160
- if (!c1.ok) return;
161
-
162
- // First collect must produce exactly one mount to the child GUID, parented
163
- // to the owned wrapper (localId 0), and one owned wrapper entity.
164
- expect(c1.value.mounts?.length).toBe(1);
165
- expect(c1.value.mounts?.[0]?.source).toBe(G_CHILD);
166
- expect(c1.value.mounts?.[0]?.memberCount).toBe(1);
167
- expect(c1.value.mounts?.[0]?.parent).toBe(0);
168
- expect(c1.value.entities.length).toBe(1);
169
- expect(noChildrenKey(c1.value)).toBe(true);
170
-
171
- // Cycle 2: serialize -> parse -> catalog -> reload -> collect.
172
- const s1 = serializeSceneAssetToPack(c1.value, w.components.entries(), G_TOP);
173
- expect(s1.ok).toBe(true);
174
- if (!s1.ok) return;
175
- const u1 = unpack(s1.value);
176
- const p1 = parse(u1.payload, u1.refs);
177
- cat(reg, G_TOP, p1);
178
- const i1 = reg.instantiate(rs(w, p1), w);
179
- expect(i1.ok).toBe(true);
180
- if (!i1.ok) return;
181
- const c2 = rootsToSceneAsset(reg, w, ownedRoots(w, i1.value) as never);
182
- expect(c2.ok).toBe(true);
183
- if (!c2.ok) return;
184
-
185
- // AC-09: collect2 is structurally equivalent to collect1 (source/memberCount
186
- // /parent per-mount, owned-set cardinality). This is the core assertion —
187
- // RED pre-fix because collect2.mounts comes back undefined.
188
- expect(structurallyEquivalent(c1.value, c2.value)).toBe(true);
189
- expect(noChildrenKey(c2.value)).toBe(true);
190
-
191
- // Cycle 3: reload once more and collect — the ghost-accretion point. From
192
- // the second collect onward the output is a strict fixed-point (D-10).
193
- const s2 = serializeSceneAssetToPack(c2.value, w.components.entries(), G_TOP);
194
- expect(s2.ok).toBe(true);
195
- if (!s2.ok) return;
196
- const u2 = unpack(s2.value);
197
- const p2 = parse(u2.payload, u2.refs);
198
- cat(reg, G_TOP, p2);
199
- const i2 = reg.instantiate(rs(w, p2), w);
200
- expect(i2.ok).toBe(true);
201
- if (!i2.ok) return;
202
- const c3 = rootsToSceneAsset(reg, w, ownedRoots(w, i2.value) as never);
203
- expect(c3.ok).toBe(true);
204
- if (!c3.ok) return;
205
-
206
- // No carrier ghost accumulation: owned + mount counts are a fixed point.
207
- expect(c3.value.entities.length).toBe(c2.value.entities.length);
208
- expect((c3.value.mounts ?? []).length).toBe((c2.value.mounts ?? []).length);
209
- expect(structurallyEquivalent(c2.value, c3.value)).toBe(true);
210
- expect(noChildrenKey(c3.value)).toBe(true);
65
+ const first = registry.instantiate(register(world, parent), world);
66
+ expect(first.ok).toBe(true);
67
+ if (!first.ok) return;
68
+ const collected1 = rootsToSceneAsset(registry, world, [first.value]);
69
+ expect(collected1.ok).toBe(true);
70
+ if (!collected1.ok) return;
71
+ expect(collected1.value.entities.house?.instance?.source).toBe(CHILD);
72
+ const packed = serializeSceneAssetToPack(collected1.value, world.components.entries(), SAVED);
73
+ expect(packed.ok).toBe(true);
74
+ if (!packed.ok) return;
75
+ const row = (packed.value.assets as Array<Record<string, unknown>>)[0];
76
+ if (!row) throw new Error('missing serialized scene');
77
+ const decoded = parse(
78
+ registry,
79
+ row.payload as Record<string, unknown>,
80
+ row.refs as readonly string[],
81
+ );
82
+ expect(decoded.entities.house?.instance?.source).toBe(CHILD);
83
+
84
+ const secondWorld = new World();
85
+ catalog(registry, SAVED, decoded);
86
+ const second = registry.instantiate(register(secondWorld, decoded), secondWorld);
87
+ expect(second.ok).toBe(true);
88
+ if (!second.ok) return;
89
+ const collected2 = rootsToSceneAsset(registry, secondWorld, [second.value]);
90
+ expect(collected2.ok).toBe(true);
91
+ if (!collected2.ok) return;
92
+ expect(Object.keys(collected2.value.entities)).toEqual(Object.keys(collected1.value.entities));
93
+ expect(collected2.value.entities.house?.instance).toEqual(
94
+ collected1.value.entities.house?.instance,
95
+ );
211
96
  });
212
97
 
213
- it('degenerate no-mount scene round-trips with zero regression', () => {
214
- const reg = mkReg();
215
- const w = new World();
216
-
217
- // Flat scene: two owned entities, no mounts.
218
- const flat: SceneAsset = {
98
+ it('a scene without nested instances remains a keyed two entity asset', () => {
99
+ const registry = new AssetRegistry(makeMockShaderRegistry());
100
+ const world = new World();
101
+ const asset: SceneAsset = {
219
102
  kind: 'scene',
220
- entities: [
221
- { localId: 0 as never, components: { Transform: { pos: [1, 0, 0] } } },
222
- { localId: 1 as never, components: { Transform: { pos: [2, 0, 0] } } },
223
- ],
103
+ entities: {
104
+ root: { components: { Transform: {} } },
105
+ child: { components: { Transform: {}, ChildOf: { parent: 'root' } } },
106
+ },
224
107
  };
225
- cat(reg, G_TOP, flat);
226
-
227
- const parse = accessParse(reg);
228
-
229
- const i0 = reg.instantiate(rs(w, flat), w);
230
- expect(i0.ok).toBe(true);
231
- if (!i0.ok) return;
232
- const c1 = rootsToSceneAsset(reg, w, ownedRoots(w, i0.value) as never);
233
- expect(c1.ok).toBe(true);
234
- if (!c1.ok) return;
235
- expect((c1.value.mounts ?? []).length).toBe(0);
236
- expect(c1.value.entities.length).toBe(2);
237
-
238
- const s1 = serializeSceneAssetToPack(c1.value, w.components.entries(), G_TOP);
239
- expect(s1.ok).toBe(true);
240
- if (!s1.ok) return;
241
- const u1 = unpack(s1.value);
242
- const p1 = parse(u1.payload, u1.refs);
243
- cat(reg, G_TOP, p1);
244
- const i1 = reg.instantiate(rs(w, p1), w);
245
- expect(i1.ok).toBe(true);
246
- if (!i1.ok) return;
247
- const c2 = rootsToSceneAsset(reg, w, ownedRoots(w, i1.value) as never);
248
- expect(c2.ok).toBe(true);
249
- if (!c2.ok) return;
108
+ const root = registry.instantiate(register(world, asset), world);
109
+ expect(root.ok).toBe(true);
110
+ if (!root.ok) return;
111
+ const collected = rootsToSceneAsset(registry, world, [root.value]);
112
+ expect(collected.ok).toBe(true);
113
+ if (!collected.ok) return;
114
+ expect(Object.keys(collected.value.entities)).toEqual(['root', 'child']);
115
+ expect(collected.value.entities.child?.components.ChildOf).toEqual({ parent: 'root' });
116
+ });
250
117
 
251
- // No extra entities appear; no mounts materialize; Children absent.
252
- expect(c2.value.entities.length).toBe(2);
253
- expect((c2.value.mounts ?? []).length).toBe(0);
254
- expect(structurallyEquivalent(c1.value, c2.value)).toBe(true);
255
- expect(noChildrenKey(c2.value)).toBe(true);
118
+ it('the transient SceneInstance state is not emitted as an authored component', () => {
119
+ const registry = new AssetRegistry(makeMockShaderRegistry());
120
+ const world = new World();
121
+ const asset: SceneAsset = {
122
+ kind: 'scene',
123
+ entities: { root: { components: { Transform: {} } } },
124
+ };
125
+ const root = registry.instantiate(register(world, asset), world);
126
+ expect(root.ok).toBe(true);
127
+ if (!root.ok) return;
128
+ expect(world.get(root.value, SceneInstance).ok).toBe(true);
129
+ const collected = rootsToSceneAsset(registry, world, [root.value]);
130
+ expect(collected.ok).toBe(true);
131
+ if (!collected.ok) return;
132
+ expect(collected.value.entities.root?.components.SceneInstance).toBeUndefined();
256
133
  });
257
134
  });