@forgeax/engine-scene 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 (38) hide show
  1. package/README.md +50 -0
  2. package/dist/__tests__/keyed-scene.integration.test.d.ts +2 -0
  3. package/dist/__tests__/keyed-scene.integration.test.d.ts.map +1 -0
  4. package/dist/assets/scene-decoder.d.ts.map +1 -1
  5. package/dist/index.d.ts +3 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.mjs +848 -229
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/instances/binding.d.ts +7 -5
  10. package/dist/instances/binding.d.ts.map +1 -1
  11. package/dist/instances/externalization.d.ts +1 -1
  12. package/dist/instances/externalization.d.ts.map +1 -1
  13. package/dist/instances/keyed.d.ts +38 -0
  14. package/dist/instances/keyed.d.ts.map +1 -0
  15. package/dist/instances/legacy.d.ts +20 -0
  16. package/dist/instances/legacy.d.ts.map +1 -0
  17. package/dist/instances/runtime-types.d.ts +20 -0
  18. package/dist/instances/runtime-types.d.ts.map +1 -0
  19. package/dist/instances/scene-instances.d.ts +27 -37
  20. package/dist/instances/scene-instances.d.ts.map +1 -1
  21. package/dist/instances/state.d.ts +6 -1
  22. package/dist/instances/state.d.ts.map +1 -1
  23. package/package.json +5 -5
  24. package/src/__tests__/asset-owner.integration.test.ts +4 -7
  25. package/src/__tests__/flat-propagation.perf.test.ts +18 -17
  26. package/src/__tests__/keyed-scene.integration.test.ts +269 -0
  27. package/src/__tests__/scene-binding.integration.test.ts +141 -32
  28. package/src/__tests__/structural.test.ts +4 -1
  29. package/src/assets/scene-decoder.ts +123 -33
  30. package/src/collect-subtree.ts +2 -2
  31. package/src/index.ts +10 -1
  32. package/src/instances/binding.ts +26 -19
  33. package/src/instances/externalization.ts +115 -97
  34. package/src/instances/keyed.ts +505 -0
  35. package/src/instances/legacy.ts +108 -0
  36. package/src/instances/runtime-types.ts +21 -0
  37. package/src/instances/scene-instances.ts +220 -83
  38. package/src/instances/state.ts +6 -1
