@forgeax/engine-scene 0.1.21 → 0.1.24

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 (87) hide show
  1. package/README.md +82 -11
  2. package/dist/__tests__/fixtures/malformed-hierarchy-edge.d.ts +5 -3
  3. package/dist/__tests__/fixtures/malformed-hierarchy-edge.d.ts.map +1 -1
  4. package/dist/__tests__/flat-propagation.contract.test.d.ts +2 -0
  5. package/dist/__tests__/flat-propagation.contract.test.d.ts.map +1 -0
  6. package/dist/__tests__/flat-propagation.derived-writer.contract.test.d.ts +2 -0
  7. package/dist/__tests__/flat-propagation.derived-writer.contract.test.d.ts.map +1 -0
  8. package/dist/__tests__/flat-propagation.perf.test.d.ts +2 -0
  9. package/dist/__tests__/flat-propagation.perf.test.d.ts.map +1 -0
  10. package/dist/__tests__/hierarchy-propagation.perf.test.d.ts +2 -0
  11. package/dist/__tests__/hierarchy-propagation.perf.test.d.ts.map +1 -0
  12. package/dist/__tests__/scene-binding.integration.test.d.ts +2 -0
  13. package/dist/__tests__/scene-binding.integration.test.d.ts.map +1 -0
  14. package/dist/__tests__/schedule-order.contract.test.d.ts +2 -0
  15. package/dist/__tests__/schedule-order.contract.test.d.ts.map +1 -0
  16. package/dist/__tests__/structural.test.d.ts +2 -0
  17. package/dist/__tests__/structural.test.d.ts.map +1 -0
  18. package/dist/__tests__/transform-carrier-consumers.test-d.d.ts +2 -0
  19. package/dist/__tests__/transform-carrier-consumers.test-d.d.ts.map +1 -0
  20. package/dist/__tests__/transform-carrier-prototype.bench.d.ts +39 -0
  21. package/dist/__tests__/transform-carrier-prototype.bench.d.ts.map +1 -0
  22. package/dist/__tests__/transform-carrier-prototype.test-d.d.ts +2 -0
  23. package/dist/__tests__/transform-carrier-prototype.test-d.d.ts.map +1 -0
  24. package/dist/__tests__/transform-carrier-prototype.test.d.ts +2 -0
  25. package/dist/__tests__/transform-carrier-prototype.test.d.ts.map +1 -0
  26. package/dist/__tests__/transform-component-version.unit.test.d.ts +2 -0
  27. package/dist/__tests__/transform-component-version.unit.test.d.ts.map +1 -0
  28. package/dist/assets/scene-decoder.d.ts.map +1 -1
  29. package/dist/components/child-of.d.ts +23 -14
  30. package/dist/components/child-of.d.ts.map +1 -1
  31. package/dist/components/children.d.ts +14 -13
  32. package/dist/components/children.d.ts.map +1 -1
  33. package/dist/components/transform.d.ts +7 -43
  34. package/dist/components/transform.d.ts.map +1 -1
  35. package/dist/errors.d.ts +23 -2
  36. package/dist/errors.d.ts.map +1 -1
  37. package/dist/index.d.ts +3 -2
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.mjs +1122 -358
  40. package/dist/index.mjs.map +1 -1
  41. package/dist/instances/binding.d.ts +29 -0
  42. package/dist/instances/binding.d.ts.map +1 -0
  43. package/dist/instances/externalization.d.ts.map +1 -1
  44. package/dist/instances/scene-instances.d.ts +6 -18
  45. package/dist/instances/scene-instances.d.ts.map +1 -1
  46. package/dist/instances/state.d.ts +28 -0
  47. package/dist/instances/state.d.ts.map +1 -0
  48. package/dist/plugin.d.ts.map +1 -1
  49. package/dist/systems/hierarchy-projection.d.ts.map +1 -1
  50. package/dist/systems/index.d.ts +1 -1
  51. package/dist/systems/index.d.ts.map +1 -1
  52. package/dist/systems/propagate-transforms.d.ts +22 -2
  53. package/dist/systems/propagate-transforms.d.ts.map +1 -1
  54. package/package.json +5 -5
  55. package/src/__tests__/components.unit.test.ts +34 -2
  56. package/src/__tests__/fixtures/malformed-hierarchy-edge.ts +26 -4
  57. package/src/__tests__/flat-propagation.contract.test.ts +128 -0
  58. package/src/__tests__/flat-propagation.derived-writer.contract.test.ts +39 -0
  59. package/src/__tests__/flat-propagation.perf.test.ts +336 -0
  60. package/src/__tests__/hierarchy-propagation.perf.test.ts +149 -0
  61. package/src/__tests__/plugin.integration.test.ts +2 -2
  62. package/src/__tests__/propagation.unit.test.ts +271 -91
  63. package/src/__tests__/scene-binding.integration.test.ts +124 -0
  64. package/src/__tests__/schedule-order.contract.test.ts +43 -0
  65. package/src/__tests__/structural.test.ts +23 -0
  66. package/src/__tests__/transform-carrier-consumers.test-d.ts +156 -0
  67. package/src/__tests__/transform-carrier-prototype.bench.ts +168 -0
  68. package/src/__tests__/transform-carrier-prototype.test-d.ts +71 -0
  69. package/src/__tests__/transform-carrier-prototype.test.ts +170 -0
  70. package/src/__tests__/{transform-change-journal.unit.test.ts → transform-component-version.unit.test.ts} +32 -39
  71. package/src/assets/scene-decoder.ts +6 -1
  72. package/src/components/child-of.ts +38 -27
  73. package/src/components/children.ts +36 -37
  74. package/src/components/transform.ts +23 -81
  75. package/src/errors.ts +26 -2
  76. package/src/index.ts +10 -1
  77. package/src/instances/binding.ts +77 -0
  78. package/src/instances/externalization.ts +7 -1
  79. package/src/instances/scene-instances.ts +51 -80
  80. package/src/instances/state.ts +62 -0
  81. package/src/plugin.ts +9 -2
  82. package/src/systems/hierarchy-projection.ts +25 -8
  83. package/src/systems/index.ts +3 -0
  84. package/src/systems/propagate-transforms.ts +1142 -240
  85. package/dist/.tsbuildinfo +0 -1
  86. package/dist/__tests__/transform-change-journal.unit.test.d.ts +0 -2
  87. package/dist/__tests__/transform-change-journal.unit.test.d.ts.map +0 -1
