@forgeax/engine-ecs 0.1.26 → 0.1.27

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 (50) hide show
  1. package/README.md +94 -35
  2. package/dist/__tests__/world-read.unit.test.d.ts +2 -0
  3. package/dist/__tests__/world-read.unit.test.d.ts.map +1 -0
  4. package/dist/commands.d.ts +2 -0
  5. package/dist/commands.d.ts.map +1 -1
  6. package/dist/index.mjs +3471 -4263
  7. package/dist/index.mjs.map +1 -1
  8. package/dist/internal.d.ts +2 -3
  9. package/dist/internal.d.ts.map +1 -1
  10. package/dist/internal.mjs +3 -333
  11. package/dist/internal.mjs.map +1 -1
  12. package/dist/projection/index.mjs.map +1 -1
  13. package/dist/shared.mjs.map +1 -1
  14. package/dist/world-entity-lifecycle.d.ts +3 -14
  15. package/dist/world-entity-lifecycle.d.ts.map +1 -1
  16. package/dist/world-internal.d.ts +65 -5
  17. package/dist/world-internal.d.ts.map +1 -1
  18. package/dist/world-read.d.ts +16 -0
  19. package/dist/world-read.d.ts.map +1 -0
  20. package/dist/world-read.mjs +8 -0
  21. package/dist/world-read.mjs.map +1 -0
  22. package/dist/world-scheduling.d.ts +0 -4
  23. package/dist/world-scheduling.d.ts.map +1 -1
  24. package/dist/world-storage-primitives.d.ts +26 -0
  25. package/dist/world-storage-primitives.d.ts.map +1 -0
  26. package/dist/world.d.ts +352 -157
  27. package/dist/world.d.ts.map +1 -1
  28. package/package.json +8 -4
  29. package/src/__tests__/command-buffer.test.ts +29 -3
  30. package/src/__tests__/hierarchy.unit.test.ts +3 -3
  31. package/src/__tests__/world-health.contract.test.ts +195 -2
  32. package/src/__tests__/world-read.unit.test.ts +30 -0
  33. package/src/commands.ts +22 -9
  34. package/src/internal.ts +5 -3
  35. package/src/world-entity-lifecycle.ts +23 -252
  36. package/src/world-internal-augmentation.d.ts +11 -0
  37. package/src/world-internal.ts +114 -63
  38. package/src/world-read.ts +38 -0
  39. package/src/world-scheduling.ts +0 -26
  40. package/src/world-storage-primitives.ts +179 -0
  41. package/src/world.ts +2590 -509
  42. package/dist/world-component-access.d.ts +0 -311
  43. package/dist/world-component-access.d.ts.map +0 -1
  44. package/dist/world-component-storage.d.ts +0 -298
  45. package/dist/world-component-storage.d.ts.map +0 -1
  46. package/dist/world-core.d.ts +0 -39
  47. package/dist/world-core.d.ts.map +0 -1
  48. package/src/world-component-access.ts +0 -1769
  49. package/src/world-component-storage.ts +0 -1264
  50. package/src/world-core.ts +0 -74
