@forgeax/engine-ecs 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 (104) hide show
  1. package/README.md +83 -17
  2. package/dist/__tests__/changed-block-summary.candidate.test.d.ts +2 -0
  3. package/dist/__tests__/changed-block-summary.candidate.test.d.ts.map +1 -0
  4. package/dist/__tests__/component-requirements.unit.test.d.ts +2 -0
  5. package/dist/__tests__/component-requirements.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/component-version-surface.test.d.ts +2 -0
  7. package/dist/__tests__/component-version-surface.test.d.ts.map +1 -0
  8. package/dist/__tests__/derived-range-writer.contract.test-d.d.ts +2 -0
  9. package/dist/__tests__/derived-range-writer.contract.test-d.d.ts.map +1 -0
  10. package/dist/__tests__/derived-range-writer.contract.test.d.ts +2 -0
  11. package/dist/__tests__/derived-range-writer.contract.test.d.ts.map +1 -0
  12. package/dist/__tests__/structural-evidence.contract.test-d.d.ts +2 -0
  13. package/dist/__tests__/structural-evidence.contract.test-d.d.ts.map +1 -0
  14. package/dist/__tests__/structural-evidence.contract.test.d.ts +2 -0
  15. package/dist/__tests__/structural-evidence.contract.test.d.ts.map +1 -0
  16. package/dist/component-schema.d.ts +17 -0
  17. package/dist/component-schema.d.ts.map +1 -1
  18. package/dist/component.d.ts +16 -7
  19. package/dist/component.d.ts.map +1 -1
  20. package/dist/errors/query-and-component-errors.d.ts +1 -1
  21. package/dist/errors/query-and-component-errors.d.ts.map +1 -1
  22. package/dist/errors/validation-errors.d.ts +24 -0
  23. package/dist/errors/validation-errors.d.ts.map +1 -1
  24. package/dist/errors.d.ts +14 -2
  25. package/dist/errors.d.ts.map +1 -1
  26. package/dist/externalization/index.mjs.map +1 -1
  27. package/dist/index.mjs +977 -255
  28. package/dist/index.mjs.map +1 -1
  29. package/dist/internal.d.ts +18 -0
  30. package/dist/internal.d.ts.map +1 -0
  31. package/dist/internal.mjs +20 -2
  32. package/dist/internal.mjs.map +1 -1
  33. package/dist/projection/index.d.ts +27 -52
  34. package/dist/projection/index.d.ts.map +1 -1
  35. package/dist/projection/index.mjs +66 -76
  36. package/dist/projection/index.mjs.map +1 -1
  37. package/dist/query/derived-range-writer.d.ts +62 -0
  38. package/dist/query/derived-range-writer.d.ts.map +1 -0
  39. package/dist/query/query.d.ts +1 -1
  40. package/dist/query/query.d.ts.map +1 -1
  41. package/dist/relationship-index.d.ts +8 -0
  42. package/dist/relationship-index.d.ts.map +1 -1
  43. package/dist/shared-ref-store.d.ts +5 -9
  44. package/dist/shared-ref-store.d.ts.map +1 -1
  45. package/dist/shared.mjs.map +1 -1
  46. package/dist/storage/change-detection.d.ts +1 -3
  47. package/dist/storage/change-detection.d.ts.map +1 -1
  48. package/dist/storage/structural-evidence.d.ts +30 -0
  49. package/dist/storage/structural-evidence.d.ts.map +1 -0
  50. package/dist/world-component-access.d.ts +65 -19
  51. package/dist/world-component-access.d.ts.map +1 -1
  52. package/dist/world-component-storage.d.ts +26 -10
  53. package/dist/world-component-storage.d.ts.map +1 -1
  54. package/dist/world-core.d.ts +2 -2
  55. package/dist/world-core.d.ts.map +1 -1
  56. package/dist/world-entity-lifecycle.d.ts.map +1 -1
  57. package/dist/world-internal.d.ts +1 -1
  58. package/dist/world-internal.d.ts.map +1 -1
  59. package/dist/world.d.ts +15 -14
  60. package/dist/world.d.ts.map +1 -1
  61. package/package.json +5 -5
  62. package/src/__tests__/changed-block-summary.candidate.test.ts +57 -0
  63. package/src/__tests__/component-requirements.unit.test.ts +76 -0
  64. package/src/__tests__/component-version-surface.test.ts +45 -0
  65. package/src/__tests__/derived-range-writer.contract.test-d.ts +35 -0
  66. package/src/__tests__/derived-range-writer.contract.test.ts +339 -0
  67. package/src/__tests__/errors.unit.test.ts +21 -5
  68. package/src/__tests__/externalization-render-read-lease.test-d.ts +1 -1
  69. package/src/__tests__/externalization-render-read-lease.unit.test.ts +15 -15
  70. package/src/__tests__/query-storage-trends.perf.test.ts +19 -0
  71. package/src/__tests__/query.unit.test.ts +10 -6
  72. package/src/__tests__/relationship-index.test.ts +147 -1
  73. package/src/__tests__/shared-ref-store.unit.test.ts +3 -8
  74. package/src/__tests__/structural-evidence.contract.test-d.ts +6 -0
  75. package/src/__tests__/structural-evidence.contract.test.ts +49 -0
  76. package/src/component-schema.ts +51 -0
  77. package/src/component.ts +21 -8
  78. package/src/errors/query-and-component-errors.ts +4 -1
  79. package/src/errors/validation-errors.ts +30 -0
  80. package/src/errors.ts +29 -5
  81. package/src/internal.ts +59 -0
  82. package/src/projection/index.ts +68 -149
  83. package/src/query/derived-range-writer.ts +443 -0
  84. package/src/query/query.ts +115 -16
  85. package/src/relationship-index.ts +13 -1
  86. package/src/shared-ref-store.ts +16 -24
  87. package/src/storage/change-detection.ts +0 -15
  88. package/src/storage/structural-evidence.ts +64 -0
  89. package/src/world-component-access.ts +479 -130
  90. package/src/world-component-storage.ts +104 -18
  91. package/src/world-core.ts +2 -2
  92. package/src/world-entity-lifecycle.ts +59 -10
  93. package/src/world-internal.ts +10 -5
  94. package/src/world.ts +97 -53
  95. package/dist/.tsbuildinfo +0 -1
  96. package/dist/__tests__/world-change-journal.unit.test.d.ts +0 -2
  97. package/dist/__tests__/world-change-journal.unit.test.d.ts.map +0 -1
  98. package/dist/__tests__/world-internal-identity.unit.test.d.ts +0 -2
  99. package/dist/__tests__/world-internal-identity.unit.test.d.ts.map +0 -1
  100. package/dist/world-change-journal.d.ts +0 -34
  101. package/dist/world-change-journal.d.ts.map +0 -1
  102. package/src/__tests__/world-change-journal.unit.test.ts +0 -97
  103. package/src/__tests__/world-internal-identity.unit.test.ts +0 -17
  104. package/src/world-change-journal.ts +0 -84
