@forgeax/engine-scene 0.1.20 → 0.1.23

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 (80) hide show
  1. package/README.md +37 -9
  2. package/dist/__tests__/flat-propagation.contract.test.d.ts +2 -0
  3. package/dist/__tests__/flat-propagation.contract.test.d.ts.map +1 -0
  4. package/dist/__tests__/flat-propagation.derived-writer.contract.test.d.ts +2 -0
  5. package/dist/__tests__/flat-propagation.derived-writer.contract.test.d.ts.map +1 -0
  6. package/dist/__tests__/flat-propagation.perf.test.d.ts +2 -0
  7. package/dist/__tests__/flat-propagation.perf.test.d.ts.map +1 -0
  8. package/dist/__tests__/scene-binding.integration.test.d.ts +2 -0
  9. package/dist/__tests__/scene-binding.integration.test.d.ts.map +1 -0
  10. package/dist/__tests__/schedule-order.contract.test.d.ts +2 -0
  11. package/dist/__tests__/schedule-order.contract.test.d.ts.map +1 -0
  12. package/dist/__tests__/structural.test.d.ts +2 -0
  13. package/dist/__tests__/structural.test.d.ts.map +1 -0
  14. package/dist/__tests__/transform-carrier-consumers.test-d.d.ts +2 -0
  15. package/dist/__tests__/transform-carrier-consumers.test-d.d.ts.map +1 -0
  16. package/dist/__tests__/transform-carrier-prototype.bench.d.ts +39 -0
  17. package/dist/__tests__/transform-carrier-prototype.bench.d.ts.map +1 -0
  18. package/dist/__tests__/transform-carrier-prototype.test-d.d.ts +2 -0
  19. package/dist/__tests__/transform-carrier-prototype.test-d.d.ts.map +1 -0
  20. package/dist/__tests__/transform-carrier-prototype.test.d.ts +2 -0
  21. package/dist/__tests__/transform-carrier-prototype.test.d.ts.map +1 -0
  22. package/dist/__tests__/transform-component-version.unit.test.d.ts +2 -0
  23. package/dist/__tests__/transform-component-version.unit.test.d.ts.map +1 -0
  24. package/dist/assets/scene-decoder.d.ts.map +1 -1
  25. package/dist/components/child-of.d.ts +15 -8
  26. package/dist/components/child-of.d.ts.map +1 -1
  27. package/dist/components/children.d.ts.map +1 -1
  28. package/dist/components/transform.d.ts +7 -43
  29. package/dist/components/transform.d.ts.map +1 -1
  30. package/dist/errors.d.ts +23 -2
  31. package/dist/errors.d.ts.map +1 -1
  32. package/dist/index.d.ts +3 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.mjs +616 -372
  35. package/dist/index.mjs.map +1 -1
  36. package/dist/instances/binding.d.ts +29 -0
  37. package/dist/instances/binding.d.ts.map +1 -0
  38. package/dist/instances/externalization.d.ts.map +1 -1
  39. package/dist/instances/scene-instances.d.ts +5 -17
  40. package/dist/instances/scene-instances.d.ts.map +1 -1
  41. package/dist/instances/state.d.ts +28 -0
  42. package/dist/instances/state.d.ts.map +1 -0
  43. package/dist/plugin.d.ts.map +1 -1
  44. package/dist/systems/hierarchy-projection.d.ts.map +1 -1
  45. package/dist/systems/index.d.ts +1 -1
  46. package/dist/systems/index.d.ts.map +1 -1
  47. package/dist/systems/propagate-transforms.d.ts +1 -2
  48. package/dist/systems/propagate-transforms.d.ts.map +1 -1
  49. package/package.json +5 -5
  50. package/src/__tests__/components.unit.test.ts +34 -2
  51. package/src/__tests__/flat-propagation.contract.test.ts +128 -0
  52. package/src/__tests__/flat-propagation.derived-writer.contract.test.ts +39 -0
  53. package/src/__tests__/flat-propagation.perf.test.ts +336 -0
  54. package/src/__tests__/plugin.integration.test.ts +2 -2
  55. package/src/__tests__/propagation.unit.test.ts +207 -91
  56. package/src/__tests__/scene-binding.integration.test.ts +124 -0
  57. package/src/__tests__/schedule-order.contract.test.ts +43 -0
  58. package/src/__tests__/structural.test.ts +23 -0
  59. package/src/__tests__/transform-carrier-consumers.test-d.ts +156 -0
  60. package/src/__tests__/transform-carrier-prototype.bench.ts +168 -0
  61. package/src/__tests__/transform-carrier-prototype.test-d.ts +71 -0
  62. package/src/__tests__/transform-carrier-prototype.test.ts +170 -0
  63. package/src/__tests__/{transform-change-journal.unit.test.ts → transform-component-version.unit.test.ts} +32 -39
  64. package/src/assets/scene-decoder.ts +6 -1
  65. package/src/components/child-of.ts +15 -8
  66. package/src/components/children.ts +6 -0
  67. package/src/components/transform.ts +23 -81
  68. package/src/errors.ts +26 -2
  69. package/src/index.ts +10 -1
  70. package/src/instances/binding.ts +77 -0
  71. package/src/instances/externalization.ts +7 -1
  72. package/src/instances/scene-instances.ts +47 -76
  73. package/src/instances/state.ts +62 -0
  74. package/src/plugin.ts +9 -2
  75. package/src/systems/hierarchy-projection.ts +25 -8
  76. package/src/systems/index.ts +3 -0
  77. package/src/systems/propagate-transforms.ts +376 -245
  78. package/dist/.tsbuildinfo +0 -1
  79. package/dist/__tests__/transform-change-journal.unit.test.d.ts +0 -2
  80. package/dist/__tests__/transform-change-journal.unit.test.d.ts.map +0 -1