@@ -0,0 +1,269 @@
1
+ import { defineComponent, World } from '@forgeax/engine-ecs';
2
+ import { err, type Handle, ok, type SceneAsset } from '@forgeax/engine-types';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { ChildOf, Children, compileKeyedSceneAsset, type KeyedSceneCompileContext } from '../index';
5
+
6
+ const Marker = defineComponent('KeyedSceneMarker', { value: 'i32' });
7
+ const DirectionalLight = defineComponent('DirectionalLight', { shadowFilter: 'f32' });
8
+
9
+ function register(world: World): void {
10
+ for (const component of [ChildOf, Children, Marker, DirectionalLight])
11
+ world.components.register(component).unwrap();
12
+ }
13
+
14
+ function compile(
15
+ world: World,
16
+ handle: Handle<'SceneAsset', 'shared'>,
17
+ asset: SceneAsset,
18
+ assets: ReadonlyMap<number, SceneAsset>,
19
+ handles: ReadonlyMap<string, Handle<'SceneAsset', 'shared'>>,
20
+ ): ReturnType<typeof compileKeyedSceneAsset> {
21
+ const context: KeyedSceneCompileContext = {
22
+ resolveSource: (source, _parent) => {
23
+ const child = handles.get(source);
24
+ return child === undefined ? err({ code: 'missing-source' }) : ok(child);
25
+ },
26
+ resolveAsset: (child) => {
27
+ const value = assets.get(Number(child));
28
+ return value === undefined ? err({ code: 'missing-asset' }) : ok(value);
29
+ },
30
+ stack: new Set([Number(handle)]),
31
+ };
32
+ return compileKeyedSceneAsset(world, handle, asset, context);
33
+ }
34
+
35
+ describe('keyed SceneAsset compiler', () => {
36
+ it('sorts keys, resolves same-scene and nested addresses, and derives private slots', () => {
37
+ const world = new World();
38
+ register(world);
39
+ const child: SceneAsset = {
40
+ kind: 'scene',
41
+ entities: {
42
+ 'door/slash': { components: { KeyedSceneMarker: { value: 2 } } },
43
+ leaf: { components: { KeyedSceneMarker: { value: 3 } } },
44
+ },
45
+ };
46
+ const parent: SceneAsset = {
47
+ kind: 'scene',
48
+ entities: {
49
+ z: {
50
+ components: {
51
+ KeyedSceneMarker: { value: 1 },
52
+ ChildOf: { parent: 'root' },
53
+ },
54
+ },
55
+ instance: {
56
+ components: { ChildOf: { parent: 'root' } },
57
+ instance: {
58
+ source: 'child-guid',
59
+ overrides: [{ target: ['door/slash'], components: { KeyedSceneMarker: { value: 9 } } }],
60
+ },
61
+ },
62
+ root: {
63
+ components: {
64
+ KeyedSceneMarker: { value: 0 },
65
+ },
66
+ },
67
+ },
68
+ };
69
+ const childHandle = world.allocSharedRef('SceneAsset', child);
70
+ const parentHandle = world.allocSharedRef('SceneAsset', parent);
71
+ const result = compile(
72
+ world,
73
+ parentHandle,
74
+ parent,
75
+ new Map([
76
+ [Number(childHandle), child],
77
+ [Number(parentHandle), parent],
78
+ ]),
79
+ new Map([['child-guid', childHandle]]),
80
+ );
81
+ expect(result.ok).toBe(true);
82
+ if (!result.ok) return;
83
+ expect([...result.value.keyByLocalId.values()]).toEqual(['root', 'z', 'instance']);
84
+ expect(result.value.resolveAddress('root')).toBe(0);
85
+ expect(result.value.resolveAddress(['instance', 'door/slash'])).toBe(3);
86
+ expect(result.value.asset.mounts?.[0]?.memberCount).toBe(2);
87
+ expect(result.value.asset.mounts?.[0]?.overrides?.[0]?.localId).toBe(3);
88
+ });
89
+
90
+ it('rejects hierarchy cycles before producing a runtime projection', () => {
91
+ const world = new World();
92
+ register(world);
93
+ const handle = world.allocSharedRef('SceneAsset', { kind: 'scene', entities: {} });
94
+ const asset: SceneAsset = {
95
+ kind: 'scene',
96
+ entities: {
97
+ a: { components: { ChildOf: { parent: 'b' } } },
98
+ b: { components: { ChildOf: { parent: 'a' } } },
99
+ },
100
+ };
101
+ const result = compile(world, handle, asset, new Map([[Number(handle), asset]]), new Map());
102
+ expect(result.ok).toBe(false);
103
+ if (result.ok) return;
104
+ expect(result.error).toMatchObject({ detail: { reason: 'hierarchy cycle' } });
105
+ });
106
+
107
+ it('rejects hierarchy cycles that cross an instance carrier', () => {
108
+ const world = new World();
109
+ register(world);
110
+ const child: SceneAsset = {
111
+ kind: 'scene',
112
+ entities: { leaf: { components: {} } },
113
+ };
114
+ const parent: SceneAsset = {
115
+ kind: 'scene',
116
+ entities: {
117
+ root: { components: { ChildOf: { parent: 'instance' } } },
118
+ instance: {
119
+ components: { ChildOf: { parent: 'root' } },
120
+ instance: { source: 'child-guid' },
121
+ },
122
+ },
123
+ };
124
+ const childHandle = world.allocSharedRef('SceneAsset', child);
125
+ const parentHandle = world.allocSharedRef('SceneAsset', parent);
126
+ const result = compile(
127
+ world,
128
+ parentHandle,
129
+ parent,
130
+ new Map([
131
+ [Number(childHandle), child],
132
+ [Number(parentHandle), parent],
133
+ ]),
134
+ new Map([['child-guid', childHandle]]),
135
+ );
136
+ expect(result).toMatchObject({
137
+ ok: false,
138
+ error: { detail: { reason: 'hierarchy cycle' } },
139
+ });
140
+ });
141
+
142
+ it('rejects a carrier parented to a root in its own child scene', () => {
143
+ const world = new World();
144
+ register(world);
145
+ const child: SceneAsset = {
146
+ kind: 'scene',
147
+ entities: {
148
+ root: { components: {} },
149
+ door: { components: { ChildOf: { parent: 'root' } } },
150
+ },
151
+ };
152
+ const parent: SceneAsset = {
153
+ kind: 'scene',
154
+ entities: {
155
+ outer: {
156
+ components: { ChildOf: { parent: ['outer', 'door'] } },
157
+ instance: { source: 'child-guid' },
158
+ },
159
+ },
160
+ };
161
+ const childHandle = world.allocSharedRef('SceneAsset', child);
162
+ const parentHandle = world.allocSharedRef('SceneAsset', parent);
163
+ const result = compile(
164
+ world,
165
+ parentHandle,
166
+ parent,
167
+ new Map([
168
+ [Number(childHandle), child],
169
+ [Number(parentHandle), parent],
170
+ ]),
171
+ new Map([['child-guid', childHandle]]),
172
+ );
173
+ expect(result).toMatchObject({
174
+ ok: false,
175
+ error: { detail: { reason: 'hierarchy cycle' } },
176
+ });
177
+ });
178
+
179
+ it('keeps every segment of a deeply nested address', () => {
180
+ const world = new World();
181
+ register(world);
182
+ const leaf: SceneAsset = { kind: 'scene', entities: { door: { components: {} } } };
183
+ const inside: SceneAsset = {
184
+ kind: 'scene',
185
+ entities: { inside: { components: {}, instance: { source: 'leaf-guid' } } },
186
+ };
187
+ const outer: SceneAsset = {
188
+ kind: 'scene',
189
+ entities: { outer: { components: {}, instance: { source: 'inside-guid' } } },
190
+ };
191
+ const leafHandle = world.allocSharedRef('SceneAsset', leaf);
192
+ const insideHandle = world.allocSharedRef('SceneAsset', inside);
193
+ const outerHandle = world.allocSharedRef('SceneAsset', outer);
194
+ const result = compile(
195
+ world,
196
+ outerHandle,
197
+ outer,
198
+ new Map([
199
+ [Number(leafHandle), leaf],
200
+ [Number(insideHandle), inside],
201
+ [Number(outerHandle), outer],
202
+ ]),
203
+ new Map([
204
+ ['leaf-guid', leafHandle],
205
+ ['inside-guid', insideHandle],
206
+ ]),
207
+ );
208
+ expect(result.ok).toBe(true);
209
+ if (!result.ok) return;
210
+ expect(result.value.resolveAddress(['outer', 'inside', 'door'])).toBe(2);
211
+ });
212
+
213
+ it('rejects unknown component fields before runtime projection', () => {
214
+ const world = new World();
215
+ register(world);
216
+ const asset: SceneAsset = {
217
+ kind: 'scene',
218
+ entities: { root: { components: { KeyedSceneMarker: { typo: 1 } } } },
219
+ };
220
+ const handle = world.allocSharedRef('SceneAsset', asset);
221
+ const result = compile(world, handle, asset, new Map([[Number(handle), asset]]), new Map());
222
+ expect(result).toMatchObject({
223
+ ok: false,
224
+ error: {
225
+ detail: {
226
+ reason: 'unknown component field',
227
+ component: 'KeyedSceneMarker',
228
+ field: 'typo',
229
+ entity: 'root',
230
+ },
231
+ },
232
+ });
233
+ });
234
+
235
+ it('migrates numeric 0.1.27 entity addresses and pcfKernelSize at compile time', () => {
236
+ const world = new World();
237
+ register(world);
238
+ const asset = {
239
+ kind: 'scene',
240
+ entities: {
241
+ '0': { components: { KeyedSceneMarker: { value: 0 } } },
242
+ '1': {
243
+ components: {
244
+ ChildOf: { parent: 0 },
245
+ DirectionalLight: { pcfKernelSize: 3 },
246
+ },
247
+ },
248
+ },
249
+ } as unknown as SceneAsset;
250
+ const handle = world.allocSharedRef('SceneAsset', asset);
251
+ const result = compile(world, handle, asset, new Map([[Number(handle), asset]]), new Map());
252
+ expect(result).toMatchObject({
253
+ ok: true,
254
+ value: {
255
+ asset: {
256
+ entities: [
257
+ { components: { KeyedSceneMarker: { value: 0 } } },
258
+ {
259
+ components: {
260
+ ChildOf: { parent: 0 },
261
+ DirectionalLight: { shadowFilter: 2 },
262
+ },
263
+ },
264
+ ],
265
+ },
266
+ },
267
+ });
268
+ });
269
+ });
@@ -5,61 +5,80 @@ import {
5
5
  resolveSceneEntity,
6
6
  type SceneEntityRef,
7
7
  sceneEntity,
8
- validateSceneBindings,
8
+ sceneEntityAddressKey,
9
+ validateSceneEntityKeys,
9
10
  } from '../instances/binding.js';