@@ -138,7 +138,7 @@ describe('executable Query', () => {
138
138
  expect(spans[0]?.get(Position).x[0]).toBe(1);
139
139
  });
140
140
 
141
- it('returns structured span capability failures', () => {
141
+ it('returns structured span capability failures and coalesces changed rows', () => {
142
142
  const world = new World();
143
143
  const optional = world
144
144
  .query({ read: [Position], optional: [Velocity] })
@@ -146,11 +146,15 @@ describe('executable Query', () => {
146
146
  .spans();
147
147
  expect(!optional.ok && optional.error.detail.reason).toBe('optional-data');
148
148
 
149
- const changed = world
150
- .query({ read: [Position], changed: [Position] })
151
- .unwrap()
152
- .spans();
153
- expect(!changed.ok && changed.error.detail.reason).toBe('row-change-filter');
149
+ const { world: changedWorld, first, second } = createWorld();
150
+ const changedQuery = changedWorld.query({ read: [Position], changed: [Position] }).unwrap();
151
+ expect([...changedQuery.spans().unwrap()].flatMap((span) => Array.from(span.entities))).toEqual(
152
+ [first, second],
153
+ );
154
+ changedWorld.set(second, Position, { x: 9 }).unwrap();
155
+ const changedSpans = [...changedQuery.spans().unwrap()];
156
+ expect(changedSpans).toHaveLength(1);
157
+ expect(Array.from(changedSpans[0]?.entities ?? [])).toEqual([second]);
154
158
  });
155
159
 
156
160
  it('does not commit observation after partial iteration', () => {
@@ -1,8 +1,12 @@
1
+ import { err } from '@forgeax/engine-types';
1
2
  import { describe, expect, it } from 'vitest';
2
- import { componentSchema } from '../component';
3
+ import { componentSchema, defineComponent } from '../component';
3
4
  import { componentDefinition } from '../component-schema';
5
+ import { ManagedBufferOutOfBoundsError } from '../errors';
6
+ import { getDerivedWriter } from '../internal';
4
7
  import { defineRelationship, RelationshipIndex, relationshipRole } from '../relationship-index';
5
8
  import { World } from '../world';
9
+ import { worldInternal } from '../world-internal';
6
10
 
7
11
  describe('relationship index', () => {
8
12
  it('keeps source writable and target materialized/read-only by role', () => {
@@ -67,4 +71,146 @@ describe('relationship index', () => {
67
71
  );
68
72
  world.spawn({ component: pair.target, data: { sources: [] } });
69
73
  });
74
+
75
+ it('detaches public target arrays from the materialized mirror and index', () => {
76
+ const pair = defineRelationship({
77
+ sourceName: 'DetachedSource',
78
+ sourceField: 'target',
79
+ targetName: 'DetachedTargets',
80
+ targetField: 'sources',
81
+ });
82
+ const world = new World();
83
+ const target = world.spawn().unwrap();
84
+ const source = world.spawn({ component: pair.source, data: { target } }).unwrap();
85
+ const beforeEpoch = world[worldInternal].getRelationshipEpoch(pair.source);
86
+ const exposed = world.get(target, pair.target).unwrap().sources;
87
+
88
+ exposed.fill(0);
89
+ exposed[0] = 0;
90
+
91
+ expect(Array.from(world.get(target, pair.target).unwrap().sources)).toEqual([source]);
92
+ expect(
93
+ Array.from(world[worldInternal].getRelationshipTargetEntities(pair.source, target)),
94
+ ).toEqual([source]);
95
+ expect(world.get(source, pair.source).unwrap().target).toBe(target);
96
+ expect(world[worldInternal].getRelationshipEpoch(pair.source)).toBe(beforeEpoch);
97
+ });
98
+
99
+ it('routes source set, row mutation, reparent, and null detach through one mirror owner', () => {
100
+ const pair = defineRelationship({
101
+ sourceName: 'OwnedSource',
102
+ sourceField: 'target',
103
+ targetName: 'OwnedTargets',
104
+ targetField: 'sources',
105
+ });
106
+ const world = new World();
107
+ const first = world.spawn().unwrap();
108
+ const second = world.spawn().unwrap();
109
+ const source = world.spawn({ component: pair.source, data: { target: first } }).unwrap();
110
+
111
+ expect(Array.from(world.get(first, pair.target).unwrap().sources)).toEqual([source]);
112
+ world.set(source, pair.source, { target: second }).unwrap();
113
+ expect(Array.from(world.get(first, pair.target).unwrap().sources)).toEqual([]);
114
+ expect(Array.from(world.get(second, pair.target).unwrap().sources)).toEqual([source]);
115
+
116
+ const row = world
117
+ .query({ write: [pair.source] })
118
+ .unwrap()
119
+ .at(source);
120
+ if (row === undefined) throw new Error('expected source row');
121
+ row.mut(pair.source).target = first;
122
+ expect(Array.from(world.get(second, pair.target).unwrap().sources)).toEqual([]);
123
+ expect(Array.from(world.get(first, pair.target).unwrap().sources)).toEqual([source]);
124
+
125
+ world.set(source, pair.source, { target: null }).unwrap();
126
+ expect(Array.from(world.get(first, pair.target).unwrap().sources)).toEqual([]);
127
+
128
+ const sourceQuery = world.query({ write: [pair.source] }).unwrap();
129
+ const sourceSpans = sourceQuery.spans();
130
+ expect(sourceSpans.ok).toBe(false);
131
+ if (!sourceSpans.ok) expect(sourceSpans.error.detail.reason).toBe('relationship-component');
132
+ const sourceWriter = getDerivedWriter(sourceQuery, pair.source);
133
+ expect(sourceWriter.ok).toBe(false);
134
+ if (!sourceWriter.ok) expect(sourceWriter.error.detail.reason).toBe('relationship-component');
135
+
136
+ const targetQuery = world.query({ write: [pair.target] }).unwrap();
137
+ const targetWriter = getDerivedWriter(targetQuery, pair.target);
138
+ expect(targetWriter.ok).toBe(false);
139
+ if (!targetWriter.ok) expect(targetWriter.error.detail.reason).toBe('relationship-component');
140
+ });
141
+
142
+ it('rejects stale source targets before changing owner state', () => {
143
+ const pair = defineRelationship({
144
+ sourceName: 'AtomicSource',
145
+ sourceField: 'target',
146
+ targetName: 'AtomicTargets',
147
+ targetField: 'sources',
148
+ });
149
+ const world = new World();
150
+ const target = world.spawn().unwrap();
151
+ const source = world.spawn({ component: pair.source, data: { target } }).unwrap();
152
+ const stale = world.spawn().unwrap();
153
+ world.despawn(stale).unwrap();
154
+ const beforeValue = world.get(source, pair.source).unwrap().target;
155
+ const beforeMirror = Array.from(world.get(target, pair.target).unwrap().sources);
156
+ const beforeMutation = world[worldInternal].getMutationEpoch();
157
+ const beforeRelationship = world[worldInternal].getRelationshipEpoch(pair.source);
158
+ const result = world.set(source, pair.source, { target: stale });
159
+
160
+ expect(result.ok).toBe(false);
161
+ if (!result.ok) expect(result.error.code).toBe('stale-entity');
162
+ expect(world.get(source, pair.source).unwrap().target).toBe(beforeValue);
163
+ expect(Array.from(world.get(target, pair.target).unwrap().sources)).toEqual(beforeMirror);
164
+ expect(world[worldInternal].getMutationEpoch()).toBe(beforeMutation);
165
+ expect(world[worldInternal].getRelationshipEpoch(pair.source)).toBe(beforeRelationship);
166
+
167
+ const row = world
168
+ .query({ write: [pair.source] })
169
+ .unwrap()
170
+ .at(source);
171
+ if (row === undefined) throw new Error('expected source row after stale rejection');
172
+ expect(() => {
173
+ row.mut(pair.source).target = stale;
174
+ }).toThrow('stale');
175
+ expect(world.get(source, pair.source).unwrap().target).toBe(beforeValue);
176
+ expect(world[worldInternal].getMutationEpoch()).toBe(beforeMutation);
177
+ expect(world[worldInternal].getRelationshipEpoch(pair.source)).toBe(beforeRelationship);
178
+ });
179
+
180
+ it('prepares mirror capacity before source structural mutation on allocation failure', () => {
181
+ const Required = defineComponent('CapacityAtomicRequired', {});
182
+ const pair = defineRelationship({
183
+ sourceName: 'CapacityAtomicSource',
184
+ sourceField: 'target',
185
+ targetName: 'CapacityAtomicTargets',
186
+ targetField: 'sources',
187
+ sourceRequires: [Required],
188
+ });
189
+ const world = new World();
190
+ const target = world.spawn().unwrap();
191
+ const source = world.spawn().unwrap();
192
+ const pool = world[worldInternal].getBufferPool();
193
+ const originalAlloc = pool.alloc;
194
+ const beforeMutation = world[worldInternal].getMutationEpoch();
195
+ const beforeStructure = world[worldInternal].getStructureEpoch();
196
+ const beforeRelationship = world[worldInternal].getRelationshipEpoch(pair.source);
197
+ Object.defineProperty(pool, 'alloc', {
198
+ configurable: true,
199
+ value: () => err(new ManagedBufferOutOfBoundsError(4, 0)),
200
+ });
201
+ try {
202
+ const result = world.addComponent(source, { component: pair.source, data: { target } });
203
+ expect(result.ok).toBe(false);
204
+ if (!result.ok) expect(result.error.code).toBe('managed-buffer-out-of-bounds');
205
+ expect(world.hasComponent(source, pair.source)).toBe(false);
206
+ expect(world.hasComponent(source, Required)).toBe(false);
207
+ expect(world.hasComponent(target, pair.target)).toBe(false);
208
+ expect(world[worldInternal].getMutationEpoch()).toBe(beforeMutation);
209
+ expect(world[worldInternal].getStructureEpoch()).toBe(beforeStructure);
210
+ expect(world[worldInternal].getRelationshipEpoch(pair.source)).toBe(beforeRelationship);
211
+ expect(pool._liveCount()).toBe(0);
212
+ } finally {
213
+ Object.defineProperty(pool, 'alloc', { configurable: true, value: originalAlloc });
214
+ }
215
+ });
70
216
  });
@@ -70,7 +70,6 @@ describe('w6 SharedRefStore: alloc + resolve', () => {
70
70
  expect(store.markChanged(handle).ok).toBe(true);
71
71
  expect(store.getMutationEpoch()).toBe(1);
72
72
  expect(store.readChangesSince(0)).toEqual({
73
- status: 'ok',
74
73
  cursor: 1,
75
74
  records: [{ epoch: 1, handle: unwrapHandle(handle) }],
76
75
  });
@@ -80,21 +79,17 @@ describe('w6 SharedRefStore: alloc + resolve', () => {
80
79
  expect(store.getMutationEpoch()).toBe(1);
81
80
  });
82
81
 
83
- it('reports bounded-journal overflow instead of hiding changed handles', () => {
82
+ it('keeps one latest mutation epoch per handle without a bounded journal', () => {
84
83
  const store = new SharedRefStore();
85
84
  const handle = store.alloc('MaterialAsset', { value: 1 });
86
85
  for (let index = 0; index < 4097; index += 1) store.markChanged(handle).unwrap();
87
86
 
88
87
  expect(store.readChangesSince(0)).toEqual({
89
- status: 'overflow',
90
88
  cursor: 4097,
91
- oldestAvailable: 2,
89
+ records: [{ epoch: 4097, handle: unwrapHandle(handle) }],
92
90
  });
93
91
  const latest = store.readChangesSince(4096);
94
- expect(latest.status).toBe('ok');
95
- if (latest.status === 'ok') {
96
- expect(latest.records).toEqual([{ epoch: 4097, handle: unwrapHandle(handle) }]);
97
- }
92
+ expect(latest.records).toEqual([{ epoch: 4097, handle: unwrapHandle(handle) }]);
98
93
  });
99
94
 
100
95
  it('resolve returns SharedRefStaleError after rc drops to 0 (gen incremented on release)', () => {
@@ -0,0 +1,6 @@
1
+ import type { StructuralEvidence, StructuralEvidenceKind } from '../storage/structural-evidence';
2
+
3
+ const kind: StructuralEvidenceKind = 'component-added';
4
+ const evidence: StructuralEvidence = { sequence: 1, kind, entity: 0 as never, componentId: 4 };
5
+ // @ts-expect-error evidence sequence is producer-owned.
6
+ evidence.sequence = 2;
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { encodeEntity } from '../entity-handle';
3
+ import { StructuralEvidenceRing } from '../storage/structural-evidence';
4
+
5
+ describe('typed structural evidence ring', () => {
6
+ it('records lifecycle, component membership, packed identity, and sequence facts', () => {
7
+ const ring = new StructuralEvidenceRing(8);
8
+ const entity = encodeEntity(4, 2);
9
+ const cursor = ring.cursor;
10
+ ring.append({ kind: 'spawn', entity });
11
+ ring.append({ kind: 'component-added', entity, componentId: 7 });
12
+ ring.append({ kind: 'component-removed', entity, componentId: 7 });
13
+ ring.append({ kind: 'despawn', entity });
14
+ const read = ring.readAfter(cursor);
15
+ expect(read.status).toBe('ok');
16
+ if (read.status !== 'ok') return;
17
+ expect(
18
+ read.events.map(({ kind, entity: packed, sequence }) => ({ kind, packed, sequence })),
19
+ ).toEqual([
20
+ { kind: 'spawn', packed: entity, sequence: 1 },
21
+ { kind: 'component-added', packed: entity, sequence: 2 },
22
+ { kind: 'component-removed', packed: entity, sequence: 3 },
23
+ { kind: 'despawn', packed: entity, sequence: 4 },
24
+ ]);
25
+ });
26
+
27
+ it('fails closed on overflow and exposes the cursor for consumer reconciliation', () => {
28
+ const ring = new StructuralEvidenceRing(2);
29
+ const cursor = ring.cursor;
30
+ const entity = encodeEntity(1, 4);
31
+ const reused = encodeEntity(1, 5);
32
+ ring.append({ kind: 'spawn', entity });
33
+ ring.append({ kind: 'despawn', entity });
34
+ ring.append({ kind: 'spawn', entity: reused });
35
+ expect(ring.readAfter(cursor)).toMatchObject({
36
+ status: 'overflow',
37
+ cursor: 3,
38
+ oldestAvailable: 2,
39
+ });
40
+ expect(ring.cursor).toBe(3);
41
+ });
42
+
43
+ it('rejects cursors outside the producer-owned sequence range', () => {
44
+ const ring = new StructuralEvidenceRing(4);
45
+ const entity = encodeEntity(3, 1);
46
+ ring.append({ kind: 'spawn', entity });
47
+ expect(() => ring.readAfter(2)).toThrow(RangeError);
48
+ });
49
+ });
@@ -18,6 +18,8 @@ export interface ComponentSchemaDefinition {
18
18
  export interface ComponentPolicy {
19
19
  readonly transient: boolean;
20
20
  readonly meta: Record<string, unknown>;
21
+ /** Components that are materialized whenever this component is added. */
22
+ readonly requires: readonly Component[];
21
23
  }
22
24
 
23
25
  /** Immutable schema reflection plus non-token policy projection. */
@@ -60,6 +62,55 @@ export function componentDefinition(component: Component): ComponentDefinition {
60
62
  return definition;
61
63
  }
62
64
 
65
+ /** Read the generic structural requirements declared by one component. */
66
+ export function componentRequirements(component: Component): readonly Component[] {
67
+ // Keep invalid/foreign tokens on the ordinary preflight error path. The
68
+ // expansion helper runs before validation, so it must not turn a structured
69
+ // `component-not-defined` Result into an uncaught registry exception.
70
+ return definitions.definitions.get(component)?.policy.requires ?? [];
71
+ }
72
+
73
+ type ComponentDataLike = {
74
+ readonly component: Component;
75
+ readonly data: Partial<Record<string, unknown>>;
76
+ };
77
+
78
+ /**
79
+ * Expand component requirements once at the structural boundary.
80
+ *
81
+ * Explicit component data wins and is never duplicated. Requirements are
82
+ * appended in declaration order, and the same identity set also terminates a
83
+ * malformed dependency cycle without a per-frame scan.
84
+ */
85
+ export function expandComponentRequirements<T extends ComponentDataLike>(
86
+ componentDatas: readonly T[],
87
+ ): T[] {
88
+ let hasRequirements = false;
89
+ for (const entry of componentDatas) {
90
+ if (componentRequirements(entry.component).length !== 0) {
91
+ hasRequirements = true;
92
+ break;
93
+ }
94
+ }
95
+ // Most structural operations use components without dependencies. Preserve
96
+ // that path without copying or allocating a Set; callers only consume the
97
+ // returned list and never mutate it.
98
+ if (!hasRequirements) return componentDatas as T[];
99
+
100
+ const expanded = [...componentDatas];
101
+ const seen = new Set<Component>(expanded.map((entry) => entry.component));
102
+ for (let index = 0; index < expanded.length; index++) {
103
+ const component = expanded[index]?.component;
104
+ if (component === undefined) continue;
105
+ for (const required of componentRequirements(component)) {
106
+ if (seen.has(required)) continue;
107
+ seen.add(required);
108
+ expanded.push({ component: required, data: {} } as T);
109
+ }
110
+ }
111
+ return expanded;
112
+ }
113
+
63
114
  /**
64
115
  * Freeze a schema value recursively. Component descriptors are authored data,
65
116
  * so nested defaults and enum label maps must not become mutation channels.
package/src/component.ts CHANGED
@@ -392,12 +392,15 @@ export type ManagedArrayElementValue<T extends ManagedArrayElementType> = T exte
392
392
  *
393
393
  * The 4 buffer/array keywords (`'buffer'` / `'buffer<N>'` / `'array<T>'` /
394
394
  * `'array<T, N>'`) all resolve directly to a concrete TypedArray (or
395
- * Uint8Array for the byte-only buffer family). The materialised value is a
396
- * read-only snapshot: for fixed `buffer<N>` / `array<T,N>` it aliases the
397
- * inline column buffer (feat-20260602); for variable `buffer` / `array<T>`
398
- * it aliases the BufferPool slot bytes (plan-strategy §2.2 D-R3 contract).
399
- * Mutation flows through `world.set` / `world.push` / `world.pop`
400
- * not direct assignment to the returned TypedArray.
395
+ * Uint8Array for the byte-only buffer family). At the public `world.get`
396
+ * boundary, a relationship-target `array<entity>` is a detached `Uint32Array`
397
+ * snapshot. Other public array fields retain their existing transient live
398
+ * TypedArray alias: fixed `buffer<N>` / `array<T,N>` values alias the inline
399
+ * column buffer (feat-20260602), while variable `buffer` / `array<T>` values
400
+ * alias the BufferPool slot bytes. Internal `readRow`, `_getArrayView`, and
401
+ * `materializeArrayView` paths always use the live zero-copy alias. Mutation
402
+ * flows through `world.set` / `world.push` / `world.pop`, not direct
403
+ * assignment to a returned TypedArray.
401
404
  *
402
405
  * The `'string'` arm resolves to a native JS `string` (D-R1 / AC-13): the
403
406
  * dispatch routes the column u32 through `UniqueRefStore.resolve(handle)`
@@ -930,7 +933,7 @@ export interface FieldDescriptor<T extends SchemaFieldType = SchemaFieldType> {
930
933
  * same meaning): scene collect skips a `transient` field just as it skips a
931
934
  * `transient` component. Granularity is sunk to the field level so a component
932
935
  * can persist most of its fields while excluding a derived/reconstructable one
933
- * (e.g. `Transform.world`). Absent means the field participates in
936
+ * (e.g. `GlobalTransform.world`). Absent means the field participates in
934
937
  * serialization.
935
938
  */
936
939
  export interface FieldReflection {
@@ -998,6 +1001,12 @@ export interface DefineComponentOptions {
998
1001
  * rebuilt by the mirror hook after instantiateScene).
999
1002
  */
1000
1003
  readonly transient?: boolean;
1004
+ /**
1005
+ * Components materialized automatically when this component is added.
1006
+ * Explicit data for a required component wins; the ECS appends only missing
1007
+ * identities at the spawn/add boundary, never from a frame system.
1008
+ */
1009
+ readonly requires?: readonly Component[];
1001
1010
  /**
1002
1011
  * Component-level open metadata namespace. Entries are copied into
1003
1012
  * `Component.meta` at registration; the ECS core assigns no meaning to any
@@ -1171,7 +1180,11 @@ export function defineComponent<const N extends string, const S extends FieldsIn
1171
1180
  registerComponentDefinition(token, {
1172
1181
  fields: frozenFields,
1173
1182
  defaults: frozenDefaults,
1174
- policy: { transient: options?.transient ?? false, meta },
1183
+ policy: {
1184
+ transient: options?.transient ?? false,
1185
+ meta,
1186
+ requires: Object.freeze([...(options?.requires ?? [])]),
1187
+ },
1175
1188
  });
1176
1189
  return Object.freeze(token);
1177
1190
  }
@@ -122,7 +122,10 @@ export class SpawnDataUnknownFieldError extends Error {
122
122
  this.detail = { component: componentName, field: fieldName, knownFields: sortedKnown };
123
123
  }
124
124
  }
125
- export type QuerySpanUnavailableReason = 'optional-data' | 'row-change-filter' | 'sparse-component';
125
+ export type QuerySpanUnavailableReason =
126
+ | 'optional-data'
127
+ | 'sparse-component'
128
+ | 'relationship-component';
126
129
 
127
130
  export class QueryDescriptorConflictError extends Error {
128
131
  override readonly name = 'QueryDescriptorConflictError';
@@ -291,6 +291,36 @@ export function validateEnumFieldValues<S extends ComponentSchema>(
291
291
  * `.hint` — names the offending field plus the valid replacement form.
292
292
  */
293
293
  const SPAWN_LIGHT_INVALID_BOUNDS_POLICY = {
294
+ intensity: {
295
+ expected: 'intensity is finite and >= 0',
296
+ hint: (componentName: string, got: number | readonly number[]) =>
297
+ `${componentName}.intensity must be a finite non-negative number (got ${got})`,
298
+ },
299
+ color: {
300
+ expected: 'color is a finite non-negative [r, g, b] vector',
301
+ hint: (componentName: string, got: number | readonly number[]) =>
302
+ `${componentName}.color must contain three finite non-negative channels (got ${JSON.stringify(got)})`,
303
+ },
304
+ width: {
305
+ expected: 'width is finite and > 0',
306
+ hint: (componentName: string, got: number | readonly number[]) =>
307
+ `${componentName}.width must be a finite positive meter value (got ${got})`,
308
+ },
309
+ height: {
310
+ expected: 'height is finite and > 0',
311
+ hint: (componentName: string, got: number | readonly number[]) =>
312
+ `${componentName}.height must be a finite positive meter value (got ${got})`,
313
+ },
314
+ irradiance: {
315
+ expected: 'irradiance is a finite 27-value SH vector',
316
+ hint: (componentName: string, got: number | readonly number[]) =>
317
+ `${componentName}.irradiance must contain 27 finite SH values (got ${JSON.stringify(got)})`,
318
+ },
319
+ radius: {
320
+ expected: 'radius is finite and >= R_MIN',
321
+ hint: (componentName: string, got: number | readonly number[]) =>
322
+ `${componentName}.radius must be a finite value >= R_MIN (got ${got})`,
323
+ },
294
324
  range: {
295
325
  expected: 'range >= 0 or Number.POSITIVE_INFINITY',
296
326
  hint: (componentName: string, got: number | readonly number[]) =>
package/src/errors.ts CHANGED
@@ -399,6 +399,20 @@ export class ChangeEpochExhaustedError extends Error {
399
399
  }
400
400
  }
401
401
 
402
+ export class DerivedRangeOutOfBoundsError extends Error {
403
+ override readonly name = 'DerivedRangeOutOfBoundsError';
404
+ readonly code = 'derived-range-out-of-bounds' as const;
405
+ readonly expected = 'a non-negative span-relative range with start + count <= span.length';
406
+ readonly hint =
407
+ 'check start and count against the QuerySpan length, then retry without changing World state';
408
+ readonly detail: { readonly start: number; readonly count: number; readonly spanLength: number };
409
+
410
+ constructor(start: number, count: number, spanLength: number) {
411
+ super(`Derived range [${start}, ${start + count}) exceeds QuerySpan length ${spanLength}.`);
412
+ this.detail = { start, count, spanLength };
413
+ }
414
+ }
415
+
402
416
  /**
403
417
  * Thrown when insertResource/removeResource is called on a World-owned
404
418
  * protected resource (Time or FixedTime).
@@ -1189,15 +1203,25 @@ export type EcsErrorDetail =
1189
1203
  readonly actualLength: number;
1190
1204
  readonly expectedStride: 16;
1191
1205
  }
1192
- // feat-20260519-light-casters-point-spot-pbr w2 — PointLight / SpotLight
1206
+ // feat-20260519-light-casters-point-spot-pbr w2 — light and local probe
1193
1207
  // spawn-time payload bound violation (plan-strategy D-S3 a). detail.field
1194
- // three-branch ('range' | 'innerOuter' | 'outerNinety') keeps four bound
1195
- // violations under one code; AI users narrow on `.detail.field` after the
1208
+ // names the validated scalar or RGB payload while one code keeps the
1209
+ // recovery surface closed; AI users narrow on `.detail.field` after the
1196
1210
  // outer `switch (err.code)` to pick the specific recovery hint.
1197
1211
  | {
1198
1212
  readonly code: 'spawn-light-invalid-bounds';
1199
- readonly field: 'range' | 'innerOuter' | 'outerNinety';
1200
- readonly got: number;
1213
+ readonly field:
1214
+ | 'direction'
1215
+ | 'intensity'
1216
+ | 'color'
1217
+ | 'width'
1218
+ | 'height'
1219
+ | 'irradiance'
1220
+ | 'radius'
1221
+ | 'range'
1222
+ | 'innerOuter'
1223
+ | 'outerNinety';
1224
+ readonly got: number | readonly number[];
1201
1225
  }
1202
1226
  // feat-20260520-2d-sprite-layer-mvp M-2 w13 — resource-setter bound
1203
1227
  // violation (plan-strategy D-4). receivedMode carries the rejected
@@ -0,0 +1,59 @@
1
+ // @forgeax/engine-ecs/internal — package-owner seams that are intentionally
2
+ // absent from the public ECS barrel.
3
+ //
4
+ // This entry keeps the existing component-owner imports stable and adds the
5
+ // typed symbol used by scene propagation. A symbol avoids putting a derived
6
+ // writer method on the discoverable Query contract while still letting the
7
+ // owning package share the exact query implementation without a private path
8
+ // import or an untyped cast at each consumer.
9
+
10
+ export * from './component';
11
+ export type { WorldInternal } from './world-internal';
12
+ export { worldInternal } from './world-internal';
13
+
14
+ import type { Result } from '@forgeax/engine-types';
15
+ import type { Component } from './component';
16
+ import type { QuerySpanUnavailableError } from './errors';
17
+ import type { DerivedRangeWriter } from './query/derived-range-writer';
18
+ import type { Query } from './query/query';
19
+
20
+ /** Internal identity for the ECS-owned derived range writer accessor. */
21
+ export const DERIVED_WRITER: unique symbol = Symbol.for(
22
+ 'forgeax.ecs.query.derivedWriter',
23
+ ) as unknown as typeof DERIVED_WRITER;
24
+
25
+ type DerivedWriterQuery<
26
+ R extends readonly Component[],
27
+ W extends readonly Component[],
28
+ O extends readonly Component[],
29
+ > = Query<R, W, O> & {
30
+ readonly [DERIVED_WRITER]: <C extends W[number]>(
31
+ component: C,
32
+ ) => Result<DerivedRangeWriter<R[number], C>, QuerySpanUnavailableError>;
33
+ };
34
+
35
+ /**
36
+ * Resolve the ECS-owned derived writer through its internal symbol seam.
37
+ * Consumers of this module retain the component/query type relationship while
38
+ * the public Query and QuerySpan surfaces remain read/write-only contracts.
39
+ */
40
+ export function getDerivedWriter<
41
+ R extends readonly Component[],
42
+ W extends readonly Component[],
43
+ O extends readonly Component[],
44
+ C extends W[number],
45
+ >(
46
+ query: Query<R, W, O>,
47
+ component: C,
48
+ ): Result<DerivedRangeWriter<R[number], C>, QuerySpanUnavailableError> {
49
+ const internalQuery = query as DerivedWriterQuery<R, W, O>;
50
+ return internalQuery[DERIVED_WRITER](component);
51
+ }
52
+
53
+ export type {
54
+ DerivedColumnBinding,
55
+ DerivedRangeCursor,
56
+ DerivedRangeRowCommit,
57
+ DerivedRangeRowProbe,
58
+ DerivedRangeWriter,
59
+ } from './query/derived-range-writer';