@@ -1,1769 +0,0 @@
1
- // @forgeax/engine-ecs — world-component-access: component storage and access.
2
- //
3
- // This module owns component rows, managed storage, array operations, archetype
4
- // migration, and the relationship callbacks that mutate component storage. World
5
- // remains the typed facade and supplies one narrow per-World state capability.
6
-
7
- import { err, isRetiredSlot, ok, type Result, unwrapHandle } from '@forgeax/engine-types';
8
- import type { BufferPool } from './buffer-pool';
9
- import {
10
- bufferFieldByteLength,
11
- type Component,
12
- type ComponentSchema,
13
- componentId,
14
- componentSchema,
15
- type InputShapeOf,
16
- isEntityField,
17
- isManagedBufferField,
18
- isManagedField,
19
- type ManagedArrayElementType,
20
- type ManagedArrayElementValue,
21
- type ShapeOf,
22
- TYPE_METADATA,
23
- } from './component';
24
- import { fillComponentDefaults, validateComponentDataKeys } from './component-default-fallback';
25
- import { componentDefinition, expandComponentRequirements } from './component-schema';
26
- import { validateManagedArrayValues, validateSharedFieldValues } from './component-value-validate';
27
- import { Entity as EntityComponent } from './entity';
28
- import {
29
- ENTITY_MAX_INDEX,
30
- ENTITY_NULL_RAW,
31
- type EntityHandle,
32
- encodeEntity,
33
- entityGeneration,
34
- entityIndex,
35
- } from './entity-handle';
36
- import {
37
- ComponentAlreadyPresentError,
38
- ComponentNotPresentError,
39
- EntityIndexOverflowError,
40
- FixedSizeMismatchError,
41
- ManagedBufferOutOfBoundsError,
42
- RelationshipSelfCycleError,
43
- RelationshipTargetReadonlyError,
44
- RemoveEssentialComponentError,
45
- StaleEntityError,
46
- validateEnumFieldValues,
47
- validateNumericFieldValues,
48
- } from './errors';
49
- import {
50
- isRelationshipTarget,
51
- RelationshipIndex,
52
- relationshipMirror,
53
- relationshipRole,
54
- } from './relationship-index';
55
- import type { SharedRefStore } from './shared-ref-store';
56
- import { type Archetype, appendArchetypeRow } from './storage/archetype';
57
- import {
58
- type ArchetypeGraph,
59
- getAddEdge,
60
- getOrCreateArchetype,
61
- getRemoveEdge,
62
- getTable,
63
- } from './storage/archetype-graph';
64
- import { removeSparseTag } from './storage/change-detection';
65
- import { arrayCountColumnName, type FieldView, normalizeBufferWrite } from './storage/column';
66
- import type { StructuralEvidenceInput } from './storage/structural-evidence';
67
- import { appendTableRow, type Table } from './storage/table';
68
- import type { UniqueRefStore } from './unique-ref-store';
69
- import type { ComponentData, EcsError, EntityRecord } from './world';
70
- import { ComponentStorage } from './world-component-storage';
71
-
72
- type ErrorContext = { readonly systemName: string };
73
-
74
- /**
75
- * Prepared target-side work for one relationship source write.
76
- *
77
- * A reservation is deliberately kept outside the World columns until the
78
- * source operation is ready to commit. That lets direct, spawn, and deferred
79
- * source writes observe BufferPool failures before adding a mirror component
80
- * or advancing any ECS epoch.
81
- */
82
- interface RelationshipPreparation {
83
- readonly target: EntityHandle;
84
- readonly mirror: Component;
85
- readonly fieldName: string;
86
- readonly mirrorPresent: boolean;
87
- reservedSlotId: number | undefined;
88
- }
89
-
90
- type ArrayFieldsOf<S extends ComponentSchema> = {
91
- [K in keyof S]: S[K] extends
92
- | `array<${ManagedArrayElementType}>`
93
- | `array<${ManagedArrayElementType}, ${number}>`
94
- ? K
95
- : never;
96
- }[keyof S];
97
-
98
- type ArrayFieldElementValue<
99
- S extends ComponentSchema,
100
- K extends keyof S,
101
- > = S[K] extends `array<${infer Elem extends ManagedArrayElementType}>`
102
- ? ManagedArrayElementValue<Elem>
103
- : S[K] extends `array<${infer Elem extends ManagedArrayElementType}, ${number}>`
104
- ? ManagedArrayElementValue<Elem>
105
- : never;
106
-
107
- function relationshipPayloadWrites(data: Readonly<Record<string, unknown>>): boolean {
108
- return Object.values(data).some((value) => {
109
- if (Array.isArray(value)) return value.length > 0;
110
- if (ArrayBuffer.isView(value)) return value.byteLength > 0;
111
- return true;
112
- });
113
- }
114
-
115
- /**
116
- * Relationship target arrays are public read-only projections. Keep the
117
- * detached-copy rule at the World.get boundary while internal relationship
118
- * owners continue to borrow the live storage through `_getArrayView`.
119
- */
120
- function detachRelationshipTargetArray(value: unknown): unknown {
121
- return value instanceof Uint32Array ? value.slice() : value;
122
- }
123
-
124
- export interface ComponentAccessState {
125
- readonly graph: ArchetypeGraph;
126
- readonly records: EntityRecord[];
127
- readonly freeIndices: number[];
128
- readonly bufferPool: BufferPool;
129
- readonly uniqueRefs: UniqueRefStore;
130
- readonly sharedRefs: SharedRefStore;
131
- readonly relationshipIndexes: Map<number, RelationshipIndex>;
132
- readonly markComponentAdded: (entity: EntityHandle, componentId: number) => void;
133
- readonly markComponentsAdded: (entity: EntityHandle, componentIds: readonly number[]) => void;
134
- readonly markComponentChanged: (entity: EntityHandle, componentId: number) => void;
135
- readonly markStructureChanged: () => void;
136
- readonly recordStructuralEvidence: (evidence: StructuralEvidenceInput) => void;
137
- routeError(err: unknown, ctx: ErrorContext): void;
138
- }
139
-
140
- export class WorldComponentAccess {
141
- private readonly storage: ComponentStorage;
142
-
143
- constructor(private readonly state: ComponentAccessState) {
144
- this.storage = new ComponentStorage(state);
145
- }
146
-
147
- private get graph(): ArchetypeGraph {
148
- return this.state.graph;
149
- }
150
-
151
- private get records(): EntityRecord[] {
152
- return this.state.records;
153
- }
154
-
155
- private table(archetype: Archetype): Table {
156
- return getTable(this.graph, archetype.tableId);
157
- }
158
-
159
- private tableRow(record: EntityRecord): number {
160
- return this.graph.archetypes[record.archetypeId]?.rows[record.archetypeRow] ?? -1;
161
- }
162
-
163
- private get freeIndices(): number[] {
164
- return this.state.freeIndices;
165
- }
166
-
167
- private get bufferPool(): BufferPool {
168
- return this.state.bufferPool;
169
- }
170
-
171
- private get uniqueRefs(): UniqueRefStore {
172
- return this.state.uniqueRefs;
173
- }
174
-
175
- private routeError(err: unknown, ctx: ErrorContext): void {
176
- this.state.routeError(err, ctx);
177
- }
178
-
179
- private relationshipIndex(component: Component): RelationshipIndex | undefined {
180
- if (relationshipRole(component)?.kind !== 'source') return undefined;
181
- let index = this.state.relationshipIndexes.get(componentId(component));
182
- if (index === undefined) {
183
- index = new RelationshipIndex();
184
- this.state.relationshipIndexes.set(componentId(component), index);
185
- }
186
- return index;
187
- }
188
-
189
- /**
190
- * Read the World-owned materialized target array without a public snapshot.
191
- * This is an internal relationship-owner path: it borrows the live
192
- * `Uint32Array` so attach/detach stays zero-copy. Public `World.get` detaches
193
- * target arrays before returning them to callers.
194
- */
195
- relationshipTargetEntries(source: Component, target: EntityHandle): readonly EntityHandle[] {
196
- const role = relationshipRole(source);
197
- if (role?.kind !== 'source') return [];
198
- const mirror = relationshipMirror(source);
199
- if (mirror === undefined) return [];
200
- const entries = this._getArrayView(target, mirror, role.targetField);
201
- return entries === undefined ? [] : (entries as unknown as readonly EntityHandle[]);
202
- }
203
-
204
- private markComponentAdded(entity: EntityHandle, component: Component): void {
205
- this.state.markComponentAdded(entity, componentId(component));
206
- }
207
-
208
- private markComponentChanged(entity: EntityHandle, component: Component): void {
209
- this.state.markComponentChanged(entity, componentId(component));
210
- }
211
-
212
- private markStructureChanged(): void {
213
- this.state.markStructureChanged();
214
- }
215
-
216
- relationshipTargetEntity(
217
- component: Component,
218
- value: Record<string, unknown>,
219
- ): EntityHandle | null {
220
- for (const [fieldName, fieldType] of Object.entries(componentSchema(component))) {
221
- if (isEntityField(fieldType)) {
222
- const raw = value[fieldName];
223
- if (raw === null || raw === undefined) return null;
224
- const asNum = raw as number;
225
- if (asNum === ENTITY_NULL_RAW) return null;
226
- return asNum as EntityHandle;
227
- }
228
- }
229
- return null;
230
- }
231
-
232
- private preflightComponentFieldValues(
233
- holder: EntityHandle | null,
234
- componentData: ComponentData,
235
- ): Result<void, EcsError> {
236
- const data = componentData.data as Record<string, unknown>;
237
- const arrayError = validateManagedArrayValues(componentData.component, data);
238
- if (arrayError !== null) return err(arrayError as unknown as EcsError);
239
- const sharedError = validateSharedFieldValues(componentData.component, data);
240
- if (sharedError !== null) return err(sharedError as unknown as EcsError);
241
- const numericError = validateNumericFieldValues(
242
- componentData.component,
243
- data,
244
- holder === null ? undefined : (holder as number),
245
- );
246
- if (numericError !== null) return err(numericError as unknown as EcsError);
247
- return ok(undefined);
248
- }
249
-
250
- /**
251
- * Validate one structural component payload without touching archetypes,
252
- * columns, relationship mirrors, epochs, or managed-reference stores.
253
- * CommandBuffer uses this same owner-level gate as the direct World facade;
254
- * the optional pending set lets a batch refer to an entity reserved earlier
255
- * in that batch without mistaking it for a stale live handle.
256
- */
257
- preflightComponentData(
258
- holder: EntityHandle | null,
259
- componentData: ComponentData,
260
- pendingEntities?: ReadonlySet<number>,
261
- unavailableEntities?: ReadonlySet<number>,
262
- ): Result<void, EcsError> {
263
- const data = componentData.data as Record<string, unknown>;
264
- const keyError = validateComponentDataKeys(componentData.component, data);
265
- if (keyError !== null) return err(keyError as unknown as EcsError);
266
- const valuePreflight = this.preflightComponentFieldValues(holder, componentData);
267
- if (!valuePreflight.ok) return valuePreflight;
268
- if (isRelationshipTarget(componentData.component) && relationshipPayloadWrites(data)) {
269
- return err(new RelationshipTargetReadonlyError(componentData.component.name, 'command'));
270
- }
271
-
272
- const filled = fillComponentDefaults(componentData.component, data);
273
- const enumError = validateEnumFieldValues(
274
- componentData.component,
275
- filled,
276
- holder === null ? undefined : (holder as number),
277
- );
278
- if (enumError !== null) return err(enumError as unknown as EcsError);
279
-
280
- const role = relationshipRole(componentData.component as Component);
281
- if (role?.kind !== 'source') return ok(undefined);
282
- const target = this.relationshipTargetEntity(componentData.component as Component, filled);
283
- if (target === null) return ok(undefined);
284
-
285
- const targetRaw = target as unknown as number;
286
- if (unavailableEntities?.has(targetRaw) === true) {
287
- const targetRecord = this.records[entityIndex(target)];
288
- return err(
289
- new StaleEntityError(target as number, entityIndex(target), entityGeneration(target), {
290
- operation: 'relationship-insert',
291
- component: componentData.component.name,
292
- expectedGeneration: entityGeneration(target),
293
- actualGeneration: targetRecord?.generation ?? -1,
294
- }),
295
- );
296
- }
297
- const targetIsPending = pendingEntities?.has(targetRaw) === true;
298
- const targetRecord = this.records[entityIndex(target)];
299
- const actualGeneration = targetRecord?.generation ?? -1;
300
- const targetLive = this.recordIsLive(targetRecord, entityGeneration(target));
301
- // A source edge is never allowed to publish a dangling target. Pending
302
- // targets are the only exception, and are admitted only for the deferred
303
- // command batch that reserved that exact handle.
304
- if (!targetIsPending && !targetLive) {
305
- return err(
306
- new StaleEntityError(target as number, entityIndex(target), entityGeneration(target), {
307
- operation: 'relationship-insert',
308
- component: componentData.component.name,
309
- expectedGeneration: entityGeneration(target),
310
- actualGeneration,
311
- }),
312
- );
313
- }
314
-
315
- // A pending holder has no row to walk yet. Once materialized, its target
316
- // is still checked by the same source-side relationship callback.
317
- if (holder === null || pendingEntities?.has(holder as unknown as number) === true) {
318
- return ok(undefined);
319
- }
320
- const roleAllowsSelf = role?.kind === 'source' && role.allowSelf;
321
- if (holder === target && !roleAllowsSelf) {
322
- return err(
323
- new RelationshipSelfCycleError(
324
- componentData.component.name,
325
- holder as number,
326
- target as number,
327
- ),
328
- );
329
- }
330
-
331
- const cycleHit =
332
- holder === target && roleAllowsSelf
333
- ? null
334
- : this.relationshipCycleHit(componentData.component as Component, target, holder);
335
- if (cycleHit !== null) {
336
- return err(
337
- new RelationshipSelfCycleError(
338
- componentData.component.name,
339
- holder as number,
340
- cycleHit as number,
341
- ),
342
- );
343
- }
344
- return ok(undefined);
345
- }
346
-
347
- private relationshipCycleHit(
348
- holderComponent: Component,
349
- start: EntityHandle,
350
- holder: EntityHandle,
351
- ): EntityHandle | null {
352
- const visited = new Set<number>();
353
- let current = start;
354
- while (true) {
355
- if (current === holder) return current;
356
- const raw = current as unknown as number;
357
- if (visited.has(raw)) return null;
358
- visited.add(raw);
359
- const record = this.records[entityIndex(current)];
360
- if (!this.recordIsLive(record, entityGeneration(current))) return null;
361
- const archetype = this.graph.archetypes[record.archetypeId];
362
- if (
363
- !archetype?.components.some(
364
- (candidate) => componentId(candidate) === componentId(holderComponent),
365
- )
366
- ) {
367
- return null;
368
- }
369
- const value = this.readRow(archetype, holderComponent, this.tableRow(record)) as Record<
370
- string,
371
- unknown
372
- >;
373
- const next = this.relationshipTargetEntity(holderComponent, value);
374
- if (next === null) return null;
375
- current = next;
376
- }
377
- }
378
-
379
- /**
380
- * Reserve target-side relationship capacity without touching World columns.
381
- *
382
- * A missing mirror reserves its first slot before the mirror archetype is
383
- * created. An existing mirror either reserves an empty slot or grows its
384
- * existing BufferPool slot; both failure paths return before any component,
385
- * relationship index, or ECS epoch changes.
386
- */
387
- prepareRelationshipInsert(
388
- component: Component,
389
- value: Record<string, unknown>,
390
- ): Result<RelationshipPreparation | undefined, EcsError> {
391
- const role = relationshipRole(component);
392
- if (role?.kind !== 'source') return ok(undefined);
393
- const target = this.relationshipTargetEntity(component, value);
394
- if (target === null) return ok(undefined);
395
- const mirror = relationshipMirror(component);
396
- if (mirror === undefined) return ok(undefined);
397
- const targetRec = this.records[entityIndex(target)];
398
- const actualGeneration = targetRec?.generation ?? -1;
399
- if (!this.recordIsLive(targetRec, entityGeneration(target))) {
400
- return err(
401
- new StaleEntityError(target as number, entityIndex(target), entityGeneration(target), {
402
- operation: 'relationship-insert',
403
- component: component.name,
404
- expectedGeneration: entityGeneration(target),
405
- actualGeneration,
406
- }),
407
- );
408
- }
409
- const targetArch = this.graph.archetypes[targetRec.archetypeId];
410
- const mirrorLocalId = componentId(mirror);
411
- const mirrorPresent =
412
- targetArch?.components.some((candidate) => componentId(candidate) === mirrorLocalId) ?? false;
413
- const fieldName = role.targetField;
414
- const arrayMeta = componentDefinition(mirror).fields[fieldName]?.arrayMeta;
415
- if (arrayMeta === undefined) {
416
- return err(new ComponentNotPresentError(target as number, mirror.name));
417
- }
418
- const meta = TYPE_METADATA[arrayMeta.elementType];
419
- if (meta?.byteSize === undefined) {
420
- return err(new ComponentNotPresentError(target as number, mirror.name));
421
- }
422
- let currentLength = 0;
423
- let slotId = 0;
424
- if (mirrorPresent) {
425
- currentLength = this._getArrayLength(target, mirror, fieldName) ?? 0;
426
- const fieldCols = this.table(targetArch as Archetype).storage.get(mirrorLocalId)?.fields;
427
- const column = fieldCols?.get(fieldName);
428
- if (column === undefined) {
429
- return err(new ComponentNotPresentError(target as number, mirror.name));
430
- }
431
- const row = this.tableRow(targetRec);
432
- slotId = column.view[row] as number;
433
- }
434
- const requiredBytes = (currentLength + 1) * meta.byteSize;
435
- if (!Number.isSafeInteger(requiredBytes)) {
436
- return err(new ManagedBufferOutOfBoundsError(requiredBytes, Number.MAX_SAFE_INTEGER));
437
- }
438
-
439
- const preparation: RelationshipPreparation = {
440
- target,
441
- mirror,
442
- fieldName,
443
- mirrorPresent,
444
- reservedSlotId: undefined,
445
- };
446
- if (!mirrorPresent || slotId === 0) {
447
- const allocated = this.bufferPool.alloc(requiredBytes);
448
- if (!allocated.ok) return allocated;
449
- preparation.reservedSlotId = allocated.value.id;
450
- } else if (this.bufferPool.view(slotId).byteLength < requiredBytes) {
451
- const grown = this.bufferPool.grow(slotId, requiredBytes);
452
- if (!grown.ok) return grown;
453
- }
454
- return ok(preparation);
455
- }
456
-
457
- /** Release a target-capacity reservation that did not reach commit. */
458
- releaseRelationshipPreparation(preparation: RelationshipPreparation | undefined): void {
459
- if (preparation === undefined || preparation.reservedSlotId === undefined) return;
460
- const slotId = preparation.reservedSlotId;
461
- this.bufferPool.release(slotId);
462
- preparation.reservedSlotId = undefined;
463
- }
464
-
465
- /** Install a reserved slot after the target mirror archetype exists. */
466
- private installRelationshipPreparation(
467
- preparation: RelationshipPreparation,
468
- ): Result<void, EcsError> {
469
- const record = this.lookupAlive(
470
- preparation.target,
471
- 'relationship-capacity',
472
- preparation.mirror.name,
473
- );
474
- if (!record.ok) return record;
475
- const arch = this.graph.archetypes[record.value.archetypeId];
476
- if (arch === undefined) {
477
- return err(
478
- new ComponentNotPresentError(preparation.target as number, preparation.mirror.name),
479
- );
480
- }
481
- const fieldCols = this.table(arch).storage.get(componentId(preparation.mirror))?.fields;
482
- const column = fieldCols?.get(preparation.fieldName);
483
- if (column === undefined) {
484
- return err(
485
- new ComponentNotPresentError(preparation.target as number, preparation.mirror.name),
486
- );
487
- }
488
- const slotId = preparation.reservedSlotId;
489
- if (slotId !== undefined) {
490
- column.view[this.tableRow(record.value)] = slotId;
491
- preparation.reservedSlotId = undefined;
492
- }
493
- return ok(undefined);
494
- }
495
-
496
- /** Append `holder` to the materialized target list. */
497
- relationshipOnInsert(
498
- holder: EntityHandle,
499
- component: Component,
500
- value: Record<string, unknown>,
501
- preparation?: RelationshipPreparation,
502
- ): Result<void, EcsError> {
503
- const role = relationshipRole(component);
504
- if (role?.kind !== 'source') return ok(undefined);
505
- const target = this.relationshipTargetEntity(component, value);
506
- if (target === null) return ok(undefined);
507
- const mirror = relationshipMirror(component);
508
- /* istanbul ignore next -- defineComponent relationship validation guarantees mirror exists */
509
- if (mirror === undefined) return ok(undefined);
510
-
511
- let prepared = preparation;
512
- if (prepared === undefined) {
513
- const preparedResult = this.prepareRelationshipInsert(component, value);
514
- if (!preparedResult.ok) return preparedResult;
515
- prepared = preparedResult.value;
516
- }
517
- if (prepared === undefined) return ok(undefined);
518
-
519
- // Lazy-create the mirror component on the target when absent (D-3c).
520
- const targetSlot = entityIndex(target);
521
- const targetRec = this.records[targetSlot];
522
- const actualGeneration = targetRec?.generation ?? -1;
523
- if (!this.recordIsLive(targetRec, entityGeneration(target))) {
524
- return err(
525
- new StaleEntityError(target as number, targetSlot, entityGeneration(target), {
526
- operation: 'relationship-insert',
527
- component: component.name,
528
- expectedGeneration: entityGeneration(target),
529
- actualGeneration: actualGeneration,
530
- }),
531
- );
532
- }
533
- const targetArch = this.graph.archetypes[targetRec.archetypeId];
534
- const mirrorLocalId = componentId(mirror);
535
- const hasMirror =
536
- targetArch?.components.some((component) => componentId(component) === mirrorLocalId) ?? false;
537
- if (!hasMirror) {
538
- const added = this._addComponentCore(
539
- target,
540
- {
541
- component: mirror,
542
- data: {} as Partial<ShapeOf<ComponentSchema>>,
543
- },
544
- true,
545
- false,
546
- true,
547
- );
548
- if (!added.ok) {
549
- this.releaseRelationshipPreparation(prepared);
550
- return added;
551
- }
552
- }
553
- const installed = this.installRelationshipPreparation(prepared);
554
- if (!installed.ok) {
555
- this.releaseRelationshipPreparation(prepared);
556
- return installed;
557
- }
558
- const targetEntries = this.relationshipTargetEntries(component, target);
559
- const slot = targetEntries.length;
560
- const mirrored = this.appendArrayElement(
561
- target,
562
- mirror as Component<string, ComponentSchema>,
563
- role.targetField as never,
564
- holder as never,
565
- );
566
- if (!mirrored.ok) {
567
- this.releaseRelationshipPreparation(prepared);
568
- return mirrored;
569
- }
570
- this.relationshipIndex(component)?.attach(holder, target, slot);
571
- return ok(undefined);
572
- }
573
-
574
- /** Remove `holder` from the materialized target list. */
575
- relationshipOnRemove(
576
- holder: EntityHandle,
577
- component: Component,
578
- oldValue: Record<string, unknown>,
579
- ): Result<void, EcsError> {
580
- const role = relationshipRole(component);
581
- if (role?.kind !== 'source') return ok(undefined);
582
- const target = this.relationshipTargetEntity(component, oldValue);
583
- if (target === null) return ok(undefined);
584
- const mirror = relationshipMirror(component);
585
- /* istanbul ignore next -- defineComponent relationship validation guarantees mirror exists */
586
- if (mirror === undefined) return ok(undefined);
587
- const targetSlot = entityIndex(target);
588
- const targetRec = this.records[targetSlot];
589
- if (!this.recordIsLive(targetRec, entityGeneration(target))) return ok(undefined);
590
-
591
- const index = this.relationshipIndex(component);
592
- if (index === undefined) return ok(undefined);
593
- const slot = index.slotOf(holder);
594
- if (slot === undefined || index.targetOf(holder) !== target) return ok(undefined);
595
- const mirrored = this.removeArrayElementAt(
596
- target,
597
- mirror as Component<string, ComponentSchema>,
598
- role.targetField as never,
599
- slot,
600
- );
601
- if (!mirrored.ok) return mirrored;
602
- index.detach(holder);
603
- if (mirrored.value !== undefined) index.updateSlot(mirrored.value, target, slot);
604
- return ok(undefined);
605
- }
606
-
607
- /**
608
- * Read component data from an entity.
609
- *
610
- * **Public array contract:** relationship target `array<entity>` fields are
611
- * detached `Uint32Array` copies. Mutating that returned array cannot alter
612
- * the materialized target, relationship index, or source. Other array
613
- * fields retain the existing transient view contract: fixed-capacity
614
- * `array<T,N>` and `buffer<N>` fields alias the archetype column buffer
615
- * directly, while variable managed arrays alias their BufferPool slot. Those
616
- * views are valid only until the next structural change (`spawn` /
617
- * `despawn` / `addComponent` / `removeComponent`); callers must re-fetch
618
- * `world.get(e, C)` on every access. Internal owners use `readRow` and
619
- * `_getArrayView` directly and retain zero-copy access.
620
- *
621
- * @returns `Result<ShapeOf<S>, EcsError>` —
622
- * `ok(ShapeOf<S>)` on success;
623
- * `err(StaleEntityError)` (`.code = 'stale-entity'`) if entity is dead;
624
- * `err(ComponentNotPresentError)` (`.code = 'component-not-present'`) if
625
- * the entity does not have the component (a never-present component on
626
- * this entity degrades to the same `component-not-present` path — there is
627
- * no separate "not registered" failure; components are global at
628
- * `defineComponent` time).
629
- *
630
- * @example
631
- * ```ts
632
- * const Position = defineComponent('Position', { x: 'f32', y: 'f32' });
633
- * const world = new World();
634
- * const e = world.spawn({ component: Position, data: { x: 1, y: 2 } }).unwrap();
635
- * const r = world.get(e, Position);
636
- * if (!r.ok) { return; } // r.error.code === 'stale-entity' on dead handle
637
- * const pos = r.value;
638
- * ```
639
- */
640
- get<S extends ComponentSchema>(
641
- entity: EntityHandle,
642
- component: Component<string, S>,
643
- ): Result<ShapeOf<S>, EcsError> {
644
- const record = this.lookupAlive(entity, 'get', component.name);
645
- if (!record.ok) return record;
646
-
647
- const rec = record.value;
648
- const arch = this.graph.archetypes[rec.archetypeId];
649
- /* istanbul ignore next -- defensive: alive record always has valid archetypeId */
650
- if (!arch) {
651
- return err(
652
- new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
653
- operation: 'get',
654
- component: component.name,
655
- expectedGeneration: entityGeneration(entity),
656
- actualGeneration: rec.generation,
657
- }),
658
- );
659
- }
660
-
661
- // Check if this archetype has the component (using World-local ID).
662
- const localId = componentId(component);
663
- if (!arch.components.some((candidate) => componentId(candidate) === localId)) {
664
- return err(new ComponentNotPresentError(entity as number, component.name));
665
- }
666
-
667
- const value = this.storage.readRow(arch, component, this.tableRow(rec));
668
- const role = relationshipRole(component);
669
- if (role?.kind === 'target') {
670
- const targetValue = value as Record<string, unknown>;
671
- targetValue[role.targetField] = detachRelationshipTargetArray(targetValue[role.targetField]);
672
- }
673
- return ok(value);
674
- }
675
-
676
- /**
677
- * Column-level zero-copy view of an `array<T, N>` / `array<T>` field.
678
- *
679
- * Resolves the live byte region for `(entity, component, fieldName)`
680
- * directly at the column level and returns the element-typed TypedArray
681
- * aliasing it (`view.buffer` is the SSOT byte region; mutations route
682
- * through `world.set`). Unlike `get`, this does NOT build the
683
- * `{}` whole-component object nor walk every schema field. Per-frame
684
- * consumers that need one column (the resolved world mat4) take this path to
685
- * avoid the `get` overhead (1 `{}` alloc + N-field readRow walk).
686
- *
687
- * Fixed `array<T,N>` columns (feat-20260602) store their elements inline, so
688
- * the view aliases the archetype column buffer directly (no BufferPool
689
- * indirection); variable `array<T>` columns still alias the BufferPool slot.
690
- * The returned view's element type follows the schema element type
691
- * (`array<entity,N>` -> `Uint32Array`, `array<f32,N>` -> `Float32Array`,
692
- * etc.) -- the prior f32-only early-return gate is removed.
693
- *
694
- * **Transient view contract:** the returned `TypedArray` aliases the column
695
- * buffer and is valid only until the next structural change (`spawn` /
696
- * `despawn` / `addComponent` / `removeComponent`). Column growth
697
- * (`growColumn`) detaches the old `ArrayBuffer` via `transfer()`; a
698
- * swap-remove at the same row index leaves the view pointing to the wrong
699
- * entity. **Callers must re-fetch `_getArrayView` on every access** and must
700
- * not hold the view across any operation that may cause archetype migration.
701
- * All existing per-frame consumers (`propagateTransforms` / `render-extract`
702
- * / `pick`) already conform -- they fetch the view inside a single pass with
703
- * no intervening structural changes.
704
- *
705
- * Returns `undefined` when the entity is dead, the component is absent, the
706
- * field does not exist, or the field is not an `array<...>` column.
707
- *
708
- * @internal Engine-internal fast path; AI users read public component values
709
- * through `world.get`. This accessor is the zero-materialization route the
710
- * propagate kernel, relationship owner, and render walk use; it bypasses
711
- * the detached public relationship-target snapshot.
712
- */
713
- _getArrayView(
714
- entity: EntityHandle,
715
- component: Component,
716
- fieldName: string,
717
- ): FieldView | undefined {
718
- const record = this.lookupAlive(entity, '_getArrayView', component.name);
719
- if (!record.ok) return undefined;
720
-
721
- const rec = record.value;
722
- const arch = this.graph.archetypes[rec.archetypeId];
723
- if (!arch) return undefined;
724
- return this.storage.readArrayView(arch, component, this.tableRow(rec), fieldName);
725
- }
726
-
727
- /** Internal zero-materialisation read for ECS-owned relationship lists. */
728
- _getArrayLength(
729
- entity: EntityHandle,
730
- component: Component,
731
- fieldName: string,
732
- ): number | undefined {
733
- const record = this.lookupAlive(entity, 'relationship-read', component.name);
734
- if (!record.ok) return undefined;
735
- const arch = this.graph.archetypes[record.value.archetypeId];
736
- if (arch === undefined) return undefined;
737
- return this.storage.readArrayLength(arch, component, this.tableRow(record.value), fieldName);
738
- }
739
-
740
- /** Internal zero-materialisation read for one ECS-owned array element. */
741
- _getArrayElement(
742
- entity: EntityHandle,
743
- component: Component,
744
- fieldName: string,
745
- index: number,
746
- ): number | undefined {
747
- const record = this.lookupAlive(entity, 'relationship-read', component.name);
748
- if (!record.ok) return undefined;
749
- const arch = this.graph.archetypes[record.value.archetypeId];
750
- if (arch === undefined) return undefined;
751
- return this.storage.readArrayElement(
752
- arch,
753
- component,
754
- this.tableRow(record.value),
755
- fieldName,
756
- index,
757
- );
758
- }
759
-
760
- /** Internal scalar-column read used by parent-first hierarchy traversal. */
761
- _getFieldValue(
762
- entity: EntityHandle,
763
- component: Component,
764
- fieldName: string,
765
- ): number | undefined {
766
- const record = this.lookupAlive(entity, 'relationship-read', component.name);
767
- if (!record.ok) return undefined;
768
- const arch = this.graph.archetypes[record.value.archetypeId];
769
- if (arch === undefined) return undefined;
770
- return this.storage.readFieldValue(arch, component, this.tableRow(record.value), fieldName);
771
- }
772
-
773
- /**
774
- * Converge one writable relationship source mutation through the source
775
- * owner. The source scalar and its materialized target list are committed as
776
- * one operation; no caller receives a raw source column view that could
777
- * bypass the mirror/index maintenance.
778
- */
779
- private setRelationshipSource(
780
- entity: EntityHandle,
781
- component: Component,
782
- value: Record<string, unknown>,
783
- record: EntityRecord,
784
- arch: Archetype,
785
- markChanged: boolean,
786
- ): Result<void, EcsError> {
787
- const role = relationshipRole(component);
788
- if (role?.kind !== 'source') return ok(undefined);
789
- const row = this.tableRow(record);
790
- const current = this.storage.readRow(arch, component, row) as Record<string, unknown>;
791
- const valuePreflight = this.preflightComponentFieldValues(entity, {
792
- component,
793
- data: value as never,
794
- });
795
- if (!valuePreflight.ok) return valuePreflight;
796
- const merged = { ...current, ...value };
797
- const enumError = validateEnumFieldValues(component, merged, entity as number);
798
- if (enumError !== null) return err(enumError as unknown as EcsError);
799
-
800
- const oldTarget = this.relationshipTargetEntity(component, current);
801
- const target = this.relationshipTargetEntity(component, merged);
802
- if (target !== null) {
803
- const targetRecord = this.records[entityIndex(target)];
804
- const actualGeneration = targetRecord?.generation ?? -1;
805
- if (!this.recordIsLive(targetRecord, entityGeneration(target))) {
806
- return err(
807
- new StaleEntityError(target as number, entityIndex(target), entityGeneration(target), {
808
- operation: 'relationship-insert',
809
- component: component.name,
810
- expectedGeneration: entityGeneration(target),
811
- actualGeneration,
812
- }),
813
- );
814
- }
815
- const roleAllowsSelf = role.allowSelf;
816
- if (entity === target && !roleAllowsSelf) {
817
- return err(
818
- new RelationshipSelfCycleError(component.name, entity as number, target as number),
819
- );
820
- }
821
- const cycleHit =
822
- entity === target && roleAllowsSelf
823
- ? null
824
- : this.relationshipCycleHit(component, target, entity);
825
- if (cycleHit !== null) {
826
- return err(
827
- new RelationshipSelfCycleError(component.name, entity as number, cycleHit as number),
828
- );
829
- }
830
- }
831
-
832
- // A same-target source write still counts as authored evidence but does
833
- // not churn the target mirror or relationship index.
834
- if (oldTarget !== target) {
835
- const prepared = this.prepareRelationshipInsert(component, merged);
836
- if (!prepared.ok) return prepared;
837
- const preparation = prepared.value;
838
- if (oldTarget !== null) {
839
- const detached = this.relationshipOnRemove(entity, component, current);
840
- if (!detached.ok) {
841
- this.releaseRelationshipPreparation(preparation);
842
- return detached;
843
- }
844
- }
845
- const attached = this.relationshipOnInsert(entity, component, merged, preparation);
846
- if (!attached.ok) {
847
- this.releaseRelationshipPreparation(preparation);
848
- return attached;
849
- }
850
- }
851
-
852
- const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
853
- const sourceColumn = fieldCols?.get(role.sourceField);
854
- if (sourceColumn === undefined) {
855
- return err(new ComponentNotPresentError(entity as number, component.name));
856
- }
857
- sourceColumn.view[row] = target === null ? ENTITY_NULL_RAW : (target as number);
858
- if (markChanged) this.markComponentChanged(entity, component);
859
- return ok(undefined);
860
- }
861
-
862
- /**
863
- * Write (partial) component data to an entity.
864
- *
865
- * @returns `Result<void, EcsError>` —
866
- * `ok(void)` on success;
867
- * `err(StaleEntityError)` (`.code = 'stale-entity'`) if entity is dead;
868
- * `err(ComponentNotPresentError)` (`.code = 'component-not-present'`) if
869
- * entity does not have the component (F-02: no longer silently ignores).
870
- *
871
- * @example
872
- * ```ts
873
- * const Position = defineComponent('Position', { x: 'f32', y: 'f32' });
874
- * const world = new World();
875
- * const e = world.spawn({ component: Position, data: { x: 0, y: 0 } }).unwrap();
876
- * const r = world.set(e, Position, { x: 10 });
877
- * if (!r.ok) { return; } // r.error.code === 'stale-entity' on dead handle
878
- * r.unwrap();
879
- * ```
880
- */
881
- set<S extends ComponentSchema>(
882
- entity: EntityHandle,
883
- component: Component<string, S>,
884
- value: Partial<InputShapeOf<S>>,
885
- markChanged = true,
886
- ): Result<void, EcsError> {
887
- const record = this.lookupAlive(entity, 'set', component.name);
888
- if (!record.ok) return record;
889
-
890
- const rec = record.value;
891
- const arch = this.graph.archetypes[rec.archetypeId];
892
- /* istanbul ignore next -- defensive: alive record always has valid archetypeId */
893
- if (!arch) {
894
- return err(
895
- new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
896
- operation: 'set',
897
- component: component.name,
898
- expectedGeneration: entityGeneration(entity),
899
- actualGeneration: rec.generation,
900
- }),
901
- );
902
- }
903
- const localId = componentId(component);
904
- if (!arch.components.some((candidate) => componentId(candidate) === localId)) {
905
- // F-02: set on missing component returns err instead of silent ignore
906
- return err(new ComponentNotPresentError(entity as number, component.name));
907
- }
908
- const role = relationshipRole(component as Component);
909
- if (role?.kind === 'target') {
910
- return err(new RelationshipTargetReadonlyError(component.name, 'set'));
911
- }
912
- if (role?.kind === 'source') {
913
- return this.setRelationshipSource(
914
- entity,
915
- component,
916
- value as Record<string, unknown>,
917
- rec,
918
- arch,
919
- markChanged,
920
- );
921
- }
922
- const valuePreflight = this.preflightComponentFieldValues(entity, {
923
- component,
924
- data: value,
925
- });
926
- if (!valuePreflight.ok) return valuePreflight;
927
- const currentValue = this.storage.readRow(arch, component, this.tableRow(rec)) as Record<
928
- string,
929
- unknown
930
- >;
931
- const enumError = validateEnumFieldValues(
932
- component,
933
- { ...currentValue, ...(value as Record<string, unknown>) },
934
- entity as number,
935
- );
936
- if (enumError !== null) return err(enumError as unknown as EcsError);
937
- if (component.storage === 'sparse') {
938
- if (markChanged) this.markComponentChanged(entity, component);
939
- return ok(undefined);
940
- }
941
- const fieldCols = this.table(arch).storage.get(localId)?.fields;
942
- if (fieldCols === undefined) {
943
- throw new Error(`Table storage for ${component.name} does not exist.`);
944
- }
945
- for (const fieldName of Object.keys(value)) {
946
- const col = fieldCols.get(fieldName);
947
- if (!col) {
948
- continue;
949
- }
950
- const fieldType = (componentSchema(component) as Record<string, string>)[fieldName] ?? '';
951
- // M1/M2 release loop (set path): release the prior managed value
952
- // BEFORE writing the new one. Single SSOT helper `releaseManagedFieldOnRow`
953
- // (feat-20260614 D-2) covers every managed-field family (`ref<T>` /
954
- // `string` / `buffer` / variable `array<T>`); it self-skips fields that
955
- // do not match `isManagedField` here, but for set-ref/string we already
956
- // gated on it so the call is hot. Zeroes the column when applicable.
957
- if (isManagedField(fieldType)) {
958
- this.storage.releaseManagedFieldOnRow(arch, component, this.tableRow(rec), fieldName);
959
- }
960
- const raw = (value as Record<string, unknown>)[fieldName];
961
- if (fieldType === 'bool') {
962
- col.view[this.tableRow(rec)] = raw ? 1 : 0;
963
- } else if (isEntityField(fieldType)) {
964
- // M3 entity field overwrite: encode null as ENTITY_NULL_RAW;
965
- // otherwise store the Entity bit pattern (slot+gen).
966
- col.view[this.tableRow(rec)] =
967
- raw === null || raw === undefined ? ENTITY_NULL_RAW : (raw as number);
968
- } else if (isManagedBufferField(fieldType)) {
969
- // M2 set path: collapsed-vocab keyword family `'buffer'` (variable) +
970
- // `'buffer<N>'` (fixed). The two shapes diverge here:
971
- // - `buffer<N>` — schema-declared byteLength is fixed; raw must be a
972
- // `Uint8Array` whose `byteLength === N`. Mismatched payloads route
973
- // `FixedSizeMismatchError` via Result.err so AI users observe an
974
- // explicit failure instead of silent truncation (verify round 1
975
- // B1 fix; charter P3 — explicit failure > silent acceptance).
976
- // - `'buffer'` — variable capacity; release the prior slot then
977
- // alloc a fresh one sized to the new payload's byteLength (mirrors
978
- // the `array<T>` set path's release-then-alloc D-5 ordering).
979
- // raw is normalized from any AllowSharedBufferSource view to a
980
- // Uint8Array over its bytes (feat-20260621 V2 / AC-A4). Non-buffer
981
- // raw (a forced cast feeding e.g. a number) normalizes to null and
982
- // is treated as a no-op (column slot stays unchanged).
983
- const isFixedBuffer = fieldType !== 'buffer';
984
- const bytes = normalizeBufferWrite(raw);
985
- if (bytes !== null) {
986
- if (isFixedBuffer) {
987
- // feat-20260602: fixed `buffer<N>` lives inline as a stride-N u8
988
- // column (arity = N bytes). Write the payload straight into the
989
- // row window -- no BufferPool slot.
990
- const expected = bufferFieldByteLength(fieldType);
991
- if (bytes.byteLength !== expected) {
992
- return err(new FixedSizeMismatchError(fieldName, expected, bytes.byteLength));
993
- }
994
- const arity = col.arity;
995
- (col.view as Uint8Array).set(bytes.subarray(0, arity), this.tableRow(rec) * arity);
996
- } else {
997
- // Variable `'buffer'` set: release prior slot via SSOT helper
998
- // (feat-20260614 D-2) then alloc fresh sized to the new payload
999
- // (verify round 1 B2 fix path). The helper zeroes the column on
1000
- // release; sentinel slot id 0 is a no-op.
1001
- this.storage.releaseManagedFieldOnRow(arch, component, this.tableRow(rec), fieldName);
1002
- const allocR = this.bufferPool.alloc(bytes.byteLength);
1003
- if (!allocR.ok) {
1004
- const ctx: ErrorContext = {
1005
- systemName: `World.set (${component.name}.${fieldName})`,
1006
- };
1007
- this.routeError(allocR.error, ctx);
1008
- col.view[this.tableRow(rec)] = 0;
1009
- continue;
1010
- }
1011
- const slot = allocR.value;
1012
- slot.view.set(bytes);
1013
- col.view[this.tableRow(rec)] = slot.id;
1014
- }
1015
- }
1016
- } else if (fieldType === 'string') {
1017
- // M1 string-field set path (AC-05 path 3): the prior handle was
1018
- // already released by the unified `isManagedField` pre-write block
1019
- // above (D-R3) -- here we just alloc the new handle and store the
1020
- // u32. Mirrors the array<T> release-then-alloc pattern (D-5) so AI
1021
- // users observe the UniqueRefStore _liveCount net-zero invariant
1022
- // on field overwrite. Missing / non-string raw -> '' fallback
1023
- // (AC-06).
1024
- const text = typeof raw === 'string' ? raw : '';
1025
- const handle = this.uniqueRefs.alloc<'String'>('String', text);
1026
- col.view[this.tableRow(rec)] = unwrapHandle(handle);
1027
- } else {
1028
- const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
1029
- if (arrayMeta !== undefined) {
1030
- // M1 set path for array<T> / array<T,N> fields (feat-20260614 D-3
1031
- // calling convention). The set semantics mirror spawn: release the
1032
- // prior slot via the SSOT helper, then alloc a fresh one sized to
1033
- // the new value, copy bytes verbatim, store slot id (+ count for
1034
- // variable). Fixed `array<T,N>` is inline — the helper short-
1035
- // circuits and writeArrayField writes directly into the row's
1036
- // stride window with no pool traffic.
1037
- this.storage.releaseManagedFieldOnRow(arch, component, this.tableRow(rec), fieldName);
1038
- this.storage.writeArrayField(
1039
- arch,
1040
- component,
1041
- this.tableRow(rec),
1042
- fieldName,
1043
- fieldType,
1044
- arrayMeta,
1045
- raw,
1046
- );
1047
- } else {
1048
- // The pre-write `releaseManagedFieldOnRow` block above already
1049
- // released the prior `'shared<T>'` rc via SharedRefStore.release;
1050
- // here we retain the new value so net rc delta is +1 / 0 / -1 per
1051
- // M4 invariant (set: -1+1=0; spawn: 0+1=+1; despawn: -1).
1052
- col.view[this.tableRow(rec)] = raw as number;
1053
- if (fieldType.startsWith('shared<') && (raw as number) !== 0) {
1054
- this.storage.retainSharedScalarHandle(raw as number, component.name, fieldName);
1055
- }
1056
- }
1057
- }
1058
- }
1059
- if (markChanged) this.markComponentChanged(entity, component);
1060
- return ok(undefined);
1061
- }
1062
-
1063
- // ──────────────────────────────────────────────────────────────────────────
1064
- // Internal relationship array maintenance. Public array mutation is always
1065
- // expressed as one `world.set` payload; these helpers only implement the
1066
- // engine-owned target projection and backpointer swap-remove path.
1067
- //
1068
- // Append/remove are engine-owned relationship maintenance only.
1069
- //
1070
- // The `fieldName` parameter is typed `ArrayFieldsOf<S>` so cross-shape
1071
- // access (entity / buffer / string / scalar field names) is rejected at
1072
- // compile time -- AI users see a TS error well before any runtime path.
1073
- // ──────────────────────────────────────────────────────────────────────────
1074
-
1075
- /**
1076
- * Append `value` to the variable `array<T>` field `fieldName` on `entity`.
1077
- *
1078
- * BufferPool grow is amortized O(1) via the size-class freelist (research
1079
- * Finding 5). Relationship target arrays grow byte-wise.
1080
- *
1081
- * @returns `Result<void, EcsError>` with the normal stale/component errors.
1082
- *
1083
- * The helper is called only by relationship synchronization.
1084
- */
1085
- private appendArrayElement<S extends ComponentSchema, K extends ArrayFieldsOf<S>>(
1086
- entity: EntityHandle,
1087
- component: Component<string, S>,
1088
- fieldName: K,
1089
- value: ArrayFieldElementValue<S, K>,
1090
- ): Result<void, EcsError> {
1091
- const record = this.lookupAlive(entity, 'relationship-append', component.name);
1092
- if (!record.ok) return record;
1093
- const rec = record.value;
1094
- const arch = this.graph.archetypes[rec.archetypeId];
1095
- /* istanbul ignore next -- alive record always has a valid archetype */
1096
- if (!arch) {
1097
- return err(
1098
- new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
1099
- operation: 'relationship-append',
1100
- component: component.name,
1101
- expectedGeneration: entityGeneration(entity),
1102
- actualGeneration: rec.generation,
1103
- }),
1104
- );
1105
- }
1106
- const localId = componentId(component);
1107
- const fieldCols = this.table(arch).storage.get(localId)?.fields;
1108
- if (!fieldCols) {
1109
- return err(new ComponentNotPresentError(entity as number, component.name));
1110
- }
1111
- const fieldNameStr = fieldName as string;
1112
- const col = fieldCols.get(fieldNameStr);
1113
- /* istanbul ignore next -- ArrayFieldsOf filter ensures the column exists */
1114
- if (!col) return err(new ComponentNotPresentError(entity as number, component.name));
1115
- const arrayMeta = componentDefinition(component).fields[fieldNameStr]?.arrayMeta;
1116
- /* istanbul ignore next -- ArrayFieldsOf filter guarantees array<*> */
1117
- if (arrayMeta === undefined) {
1118
- return err(new ComponentNotPresentError(entity as number, component.name));
1119
- }
1120
- const meta = TYPE_METADATA[arrayMeta.elementType];
1121
- /* istanbul ignore next -- arrayMeta.elementType is guaranteed in TYPE_METADATA */
1122
- if (!meta) return err(new ComponentNotPresentError(entity as number, component.name));
1123
- // biome-ignore lint/style/noNonNullAssertion: ManagedArrayElementType always scalar -> byteSize present
1124
- const elementBytes = meta.byteSize!;
1125
- const slotId = col.view[this.tableRow(rec)] as number;
1126
-
1127
- const countCol = fieldCols.get(arrayCountColumnName(fieldNameStr));
1128
- /* istanbul ignore next -- variable arrays always allocate the count column */
1129
- if (countCol === undefined) {
1130
- return err(new ComponentNotPresentError(entity as number, component.name));
1131
- }
1132
- const count = countCol.view[this.tableRow(rec)] as number;
1133
- const newCount = count + 1;
1134
- const newByteLength = newCount * elementBytes;
1135
-
1136
- let liveSlotId = slotId;
1137
- if (liveSlotId === 0) {
1138
- // Empty/unallocated slot — alloc fresh.
1139
- const allocR = this.bufferPool.alloc(newByteLength);
1140
- if (!allocR.ok) return err(allocR.error);
1141
- liveSlotId = allocR.value.id;
1142
- col.view[this.tableRow(rec)] = liveSlotId;
1143
- } else {
1144
- // A previously-allocated slot may have drained below its high-water
1145
- // mark: swap-remove (`_removeArrayElementByValue`) and `pop` only lower
1146
- // the count column, never shrink the managed buffer. When the refilled
1147
- // length still fits inside the slot's current logical length, reuse the
1148
- // buffer in place -- routing through `grow` would hit the (correct, but
1149
- // here irrelevant) shrink-not-supported guard and strand the field
1150
- // (e.g. `Children.entities` never repopulating after a full drain).
1151
- if (newByteLength > this.bufferPool.view(liveSlotId).byteLength) {
1152
- const growR = this.bufferPool.grow(liveSlotId, newByteLength);
1153
- if (!growR.ok) return err(growR.error);
1154
- }
1155
- }
1156
- const liveBytes = this.bufferPool.view(liveSlotId);
1157
- // Reinterpret the slot bytes as the element-typed view and write at the
1158
- // tail index. Entity values are stored as their u32 bit pattern.
1159
- this.storage.writeArrayElementAt(liveBytes, count, arrayMeta.elementType, value as number);
1160
- countCol.view[this.tableRow(rec)] = newCount;
1161
- this.markComponentChanged(entity, component);
1162
- return ok(undefined);
1163
- }
1164
-
1165
- /**
1166
- * Remove one variable-array element at a known slot. Relationship holders
1167
- * supply the slot from their backpointer, so this is O(1) and never scans
1168
- * the materialized target array.
1169
- */
1170
- private removeArrayElementAt(
1171
- entity: EntityHandle,
1172
- component: Component<string, ComponentSchema>,
1173
- fieldName: string,
1174
- slot: number,
1175
- ): Result<EntityHandle | undefined, EcsError> {
1176
- const record = this.lookupAlive(entity, 'removeArrayElementAt', component.name);
1177
- if (!record.ok) return record;
1178
- const rec = record.value;
1179
- const arch = this.graph.archetypes[rec.archetypeId];
1180
- if (!arch) return err(new ComponentNotPresentError(entity as number, component.name));
1181
- const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
1182
- if (!fieldCols) return err(new ComponentNotPresentError(entity as number, component.name));
1183
- const col = fieldCols.get(fieldName);
1184
- const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
1185
- const countCol = fieldCols.get(arrayCountColumnName(fieldName));
1186
- if (!col || !arrayMeta || arrayMeta.length !== undefined || !countCol) {
1187
- return err(new ComponentNotPresentError(entity as number, component.name));
1188
- }
1189
- const row = this.tableRow(rec);
1190
- const count = countCol.view[row] as number;
1191
- if (slot < 0 || slot >= count) return ok(undefined);
1192
- const slotId = col.view[row] as number;
1193
- if (slotId === 0) return ok(undefined);
1194
- const liveBytes = this.bufferPool.view(slotId);
1195
- const last = count - 1;
1196
- const moved =
1197
- slot === last
1198
- ? undefined
1199
- : (this.storage.readArrayElementAt(liveBytes, last, arrayMeta.elementType) as EntityHandle);
1200
- if (slot !== last) {
1201
- this.storage.writeArrayElementAt(liveBytes, slot, arrayMeta.elementType, moved as number);
1202
- }
1203
- countCol.view[row] = last;
1204
- this.markComponentChanged(entity, component);
1205
- return ok(moved);
1206
- }
1207
-
1208
- // ──────────────────────────────────────────────────────────────────────────
1209
- // addComponent / removeComponent (archetype migration via edges, AC-07)
1210
- // ──────────────────────────────────────────────────────────────────────────
1211
-
1212
- /**
1213
- * Add a component to an existing entity, triggering archetype migration.
1214
- *
1215
- * @returns `Result<void, EcsError>` —
1216
- * `ok(void)` on success;
1217
- * `err(StaleEntityError)` (`.code = 'stale-entity'`) if entity is dead;
1218
- * `err(ComponentAlreadyPresentError)` (`.code = 'component-already-present'`)
1219
- * if entity already has the component (E-03).
1220
- *
1221
- * @example
1222
- * ```ts
1223
- * const Position = defineComponent('Position', { x: 'f32', y: 'f32' });
1224
- * const Velocity = defineComponent('Velocity', { dx: 'f32', dy: 'f32' });
1225
- * const world = new World();
1226
- * const e = world.spawn({ component: Position, data: { x: 0, y: 0 } }).unwrap();
1227
- * const r = world.addComponent(e, { component: Velocity, data: { dx: 1, dy: 0 } });
1228
- * if (!r.ok) { return; } // r.error.code === 'stale-entity' on dead handle
1229
- * r.unwrap();
1230
- * ```
1231
- */
1232
- addComponent<S extends ComponentSchema>(
1233
- entity: EntityHandle,
1234
- componentData: ComponentData<S>,
1235
- ): Result<void, EcsError> {
1236
- return this._addComponentCore(entity, componentData, false);
1237
- }
1238
-
1239
- /**
1240
- * Core implementation of `addComponent` with reentry guard.
1241
- *
1242
- * @param internal — `true` when called from relationship maintenance
1243
- * (lazy mirror create or exclusive reparent).
1244
- * @internal
1245
- */
1246
- _addComponentCore<S extends ComponentSchema>(
1247
- entity: EntityHandle,
1248
- componentData: ComponentData<S>,
1249
- internal: boolean,
1250
- resolveRequirements = true,
1251
- skipVariableArrayInitialization = false,
1252
- ): Result<void, EcsError> {
1253
- const record = this.lookupAlive(entity, 'addComponent', componentData.component.name);
1254
- if (!record.ok) return record;
1255
-
1256
- const rec = record.value;
1257
- let srcArch = this.graph.archetypes[rec.archetypeId];
1258
- /* istanbul ignore next -- defensive: alive record always has valid archetypeId */
1259
- if (!srcArch) {
1260
- return err(
1261
- new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
1262
- operation: 'addComponent',
1263
- component: componentData.component.name,
1264
- expectedGeneration: entityGeneration(entity),
1265
- actualGeneration: rec.generation,
1266
- }),
1267
- );
1268
- }
1269
-
1270
- const preflight = this.preflightComponentData(entity, componentData);
1271
- if (!preflight.ok) return preflight;
1272
-
1273
- // bug-20260615: unknown-key fail-fast BEFORE archetype mutation so a
1274
- // typo aborts cleanly without partial state (mirrors _spawnCore).
1275
- const keyErr = validateComponentDataKeys(
1276
- componentData.component,
1277
- componentData.data as Record<string, unknown>,
1278
- );
1279
- if (keyErr !== null) {
1280
- return err(keyErr as unknown as EcsError);
1281
- }
1282
- const arrayErr = validateManagedArrayValues(
1283
- componentData.component,
1284
- componentData.data as Record<string, unknown>,
1285
- );
1286
- if (arrayErr !== null) {
1287
- return err(arrayErr as unknown as EcsError);
1288
- }
1289
- // feat-20260713 M2 / w9: P3 shared-field value gate (see _spawnCore). Runs
1290
- // before archetype mutation so a mis-bound GUID aborts cleanly.
1291
- const sharedErr = validateSharedFieldValues(
1292
- componentData.component,
1293
- componentData.data as Record<string, unknown>,
1294
- );
1295
- if (sharedErr !== null) {
1296
- return err(sharedErr as unknown as EcsError);
1297
- }
1298
- const filled = fillComponentDefaults(
1299
- componentData.component,
1300
- componentData.data as Record<string, unknown>,
1301
- );
1302
- const enumErr = validateEnumFieldValues(componentData.component, filled, entity as number);
1303
- if (enumErr !== null) {
1304
- return err(enumErr as unknown as EcsError);
1305
- }
1306
-
1307
- const localId = componentId(componentData.component);
1308
- let relationshipPreparation: RelationshipPreparation | undefined;
1309
- const componentAlreadyPresent = srcArch.components.some(
1310
- (candidate) => componentId(candidate) === localId,
1311
- );
1312
- // Reserve relationship target storage before resolving required source
1313
- // components. A failed mirror allocation must not leave a requirement
1314
- // component (or its epochs) behind on the source entity.
1315
- if (
1316
- !internal &&
1317
- !componentAlreadyPresent &&
1318
- relationshipRole(componentData.component as Component)?.kind === 'source'
1319
- ) {
1320
- const prepared = this.prepareRelationshipInsert(
1321
- componentData.component as Component,
1322
- filled as Record<string, unknown>,
1323
- );
1324
- if (!prepared.ok) return prepared;
1325
- relationshipPreparation = prepared.value;
1326
- }
1327
-
1328
- // Generic component requirements are resolved once at the structural
1329
- // boundary. Explicit data remains authoritative; only missing required
1330
- // identities are added before the requested component is migrated.
1331
- if (resolveRequirements) {
1332
- const required = expandComponentRequirements([componentData]).slice(1);
1333
- for (const requirement of required) {
1334
- if (
1335
- srcArch.components.some(
1336
- (candidate) => componentId(candidate) === componentId(requirement.component),
1337
- )
1338
- ) {
1339
- continue;
1340
- }
1341
- // The closure is expanded once above. Bypass requirement expansion for
1342
- // each member so malformed dependency cycles remain finite and the
1343
- // structural work still happens in one deterministic sequence.
1344
- const added = this._addComponentCore(entity, requirement as ComponentData, internal, false);
1345
- if (!added.ok) {
1346
- this.releaseRelationshipPreparation(relationshipPreparation);
1347
- return added;
1348
- }
1349
- srcArch = this.graph.archetypes[rec.archetypeId];
1350
- if (!srcArch) {
1351
- this.releaseRelationshipPreparation(relationshipPreparation);
1352
- return err(
1353
- new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
1354
- operation: 'addComponent',
1355
- component: componentData.component.name,
1356
- expectedGeneration: rec.generation,
1357
- actualGeneration: rec.generation,
1358
- }),
1359
- );
1360
- }
1361
- }
1362
- }
1363
-
1364
- // Check if entity already has this component (using World-local ID).
1365
- if (srcArch.components.some((candidate) => componentId(candidate) === localId)) {
1366
- this.releaseRelationshipPreparation(relationshipPreparation);
1367
- // M2 exclusive relationship: re-adding the holder with a (possibly new)
1368
- // target auto-reparents instead of failing (AC-12). Route the complete
1369
- // source mutation through one owner operation so the old mirror, new
1370
- // mirror, source scalar, and backpointer converge atomically. Engine
1371
- // internal mirror maintenance never re-adds a source component.
1372
- const role = relationshipRole(componentData.component as Component);
1373
- if (role?.kind === 'source' && role.exclusive && !internal) {
1374
- return this.setRelationshipSource(
1375
- entity,
1376
- componentData.component as Component,
1377
- filled as Record<string, unknown>,
1378
- rec,
1379
- srcArch,
1380
- true,
1381
- );
1382
- }
1383
- return err(new ComponentAlreadyPresentError(entity as number, componentData.component.name));
1384
- }
1385
-
1386
- // Get target archetype via edge cache.
1387
- const targetArch = getAddEdge(
1388
- this.graph,
1389
- srcArch,
1390
- localId,
1391
- componentData.component as Component,
1392
- );
1393
-
1394
- if (componentData.component.storage === 'sparse') {
1395
- this.storage.moveEntityArchetype(rec, srcArch, targetArch);
1396
- } else {
1397
- this.storage.migrateEntity(rec, srcArch, targetArch);
1398
- }
1399
-
1400
- // Write the new component's data. Apply layer-2 + layer-3 silent
1401
- // fallback so addComponent shares the SAME default-resolution path
1402
- // as spawn / SceneAsset.instantiate (feat-20260517 / M2 / AC-04
1403
- // research §F4 auto-symmetry; ComponentData<S>['data'] is the
1404
- // physical bridge).
1405
- if (componentData.component.storage === 'table') {
1406
- this.storage.writeRow(
1407
- targetArch,
1408
- componentData.component,
1409
- this.tableRow(rec),
1410
- filled as ShapeOf<S>,
1411
- skipVariableArrayInitialization ? { skipVariableArrayInitialization: true } : undefined,
1412
- );
1413
- }
1414
- this.markComponentAdded(entity, componentData.component as Component);
1415
- // Relationship sync: append to the materialized target list.
1416
- if (!internal && relationshipRole(componentData.component as Component)?.kind === 'source') {
1417
- const relationshipResult = this.relationshipOnInsert(
1418
- entity,
1419
- componentData.component as Component,
1420
- filled as Record<string, unknown>,
1421
- relationshipPreparation,
1422
- );
1423
- if (!relationshipResult.ok) {
1424
- this.releaseRelationshipPreparation(relationshipPreparation);
1425
- return relationshipResult;
1426
- }
1427
- }
1428
-
1429
- this.markStructureChanged();
1430
- this.state.recordStructuralEvidence({
1431
- kind: 'component-added',
1432
- entity,
1433
- componentId: localId,
1434
- });
1435
- return ok(undefined);
1436
- }
1437
-
1438
- /**
1439
- * Remove a component from an existing entity, triggering archetype migration.
1440
- *
1441
- * @returns `Result<void, EcsError>` —
1442
- * `ok(void)` on success;
1443
- * `err(StaleEntityError)` (`.code = 'stale-entity'`) if entity is dead;
1444
- * `err(ComponentNotPresentError)` (`.code = 'component-not-present'`)
1445
- * if entity doesn't have the component (E-04).
1446
- *
1447
- * @example
1448
- * ```ts
1449
- * const Position = defineComponent('Position', { x: 'f32', y: 'f32' });
1450
- * const world = new World();
1451
- * const e = world.spawn({ component: Position, data: { x: 0, y: 0 } }).unwrap();
1452
- * const r = world.removeComponent(e, Position);
1453
- * if (!r.ok) { return; } // r.error.code === 'stale-entity' on dead handle
1454
- * r.unwrap();
1455
- * ```
1456
- */
1457
- removeComponent<S extends ComponentSchema>(
1458
- entity: EntityHandle,
1459
- component: Component<string, S>,
1460
- ): Result<void, EcsError> {
1461
- return this._removeComponentCore(entity, component, false);
1462
- }
1463
-
1464
- /**
1465
- * Core implementation of `removeComponent` with reentry guard.
1466
- *
1467
- * @param internal — `true` when called from relationship maintenance
1468
- * (exclusive reparent).
1469
- * @internal
1470
- */
1471
- _removeComponentCore<S extends ComponentSchema>(
1472
- entity: EntityHandle,
1473
- component: Component<string, S>,
1474
- internal: boolean,
1475
- ): Result<void, EcsError> {
1476
- // Essential-component hard reject (feat-20260602 / plan-strategy D-3): the
1477
- // id=0 `Entity` component is carried by every archetype unconditionally (it
1478
- // is the row's own packed handle) and cannot be removed. Reject before any
1479
- // liveness lookup so the rejection is structural, not entity-state-dependent.
1480
- if (componentId(component) === componentId(EntityComponent)) {
1481
- return err(new RemoveEssentialComponentError(component.name));
1482
- }
1483
-
1484
- const record = this.lookupAlive(entity, 'removeComponent', component.name);
1485
- if (!record.ok) return record;
1486
-
1487
- const rec = record.value;
1488
- const srcArch = this.graph.archetypes[rec.archetypeId];
1489
- /* istanbul ignore next -- defensive: alive record always has valid archetypeId */
1490
- if (!srcArch) {
1491
- return err(
1492
- new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
1493
- operation: 'removeComponent',
1494
- component: component.name,
1495
- expectedGeneration: entityGeneration(entity),
1496
- actualGeneration: rec.generation,
1497
- }),
1498
- );
1499
- }
1500
-
1501
- // Check if entity has this component (using World-local ID).
1502
- const localId = componentId(component);
1503
- if (!srcArch.components.some((candidate) => componentId(candidate) === localId)) {
1504
- return err(new ComponentNotPresentError(entity as number, component.name));
1505
- }
1506
-
1507
- // Capture the old relationship value before column removal so the
1508
- // materialized target list can be pruned.
1509
- const role = relationshipRole(component as Component);
1510
- const needsOldValue = role?.kind === 'source' && !internal;
1511
- if (needsOldValue) {
1512
- const oldValue = this.storage.readRow(
1513
- srcArch,
1514
- component as Component,
1515
- this.tableRow(rec),
1516
- ) as Record<string, unknown>;
1517
- // Relationship sync: prune the holder from the target's materialized list.
1518
- if (role?.kind === 'source' && !internal) {
1519
- const relation = this.relationshipOnRemove(entity, component as Component, oldValue);
1520
- if (!relation.ok) return relation;
1521
- }
1522
- }
1523
-
1524
- // M1 release loop (removeComponent path): release every `ref<T>` field
1525
- // on the component being removed before migration drops the row.
1526
- if (component.storage === 'table') {
1527
- this.storage.releaseManagedRefsOnRow(srcArch, component as Component, this.tableRow(rec));
1528
- }
1529
-
1530
- // Get target archetype via edge cache.
1531
- const targetArch = getRemoveEdge(this.graph, srcArch, localId);
1532
-
1533
- if (component.storage === 'sparse') {
1534
- this.storage.moveEntityArchetype(rec, srcArch, targetArch);
1535
- const set = this.graph.sparseTags.get(componentId(component));
1536
- if (set !== undefined) removeSparseTag(set, entity);
1537
- } else {
1538
- this.storage.migrateEntity(rec, srcArch, targetArch);
1539
- }
1540
- this.markStructureChanged();
1541
- this.state.recordStructuralEvidence({
1542
- kind: 'component-removed',
1543
- entity,
1544
- componentId: localId,
1545
- });
1546
- return ok(undefined);
1547
- }
1548
-
1549
- // ──────────────────────────────────────────────────────────────────────────
1550
- // Internal — deferred command support (CommandBuffer interface)
1551
- // ──────────────────────────────────────────────────────────────────────────
1552
-
1553
- /**
1554
- * @internal Allocate a pending entity for deferred spawn.
1555
- * Returns an Entity handle. The entity is "pending" because
1556
- * archetypeId === -1 (set by allocateIndex); no separate flag needed.
1557
- */
1558
- _allocatePendingEntity(): EntityHandle {
1559
- const indexSlot = this.allocateIndex();
1560
- // biome-ignore lint/style/noNonNullAssertion: allocateIndex guarantees a valid slot with an initialized record
1561
- return encodeEntity(indexSlot, this.records[indexSlot]!.generation);
1562
- }
1563
-
1564
- /**
1565
- * Return a deferred-spawn reservation to the free-list without publishing a
1566
- * row or advancing an epoch. CommandBuffer.abort is the sole caller; a
1567
- * materialized entity is intentionally left untouched so an unexpected
1568
- * post-write failure poisons the World instead of attempting an unsafe undo.
1569
- */
1570
- _cancelPendingEntity(entity: EntityHandle): void {
1571
- const slot = entityIndex(entity);
1572
- const record = this.records[slot];
1573
- if (record === undefined || record.generation !== entityGeneration(entity)) return;
1574
- if (record.archetypeId !== -1 || record.archetypeRow !== -1) return;
1575
- record.generation += 1;
1576
- if (!isRetiredSlot(record.generation)) this.freeIndices.push(slot);
1577
- }
1578
-
1579
- /**
1580
- * @internal Materialize a pending entity: actually place it into an archetype.
1581
- * Idempotent: a record with archetypeId !== -1 is already materialized.
1582
- */
1583
- _materializePendingEntity(
1584
- entity: EntityHandle,
1585
- componentDatas: ComponentData[],
1586
- ): Result<void, EcsError> {
1587
- componentDatas = expandComponentRequirements(componentDatas);
1588
- const slot = entityIndex(entity);
1589
- const record = this.records[slot];
1590
- if (!record || record.archetypeId !== -1) return ok(undefined);
1591
-
1592
- // Reserve target-side relationship storage before materializing this
1593
- // pending row. The command preflight has already checked the batch graph;
1594
- // this owner-level reservation closes the remaining managed-capacity
1595
- // failure window without adding a mirror component or advancing an epoch.
1596
- const relationshipPreparations: (RelationshipPreparation | undefined)[] = [];
1597
- for (let index = 0; index < componentDatas.length; index += 1) {
1598
- const componentData = componentDatas[index];
1599
- if (
1600
- componentData === undefined ||
1601
- relationshipRole(componentData.component as Component)?.kind !== 'source'
1602
- ) {
1603
- continue;
1604
- }
1605
- const filled = fillComponentDefaults(
1606
- componentData.component,
1607
- componentData.data as Record<string, unknown>,
1608
- );
1609
- const target = this.relationshipTargetEntity(
1610
- componentData.component as Component,
1611
- filled as Record<string, unknown>,
1612
- );
1613
- const targetRecord = target === null ? undefined : this.records[entityIndex(target)];
1614
- // A pending target is materialized by an earlier command in the normal
1615
- // supported order. Leave it to the existing relationship callback when
1616
- // that target row becomes live; current-world targets are fully
1617
- // prepared before this source row is appended.
1618
- if (
1619
- target !== null &&
1620
- targetRecord !== undefined &&
1621
- this.recordIsLive(targetRecord, entityGeneration(target))
1622
- ) {
1623
- const prepared = this.prepareRelationshipInsert(
1624
- componentData.component as Component,
1625
- filled as Record<string, unknown>,
1626
- );
1627
- if (!prepared.ok) {
1628
- for (const reservation of relationshipPreparations) {
1629
- this.releaseRelationshipPreparation(reservation);
1630
- }
1631
- return prepared;
1632
- }
1633
- relationshipPreparations[index] = prepared.value;
1634
- }
1635
- }
1636
-
1637
- // Find or create target archetype (using World-local IDs).
1638
- const componentIds = componentDatas.map((cd) => componentId(cd.component));
1639
- const components = componentDatas.map((cd) => cd.component);
1640
- const arch = getOrCreateArchetype(this.graph, componentIds, components);
1641
-
1642
- // Append entity row.
1643
- const table = this.table(arch);
1644
- const tableRow = appendTableRow(table, entity);
1645
- const archetypeRow = appendArchetypeRow(arch, tableRow);
1646
- record.archetypeId = arch.id;
1647
- record.archetypeRow = archetypeRow;
1648
-
1649
- // Write initial data. Apply layer-2 + layer-3 silent fallback so
1650
- // deferred-spawn (Commands.spawn) shares the SAME default-resolution
1651
- // path as the synchronous `world.spawn` / `addComponent` /
1652
- // SceneAsset.instantiate (feat-20260517 / M2 / AC-04 + AC-09).
1653
- this.state.markComponentsAdded(entity, [
1654
- componentId(EntityComponent),
1655
- ...componentDatas.map((cd) => componentId(cd.component)),
1656
- ]);
1657
- for (const cd of componentDatas) {
1658
- const filled = fillComponentDefaults(cd.component, cd.data as Record<string, unknown>);
1659
- this.storage.writeRow(arch, cd.component, tableRow, filled as ShapeOf<ComponentSchema>);
1660
- }
1661
-
1662
- // Essential id=0 `Entity` column write (feat-20260602 / plan-strategy D-3),
1663
- // mirroring the synchronous `spawn` path: the deferred handle was minted at
1664
- // `_allocatePendingEntity` time and is passed in here.
1665
- this.storage.writeEntitySelf(arch, tableRow, entity);
1666
-
1667
- // Publish relationship targets after all rows are written.
1668
- for (let index = 0; index < componentDatas.length; index += 1) {
1669
- const cd = componentDatas[index];
1670
- if (cd === undefined) continue;
1671
- if (relationshipRole(cd.component as Component)?.kind === 'source') {
1672
- const filled = fillComponentDefaults(cd.component, cd.data as Record<string, unknown>);
1673
- const relationshipResult = this.relationshipOnInsert(
1674
- entity,
1675
- cd.component as Component,
1676
- filled as Record<string, unknown>,
1677
- relationshipPreparations[index],
1678
- );
1679
- if (!relationshipResult.ok) {
1680
- for (const reservation of relationshipPreparations) {
1681
- this.releaseRelationshipPreparation(reservation);
1682
- }
1683
- return relationshipResult;
1684
- }
1685
- }
1686
- }
1687
- this.markStructureChanged();
1688
- this.state.recordStructuralEvidence({
1689
- kind: 'spawn',
1690
- entity,
1691
- });
1692
- return ok(undefined);
1693
- }
1694
-
1695
- // ──────────────────────────────────────────────────────────────────────────
1696
- // Internal — entity index allocation
1697
- // ──────────────────────────────────────────────────────────────────────────
1698
-
1699
- allocateIndex(): number {
1700
- const recycled = this.freeIndices.pop();
1701
- if (recycled !== undefined) {
1702
- return recycled;
1703
- }
1704
- const slot = this.records.length;
1705
- if (slot > ENTITY_MAX_INDEX) {
1706
- throw new EntityIndexOverflowError(slot);
1707
- }
1708
- this.records.push({ generation: 0, archetypeId: -1, archetypeRow: -1 });
1709
- return slot;
1710
- }
1711
-
1712
- /**
1713
- * Single liveness predicate (feat-20260602 / plan-strategy D-4): a slot is
1714
- * live for a given handle generation iff the record exists, its generation
1715
- * still matches the handle (despawn bumps generation, so a stale or recycled
1716
- * handle fails here), and the slot is materialized into an archetype
1717
- * (archetypeId !== -1). Replaces the former `record.alive && record.generation
1718
- * === gen` conjunction and the intermediate `!record.pending` clause.
1719
- */
1720
- recordIsLive(record: EntityRecord | undefined, gen: number): record is EntityRecord {
1721
- return record !== undefined && record.generation === gen && record.archetypeId !== -1;
1722
- }
1723
-
1724
- lookupAlive(
1725
- entity: EntityHandle,
1726
- operation: string,
1727
- component?: string,
1728
- ): Result<EntityRecord, EcsError> {
1729
- const slot = entityIndex(entity);
1730
- const gen = entityGeneration(entity);
1731
- const record = this.records[slot];
1732
- if (!this.recordIsLive(record, gen)) {
1733
- return err(
1734
- new StaleEntityError(entity as number, slot, gen, {
1735
- operation,
1736
- ...(component !== undefined ? { component } : {}),
1737
- expectedGeneration: gen,
1738
- actualGeneration: this.records[slot]?.generation ?? -1,
1739
- }),
1740
- );
1741
- }
1742
- return ok(record);
1743
- }
1744
-
1745
- readRow<S extends ComponentSchema>(
1746
- arch: Archetype,
1747
- component: Component<string, S>,
1748
- row: number,
1749
- ): ShapeOf<S> {
1750
- return this.storage.readRow(arch, component, row);
1751
- }
1752
-
1753
- writeEntitySelf(arch: Archetype, row: number, handle: EntityHandle): void {
1754
- this.storage.writeEntitySelf(arch, row, handle);
1755
- }
1756
-
1757
- writeRow<S extends ComponentSchema>(
1758
- arch: Archetype,
1759
- component: Component<string, S>,
1760
- row: number,
1761
- value: ShapeOf<S>,
1762
- ): void {
1763
- this.storage.writeRow(arch, component, row, value);
1764
- }
1765
-
1766
- releaseManagedRefsOnRow(arch: Archetype, component: Component, row: number): void {
1767
- this.storage.releaseManagedRefsOnRow(arch, component, row);
1768
- }
1769
- }