10
11
  import { externalizeSceneAsset } from '../instances/externalization.js';
11
12
 
12
- describe('SceneEntityRef instance binding', () => {
13
- it('resolves by scene sourceKey and bindingKey, not display name', () => {
13
+ describe('SceneEntityRef instance address', () => {
14
+ const bindings = (address: SceneEntityRef['address'], value: number) =>
15
+ new Map([[sceneEntityAddressKey(address), value]]);
16
+
17
+ it('resolves by scene source and authored entity address', () => {
14
18
  const ref: SceneEntityRef = sceneEntity('level/main', 'player');
15
19
  const first = resolveSceneEntity(ref, {
16
20
  sceneSourceKey: 'level/main',
17
- bindings: new Map([['player', 11]]),
21
+ bindings: bindings('player', 11),
18
22
  });
19
23
  const second = resolveSceneEntity(ref, {
20
24
  sceneSourceKey: 'level/main',
21
- bindings: new Map([['player', 22]]),
25
+ bindings: bindings('player', 22),
22
26
  });
23
27
 
24
28
  expect(first).toEqual({ ok: true, value: 11 });
25
29
  expect(second).toEqual({ ok: true, value: 22 });
26
30
  });
27
31
 
28
- it('rejects a missing or cross-instance binding with a closed error', () => {
32
+ it('rejects a missing or cross-instance address with a closed error', () => {
29
33
  const result = resolveSceneEntity(sceneEntity('level/main', 'camera'), {
30
34
  sceneSourceKey: 'level/main',
31
- bindings: new Map([['player', 11]]),
35
+ bindings: bindings('player', 11),
32
36
  });
33
37
 
34
38
  expect(result).toMatchObject({
35
39
  ok: false,
36
40
  error: {
37
41
  code: 'scene-binding-missing',
38
- expected: expect.stringContaining('bindingKey'),
42
+ expected: expect.stringContaining('entity key'),
39
43
  hint: expect.stringContaining('declare'),
40
- detail: { sceneSourceKey: 'level/main', bindingKey: 'camera' },
44
+ detail: { sceneSourceKey: 'level/main', address: 'camera' },
41
45
  },
42
46
  });
43
47
  });
44
48
 
45
49
  it('rejects a reference from another concrete SceneInstance', () => {
46
- const ref = sceneEntity('level/other', 'player');
47
- const result = resolveSceneEntity(ref, {
50
+ const result = resolveSceneEntity(sceneEntity('level/other', 'player'), {
48
51
  sceneSourceKey: 'level/main',
49
- bindings: new Map([['player', 11]]),
52
+ bindings: bindings('player', 11),
50
53
  });
51
54
 
52
55
  expect(result).toMatchObject({
53
56
  ok: false,
54
57
  error: {
55
58
  code: 'scene-binding-wrong-instance',
56
- detail: { sceneSourceKey: 'level/other', bindingKey: 'player' },
59
+ detail: { sceneSourceKey: 'level/other', address: 'player' },
57
60
  },
58
61
  });
59
62
  });
60
63
 
61
- it('rejects duplicate declarations before an instance can publish bindings', () => {
62
- const result = validateSceneBindings('level/main', ['player', 'player']);
64
+ it('requires an explicit anonymous source key for direct POD scenes', () => {
65
+ const anonymous = resolveSceneEntity(sceneEntity('', 'player'), {
66
+ sceneSourceKey: '',
67
+ bindings: bindings('player', 11),
68
+ });
69
+ const fabricated = resolveSceneEntity(sceneEntity('unrelated/source', 'player'), {
70
+ sceneSourceKey: '',
71
+ bindings: bindings('player', 11),
72
+ });
73
+ expect(anonymous).toEqual({ ok: true, value: 11 });
74
+ expect(fabricated).toMatchObject({
75
+ ok: false,
76
+ error: { code: 'scene-binding-wrong-instance' },
77
+ });
78
+ });
79
+
80
+ it('rejects duplicate authored entity keys before publication', () => {
81
+ const result = validateSceneEntityKeys('level/main', ['player', 'player']);
63
82
 
64
83
  expect(result).toMatchObject({
65
84
  ok: false,
@@ -67,31 +86,57 @@ describe('SceneEntityRef instance binding', () => {
67
86
  code: 'scene-binding-duplicate',
68
87
  expected: expect.stringContaining('unique'),
69
88
  hint: expect.stringContaining('rename'),
70
- detail: { sceneSourceKey: 'level/main', bindingKey: 'player' },
89
+ detail: { sceneSourceKey: 'level/main', address: 'player' },
71
90
  },
72
91
  });
73
92
  });
74
93
 
75
- it('keeps binding identity stable when the display name or source path changes', () => {
76
- const ref = sceneEntity('level/main', 'player');
77
- const result = resolveSceneEntity(ref, {
94
+ it('supports nested addresses without using display names or paths', () => {
95
+ const ref = sceneEntity('level/main', ['house', 'door']);
96
+ expect(ref).toEqual({ sceneSourceKey: 'level/main', address: ['house', 'door'] });
97
+ expect(ref).not.toHaveProperty('name');
98
+ expect(ref).not.toHaveProperty('path');
99
+ });
100
+
101
+ it('treats a one-segment address array as the same direct key', () => {
102
+ const direct = resolveSceneEntity(sceneEntity('level/main', 'player'), {
103
+ sceneSourceKey: 'level/main',
104
+ bindings: bindings('player', 11),
105
+ });
106
+ const array = resolveSceneEntity(sceneEntity('level/main', ['player']), {
78
107
  sceneSourceKey: 'level/main',
79
- bindings: new Map([['player', 11]]),
108
+ bindings: bindings(['player'], 11),
80
109
  });
110
+ expect(direct).toEqual({ ok: true, value: 11 });
111
+ expect(array).toEqual(direct);
112
+ });
81
113
 
82
- expect(result).toEqual({ ok: true, value: 11 });
83
- expect(ref).not.toHaveProperty('name');
84
- expect(ref).not.toHaveProperty('path');
114
+ it('keeps literal and nested addresses distinct', () => {
115
+ const literal = '["house","door"]';
116
+ const nested = ['house', 'door'] as const;
117
+ const map = new Map([
118
+ [sceneEntityAddressKey(literal), 11],
119
+ [sceneEntityAddressKey(nested), 22],
120
+ ]);
121
+ const literalResult = resolveSceneEntity(sceneEntity('level/main', literal), {
122
+ sceneSourceKey: 'level/main',
123
+ bindings: map,
124
+ });
125
+ const nestedResult = resolveSceneEntity(sceneEntity('level/main', nested), {
126
+ sceneSourceKey: 'level/main',
127
+ bindings: map,
128
+ });
129
+ expect(literalResult).toEqual({ ok: true, value: 11 });
130
+ expect(nestedResult).toEqual({ ok: true, value: 22 });
85
131
  });
86
132
 
87
- it('preserves scene source and binding metadata through decode and externalization', async () => {
133
+ it('preserves keyed scene data through decode and externalization', async () => {
88
134
  const envelope = {
89
135
  guid: '00000000-0000-0000-0000-000000000021',
90
136
  kind: 'scene',
91
137
  payload: {
92
138
  kind: 'scene' as const,
93
- sourceKey: 'world/main',
94
- entities: [{ localId: 0, bindingKey: 'player', components: {} }],
139
+ entities: { player: { components: {} } },
95
140
  },
96
141
  refs: [],
97
142
  artifacts: {},
@@ -103,22 +148,86 @@ describe('SceneEntityRef instance binding', () => {
103
148
  });
104
149
  expect(decoded).toMatchObject({
105
150
  ok: true,
106
- value: {
107
- sourceKey: 'world/main',
108
- entities: [{ bindingKey: 'player' }],
109
- },
151
+ value: { entities: { player: { components: {} } } },
110
152
  });
111
153
  if (!decoded.ok) return;
112
154
 
113
155
  const externalized = externalizeSceneAsset(decoded.value, () => ({}));
156
+ expect(externalized).toMatchObject({
157
+ ok: true,
158
+ value: { payload: { entities: { player: { components: {} } } } },
159
+ });
160
+ });
161
+
162
+ it('keeps legacy binding keys when decoding an array SceneAsset', async () => {
163
+ const envelope = {
164
+ guid: '00000000-0000-0000-0000-000000000022',
165
+ kind: 'scene',
166
+ payload: {
167
+ kind: 'scene' as const,
168
+ entities: [
169
+ { localId: 0, bindingKey: 'root', components: {} },
170
+ { localId: 1, bindingKey: 'player', components: { ChildOf: { parent: 0 } } },
171
+ ],
172
+ },
173
+ refs: [],
174
+ artifacts: {},
175
+ } as unknown as Parameters<typeof sceneAssetDecoder.decode>[0]['envelope'];
176
+ const decoded = await sceneAssetDecoder.decode({
177
+ envelope,
178
+ artifacts: { read: async () => ok(new Uint8Array()) },
179
+ signal: new AbortController().signal,
180
+ });
181
+ expect(decoded).toMatchObject({
182
+ ok: true,
183
+ value: {
184
+ entities: {
185
+ root: { components: {} },
186
+ player: { components: { ChildOf: { parent: 'root' } } },
187
+ },
188
+ },
189
+ });
190
+ });
191
+
192
+ it('migrates the 0.1.27 numeric template fields at the externalization boundary', () => {
193
+ const legacy = {
194
+ kind: 'scene',
195
+ entities: [
196
+ { localId: 0, components: { Name: { value: 'Root' } } },
197
+ {
198
+ localId: 1,
199
+ components: {
200
+ ChildOf: { parent: 0 },
201
+ DirectionalLight: { pcfKernelSize: 3 },
202
+ },
203
+ },
204
+ ],
205
+ } as unknown as import('@forgeax/engine-types').SceneAsset;
206
+ const externalized = externalizeSceneAsset(legacy, (componentName) => {
207
+ if (componentName === 'ChildOf') return { parent: 'entity' };
208
+ if (componentName === 'DirectionalLight') {
209
+ return { pcfKernelSize: 'f32', shadowFilter: 'f32' };
210
+ }
211
+ return {};
212
+ });
114
213
  expect(externalized).toMatchObject({
115
214
  ok: true,
116
215
  value: {
117
216
  payload: {
118
- sourceKey: 'world/main',
119
- entities: [{ bindingKey: 'player' }],
217
+ entities: {
218
+ '1': {
219
+ components: {
220
+ ChildOf: { parent: '0' },
221
+ DirectionalLight: { shadowFilter: 2 },
222
+ },
223
+ },
224
+ },
120
225
  },
121
226
  },
122
227
  });
228
+ if (!externalized.ok) return;
229
+ expect(externalized.value.payload.entities).not.toHaveProperty(
230
+ '1.components.DirectionalLight.pcfKernelSize',
231
+ );
123
232
  });
124
233
  });
@@ -15,7 +15,10 @@ describe('SceneInstance structure characterization', () => {
15
15
  hasWorldStateWeakMap: source.includes('const sceneWorldStates = new WeakMap'),
16
16
  };
17
17
 
18
- expect(snapshot.lines).toBeLessThan(1564);
18
+ // The keyed authoring compiler lives in keyed.ts; this is the post-migration
19
+ // runtime projection baseline, with a small guard against re-growing the
20
+ // former numeric/mount implementation.
21
+ expect(snapshot.lines).toBeLessThan(1700);
19
22
  expect(snapshot.exportedFunctions).toBe(27);
20
23
  expect(snapshot.hasStatePayload).toBe(false);
21
24
  expect(snapshot.hasWorldStateWeakMap).toBe(false);