@@ -1,163 +1,343 @@
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';
7
+ import {
8
+ beginTransformPropagationTrace,
9
+ endTransformPropagationTrace,
10
+ } from '../systems/propagate-transforms';
11
+ import { setMalformedParentEdge } from './fixtures/malformed-hierarchy-edge';
12
+
13
+ function spawnTransform(world: World, pos: number[], parent?: EntityHandle) {
14
+ const components = [{ component: Transform, data: { pos } }];
15
+ if (parent === undefined) return world.spawn(...components).unwrap();
16
+ return world.spawn(...components, { component: ChildOf, data: { parent } }).unwrap();
17
+ }
5
18
 
6
19
  describe('scene propagation', () => {
7
- it('registers ordinary transform owners without exposing terminal publication as a schedule', () => {
20
+ it('materializes the derived world component from Transform at the ECS boundary', () => {
8
21
  const world = new World();
9
- registerPropagateTransforms(world);
22
+ const entity = world.spawn({ component: Transform, data: { pos: [2, 0, 0] } }).unwrap();
23
+
24
+ expect(world.get(entity, GlobalTransform).ok).toBe(true);
25
+ });
26
+
27
+ it('materializes the transform pair when a hierarchy edge is the only authored component', () => {
28
+ const world = new World();
29
+ const parent = world.spawn({ component: Transform, data: { pos: [4, 0, 0] } }).unwrap();
30
+ const child = world.spawn({ component: ChildOf, data: { parent } }).unwrap();
31
+
32
+ expect(world.get(child, Transform).ok).toBe(true);
33
+ expect(world.get(child, GlobalTransform).ok).toBe(true);
34
+ expect(propagateTransforms(world).ok).toBe(true);
35
+ expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(4);
36
+ });
37
+
38
+ it('rejects a Transform-only entity with a structured pair error', () => {
39
+ const world = new World();
40
+ const entity = world.spawn({ component: Transform, data: { pos: [2, 0, 0] } }).unwrap();
41
+ world.removeComponent(entity, GlobalTransform).unwrap();
42
+
43
+ const result = propagateTransforms(world);
44
+
45
+ expect(result.ok).toBe(false);
46
+ if (!result.ok) {
47
+ expect(result.error.code).toBe('hierarchy-broken');
48
+ expect(result.error.expected).toContain('GlobalTransform pair');
49
+ expect(result.error.detail?.entity).toBe(entity);
50
+ }
51
+ });
52
+
53
+ it('rejects a GlobalTransform-only entity with a structured pair error', () => {
54
+ const world = new World();
55
+ const entity = world.spawn({ component: GlobalTransform, data: {} }).unwrap();
56
+
57
+ const result = propagateTransforms(world);
58
+
59
+ expect(result.ok).toBe(false);
60
+ if (!result.ok) {
61
+ expect(result.error.code).toBe('hierarchy-broken');
62
+ expect(result.error.expected).toContain('Transform pair');
63
+ expect(result.error.detail?.entity).toBe(entity);
64
+ }
65
+ });
66
+
67
+ it('preserves the ECS range-publication error in flat propagation diagnostics', () => {
68
+ const world = new World();
69
+ spawnTransform(world, [2, 0, 0]);
70
+ const internal = world[worldInternal];
71
+ const originalMarkRange = internal.markComponentRangeChanged;
72
+ Object.defineProperty(internal, 'markComponentRangeChanged', {
73
+ configurable: true,
74
+ value: () => {
75
+ throw new ChangeEpochExhaustedError(Number.MAX_SAFE_INTEGER);
76
+ },
77
+ });
78
+ try {
79
+ const result = propagateTransforms(world);
80
+ expect(result.ok).toBe(false);
81
+ if (!result.ok) {
82
+ expect(result.error.code).toBe('hierarchy-broken');
83
+ expect(result.error.detail?.kind).toBe('derived-write');
84
+ if (result.error.detail?.kind === 'derived-write') {
85
+ expect(result.error.detail.cause.code).toBe('change-epoch-exhausted');
86
+ expect(result.error.detail.cause.expected).toBe(
87
+ 'mutationEpoch < Number.MAX_SAFE_INTEGER',
88
+ );
89
+ expect(result.error.detail.cause.hint).toContain('Rebuild the World');
90
+ expect(result.error.detail.cause.detail).toEqual({ epoch: Number.MAX_SAFE_INTEGER });
91
+ }
92
+ }
93
+ } finally {
94
+ Object.defineProperty(internal, 'markComponentRangeChanged', {
95
+ configurable: true,
96
+ value: originalMarkRange,
97
+ });
98
+ }
99
+ });
10
100
 
101
+ it('registers the TransformPropagation owner in Update and FixedUpdate', () => {
102
+ const world = new World();
103
+ registerPropagateTransforms(world);
11
104
  expect(
12
105
  world
13
106
  .inspect()
14
107
  .schedules.find((entry) => entry.schedule === Update)
15
- ?.systems.map((system) => system.name),
108
+ ?.systems.map((s) => s.name),
16
109
  ).toContain('propagateTransforms');
17
110
  expect(
18
111
  world
19
112
  .inspect()
20
113
  .schedules.find((entry) => entry.schedule === FixedUpdate)
21
- ?.systems.map((system) => system.name),
114
+ ?.systems.map((s) => s.name),
22
115
  ).toContain('propagateTransformsFixed');
23
- expect(world.inspect().schedules.map((entry) => entry.schedule.name)).not.toContain(
24
- 'FramePublish',
25
- );
26
116
  });
27
- it('propagates a root and child in a headless world', () => {
117
+
118
+ it('propagates a root and child into GlobalTransform', () => {
28
119
  const world = new World();
29
120
  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();
121
+ const root = spawnTransform(world, [2, 0, 0]);
122
+ const child = spawnTransform(world, [3, 0, 0], root);
37
123
  expect(world.update(1 / 60).ok).toBe(true);
38
- expect(world.get(child, Transform).unwrap().world[12]).toBeCloseTo(5);
124
+ expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(5);
39
125
  });
40
126
 
41
127
  it('cascades linked children when a parent is despawned', () => {
42
128
  const world = new World();
43
129
  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);
130
+ const parent = spawnTransform(world, [0, 0, 0]);
131
+ const child = spawnTransform(world, [1, 0, 0], parent);
132
+
133
+ world.despawn(parent).unwrap();
134
+
135
+ expect(world.update(1 / 60).ok).toBe(true);
51
136
  expect(world.get(child, Transform).ok).toBe(false);
52
137
  });
53
138
 
54
139
  it('keeps equal entity handles isolated across Worlds', () => {
55
140
  const first = new World();
56
141
  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
-
142
+ const firstRoot = spawnTransform(first, [3, 0, 0]);
143
+ const secondRoot = spawnTransform(second, [7, 0, 0]);
60
144
  expect(firstRoot).toBe(secondRoot);
61
145
  expect(propagateTransforms(first).ok).toBe(true);
62
146
  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);
147
+ expect(first.get(firstRoot, GlobalTransform).unwrap().world[12]).toBeCloseTo(3);
148
+ expect(second.get(secondRoot, GlobalTransform).unwrap().world[12]).toBeCloseTo(7);
65
149
  });
66
150
 
67
- it('reuses a stable propagation result and invalidates it after a local edit', () => {
151
+ it('executes repeated propagation and preserves stable world semantics', () => {
68
152
  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();
153
+ const root = spawnTransform(world, [2, 0, 0]);
154
+ const child = spawnTransform(world, [3, 0, 0], root);
76
155
 
77
156
  const first = propagateTransforms(world);
78
157
  const second = propagateTransforms(world);
79
- expect(first).toBe(second);
80
- expect(world.get(child, Transform).unwrap().world[12]).toBeCloseTo(5);
158
+ expect(first.ok).toBe(true);
159
+ expect(second.ok).toBe(true);
160
+ expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(5);
161
+
162
+ const corruptedWorld = new Float32Array(16);
163
+ corruptedWorld[12] = -100;
164
+ world.set(child, GlobalTransform, { world: corruptedWorld }).unwrap();
165
+ const repaired = propagateTransforms(world);
166
+ expect(repaired.ok).toBe(true);
167
+ expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(5);
81
168
 
82
169
  world.set(root, Transform, { pos: [7, 0, 0] }).unwrap();
83
170
  const third = propagateTransforms(world);
84
- expect(third).not.toBe(second);
85
- expect(world.get(child, Transform).unwrap().world[12]).toBeCloseTo(10);
171
+ expect(third.ok).toBe(true);
172
+ expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(10);
86
173
  });
87
174
 
88
- it('incrementally recomputes an edited transform and its descendants', () => {
175
+ it('recomputes the edited transform and its descendants', () => {
89
176
  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
-
177
+ const root = spawnTransform(world, [2, 0, 0]);
178
+ const child = spawnTransform(world, [3, 0, 0], root);
179
+ const grandchild = spawnTransform(world, [4, 0, 0], child);
180
+ const sibling = spawnTransform(world, [20, 0, 0]);
105
181
  propagateTransforms(world).unwrap();
106
182
  world.set(child, Transform, { pos: [8, 0, 0] }).unwrap();
107
183
  propagateTransforms(world).unwrap();
184
+ expect(world.get(root, GlobalTransform).unwrap().world[12]).toBeCloseTo(2);
185
+ expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(10);
186
+ expect(world.get(grandchild, GlobalTransform).unwrap().world[12]).toBeCloseTo(14);
187
+ expect(world.get(sibling, GlobalTransform).unwrap().world[12]).toBeCloseTo(20);
188
+ });
189
+
190
+ it('recomputes a child after reparenting to a different live parent', () => {
191
+ const world = new World();
192
+ const firstParent = spawnTransform(world, [10, 0, 0]);
193
+ const secondParent = spawnTransform(world, [100, 0, 0]);
194
+ const child = spawnTransform(world, [1, 0, 0], firstParent);
195
+ propagateTransforms(world).unwrap();
196
+ world.set(child, ChildOf, { parent: secondParent }).unwrap();
197
+ propagateTransforms(world).unwrap();
198
+ expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(101);
199
+ });
200
+
201
+ it('recomputes a detached ChildOf entity as a flat root even when local data is unchanged', () => {
202
+ const world = new World();
203
+ const parent = spawnTransform(world, [10, 0, 0]);
204
+ const child = spawnTransform(world, [1, 0, 0], parent);
205
+ propagateTransforms(world).unwrap();
206
+
207
+ world.removeComponent(child, ChildOf).unwrap();
208
+ const result = propagateTransforms(world);
108
209
 
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);
210
+ expect(result.ok).toBe(true);
211
+ expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(1);
113
212
  });
114
213
 
115
- it('expands a generic ChildOf mutation from the validated hierarchy snapshot', () => {
214
+ it('reuses one hierarchy root cursor and reports linear traversal counters', () => {
116
215
  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();
216
+ for (let index = 0; index < 32; index += 1) {
217
+ const root = spawnTransform(world, [index, 0, 0]);
218
+ spawnTransform(world, [1, 0, 0], root);
219
+ }
220
+ propagateTransforms(world).unwrap();
221
+
222
+ beginTransformPropagationTrace();
223
+ propagateTransforms(world).unwrap();
224
+ const trace = endTransformPropagationTrace();
225
+
226
+ expect(trace.hierarchyRootInvocations).toBeGreaterThan(0);
227
+ expect(trace.hierarchyRootCursorReuses).toBe(trace.hierarchyRootInvocations);
228
+ expect(trace.hierarchyRootCursorAllocations).toBe(0);
229
+ expect(trace.hierarchyRowsEvaluated).toBeGreaterThanOrEqual(32);
230
+ expect(trace.hierarchyEdgesVisited).toBeGreaterThanOrEqual(32);
231
+ expect(trace.hierarchyResidualParentProbes).toBe(0);
232
+ });
233
+
234
+ it('publishes a local fallback before returning a stale-parent error', () => {
235
+ const world = new World();
236
+ const parent = spawnTransform(world, [10, 0, 0]);
237
+ const child = spawnTransform(world, [1, 0, 0], parent);
238
+ propagateTransforms(world).unwrap();
239
+ const baseline = new Float32Array(world.get(child, GlobalTransform).unwrap().world);
240
+
241
+ const staleParent = spawnTransform(world, [50, 0, 0]);
242
+ world.despawn(staleParent).unwrap();
243
+ // Public relationship writes reject stale targets before changing either
244
+ // side. Corrupt the source only through the test-owned internal fixture
245
+ // to exercise propagation's explicit malformed-input fallback.
246
+ setMalformedParentEdge(world, child, staleParent);
247
+ const fault = propagateTransforms(world);
248
+
249
+ expect(fault.ok).toBe(false);
250
+ if (!fault.ok) expect(fault.error.code).toBe('hierarchy-broken');
251
+ const fallback = world.get(child, GlobalTransform).unwrap().world;
252
+ expect(fallback[12]).toBeCloseTo(1);
253
+ expect(fallback[12]).not.toBeCloseTo(baseline[12] ?? Number.NaN);
254
+ });
255
+
256
+ it('cuts every member of a cycle to a finite local root fallback', () => {
257
+ const world = new World();
258
+ const root = spawnTransform(world, [0, 0, 0]);
259
+ const first = spawnTransform(world, [1, 0, 0], root);
260
+ const second = spawnTransform(world, [2, 0, 0], first);
261
+ setMalformedParentEdge(world, first, second);
262
+
263
+ const result = propagateTransforms(world);
264
+
265
+ expect(result.ok).toBe(false);
266
+ if (!result.ok) expect(result.error.code).toBe('hierarchy-cycle');
267
+ for (const entity of [first, second]) {
268
+ const matrix = world.get(entity, GlobalTransform).unwrap().world;
269
+ expect([...matrix].every(Number.isFinite)).toBe(true);
270
+ }
271
+ });
272
+
273
+ it('bounds rootless-cycle parent probes to one residual pass', () => {
274
+ const world = new World();
275
+ const root = spawnTransform(world, [0, 0, 0]);
276
+ const nodes: EntityHandle[] = [];
277
+ let parent = root;
278
+ for (let index = 0; index < 48; index += 1) {
279
+ const node = spawnTransform(world, [1, 0, 0], parent);
280
+ nodes.push(node);
281
+ parent = node;
282
+ }
283
+ propagateTransforms(world).unwrap();
284
+ const first = nodes[0];
285
+ const last = nodes[nodes.length - 1];
286
+ if (first === undefined || last === undefined) throw new Error('expected cycle nodes');
287
+ setMalformedParentEdge(world, first, last);
288
+
289
+ beginTransformPropagationTrace();
290
+ const result = propagateTransforms(world);
291
+ const trace = endTransformPropagationTrace();
292
+
293
+ expect(result.ok).toBe(false);
294
+ if (!result.ok) expect(result.error.code).toBe('hierarchy-cycle');
295
+ expect(trace.hierarchyResidualParentProbes).toBeGreaterThan(0);
296
+ expect(trace.hierarchyResidualParentProbes).toBeLessThanOrEqual(nodes.length + 1);
297
+ expect(trace.hierarchyEdgesVisited).toBeLessThanOrEqual(nodes.length + 1);
298
+ });
299
+
300
+ it('expands a generic ChildOf mutation and updates descendants', () => {
301
+ const world = new World();
302
+ const firstParent = spawnTransform(world, [10, 0, 0]);
303
+ const secondParent = spawnTransform(world, [100, 0, 0]);
304
+ const child = spawnTransform(world, [1, 0, 0], firstParent);
305
+ const grandchild = spawnTransform(world, [2, 0, 0], child);
131
306
 
132
307
  propagateTransforms(world).unwrap();
133
308
  world.set(child, ChildOf, { parent: secondParent }).unwrap();
134
309
  propagateTransforms(world).unwrap();
135
310
 
136
- expect(world.get(child, Transform).unwrap().world[12]).toBeCloseTo(101);
137
- expect(world.get(grandchild, Transform).unwrap().world[12]).toBeCloseTo(103);
311
+ expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(101);
312
+ expect(world.get(grandchild, GlobalTransform).unwrap().world[12]).toBeCloseTo(103);
313
+ });
314
+
315
+ it('returns a structured error for a parent cycle', () => {
316
+ const world = new World();
317
+ const root = spawnTransform(world, [1, 0, 0]);
318
+ const child = spawnTransform(world, [2, 0, 0], root);
319
+ const result = world.addComponent(root, { component: ChildOf, data: { parent: child } });
320
+ expect(result.ok).toBe(false);
321
+ if (!result.ok) {
322
+ expect(result.error.code).toBe('relationship-self-cycle');
323
+ expect(result.error.hint).toContain('descendant');
324
+ }
138
325
  });
139
326
 
140
- it('publishes transform writes when propagation is explicitly ordered after gameplay', () => {
327
+ it('publishes local writes before the same-tick world read', () => {
141
328
  const world = new World();
142
329
  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();
330
+ const root = spawnTransform(world, [2, 0, 0]);
331
+ const child = spawnTransform(world, [3, 0, 0], root);
150
332
  world
151
333
  .addSystem(Update, {
152
334
  name: 'late-pose',
153
- before: ['propagateTransforms'],
154
335
  queries: [],
336
+ before: ['propagateTransforms'],
155
337
  fn: () => world.set(child, Transform, { pos: [8, 0, 0] }),
156
338
  })
157
339
  .unwrap();
158
-
159
340
  world.update(0).unwrap();
160
-
161
- expect(world.get(child, Transform).unwrap().world[12]).toBeCloseTo(10);
341
+ expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(10);
162
342
  });
163
343
  });
@@ -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
+ });