@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
@@ -22,7 +22,7 @@ import {
22
22
  TYPE_METADATA,
23
23
  } from './component';
24
24
  import { fillComponentDefaults, validateComponentDataKeys } from './component-default-fallback';
25
- import { componentDefinition } from './component-schema';
25
+ import { componentDefinition, expandComponentRequirements } from './component-schema';
26
26
  import { validateManagedArrayValues, validateSharedFieldValues } from './component-value-validate';
27
27
  import { Entity as EntityComponent } from './entity';
28
28
  import {
@@ -63,6 +63,7 @@ import {
63
63
  } from './storage/archetype-graph';
64
64
  import { removeSparseTag } from './storage/change-detection';
65
65
  import { arrayCountColumnName, type FieldView, normalizeBufferWrite } from './storage/column';
66
+ import type { StructuralEvidenceInput } from './storage/structural-evidence';
66
67
  import { appendTableRow, type Table } from './storage/table';
67
68
  import type { UniqueRefStore } from './unique-ref-store';
68
69
  import type { ComponentData, EcsError, EntityRecord } from './world';
@@ -70,6 +71,22 @@ import { ComponentStorage } from './world-component-storage';
70
71
 
71
72
  type ErrorContext = { readonly systemName: string };
72
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
+
73
90
  type ArrayFieldsOf<S extends ComponentSchema> = {
74
91
  [K in keyof S]: S[K] extends
75
92
  | `array<${ManagedArrayElementType}>`
@@ -95,6 +112,15 @@ function relationshipPayloadWrites(data: Readonly<Record<string, unknown>>): boo
95
112
  });
96
113
  }
