@forgeax/engine-ecs 0.1.23 → 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.
- package/README.md +33 -1
- package/dist/component.d.ts +9 -6
- package/dist/component.d.ts.map +1 -1
- package/dist/errors/query-and-component-errors.d.ts +1 -1
- package/dist/errors/query-and-component-errors.d.ts.map +1 -1
- package/dist/externalization/index.mjs.map +1 -1
- package/dist/index.mjs +566 -131
- package/dist/index.mjs.map +1 -1
- package/dist/internal.d.ts +3 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.mjs +6 -1
- package/dist/internal.mjs.map +1 -1
- package/dist/projection/index.mjs.map +1 -1
- package/dist/query/derived-range-writer.d.ts +14 -0
- package/dist/query/derived-range-writer.d.ts.map +1 -1
- package/dist/query/query.d.ts.map +1 -1
- package/dist/shared.mjs.map +1 -1
- package/dist/world-component-access.d.ts +63 -18
- package/dist/world-component-access.d.ts.map +1 -1
- package/dist/world-component-storage.d.ts +26 -10
- package/dist/world-component-storage.d.ts.map +1 -1
- package/dist/world-entity-lifecycle.d.ts.map +1 -1
- package/dist/world-internal.d.ts +1 -1
- package/dist/world-internal.d.ts.map +1 -1
- package/dist/world.d.ts +5 -0
- package/dist/world.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/relationship-index.test.ts +147 -1
- package/src/component.ts +9 -6
- package/src/errors/query-and-component-errors.ts +4 -1
- package/src/internal.ts +3 -0
- package/src/query/derived-range-writer.ts +111 -0
- package/src/query/query.ts +26 -1
- package/src/world-component-access.ts +428 -127
- package/src/world-component-storage.ts +104 -18
- package/src/world-entity-lifecycle.ts +49 -9
- package/src/world-internal.ts +5 -0
- package/src/world.ts +52 -3
|
@@ -71,6 +71,22 @@ import { ComponentStorage } from './world-component-storage';
|
|
|
71
71
|
|
|
72
72
|
type ErrorContext = { readonly systemName: string };
|
|
73
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
|
+
|
|
74
90
|
type ArrayFieldsOf<S extends ComponentSchema> = {
|
|
75
91
|
[K in keyof S]: S[K] extends
|
|
76
92
|
| `array<${ManagedArrayElementType}>`
|
|
@@ -96,6 +112,15 @@ function relationshipPayloadWrites(data: Readonly<Record<string, unknown>>): boo
|
|
|
96
112
|
});
|
|
97
113
|
}
|
|
98
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
|
+
|
|
99
124
|
export interface ComponentAccessState {
|
|
100
125
|
readonly graph: ArchetypeGraph;
|
|
101
126
|
readonly records: EntityRecord[];
|
|
@@ -161,16 +186,19 @@ export class WorldComponentAccess {
|
|
|
161
186
|
return index;
|
|
162
187
|
}
|
|
163
188
|
|
|
164
|
-
/**
|
|
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
|
+
*/
|
|
165
195
|
relationshipTargetEntries(source: Component, target: EntityHandle): readonly EntityHandle[] {
|
|
166
196
|
const role = relationshipRole(source);
|
|
167
197
|
if (role?.kind !== 'source') return [];
|
|
168
198
|
const mirror = relationshipMirror(source);
|
|
169
199
|
if (mirror === undefined) return [];
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
const entries = (result.value as Record<string, unknown>)[role.targetField];
|
|
173
|
-
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[]);
|
|
174
202
|
}
|
|
175
203
|
|
|
176
204
|
private markComponentAdded(entity: EntityHandle, component: Component): void {
|
|
@@ -270,9 +298,10 @@ export class WorldComponentAccess {
|
|
|
270
298
|
const targetRecord = this.records[entityIndex(target)];
|
|
271
299
|
const actualGeneration = targetRecord?.generation ?? -1;
|
|
272
300
|
const targetLive = this.recordIsLive(targetRecord, entityGeneration(target));
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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) {
|
|
276
305
|
return err(
|
|
277
306
|
new StaleEntityError(target as number, entityIndex(target), entityGeneration(target), {
|
|
278
307
|
operation: 'relationship-insert',
|
|
@@ -347,11 +376,18 @@ export class WorldComponentAccess {
|
|
|
347
376
|
}
|
|
348
377
|
}
|
|
349
378
|
|
|
350
|
-
/**
|
|
351
|
-
|
|
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(
|
|
352
388
|
component: Component,
|
|
353
389
|
value: Record<string, unknown>,
|
|
354
|
-
): Result<
|
|
390
|
+
): Result<RelationshipPreparation | undefined, EcsError> {
|
|
355
391
|
const role = relationshipRole(component);
|
|
356
392
|
if (role?.kind !== 'source') return ok(undefined);
|
|
357
393
|
const target = this.relationshipTargetEntity(component, value);
|
|
@@ -371,19 +407,91 @@ export class WorldComponentAccess {
|
|
|
371
407
|
);
|
|
372
408
|
}
|
|
373
409
|
const targetArch = this.graph.archetypes[targetRec.archetypeId];
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
false;
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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),
|
|
480
|
+
);
|
|
481
|
+
}
|
|
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),
|
|
382
487
|
);
|
|
383
|
-
if (!added.ok) return added;
|
|
384
488
|
}
|
|
385
|
-
const
|
|
386
|
-
|
|
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);
|
|
387
495
|
}
|
|
388
496
|
|
|
389
497
|
/** Append `holder` to the materialized target list. */
|
|
@@ -391,6 +499,7 @@ export class WorldComponentAccess {
|
|
|
391
499
|
holder: EntityHandle,
|
|
392
500
|
component: Component,
|
|
393
501
|
value: Record<string, unknown>,
|
|
502
|
+
preparation?: RelationshipPreparation,
|
|
394
503
|
): Result<void, EcsError> {
|
|
395
504
|
const role = relationshipRole(component);
|
|
396
505
|
if (role?.kind !== 'source') return ok(undefined);
|
|
@@ -400,19 +509,28 @@ export class WorldComponentAccess {
|
|
|
400
509
|
/* istanbul ignore next -- defineComponent relationship validation guarantees mirror exists */
|
|
401
510
|
if (mirror === undefined) return ok(undefined);
|
|
402
511
|
|
|
403
|
-
|
|
404
|
-
if (
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
if (prepared.error.code === 'stale-entity') return ok(undefined);
|
|
409
|
-
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;
|
|
410
517
|
}
|
|
518
|
+
if (prepared === undefined) return ok(undefined);
|
|
411
519
|
|
|
412
520
|
// Lazy-create the mirror component on the target when absent (D-3c).
|
|
413
521
|
const targetSlot = entityIndex(target);
|
|
414
522
|
const targetRec = this.records[targetSlot];
|
|
415
|
-
|
|
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
|
+
}
|
|
416
534
|
const targetArch = this.graph.archetypes[targetRec.archetypeId];
|
|
417
535
|
const mirrorLocalId = componentId(mirror);
|
|
418
536
|
const hasMirror =
|
|
@@ -425,8 +543,18 @@ export class WorldComponentAccess {
|
|
|
425
543
|
data: {} as Partial<ShapeOf<ComponentSchema>>,
|
|
426
544
|
},
|
|
427
545
|
true,
|
|
546
|
+
false,
|
|
547
|
+
true,
|
|
428
548
|
);
|
|
429
|
-
if (!added.ok)
|
|
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;
|
|
430
558
|
}
|
|
431
559
|
const targetEntries = this.relationshipTargetEntries(component, target);
|
|
432
560
|
const slot = targetEntries.length;
|
|
@@ -436,7 +564,10 @@ export class WorldComponentAccess {
|
|
|
436
564
|
role.targetField as never,
|
|
437
565
|
holder as never,
|
|
438
566
|
);
|
|
439
|
-
if (!mirrored.ok)
|
|
567
|
+
if (!mirrored.ok) {
|
|
568
|
+
this.releaseRelationshipPreparation(prepared);
|
|
569
|
+
return mirrored;
|
|
570
|
+
}
|
|
440
571
|
this.relationshipIndex(component)?.attach(holder, target, slot);
|
|
441
572
|
return ok(undefined);
|
|
442
573
|
}
|
|
@@ -477,15 +608,16 @@ export class WorldComponentAccess {
|
|
|
477
608
|
/**
|
|
478
609
|
* Read component data from an entity.
|
|
479
610
|
*
|
|
480
|
-
* **
|
|
481
|
-
* `
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
* `
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
*
|
|
488
|
-
* `
|
|
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.
|
|
489
621
|
*
|
|
490
622
|
* @returns `Result<ShapeOf<S>, EcsError>` —
|
|
491
623
|
* `ok(ShapeOf<S>)` on success;
|
|
@@ -533,7 +665,13 @@ export class WorldComponentAccess {
|
|
|
533
665
|
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
534
666
|
}
|
|
535
667
|
|
|
536
|
-
|
|
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);
|
|
537
675
|
}
|
|
538
676
|
|
|
539
677
|
/**
|
|
@@ -568,9 +706,10 @@ export class WorldComponentAccess {
|
|
|
568
706
|
* Returns `undefined` when the entity is dead, the component is absent, the
|
|
569
707
|
* field does not exist, or the field is not an `array<...>` column.
|
|
570
708
|
*
|
|
571
|
-
* @internal Engine-internal fast path; AI users read
|
|
572
|
-
* `world.get
|
|
573
|
-
*
|
|
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.
|
|
574
713
|
*/
|
|
575
714
|
_getArrayView(
|
|
576
715
|
entity: EntityHandle,
|
|
@@ -586,6 +725,141 @@ export class WorldComponentAccess {
|
|
|
586
725
|
return this.storage.readArrayView(arch, component, this.tableRow(rec), fieldName);
|
|
587
726
|
}
|
|
588
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
|
+
|
|
589
863
|
/**
|
|
590
864
|
* Write (partial) component data to an entity.
|
|
591
865
|
*
|
|
@@ -632,6 +906,20 @@ export class WorldComponentAccess {
|
|
|
632
906
|
// F-02: set on missing component returns err instead of silent ignore
|
|
633
907
|
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
634
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
|
+
}
|
|
635
923
|
const valuePreflight = this.preflightComponentFieldValues(entity, {
|
|
636
924
|
component,
|
|
637
925
|
data: value,
|
|
@@ -875,58 +1163,6 @@ export class WorldComponentAccess {
|
|
|
875
1163
|
return ok(undefined);
|
|
876
1164
|
}
|
|
877
1165
|
|
|
878
|
-
private ensureArrayCapacity<S extends ComponentSchema, K extends ArrayFieldsOf<S>>(
|
|
879
|
-
entity: EntityHandle,
|
|
880
|
-
component: Component<string, S>,
|
|
881
|
-
fieldName: K,
|
|
882
|
-
minimum: number,
|
|
883
|
-
): Result<void, EcsError> {
|
|
884
|
-
const record = this.lookupAlive(entity, 'relationship-capacity', component.name);
|
|
885
|
-
if (!record.ok) return record;
|
|
886
|
-
const rec = record.value;
|
|
887
|
-
const arch = this.graph.archetypes[rec.archetypeId];
|
|
888
|
-
if (!arch) {
|
|
889
|
-
return err(
|
|
890
|
-
new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
|
|
891
|
-
operation: 'relationship-capacity',
|
|
892
|
-
component: component.name,
|
|
893
|
-
expectedGeneration: entityGeneration(entity),
|
|
894
|
-
actualGeneration: rec.generation,
|
|
895
|
-
}),
|
|
896
|
-
);
|
|
897
|
-
}
|
|
898
|
-
const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
|
|
899
|
-
if (!fieldCols) return err(new ComponentNotPresentError(entity as number, component.name));
|
|
900
|
-
const fieldNameStr = fieldName as string;
|
|
901
|
-
const col = fieldCols.get(fieldNameStr);
|
|
902
|
-
if (!col) return err(new ComponentNotPresentError(entity as number, component.name));
|
|
903
|
-
const arrayMeta = componentDefinition(component).fields[fieldNameStr]?.arrayMeta;
|
|
904
|
-
if (arrayMeta === undefined) {
|
|
905
|
-
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
906
|
-
}
|
|
907
|
-
const meta = TYPE_METADATA[arrayMeta.elementType];
|
|
908
|
-
if (!meta?.byteSize) {
|
|
909
|
-
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
910
|
-
}
|
|
911
|
-
const maximum = Math.floor(262_144 / meta.byteSize);
|
|
912
|
-
if (!Number.isSafeInteger(minimum) || minimum < 0 || minimum > maximum) {
|
|
913
|
-
return err(new ManagedBufferOutOfBoundsError(minimum, maximum));
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
const byteLength = minimum * meta.byteSize;
|
|
917
|
-
const slotId = col.view[this.tableRow(rec)] as number;
|
|
918
|
-
if (slotId === 0) {
|
|
919
|
-
if (minimum === 0) return ok(undefined);
|
|
920
|
-
const allocated = this.bufferPool.alloc(byteLength);
|
|
921
|
-
if (!allocated.ok) return allocated;
|
|
922
|
-
col.view[this.tableRow(rec)] = allocated.value.id;
|
|
923
|
-
return ok(undefined);
|
|
924
|
-
}
|
|
925
|
-
if (this.bufferPool.view(slotId).byteLength >= byteLength) return ok(undefined);
|
|
926
|
-
const grown = this.bufferPool.grow(slotId, byteLength);
|
|
927
|
-
return grown.ok ? ok(undefined) : grown;
|
|
928
|
-
}
|
|
929
|
-
|
|
930
1166
|
/**
|
|
931
1167
|
* Remove one variable-array element at a known slot. Relationship holders
|
|
932
1168
|
* supply the slot from their backpointer, so this is O(1) and never scans
|
|
@@ -1013,6 +1249,7 @@ export class WorldComponentAccess {
|
|
|
1013
1249
|
componentData: ComponentData<S>,
|
|
1014
1250
|
internal: boolean,
|
|
1015
1251
|
resolveRequirements = true,
|
|
1252
|
+
skipVariableArrayInitialization = false,
|
|
1016
1253
|
): Result<void, EcsError> {
|
|
1017
1254
|
const record = this.lookupAlive(entity, 'addComponent', componentData.component.name);
|
|
1018
1255
|
if (!record.ok) return record;
|
|
@@ -1068,6 +1305,27 @@ export class WorldComponentAccess {
|
|
|
1068
1305
|
return err(enumErr as unknown as EcsError);
|
|
1069
1306
|
}
|
|
1070
1307
|
|
|
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
|
+
|
|
1071
1329
|
// Generic component requirements are resolved once at the structural
|
|
1072
1330
|
// boundary. Explicit data remains authoritative; only missing required
|
|
1073
1331
|
// identities are added before the requested component is migrated.
|
|
@@ -1085,9 +1343,13 @@ export class WorldComponentAccess {
|
|
|
1085
1343
|
// each member so malformed dependency cycles remain finite and the
|
|
1086
1344
|
// structural work still happens in one deterministic sequence.
|
|
1087
1345
|
const added = this._addComponentCore(entity, requirement as ComponentData, internal, false);
|
|
1088
|
-
if (!added.ok)
|
|
1346
|
+
if (!added.ok) {
|
|
1347
|
+
this.releaseRelationshipPreparation(relationshipPreparation);
|
|
1348
|
+
return added;
|
|
1349
|
+
}
|
|
1089
1350
|
srcArch = this.graph.archetypes[rec.archetypeId];
|
|
1090
1351
|
if (!srcArch) {
|
|
1352
|
+
this.releaseRelationshipPreparation(relationshipPreparation);
|
|
1091
1353
|
return err(
|
|
1092
1354
|
new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
|
|
1093
1355
|
operation: 'addComponent',
|
|
@@ -1101,44 +1363,27 @@ export class WorldComponentAccess {
|
|
|
1101
1363
|
}
|
|
1102
1364
|
|
|
1103
1365
|
// Check if entity already has this component (using World-local ID).
|
|
1104
|
-
const localId = componentId(componentData.component);
|
|
1105
1366
|
if (srcArch.components.some((candidate) => componentId(candidate) === localId)) {
|
|
1367
|
+
this.releaseRelationshipPreparation(relationshipPreparation);
|
|
1106
1368
|
// M2 exclusive relationship: re-adding the holder with a (possibly new)
|
|
1107
|
-
// target auto-reparents instead of failing (AC-12).
|
|
1108
|
-
//
|
|
1109
|
-
//
|
|
1110
|
-
//
|
|
1111
|
-
// removeComponent + addComponent each touch the mirror exactly once and
|
|
1112
|
-
// the mirror component carries no relationship of its own, so there is
|
|
1113
|
-
// no recursion. Reparent only fires for top-level user calls
|
|
1114
|
-
// (!internal); engine-internal lazy create / append
|
|
1115
|
-
// 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.
|
|
1116
1373
|
const role = relationshipRole(componentData.component as Component);
|
|
1117
1374
|
if (role?.kind === 'source' && role.exclusive && !internal) {
|
|
1118
|
-
|
|
1119
|
-
componentData.component as Component,
|
|
1120
|
-
filled as Record<string, unknown>,
|
|
1121
|
-
);
|
|
1122
|
-
if (!prepared.ok) return prepared;
|
|
1123
|
-
const removeR = this._removeComponentCore(
|
|
1375
|
+
return this.setRelationshipSource(
|
|
1124
1376
|
entity,
|
|
1125
1377
|
componentData.component as Component,
|
|
1126
|
-
|
|
1378
|
+
filled as Record<string, unknown>,
|
|
1379
|
+
rec,
|
|
1380
|
+
srcArch,
|
|
1381
|
+
true,
|
|
1127
1382
|
);
|
|
1128
|
-
if (!removeR.ok) return removeR;
|
|
1129
|
-
return this._addComponentCore(entity, componentData, false);
|
|
1130
1383
|
}
|
|
1131
1384
|
return err(new ComponentAlreadyPresentError(entity as number, componentData.component.name));
|
|
1132
1385
|
}
|
|
1133
1386
|
|
|
1134
|
-
if (!internal && relationshipRole(componentData.component as Component)?.kind === 'source') {
|
|
1135
|
-
const prepared = this.prepareRelationshipInsert(
|
|
1136
|
-
componentData.component as Component,
|
|
1137
|
-
filled as Record<string, unknown>,
|
|
1138
|
-
);
|
|
1139
|
-
if (!prepared.ok) return prepared;
|
|
1140
|
-
}
|
|
1141
|
-
|
|
1142
1387
|
// Get target archetype via edge cache.
|
|
1143
1388
|
const targetArch = getAddEdge(
|
|
1144
1389
|
this.graph,
|
|
@@ -1164,6 +1409,7 @@ export class WorldComponentAccess {
|
|
|
1164
1409
|
componentData.component,
|
|
1165
1410
|
this.tableRow(rec),
|
|
1166
1411
|
filled as ShapeOf<S>,
|
|
1412
|
+
skipVariableArrayInitialization ? { skipVariableArrayInitialization: true } : undefined,
|
|
1167
1413
|
);
|
|
1168
1414
|
}
|
|
1169
1415
|
this.markComponentAdded(entity, componentData.component as Component);
|
|
@@ -1173,8 +1419,12 @@ export class WorldComponentAccess {
|
|
|
1173
1419
|
entity,
|
|
1174
1420
|
componentData.component as Component,
|
|
1175
1421
|
filled as Record<string, unknown>,
|
|
1422
|
+
relationshipPreparation,
|
|
1176
1423
|
);
|
|
1177
|
-
if (!relationshipResult.ok)
|
|
1424
|
+
if (!relationshipResult.ok) {
|
|
1425
|
+
this.releaseRelationshipPreparation(relationshipPreparation);
|
|
1426
|
+
return relationshipResult;
|
|
1427
|
+
}
|
|
1178
1428
|
}
|
|
1179
1429
|
|
|
1180
1430
|
this.markStructureChanged();
|
|
@@ -1340,6 +1590,51 @@ export class WorldComponentAccess {
|
|
|
1340
1590
|
const record = this.records[slot];
|
|
1341
1591
|
if (!record || record.archetypeId !== -1) return ok(undefined);
|
|
1342
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
|
+
|
|
1343
1638
|
// Find or create target archetype (using World-local IDs).
|
|
1344
1639
|
const componentIds = componentDatas.map((cd) => componentId(cd.component));
|
|
1345
1640
|
const components = componentDatas.map((cd) => cd.component);
|
|
@@ -1371,15 +1666,21 @@ export class WorldComponentAccess {
|
|
|
1371
1666
|
this.storage.writeEntitySelf(arch, tableRow, entity);
|
|
1372
1667
|
|
|
1373
1668
|
// Publish relationship targets after all rows are written.
|
|
1374
|
-
for (
|
|
1669
|
+
for (let index = 0; index < componentDatas.length; index += 1) {
|
|
1670
|
+
const cd = componentDatas[index];
|
|
1671
|
+
if (cd === undefined) continue;
|
|
1375
1672
|
if (relationshipRole(cd.component as Component)?.kind === 'source') {
|
|
1376
1673
|
const filled = fillComponentDefaults(cd.component, cd.data as Record<string, unknown>);
|
|
1377
1674
|
const relationshipResult = this.relationshipOnInsert(
|
|
1378
1675
|
entity,
|
|
1379
1676
|
cd.component as Component,
|
|
1380
1677
|
filled as Record<string, unknown>,
|
|
1678
|
+
relationshipPreparations[index],
|
|
1381
1679
|
);
|
|
1382
1680
|
if (!relationshipResult.ok) {
|
|
1681
|
+
for (const reservation of relationshipPreparations) {
|
|
1682
|
+
this.releaseRelationshipPreparation(reservation);
|
|
1683
|
+
}
|
|
1383
1684
|
return relationshipResult;
|
|
1384
1685
|
}
|
|
1385
1686
|
}
|