@@ -1,163 +1,279 @@
1
- import { FixedUpdate, Update, World } from '@forgeax/engine-ecs';
1
+ import { type EntityHandle, FixedUpdate, Update, World } from '@forgeax/engine-ecs';
2
2
  import { describe, expect, it } from 'vitest';
3
- import { ChildOf, propagateTransforms, Transform } from '../index';
3
+ import { ChangeEpochExhaustedError } from '../../../ecs/src/errors';
4
+ import { worldInternal } from '../../../ecs/src/world-internal';
5
+ import { ChildOf, GlobalTransform, propagateTransforms, Transform } from '../index';
4
6
  import { registerPropagateTransforms } from '../systems';
5
7
 
8
+ function spawnTransform(world: World, pos: number[], parent?: EntityHandle) {
9
+ const components = [{ component: Transform, data: { pos } }];
10
+ if (parent === undefined) return world.spawn(...components).unwrap();
11
+ return world.spawn(...components, { component: ChildOf, data: { parent } }).unwrap();
12
+ }
13
+
6
14
  describe('scene propagation', () => {
7
- it('registers ordinary transform owners without exposing terminal publication as a schedule', () => {
15
+ it('materializes the derived world component from Transform at the ECS boundary', () => {
8
16
  const world = new World();
9
- registerPropagateTransforms(world);
17
+ const entity = world.spawn({ component: Transform, data: { pos: [2, 0, 0] } }).unwrap();
18
+
19
+ expect(world.get(entity, GlobalTransform).ok).toBe(true);
20
+ });
21
+
22
+ it('materializes the transform pair when a hierarchy edge is the only authored component', () => {
23
+ const world = new World();
24
+ const parent = world.spawn({ component: Transform, data: { pos: [4, 0, 0] } }).unwrap();
25
+ const child = world.spawn({ component: ChildOf, data: { parent } }).unwrap();
26
+
27
+ expect(world.get(child, Transform).ok).toBe(true);
28
+ expect(world.get(child, GlobalTransform).ok).toBe(true);
29
+ expect(propagateTransforms(world).ok).toBe(true);
30
+ expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(4);
31
+ });
32
+
33
+ it('rejects a Transform-only entity with a structured pair error', () => {
34
+ const world = new World();
35
+ const entity = world.spawn({ component: Transform, data: { pos: [2, 0, 0] } }).unwrap();
36
+ world.removeComponent(entity, GlobalTransform).unwrap();
37
+
38
+ const result = propagateTransforms(world);
39
+
40
+ expect(result.ok).toBe(false);
41
+ if (!result.ok) {
42
+ expect(result.error.code).toBe('hierarchy-broken');
43
+ expect(result.error.expected).toContain('GlobalTransform pair');
44
+ expect(result.error.detail?.entity).toBe(entity);
45
+ }
46
+ });
47
+
48
+ it('rejects a GlobalTransform-only entity with a structured pair error', () => {
49
+ const world = new World();
50
+ const entity = world.spawn({ component: GlobalTransform, data: {} }).unwrap();
51
+
52
+ const result = propagateTransforms(world);
10
53
 
54
+ expect(result.ok).toBe(false);
55
+ if (!result.ok) {
56
+ expect(result.error.code).toBe('hierarchy-broken');
57
+ expect(result.error.expected).toContain('Transform pair');
58
+ expect(result.error.detail?.entity).toBe(entity);
59
+ }
60
+ });
61
+
62
+ it('preserves the ECS range-publication error in flat propagation diagnostics', () => {
63
+ const world = new World();
64
+ spawnTransform(world, [2, 0, 0]);
65
+ const internal = world[worldInternal];
66
+ const originalMarkRange = internal.markComponentRangeChanged;
67
+ Object.defineProperty(internal, 'markComponentRangeChanged', {
68
+ configurable: true,
69
+ value: () => {
70
+ throw new ChangeEpochExhaustedError(Number.MAX_SAFE_INTEGER);
71
+ },
72
+ });
73
+ try {
74
+ const result = propagateTransforms(world);
75
+ expect(result.ok).toBe(false);
76
+ if (!result.ok) {
77
+ expect(result.error.code).toBe('hierarchy-broken');
78
+ expect(result.error.detail?.kind).toBe('derived-write');
79
+ if (result.error.detail?.kind === 'derived-write') {
80
+ expect(result.error.detail.cause.code).toBe('change-epoch-exhausted');
81
+ expect(result.error.detail.cause.expected).toBe(
82
+ 'mutationEpoch < Number.MAX_SAFE_INTEGER',
83
+ );
84
+ expect(result.error.detail.cause.hint).toContain('Rebuild the World');
85
+ expect(result.error.detail.cause.detail).toEqual({ epoch: Number.MAX_SAFE_INTEGER });
86
+ }
87
+ }
88
+ } finally {
89
+ Object.defineProperty(internal, 'markComponentRangeChanged', {
90
+ configurable: true,
91
+ value: originalMarkRange,
92
+ });
93
+ }
94
+ });
95
+
96
+ it('registers the TransformPropagation owner in Update and FixedUpdate', () => {
97
+ const world = new World();
98
+ registerPropagateTransforms(world);
11
99
  expect(
12
100
  world
13
101
  .inspect()
14
102
  .schedules.find((entry) => entry.schedule === Update)
15
- ?.systems.map((system) => system.name),
103
+ ?.systems.map((s) => s.name),
16
104
  ).toContain('propagateTransforms');
17
105
  expect(
18
106
  world
19
107
  .inspect()
20
108
  .schedules.find((entry) => entry.schedule === FixedUpdate)
21
- ?.systems.map((system) => system.name),
109
+ ?.systems.map((s) => s.name),
22
110
  ).toContain('propagateTransformsFixed');
23
- expect(world.inspect().schedules.map((entry) => entry.schedule.name)).not.toContain(
24
- 'FramePublish',
25
- );
26
111
  });
27
- it('propagates a root and child in a headless world', () => {
112
+
113
+ it('propagates a root and child into GlobalTransform', () => {
28
114
  const world = new World();
29
115
  registerPropagateTransforms(world);
30
- const root = world.spawn({ component: Transform, data: { pos: [2, 0, 0] } }).unwrap();
31
- const child = world
32
- .spawn(
33
- { component: Transform, data: { pos: [3, 0, 0] } },
34
- { component: ChildOf, data: { parent: root } },
35
- )
36
- .unwrap();
116
+ const root = spawnTransform(world, [2, 0, 0]);
117
+ const child = spawnTransform(world, [3, 0, 0], root);
37
118
  expect(world.update(1 / 60).ok).toBe(true);
38
- expect(world.get(child, Transform).unwrap().world[12]).toBeCloseTo(5);
119
+ expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(5);
39
120
  });
40
121
 
41
122
  it('cascades linked children when a parent is despawned', () => {
42
123
  const world = new World();
43
124
  registerPropagateTransforms(world);
44
- const parent = world.spawn({ component: Transform, data: {} }).unwrap();
45
- const child = world
46
- .spawn({ component: Transform, data: {} }, { component: ChildOf, data: { parent } })
47
- .unwrap();
48
- world.despawn(parent);
49
- const result = world.update(1 / 60);
50
- expect(result.ok).toBe(true);
125
+ const parent = spawnTransform(world, [0, 0, 0]);
126
+ const child = spawnTransform(world, [1, 0, 0], parent);
127
+
128
+ world.despawn(parent).unwrap();
129
+
130
+ expect(world.update(1 / 60).ok).toBe(true);
51
131
  expect(world.get(child, Transform).ok).toBe(false);
52
132
  });
53
133
 
54
134
  it('keeps equal entity handles isolated across Worlds', () => {
55
135
  const first = new World();
56
136
  const second = new World();
57
- const firstRoot = first.spawn({ component: Transform, data: { pos: [3, 0, 0] } }).unwrap();
58
- const secondRoot = second.spawn({ component: Transform, data: { pos: [7, 0, 0] } }).unwrap();
59
-
137
+ const firstRoot = spawnTransform(first, [3, 0, 0]);
138
+ const secondRoot = spawnTransform(second, [7, 0, 0]);
60
139
  expect(firstRoot).toBe(secondRoot);
61
140
  expect(propagateTransforms(first).ok).toBe(true);
62
141
  expect(propagateTransforms(second).ok).toBe(true);
63
- expect(first.get(firstRoot, Transform).unwrap().world[12]).toBeCloseTo(3);
64
- expect(second.get(secondRoot, Transform).unwrap().world[12]).toBeCloseTo(7);
142
+ expect(first.get(firstRoot, GlobalTransform).unwrap().world[12]).toBeCloseTo(3);
143
+ expect(second.get(secondRoot, GlobalTransform).unwrap().world[12]).toBeCloseTo(7);
65
144
  });
66
145
 
67
- it('reuses a stable propagation result and invalidates it after a local edit', () => {
146
+ it('executes repeated propagation and preserves stable world semantics', () => {
68
147
  const world = new World();
69
- const root = world.spawn({ component: Transform, data: { pos: [2, 0, 0] } }).unwrap();
70
- const child = world
71
- .spawn(
72
- { component: Transform, data: { pos: [3, 0, 0] } },
73
- { component: ChildOf, data: { parent: root } },
74
- )
75
- .unwrap();
148
+ const root = spawnTransform(world, [2, 0, 0]);
149
+ const child = spawnTransform(world, [3, 0, 0], root);
76
150
 
77
151
  const first = propagateTransforms(world);
78
152
  const second = propagateTransforms(world);
79
- expect(first).toBe(second);
80
- expect(world.get(child, Transform).unwrap().world[12]).toBeCloseTo(5);
153
+ expect(first.ok).toBe(true);
154
+ expect(second.ok).toBe(true);
155
+ expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(5);
156
+
157
+ const corruptedWorld = new Float32Array(16);
158
+ corruptedWorld[12] = -100;
159
+ world.set(child, GlobalTransform, { world: corruptedWorld }).unwrap();
160
+ const repaired = propagateTransforms(world);
161
+ expect(repaired.ok).toBe(true);
162
+ expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(5);
81
163
 
82
164
  world.set(root, Transform, { pos: [7, 0, 0] }).unwrap();
83
165
  const third = propagateTransforms(world);
84
- expect(third).not.toBe(second);
85
- expect(world.get(child, Transform).unwrap().world[12]).toBeCloseTo(10);
166
+ expect(third.ok).toBe(true);
167
+ expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(10);
86
168
  });
87
169
 
88
- it('incrementally recomputes an edited transform and its descendants', () => {
170
+ it('recomputes the edited transform and its descendants', () => {
89
171
  const world = new World();
90
- const root = world.spawn({ component: Transform, data: { pos: [2, 0, 0] } }).unwrap();
91
- const child = world
92
- .spawn(
93
- { component: Transform, data: { pos: [3, 0, 0] } },
94
- { component: ChildOf, data: { parent: root } },
95
- )
96
- .unwrap();
97
- const grandchild = world
98
- .spawn(
99
- { component: Transform, data: { pos: [4, 0, 0] } },
100
- { component: ChildOf, data: { parent: child } },
101
- )
102
- .unwrap();
103
- const sibling = world.spawn({ component: Transform, data: { pos: [20, 0, 0] } }).unwrap();
104
-
172
+ const root = spawnTransform(world, [2, 0, 0]);
173
+ const child = spawnTransform(world, [3, 0, 0], root);
174
+ const grandchild = spawnTransform(world, [4, 0, 0], child);
175
+ const sibling = spawnTransform(world, [20, 0, 0]);
105
176
  propagateTransforms(world).unwrap();
106
177
  world.set(child, Transform, { pos: [8, 0, 0] }).unwrap();
107
178
  propagateTransforms(world).unwrap();
179
+ expect(world.get(root, GlobalTransform).unwrap().world[12]).toBeCloseTo(2);
180
+ expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(10);
181
+ expect(world.get(grandchild, GlobalTransform).unwrap().world[12]).toBeCloseTo(14);
182
+ expect(world.get(sibling, GlobalTransform).unwrap().world[12]).toBeCloseTo(20);
183
+ });
108
184
 
109
- expect(world.get(root, Transform).unwrap().world[12]).toBeCloseTo(2);
110
- expect(world.get(child, Transform).unwrap().world[12]).toBeCloseTo(10);
111
- expect(world.get(grandchild, Transform).unwrap().world[12]).toBeCloseTo(14);
112
- expect(world.get(sibling, Transform).unwrap().world[12]).toBeCloseTo(20);
185
+ it('recomputes a child after reparenting to a different live parent', () => {
186
+ const world = new World();
187
+ const firstParent = spawnTransform(world, [10, 0, 0]);
188
+ const secondParent = spawnTransform(world, [100, 0, 0]);
189
+ const child = spawnTransform(world, [1, 0, 0], firstParent);
190
+ propagateTransforms(world).unwrap();
191
+ world.set(child, ChildOf, { parent: secondParent }).unwrap();
192
+ propagateTransforms(world).unwrap();
193
+ expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(101);
113
194
  });
114
195
 
115
- it('expands a generic ChildOf mutation from the validated hierarchy snapshot', () => {
196
+ it('publishes a local fallback before returning a stale-parent error', () => {
116
197
  const world = new World();
117
- const firstParent = world.spawn({ component: Transform, data: { pos: [10, 0, 0] } }).unwrap();
118
- const secondParent = world.spawn({ component: Transform, data: { pos: [100, 0, 0] } }).unwrap();
119
- const child = world
120
- .spawn(
121
- { component: Transform, data: { pos: [1, 0, 0] } },
122
- { component: ChildOf, data: { parent: firstParent } },
123
- )
124
- .unwrap();
125
- const grandchild = world
126
- .spawn(
127
- { component: Transform, data: { pos: [2, 0, 0] } },
128
- { component: ChildOf, data: { parent: child } },
129
- )
130
- .unwrap();
198
+ const parent = spawnTransform(world, [10, 0, 0]);
199
+ const child = spawnTransform(world, [1, 0, 0], parent);
200
+ propagateTransforms(world).unwrap();
201
+ const baseline = new Float32Array(world.get(child, GlobalTransform).unwrap().world);
202
+
203
+ const staleParent = spawnTransform(world, [50, 0, 0]);
204
+ world.despawn(staleParent).unwrap();
205
+ world.set(child, ChildOf, { parent: staleParent }).unwrap();
206
+ const fault = propagateTransforms(world);
207
+
208
+ expect(fault.ok).toBe(false);
209
+ if (!fault.ok) expect(fault.error.code).toBe('hierarchy-broken');
210
+ const fallback = world.get(child, GlobalTransform).unwrap().world;
211
+ expect(fallback[12]).toBeCloseTo(1);
212
+ expect(fallback[12]).not.toBeCloseTo(baseline[12] ?? Number.NaN);
213
+
214
+ world.set(child, ChildOf, { parent }).unwrap();
215
+ expect(propagateTransforms(world).ok).toBe(true);
216
+ expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(baseline[12] ?? 0);
217
+ });
218
+
219
+ it('cuts every member of a cycle to a finite local root fallback', () => {
220
+ const world = new World();
221
+ const root = spawnTransform(world, [0, 0, 0]);
222
+ const first = spawnTransform(world, [1, 0, 0], root);
223
+ const second = spawnTransform(world, [2, 0, 0], first);
224
+ world.set(first, ChildOf, { parent: second }).unwrap();
225
+
226
+ const result = propagateTransforms(world);
227
+
228
+ expect(result.ok).toBe(false);
229
+ if (!result.ok) expect(result.error.code).toBe('hierarchy-cycle');
230
+ for (const entity of [first, second]) {
231
+ const matrix = world.get(entity, GlobalTransform).unwrap().world;
232
+ expect([...matrix].every(Number.isFinite)).toBe(true);
233
+ }
234
+ });
235
+
236
+ it('expands a generic ChildOf mutation and updates descendants', () => {
237
+ const world = new World();
238
+ const firstParent = spawnTransform(world, [10, 0, 0]);
239
+ const secondParent = spawnTransform(world, [100, 0, 0]);
240
+ const child = spawnTransform(world, [1, 0, 0], firstParent);
241
+ const grandchild = spawnTransform(world, [2, 0, 0], child);
131
242
 
132
243
  propagateTransforms(world).unwrap();
133
244
  world.set(child, ChildOf, { parent: secondParent }).unwrap();
134
245
  propagateTransforms(world).unwrap();
135
246
 
136
- expect(world.get(child, Transform).unwrap().world[12]).toBeCloseTo(101);
137
- expect(world.get(grandchild, Transform).unwrap().world[12]).toBeCloseTo(103);
247
+ expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(101);
248
+ expect(world.get(grandchild, GlobalTransform).unwrap().world[12]).toBeCloseTo(103);
138
249
  });
139
250
 
140
- it('publishes transform writes when propagation is explicitly ordered after gameplay', () => {
251
+ it('returns a structured error for a parent cycle', () => {
252
+ const world = new World();
253
+ const root = spawnTransform(world, [1, 0, 0]);
254
+ const child = spawnTransform(world, [2, 0, 0], root);
255
+ const result = world.addComponent(root, { component: ChildOf, data: { parent: child } });
256
+ expect(result.ok).toBe(false);
257
+ if (!result.ok) {
258
+ expect(result.error.code).toBe('relationship-self-cycle');
259
+ expect(result.error.hint).toContain('descendant');
260
+ }
261
+ });
262
+
263
+ it('publishes local writes before the same-tick world read', () => {
141
264
  const world = new World();
142
265
  registerPropagateTransforms(world);
143
- const root = world.spawn({ component: Transform, data: { pos: [2, 0, 0] } }).unwrap();
144
- const child = world
145
- .spawn(
146
- { component: Transform, data: { pos: [3, 0, 0] } },
147
- { component: ChildOf, data: { parent: root } },
148
- )
149
- .unwrap();
266
+ const root = spawnTransform(world, [2, 0, 0]);
267
+ const child = spawnTransform(world, [3, 0, 0], root);
150
268
  world
151
269
  .addSystem(Update, {
152
270
  name: 'late-pose',
153
- before: ['propagateTransforms'],
154
271
  queries: [],
272
+ before: ['propagateTransforms'],
155
273
  fn: () => world.set(child, Transform, { pos: [8, 0, 0] }),
156
274
  })
157
275
  .unwrap();
158
-
159
276
  world.update(0).unwrap();
160
-
161
- expect(world.get(child, Transform).unwrap().world[12]).toBeCloseTo(10);
277
+ expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(10);
162
278
  });
163
279
  });
@@ -0,0 +1,124 @@
1
+ import { ok } from '@forgeax/engine-types';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { sceneAssetDecoder } from '../assets/scene-decoder.js';
4
+ import {
5
+ resolveSceneEntity,
6
+ type SceneEntityRef,
7
+ sceneEntity,
8
+ validateSceneBindings,
9
+ } from '../instances/binding.js';
10
+ import { externalizeSceneAsset } from '../instances/externalization.js';
11
+
12
+ describe('SceneEntityRef instance binding', () => {
13
+ it('resolves by scene sourceKey and bindingKey, not display name', () => {
14
+ const ref: SceneEntityRef = sceneEntity('level/main', 'player');
15
+ const first = resolveSceneEntity(ref, {
16
+ sceneSourceKey: 'level/main',
17
+ bindings: new Map([['player', 11]]),
18
+ });
19
+ const second = resolveSceneEntity(ref, {
20
+ sceneSourceKey: 'level/main',
21
+ bindings: new Map([['player', 22]]),
22
+ });
23
+
24
+ expect(first).toEqual({ ok: true, value: 11 });
25
+ expect(second).toEqual({ ok: true, value: 22 });
26
+ });
27
+
28
+ it('rejects a missing or cross-instance binding with a closed error', () => {
29
+ const result = resolveSceneEntity(sceneEntity('level/main', 'camera'), {
30
+ sceneSourceKey: 'level/main',
31
+ bindings: new Map([['player', 11]]),
32
+ });
33
+
34
+ expect(result).toMatchObject({
35
+ ok: false,
36
+ error: {
37
+ code: 'scene-binding-missing',
38
+ expected: expect.stringContaining('bindingKey'),
39
+ hint: expect.stringContaining('declare'),
40
+ detail: { sceneSourceKey: 'level/main', bindingKey: 'camera' },
41
+ },
42
+ });
43
+ });
44
+
45
+ it('rejects a reference from another concrete SceneInstance', () => {
46
+ const ref = sceneEntity('level/other', 'player');
47
+ const result = resolveSceneEntity(ref, {
48
+ sceneSourceKey: 'level/main',
49
+ bindings: new Map([['player', 11]]),
50
+ });
51
+
52
+ expect(result).toMatchObject({
53
+ ok: false,
54
+ error: {
55
+ code: 'scene-binding-wrong-instance',
56
+ detail: { sceneSourceKey: 'level/other', bindingKey: 'player' },
57
+ },
58
+ });
59
+ });
60
+
61
+ it('rejects duplicate declarations before an instance can publish bindings', () => {
62
+ const result = validateSceneBindings('level/main', ['player', 'player']);
63
+
64
+ expect(result).toMatchObject({
65
+ ok: false,
66
+ error: {
67
+ code: 'scene-binding-duplicate',
68
+ expected: expect.stringContaining('unique'),
69
+ hint: expect.stringContaining('rename'),
70
+ detail: { sceneSourceKey: 'level/main', bindingKey: 'player' },
71
+ },
72
+ });
73
+ });
74
+
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, {
78
+ sceneSourceKey: 'level/main',
79
+ bindings: new Map([['player', 11]]),
80
+ });
81
+
82
+ expect(result).toEqual({ ok: true, value: 11 });
83
+ expect(ref).not.toHaveProperty('name');
84
+ expect(ref).not.toHaveProperty('path');
85
+ });
86
+
87
+ it('preserves scene source and binding metadata through decode and externalization', async () => {
88
+ const envelope = {
89
+ guid: '00000000-0000-0000-0000-000000000021',
90
+ kind: 'scene',
91
+ payload: {
92
+ kind: 'scene' as const,
93
+ sourceKey: 'world/main',
94
+ entities: [{ localId: 0, bindingKey: 'player', components: {} }],
95
+ },
96
+ refs: [],
97
+ artifacts: {},
98
+ } as unknown as Parameters<typeof sceneAssetDecoder.decode>[0]['envelope'];
99
+ const decoded = await sceneAssetDecoder.decode({
100
+ envelope,
101
+ artifacts: { read: async () => ok(new Uint8Array()) },
102
+ signal: new AbortController().signal,
103
+ });
104
+ expect(decoded).toMatchObject({
105
+ ok: true,
106
+ value: {
107
+ sourceKey: 'world/main',
108
+ entities: [{ bindingKey: 'player' }],
109
+ },
110
+ });
111
+ if (!decoded.ok) return;
112
+
113
+ const externalized = externalizeSceneAsset(decoded.value, () => ({}));
114
+ expect(externalized).toMatchObject({
115
+ ok: true,
116
+ value: {
117
+ payload: {
118
+ sourceKey: 'world/main',
119
+ entities: [{ bindingKey: 'player' }],
120
+ },
121
+ },
122
+ });
123
+ });
124
+ });
@@ -0,0 +1,43 @@
1
+ import { FixedUpdate, Update, World } from '@forgeax/engine-ecs';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { GlobalTransform, Transform } from '../index';
4
+ import { registerPropagateTransforms } from '../systems';
5
+
6
+ describe('TransformPropagation schedule contract', () => {
7
+ it('exposes one ordering token in both Update and FixedUpdate', () => {
8
+ const world = new World();
9
+ registerPropagateTransforms(world);
10
+ const inspection = world.inspect();
11
+ const update = inspection.schedules.find((entry) => entry.schedule === Update);
12
+ const fixed = inspection.schedules.find((entry) => entry.schedule === FixedUpdate);
13
+
14
+ expect(update?.systems.map((system) => system.name)).toContain('propagateTransforms');
15
+ expect(fixed?.systems.map((system) => system.name)).toContain('propagateTransformsFixed');
16
+ expect(inspection.schedules.map((entry) => entry.schedule.name)).toEqual(
17
+ expect.arrayContaining(['Update', 'FixedUpdate']),
18
+ );
19
+ expect(inspection.schedules.map((entry) => entry.schedule.name)).not.toContain('FramePublish');
20
+ });
21
+
22
+ it('keeps local and world query surfaces disjoint', () => {
23
+ const world = new World();
24
+ const entity = world.spawn({ component: Transform, data: { pos: [4, 0, 0] } }).unwrap();
25
+ registerPropagateTransforms(world);
26
+
27
+ expect(
28
+ world
29
+ .query({ read: [Transform] })
30
+ .unwrap()
31
+ .at(entity)
32
+ ?.get(Transform).pos[0],
33
+ ).toBe(4);
34
+ expect(
35
+ world
36
+ .query({ read: [GlobalTransform] })
37
+ .unwrap()
38
+ .at(entity),
39
+ ).toBeDefined();
40
+ expect(world.update(0).ok).toBe(true);
41
+ expect(world.get(entity, GlobalTransform).unwrap().world[12]).toBeCloseTo(4);
42
+ });
43
+ });
@@ -0,0 +1,23 @@
1
+ // @ts-expect-error The package test tsconfig intentionally omits Node globals.
2
+ import { readFileSync } from 'node:fs';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ describe('SceneInstance structure characterization', () => {
6
+ it('records the current instance state surface', async () => {
7
+ const source = readFileSync(
8
+ new URL('../instances/scene-instances.ts', import.meta.url),
9
+ 'utf8',
10
+ );
11
+ const snapshot = {
12
+ lines: source.split('\n').length - 1,
13
+ exportedFunctions: (source.match(/^export function\b/gm) ?? []).length,
14
+ hasStatePayload: source.includes('export interface SceneInstanceStatePayload'),
15
+ hasWorldStateWeakMap: source.includes('const sceneWorldStates = new WeakMap'),
16
+ };
17
+
18
+ expect(snapshot.lines).toBeLessThan(1564);
19
+ expect(snapshot.exportedFunctions).toBe(27);
20
+ expect(snapshot.hasStatePayload).toBe(false);
21
+ expect(snapshot.hasWorldStateWeakMap).toBe(false);
22
+ });
23
+ });