97
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
+
98
124
  export interface ComponentAccessState {
99
125
  readonly graph: ArchetypeGraph;
100
126
  readonly records: EntityRecord[];
@@ -106,8 +132,8 @@ export interface ComponentAccessState {
106
132
  readonly markComponentAdded: (entity: EntityHandle, componentId: number) => void;
107
133
  readonly markComponentsAdded: (entity: EntityHandle, componentIds: readonly number[]) => void;
108
134
  readonly markComponentChanged: (entity: EntityHandle, componentId: number) => void;
109
- readonly removeComponentChange: (entity: EntityHandle, componentId: number) => void;
110
135
  readonly markStructureChanged: () => void;
136
+ readonly recordStructuralEvidence: (evidence: StructuralEvidenceInput) => void;
111
137
  routeError(err: unknown, ctx: ErrorContext): void;
112
138
  }
113
139
 
@@ -160,16 +186,19 @@ export class WorldComponentAccess {
160
186
  return index;
161
187
  }
162
188
 
163
- /** Read the World-owned materialized target array; never consults a shadow list. */
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
+ */
164
195
  relationshipTargetEntries(source: Component, target: EntityHandle): readonly EntityHandle[] {
165
196
  const role = relationshipRole(source);
166
197
  if (role?.kind !== 'source') return [];
167
198
  const mirror = relationshipMirror(source);
168
199
  if (mirror === undefined) return [];
169
- const result = this.get(target, mirror);
170
- if (!result.ok) return [];
171
- const entries = (result.value as Record<string, unknown>)[role.targetField];
172
- return entries !== undefined && typeof entries === 'object' ? (entries as EntityHandle[]) : [];
200
+ const entries = this._getArrayView(target, mirror, role.targetField);
201
+ return entries === undefined ? [] : (entries as unknown as readonly EntityHandle[]);
173
202
  }
174
203
 
175
204
  private markComponentAdded(entity: EntityHandle, component: Component): void {
@@ -269,9 +298,10 @@ export class WorldComponentAccess {
269
298
  const targetRecord = this.records[entityIndex(target)];
270
299
  const actualGeneration = targetRecord?.generation ?? -1;
271
300
  const targetLive = this.recordIsLive(targetRecord, entityGeneration(target));
272
- const holderIsPending =
273
- holder === null || pendingEntities?.has(holder as unknown as number) === true;
274
- if (!targetIsPending && !targetLive && !holderIsPending) {
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) {
275
305
  return err(
276
306
  new StaleEntityError(target as number, entityIndex(target), entityGeneration(target), {
277
307
  operation: 'relationship-insert',
@@ -346,11 +376,18 @@ export class WorldComponentAccess {
346
376
  }
347
377
  }
348
378
 
349
- /** Prepare the target side before a source archetype mutation commits. */
350
- private prepareRelationshipInsert(
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(
351
388
  component: Component,
352
389
  value: Record<string, unknown>,
353
- ): Result<void, EcsError> {
390
+ ): Result<RelationshipPreparation | undefined, EcsError> {
354
391
  const role = relationshipRole(component);
355
392
  if (role?.kind !== 'source') return ok(undefined);
356
393
  const target = this.relationshipTargetEntity(component, value);
@@ -370,19 +407,91 @@ export class WorldComponentAccess {
370
407
  );
371
408
  }
372
409
  const targetArch = this.graph.archetypes[targetRec.archetypeId];
373
- const hasMirror =
374
- targetArch?.components.some((candidate) => componentId(candidate) === componentId(mirror)) ??
375
- false;
376
- if (!hasMirror) {
377
- const added = this._addComponentCore(
378
- target,
379
- { component: mirror, data: {} as Partial<ShapeOf<ComponentSchema>> },
380
- true,
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
+ const maximum = Math.floor(262_144 / meta.byteSize);
436
+ if (!Number.isSafeInteger(currentLength + 1) || currentLength + 1 > maximum) {
437
+ return err(new ManagedBufferOutOfBoundsError(requiredBytes, 262_144));
438
+ }
439
+
440
+ const preparation: RelationshipPreparation = {
441
+ target,
442
+ mirror,
443
+ fieldName,
444
+ mirrorPresent,
445
+ reservedSlotId: undefined,
446
+ };
447
+ if (!mirrorPresent || slotId === 0) {
448
+ const allocated = this.bufferPool.alloc(requiredBytes);
449
+ if (!allocated.ok) return allocated;
450
+ preparation.reservedSlotId = allocated.value.id;
451
+ } else if (this.bufferPool.view(slotId).byteLength < requiredBytes) {
452
+ const grown = this.bufferPool.grow(slotId, requiredBytes);
453
+ if (!grown.ok) return grown;
454
+ }
455
+ return ok(preparation);
456
+ }
457
+
458
+ /** Release a target-capacity reservation that did not reach commit. */
459
+ releaseRelationshipPreparation(preparation: RelationshipPreparation | undefined): void {
460
+ if (preparation === undefined || preparation.reservedSlotId === undefined) return;
461
+ const slotId = preparation.reservedSlotId;
462
+ this.bufferPool.release(slotId);
463
+ preparation.reservedSlotId = undefined;
464
+ }
465
+
466
+ /** Install a reserved slot after the target mirror archetype exists. */
467
+ private installRelationshipPreparation(
468
+ preparation: RelationshipPreparation,
469
+ ): Result<void, EcsError> {
470
+ const record = this.lookupAlive(
471
+ preparation.target,
472
+ 'relationship-capacity',
473
+ preparation.mirror.name,
474
+ );
475
+ if (!record.ok) return record;
476
+ const arch = this.graph.archetypes[record.value.archetypeId];
477
+ if (arch === undefined) {
478
+ return err(
479
+ new ComponentNotPresentError(preparation.target as number, preparation.mirror.name),
381
480
  );
382
- if (!added.ok) return added;
383
481
  }
384
- const length = this.relationshipTargetEntries(component, target).length;
385
- return this.ensureArrayCapacity(target, mirror, role.targetField as never, length + 1);
482
+ const fieldCols = this.table(arch).storage.get(componentId(preparation.mirror))?.fields;
483
+ const column = fieldCols?.get(preparation.fieldName);
484
+ if (column === undefined) {
485
+ return err(
486
+ new ComponentNotPresentError(preparation.target as number, preparation.mirror.name),
487
+ );
488
+ }
489
+ const slotId = preparation.reservedSlotId;
490
+ if (slotId !== undefined) {
491
+ column.view[this.tableRow(record.value)] = slotId;
492
+ preparation.reservedSlotId = undefined;
493
+ }
494
+ return ok(undefined);
386
495
  }
387
496
 
388
497
  /** Append `holder` to the materialized target list. */
@@ -390,6 +499,7 @@ export class WorldComponentAccess {
390
499
  holder: EntityHandle,
391
500
  component: Component,
392
501
  value: Record<string, unknown>,
502
+ preparation?: RelationshipPreparation,
393
503
  ): Result<void, EcsError> {
394
504
  const role = relationshipRole(component);
395
505
  if (role?.kind !== 'source') return ok(undefined);
@@ -399,19 +509,28 @@ export class WorldComponentAccess {
399
509
  /* istanbul ignore next -- defineComponent relationship validation guarantees mirror exists */
400
510
  if (mirror === undefined) return ok(undefined);
401
511
 
402
- const prepared = this.prepareRelationshipInsert(component, value);
403
- if (!prepared.ok) {
404
- // A dangling source edge is still useful state: hierarchy/animation
405
- // projections report the missing target. The target mirror cannot be
406
- // updated, but insertion itself remains atomic and successful.
407
- if (prepared.error.code === 'stale-entity') return ok(undefined);
408
- return prepared;
512
+ let prepared = preparation;
513
+ if (prepared === undefined) {
514
+ const preparedResult = this.prepareRelationshipInsert(component, value);
515
+ if (!preparedResult.ok) return preparedResult;
516
+ prepared = preparedResult.value;
409
517
  }
518
+ if (prepared === undefined) return ok(undefined);
410
519
 
411
520
  // Lazy-create the mirror component on the target when absent (D-3c).
412
521
  const targetSlot = entityIndex(target);
413
522
  const targetRec = this.records[targetSlot];
414
- if (!this.recordIsLive(targetRec, entityGeneration(target))) return ok(undefined);
523
+ const actualGeneration = targetRec?.generation ?? -1;
524
+ if (!this.recordIsLive(targetRec, entityGeneration(target))) {
525
+ return err(
526
+ new StaleEntityError(target as number, targetSlot, entityGeneration(target), {
527
+ operation: 'relationship-insert',
528
+ component: component.name,
529
+ expectedGeneration: entityGeneration(target),
530
+ actualGeneration: actualGeneration,
531
+ }),
532
+ );
533
+ }
415
534
  const targetArch = this.graph.archetypes[targetRec.archetypeId];
416
535
  const mirrorLocalId = componentId(mirror);
417
536
  const hasMirror =
@@ -424,8 +543,18 @@ export class WorldComponentAccess {
424
543
  data: {} as Partial<ShapeOf<ComponentSchema>>,
425
544
  },
426
545
  true,
546
+ false,
547
+ true,
427
548
  );
428
- if (!added.ok) return added;
549
+ if (!added.ok) {
550
+ this.releaseRelationshipPreparation(prepared);
551
+ return added;
552
+ }
553
+ }
554
+ const installed = this.installRelationshipPreparation(prepared);
555
+ if (!installed.ok) {
556
+ this.releaseRelationshipPreparation(prepared);
557
+ return installed;
429
558
  }
430
559
  const targetEntries = this.relationshipTargetEntries(component, target);
431
560
  const slot = targetEntries.length;
@@ -435,7 +564,10 @@ export class WorldComponentAccess {
435
564
  role.targetField as never,
436
565
  holder as never,
437
566
  );
438
- if (!mirrored.ok) return mirrored;
567
+ if (!mirrored.ok) {
568
+ this.releaseRelationshipPreparation(prepared);
569
+ return mirrored;
570
+ }
439
571
  this.relationshipIndex(component)?.attach(holder, target, slot);
440
572
  return ok(undefined);
441
573
  }
@@ -476,15 +608,16 @@ export class WorldComponentAccess {
476
608
  /**
477
609
  * Read component data from an entity.
478
610
  *
479
- * **Transient view contract (feat-20260602):** for fixed-capacity
480
- * `array<T,N>` and `buffer<N>` fields, the returned `TypedArray` (and any
481
- * subarray of it) aliases the archetype column buffer directly. The view is
482
- * valid only until the next structural change (`spawn` / `despawn` /
483
- * `addComponent` / `removeComponent`). Holding a view across a structural
484
- * change is undefined behaviour -- the backing `ArrayBuffer` is detached on
485
- * column growth, and swap-remove at the same row index points to the wrong
486
- * entity. **Re-fetch `world.get(e, C)` on every access.** See
487
- * `packages/ecs/README.md` Transient view contract section.
611
+ * **Public array contract:** relationship target `array<entity>` fields are
612
+ * detached `Uint32Array` copies. Mutating that returned array cannot alter
613
+ * the materialized target, relationship index, or source. Other array
614
+ * fields retain the existing transient view contract: fixed-capacity
615
+ * `array<T,N>` and `buffer<N>` fields alias the archetype column buffer
616
+ * directly, while variable managed arrays alias their BufferPool slot. Those
617
+ * views are valid only until the next structural change (`spawn` /
618
+ * `despawn` / `addComponent` / `removeComponent`); callers must re-fetch
619
+ * `world.get(e, C)` on every access. Internal owners use `readRow` and
620
+ * `_getArrayView` directly and retain zero-copy access.
488
621
  *
489
622
  * @returns `Result<ShapeOf<S>, EcsError>` —
490
623
  * `ok(ShapeOf<S>)` on success;
@@ -532,7 +665,13 @@ export class WorldComponentAccess {
532
665
  return err(new ComponentNotPresentError(entity as number, component.name));
533
666
  }
534
667
 
535
- return ok(this.storage.readRow(arch, component, this.tableRow(rec)));
668
+ const value = this.storage.readRow(arch, component, this.tableRow(rec));
669
+ const role = relationshipRole(component);
670
+ if (role?.kind === 'target') {
671
+ const targetValue = value as Record<string, unknown>;
672
+ targetValue[role.targetField] = detachRelationshipTargetArray(targetValue[role.targetField]);
673
+ }
674
+ return ok(value);
536
675
  }
537
676
 
538
677
  /**
@@ -567,9 +706,10 @@ export class WorldComponentAccess {
567
706
  * Returns `undefined` when the entity is dead, the component is absent, the
568
707
  * field does not exist, or the field is not an `array<...>` column.
569
708
  *
570
- * @internal Engine-internal fast path; AI users read the typed view through
571
- * `world.get(e, Transform).world`. The accessor is the zero-materialization
572
- * route the propagate kernel and render walk use.
709
+ * @internal Engine-internal fast path; AI users read public component values
710
+ * through `world.get`. This accessor is the zero-materialization route the
711
+ * propagate kernel, relationship owner, and render walk use; it bypasses
712
+ * the detached public relationship-target snapshot.
573
713
  */
574
714
  _getArrayView(
575
715
  entity: EntityHandle,
@@ -585,6 +725,141 @@ export class WorldComponentAccess {
585
725
  return this.storage.readArrayView(arch, component, this.tableRow(rec), fieldName);
586
726
  }
587
727
 
728
+ /** Internal zero-materialisation read for ECS-owned relationship lists. */
729
+ _getArrayLength(
730
+ entity: EntityHandle,
731
+ component: Component,
732
+ fieldName: string,
733
+ ): number | undefined {
734
+ const record = this.lookupAlive(entity, 'relationship-read', component.name);
735
+ if (!record.ok) return undefined;
736
+ const arch = this.graph.archetypes[record.value.archetypeId];
737
+ if (arch === undefined) return undefined;
738
+ return this.storage.readArrayLength(arch, component, this.tableRow(record.value), fieldName);
739
+ }
740
+
741
+ /** Internal zero-materialisation read for one ECS-owned array element. */
742
+ _getArrayElement(
743
+ entity: EntityHandle,
744
+ component: Component,
745
+ fieldName: string,
746
+ index: number,
747
+ ): number | undefined {
748
+ const record = this.lookupAlive(entity, 'relationship-read', component.name);
749
+ if (!record.ok) return undefined;
750
+ const arch = this.graph.archetypes[record.value.archetypeId];
751
+ if (arch === undefined) return undefined;
752
+ return this.storage.readArrayElement(
753
+ arch,
754
+ component,
755
+ this.tableRow(record.value),
756
+ fieldName,
757
+ index,
758
+ );
759
+ }
760
+
761
+ /** Internal scalar-column read used by parent-first hierarchy traversal. */
762
+ _getFieldValue(
763
+ entity: EntityHandle,
764
+ component: Component,
765
+ fieldName: string,
766
+ ): number | undefined {
767
+ const record = this.lookupAlive(entity, 'relationship-read', component.name);
768
+ if (!record.ok) return undefined;
769
+ const arch = this.graph.archetypes[record.value.archetypeId];
770
+ if (arch === undefined) return undefined;
771
+ return this.storage.readFieldValue(arch, component, this.tableRow(record.value), fieldName);
772
+ }
773
+
774
+ /**
775
+ * Converge one writable relationship source mutation through the source
776
+ * owner. The source scalar and its materialized target list are committed as
777
+ * one operation; no caller receives a raw source column view that could
778
+ * bypass the mirror/index maintenance.
779
+ */
780
+ private setRelationshipSource(
781
+ entity: EntityHandle,
782
+ component: Component,
783
+ value: Record<string, unknown>,
784
+ record: EntityRecord,
785
+ arch: Archetype,
786
+ markChanged: boolean,
787
+ ): Result<void, EcsError> {
788
+ const role = relationshipRole(component);
789
+ if (role?.kind !== 'source') return ok(undefined);
790
+ const row = this.tableRow(record);
791
+ const current = this.storage.readRow(arch, component, row) as Record<string, unknown>;
792
+ const valuePreflight = this.preflightComponentFieldValues(entity, {
793
+ component,
794
+ data: value as never,
795
+ });
796
+ if (!valuePreflight.ok) return valuePreflight;
797
+ const merged = { ...current, ...value };
798
+ const enumError = validateEnumFieldValues(component, merged, entity as number);
799
+ if (enumError !== null) return err(enumError as unknown as EcsError);
800
+
801
+ const oldTarget = this.relationshipTargetEntity(component, current);
802
+ const target = this.relationshipTargetEntity(component, merged);
803
+ if (target !== null) {
804
+ const targetRecord = this.records[entityIndex(target)];
805
+ const actualGeneration = targetRecord?.generation ?? -1;
806
+ if (!this.recordIsLive(targetRecord, entityGeneration(target))) {
807
+ return err(
808
+ new StaleEntityError(target as number, entityIndex(target), entityGeneration(target), {
809
+ operation: 'relationship-insert',
810
+ component: component.name,
811
+ expectedGeneration: entityGeneration(target),
812
+ actualGeneration,
813
+ }),
814
+ );
815
+ }
816
+ const roleAllowsSelf = role.allowSelf;
817
+ if (entity === target && !roleAllowsSelf) {
818
+ return err(
819
+ new RelationshipSelfCycleError(component.name, entity as number, target as number),
820
+ );
821
+ }
822
+ const cycleHit =
823
+ entity === target && roleAllowsSelf
824
+ ? null
825
+ : this.relationshipCycleHit(component, target, entity);
826
+ if (cycleHit !== null) {
827
+ return err(
828
+ new RelationshipSelfCycleError(component.name, entity as number, cycleHit as number),
829
+ );
830
+ }
831
+ }
832
+
833
+ // A same-target source write still counts as authored evidence but does
834
+ // not churn the target mirror or relationship index.
835
+ if (oldTarget !== target) {
836
+ const prepared = this.prepareRelationshipInsert(component, merged);
837
+ if (!prepared.ok) return prepared;
838
+ const preparation = prepared.value;
839
+ if (oldTarget !== null) {
840
+ const detached = this.relationshipOnRemove(entity, component, current);
841
+ if (!detached.ok) {
842
+ this.releaseRelationshipPreparation(preparation);
843
+ return detached;
844
+ }
845
+ }
846
+ const attached = this.relationshipOnInsert(entity, component, merged, preparation);
847
+ if (!attached.ok) {
848
+ this.releaseRelationshipPreparation(preparation);
849
+ return attached;
850
+ }
851
+ }
852
+
853
+ const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
854
+ const sourceColumn = fieldCols?.get(role.sourceField);
855
+ if (sourceColumn === undefined) {
856
+ return err(new ComponentNotPresentError(entity as number, component.name));
857
+ }
858
+ sourceColumn.view[row] = target === null ? ENTITY_NULL_RAW : (target as number);
859
+ if (markChanged) this.markComponentChanged(entity, component);
860
+ return ok(undefined);
861
+ }
862
+
588
863
  /**
589
864
  * Write (partial) component data to an entity.
590
865
  *
@@ -631,6 +906,20 @@ export class WorldComponentAccess {
631
906
  // F-02: set on missing component returns err instead of silent ignore
632
907
  return err(new ComponentNotPresentError(entity as number, component.name));
633
908
  }
909
+ const role = relationshipRole(component as Component);
910
+ if (role?.kind === 'target') {
911
+ return err(new RelationshipTargetReadonlyError(component.name, 'set'));
912
+ }
913
+ if (role?.kind === 'source') {
914
+ return this.setRelationshipSource(
915
+ entity,
916
+ component,
917
+ value as Record<string, unknown>,
918
+ rec,
919
+ arch,
920
+ markChanged,
921
+ );
922
+ }
634
923
  const valuePreflight = this.preflightComponentFieldValues(entity, {
635
924
  component,
636
925
  data: value,
@@ -874,58 +1163,6 @@ export class WorldComponentAccess {
874
1163
  return ok(undefined);
875
1164
  }
876
1165
 
877
- private ensureArrayCapacity<S extends ComponentSchema, K extends ArrayFieldsOf<S>>(
878
- entity: EntityHandle,
879
- component: Component<string, S>,
880
- fieldName: K,
881
- minimum: number,
882
- ): Result<void, EcsError> {
883
- const record = this.lookupAlive(entity, 'relationship-capacity', component.name);
884
- if (!record.ok) return record;
885
- const rec = record.value;
886
- const arch = this.graph.archetypes[rec.archetypeId];
887
- if (!arch) {
888
- return err(
889
- new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
890
- operation: 'relationship-capacity',
891
- component: component.name,
892
- expectedGeneration: entityGeneration(entity),
893
- actualGeneration: rec.generation,
894
- }),
895
- );
896
- }
897
- const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
898
- if (!fieldCols) return err(new ComponentNotPresentError(entity as number, component.name));
899
- const fieldNameStr = fieldName as string;
900
- const col = fieldCols.get(fieldNameStr);
901
- if (!col) return err(new ComponentNotPresentError(entity as number, component.name));
902
- const arrayMeta = componentDefinition(component).fields[fieldNameStr]?.arrayMeta;
903
- if (arrayMeta === undefined) {
904
- return err(new ComponentNotPresentError(entity as number, component.name));
905
- }
906
- const meta = TYPE_METADATA[arrayMeta.elementType];
907
- if (!meta?.byteSize) {
908
- return err(new ComponentNotPresentError(entity as number, component.name));
909
- }
910
- const maximum = Math.floor(262_144 / meta.byteSize);
911
- if (!Number.isSafeInteger(minimum) || minimum < 0 || minimum > maximum) {
912
- return err(new ManagedBufferOutOfBoundsError(minimum, maximum));
913
- }
914
-
915
- const byteLength = minimum * meta.byteSize;
916
- const slotId = col.view[this.tableRow(rec)] as number;
917
- if (slotId === 0) {
918
- if (minimum === 0) return ok(undefined);
919
- const allocated = this.bufferPool.alloc(byteLength);
920
- if (!allocated.ok) return allocated;
921
- col.view[this.tableRow(rec)] = allocated.value.id;
922
- return ok(undefined);
923
- }
924
- if (this.bufferPool.view(slotId).byteLength >= byteLength) return ok(undefined);
925
- const grown = this.bufferPool.grow(slotId, byteLength);
926
- return grown.ok ? ok(undefined) : grown;
927
- }
928
-
929
1166
  /**
930
1167
  * Remove one variable-array element at a known slot. Relationship holders
931
1168
  * supply the slot from their backpointer, so this is O(1) and never scans
@@ -1011,12 +1248,14 @@ export class WorldComponentAccess {
1011
1248
  entity: EntityHandle,
1012
1249
  componentData: ComponentData<S>,
1013
1250
  internal: boolean,
1251
+ resolveRequirements = true,
1252
+ skipVariableArrayInitialization = false,
1014
1253
  ): Result<void, EcsError> {
1015
1254
  const record = this.lookupAlive(entity, 'addComponent', componentData.component.name);
1016
1255
  if (!record.ok) return record;
1017
1256
 
1018
1257
  const rec = record.value;
1019
- const srcArch = this.graph.archetypes[rec.archetypeId];
1258
+ let srcArch = this.graph.archetypes[rec.archetypeId];
1020
1259
  /* istanbul ignore next -- defensive: alive record always has valid archetypeId */
1021
1260
  if (!srcArch) {
1022
1261
  return err(
@@ -1066,45 +1305,85 @@ export class WorldComponentAccess {
1066
1305
  return err(enumErr as unknown as EcsError);
1067
1306
  }
1068
1307
 
1069
- // Check if entity already has this component (using World-local ID).
1070
1308
  const localId = componentId(componentData.component);
1309
+ let relationshipPreparation: RelationshipPreparation | undefined;
1310
+ const componentAlreadyPresent = srcArch.components.some(
1311
+ (candidate) => componentId(candidate) === localId,
1312
+ );
1313
+ // Reserve relationship target storage before resolving required source
1314
+ // components. A failed mirror allocation must not leave a requirement
1315
+ // component (or its epochs) behind on the source entity.
1316
+ if (
1317
+ !internal &&
1318
+ !componentAlreadyPresent &&
1319
+ relationshipRole(componentData.component as Component)?.kind === 'source'
1320
+ ) {
1321
+ const prepared = this.prepareRelationshipInsert(
1322
+ componentData.component as Component,
1323
+ filled as Record<string, unknown>,
1324
+ );
1325
+ if (!prepared.ok) return prepared;
1326
+ relationshipPreparation = prepared.value;
1327
+ }
1328
+
1329
+ // Generic component requirements are resolved once at the structural
1330
+ // boundary. Explicit data remains authoritative; only missing required
1331
+ // identities are added before the requested component is migrated.
1332
+ if (resolveRequirements) {
1333
+ const required = expandComponentRequirements([componentData]).slice(1);
1334
+ for (const requirement of required) {
1335
+ if (
1336
+ srcArch.components.some(
1337
+ (candidate) => componentId(candidate) === componentId(requirement.component),
1338
+ )
1339
+ ) {
1340
+ continue;
1341
+ }
1342
+ // The closure is expanded once above. Bypass requirement expansion for
1343
+ // each member so malformed dependency cycles remain finite and the
1344
+ // structural work still happens in one deterministic sequence.
1345
+ const added = this._addComponentCore(entity, requirement as ComponentData, internal, false);
1346
+ if (!added.ok) {
1347
+ this.releaseRelationshipPreparation(relationshipPreparation);
1348
+ return added;
1349
+ }
1350
+ srcArch = this.graph.archetypes[rec.archetypeId];
1351
+ if (!srcArch) {
1352
+ this.releaseRelationshipPreparation(relationshipPreparation);
1353
+ return err(
1354
+ new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
1355
+ operation: 'addComponent',
1356
+ component: componentData.component.name,
1357
+ expectedGeneration: rec.generation,
1358
+ actualGeneration: rec.generation,
1359
+ }),
1360
+ );
1361
+ }
1362
+ }
1363
+ }
1364
+
1365
+ // Check if entity already has this component (using World-local ID).
1071
1366
  if (srcArch.components.some((candidate) => componentId(candidate) === localId)) {
1367
+ this.releaseRelationshipPreparation(relationshipPreparation);
1072
1368
  // M2 exclusive relationship: re-adding the holder with a (possibly new)
1073
- // target auto-reparents instead of failing (AC-12). Prune the old side
1074
- // first (removeComponent prunes the old target), then fall through to
1075
- // the normal add (which appends the new target). The two steps keep the
1076
- // materialized target list consistent (AC-13);
1077
- // removeComponent + addComponent each touch the mirror exactly once and
1078
- // the mirror component carries no relationship of its own, so there is
1079
- // no recursion. Reparent only fires for top-level user calls
1080
- // (!internal); engine-internal lazy create / append
1081
- // never re-add an existing relationship component.
1369
+ // target auto-reparents instead of failing (AC-12). Route the complete
1370
+ // source mutation through one owner operation so the old mirror, new
1371
+ // mirror, source scalar, and backpointer converge atomically. Engine
1372
+ // internal mirror maintenance never re-adds a source component.
1082
1373
  const role = relationshipRole(componentData.component as Component);
1083
1374
  if (role?.kind === 'source' && role.exclusive && !internal) {
1084
- const prepared = this.prepareRelationshipInsert(
1085
- componentData.component as Component,
1086
- filled as Record<string, unknown>,
1087
- );
1088
- if (!prepared.ok) return prepared;
1089
- const removeR = this._removeComponentCore(
1375
+ return this.setRelationshipSource(
1090
1376
  entity,
1091
1377
  componentData.component as Component,
1092
- false,
1378
+ filled as Record<string, unknown>,
1379
+ rec,
1380
+ srcArch,
1381
+ true,
1093
1382
  );
1094
- if (!removeR.ok) return removeR;
1095
- return this._addComponentCore(entity, componentData, false);
1096
1383
  }
1097
1384
  return err(new ComponentAlreadyPresentError(entity as number, componentData.component.name));
1098
1385
  }
1099
1386
 
1100
- if (!internal && relationshipRole(componentData.component as Component)?.kind === 'source') {
1101
- const prepared = this.prepareRelationshipInsert(
1102
- componentData.component as Component,
1103
- filled as Record<string, unknown>,
1104
- );
1105
- if (!prepared.ok) return prepared;
1106
- }
1107
-
1108
1387
  // Get target archetype via edge cache.
1109
1388
  const targetArch = getAddEdge(
1110
1389
  this.graph,
@@ -1130,6 +1409,7 @@ export class WorldComponentAccess {
1130
1409
  componentData.component,
1131
1410
  this.tableRow(rec),
1132
1411
  filled as ShapeOf<S>,
1412
+ skipVariableArrayInitialization ? { skipVariableArrayInitialization: true } : undefined,
1133
1413
  );
1134
1414
  }
1135
1415
  this.markComponentAdded(entity, componentData.component as Component);
@@ -1139,11 +1419,20 @@ export class WorldComponentAccess {
1139
1419
  entity,
1140
1420
  componentData.component as Component,
1141
1421
  filled as Record<string, unknown>,
1422
+ relationshipPreparation,
1142
1423
  );
1143
- if (!relationshipResult.ok) return relationshipResult;
1424
+ if (!relationshipResult.ok) {
1425
+ this.releaseRelationshipPreparation(relationshipPreparation);
1426
+ return relationshipResult;
1427
+ }
1144
1428
  }
1145
1429
 
1146
1430
  this.markStructureChanged();
1431
+ this.state.recordStructuralEvidence({
1432
+ kind: 'component-added',
1433
+ entity,
1434
+ componentId: localId,
1435
+ });
1147
1436
  return ok(undefined);
1148
1437
  }
1149
1438
 
@@ -1249,8 +1538,12 @@ export class WorldComponentAccess {
1249
1538
  } else {
1250
1539
  this.storage.migrateEntity(rec, srcArch, targetArch);
1251
1540
  }
1252
- this.state.removeComponentChange(entity, componentId(component));
1253
1541
  this.markStructureChanged();
1542
+ this.state.recordStructuralEvidence({
1543
+ kind: 'component-removed',
1544
+ entity,
1545
+ componentId: localId,
1546
+ });
1254
1547
  return ok(undefined);
1255
1548
  }
1256
1549
 
@@ -1292,10 +1585,56 @@ export class WorldComponentAccess {
1292
1585
  entity: EntityHandle,
1293
1586
  componentDatas: ComponentData[],
1294
1587
  ): Result<void, EcsError> {
1588
+ componentDatas = expandComponentRequirements(componentDatas);
1295
1589
  const slot = entityIndex(entity);
1296
1590
  const record = this.records[slot];
1297
1591
  if (!record || record.archetypeId !== -1) return ok(undefined);
1298
1592
 
1593
+ // Reserve target-side relationship storage before materializing this
1594
+ // pending row. The command preflight has already checked the batch graph;
1595
+ // this owner-level reservation closes the remaining managed-capacity
1596
+ // failure window without adding a mirror component or advancing an epoch.
1597
+ const relationshipPreparations: (RelationshipPreparation | undefined)[] = [];
1598
+ for (let index = 0; index < componentDatas.length; index += 1) {
1599
+ const componentData = componentDatas[index];
1600
+ if (
1601
+ componentData === undefined ||
1602
+ relationshipRole(componentData.component as Component)?.kind !== 'source'
1603
+ ) {
1604
+ continue;
1605
+ }
1606
+ const filled = fillComponentDefaults(
1607
+ componentData.component,
1608
+ componentData.data as Record<string, unknown>,
1609
+ );
1610
+ const target = this.relationshipTargetEntity(
1611
+ componentData.component as Component,
1612
+ filled as Record<string, unknown>,
1613
+ );
1614
+ const targetRecord = target === null ? undefined : this.records[entityIndex(target)];
1615
+ // A pending target is materialized by an earlier command in the normal
1616
+ // supported order. Leave it to the existing relationship callback when
1617
+ // that target row becomes live; current-world targets are fully
1618
+ // prepared before this source row is appended.
1619
+ if (
1620
+ target !== null &&
1621
+ targetRecord !== undefined &&
1622
+ this.recordIsLive(targetRecord, entityGeneration(target))
1623
+ ) {
1624
+ const prepared = this.prepareRelationshipInsert(
1625
+ componentData.component as Component,
1626
+ filled as Record<string, unknown>,
1627
+ );
1628
+ if (!prepared.ok) {
1629
+ for (const reservation of relationshipPreparations) {
1630
+ this.releaseRelationshipPreparation(reservation);
1631
+ }
1632
+ return prepared;
1633
+ }
1634
+ relationshipPreparations[index] = prepared.value;
1635
+ }
1636
+ }
1637
+
1299
1638
  // Find or create target archetype (using World-local IDs).
1300
1639
  const componentIds = componentDatas.map((cd) => componentId(cd.component));
1301
1640
  const components = componentDatas.map((cd) => cd.component);
@@ -1327,20 +1666,30 @@ export class WorldComponentAccess {
1327
1666
  this.storage.writeEntitySelf(arch, tableRow, entity);
1328
1667
 
1329
1668
  // Publish relationship targets after all rows are written.
1330
- for (const cd of componentDatas) {
1669
+ for (let index = 0; index < componentDatas.length; index += 1) {
1670
+ const cd = componentDatas[index];
1671
+ if (cd === undefined) continue;
1331
1672
  if (relationshipRole(cd.component as Component)?.kind === 'source') {
1332
1673
  const filled = fillComponentDefaults(cd.component, cd.data as Record<string, unknown>);
1333
1674
  const relationshipResult = this.relationshipOnInsert(
1334
1675
  entity,
1335
1676
  cd.component as Component,
1336
1677
  filled as Record<string, unknown>,
1678
+ relationshipPreparations[index],
1337
1679
  );
1338
1680
  if (!relationshipResult.ok) {
1681
+ for (const reservation of relationshipPreparations) {
1682
+ this.releaseRelationshipPreparation(reservation);
1683
+ }
1339
1684
  return relationshipResult;
1340
1685
  }
1341
1686
  }
1342
1687
  }
1343
1688
  this.markStructureChanged();
1689
+ this.state.recordStructuralEvidence({
1690
+ kind: 'spawn',
1691
+ entity,
1692
+ });
1344
1693
  return ok(undefined);
1345
1694
  }
1346
1695