@forgeax/engine-ecs 0.1.23 → 0.1.25
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 +42 -1
- package/dist/buffer-pool.d.ts +4 -5
- package/dist/buffer-pool.d.ts.map +1 -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 +597 -152
- 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__/archetype.unit.test.ts +38 -10
- package/src/__tests__/relationship-index.test.ts +147 -1
- package/src/buffer-pool.ts +47 -74
- 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 +427 -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,90 @@ 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
|
+
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),
|
|
382
486
|
);
|
|
383
|
-
if (!added.ok) return added;
|
|
384
487
|
}
|
|
385
|
-
const
|
|
386
|
-
|
|
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);
|
|
387
494
|
}
|
|
388
495
|
|
|
389
496
|
/** Append `holder` to the materialized target list. */
|
|
@@ -391,6 +498,7 @@ export class WorldComponentAccess {
|
|
|
391
498
|
holder: EntityHandle,
|
|
392
499
|
component: Component,
|
|
393
500
|
value: Record<string, unknown>,
|
|
501
|
+
preparation?: RelationshipPreparation,
|
|
394
502
|
): Result<void, EcsError> {
|
|
395
503
|
const role = relationshipRole(component);
|
|
396
504
|
if (role?.kind !== 'source') return ok(undefined);
|
|
@@ -400,19 +508,28 @@ export class WorldComponentAccess {
|
|
|
400
508
|
/* istanbul ignore next -- defineComponent relationship validation guarantees mirror exists */
|
|
401
509
|
if (mirror === undefined) return ok(undefined);
|
|
402
510
|
|
|
403
|
-
|
|
404
|
-
if (
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
if (prepared.error.code === 'stale-entity') return ok(undefined);
|
|
409
|
-
return prepared;
|
|
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;
|
|
410
516
|
}
|
|
517
|
+
if (prepared === undefined) return ok(undefined);
|
|
411
518
|
|
|
412
519
|
// Lazy-create the mirror component on the target when absent (D-3c).
|
|
413
520
|
const targetSlot = entityIndex(target);
|
|
414
521
|
const targetRec = this.records[targetSlot];
|
|
415
|
-
|
|
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
|
+
}
|
|
416
533
|
const targetArch = this.graph.archetypes[targetRec.archetypeId];
|
|
417
534
|
const mirrorLocalId = componentId(mirror);
|
|
418
535
|
const hasMirror =
|
|
@@ -425,8 +542,18 @@ export class WorldComponentAccess {
|
|
|
425
542
|
data: {} as Partial<ShapeOf<ComponentSchema>>,
|
|
426
543
|
},
|
|
427
544
|
true,
|
|
545
|
+
false,
|
|
546
|
+
true,
|
|
428
547
|
);
|
|
429
|
-
if (!added.ok)
|
|
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;
|
|
430
557
|
}
|
|
431
558
|
const targetEntries = this.relationshipTargetEntries(component, target);
|
|
432
559
|
const slot = targetEntries.length;
|
|
@@ -436,7 +563,10 @@ export class WorldComponentAccess {
|
|
|
436
563
|
role.targetField as never,
|
|
437
564
|
holder as never,
|
|
438
565
|
);
|
|
439
|
-
if (!mirrored.ok)
|
|
566
|
+
if (!mirrored.ok) {
|
|
567
|
+
this.releaseRelationshipPreparation(prepared);
|
|
568
|
+
return mirrored;
|
|
569
|
+
}
|
|
440
570
|
this.relationshipIndex(component)?.attach(holder, target, slot);
|
|
441
571
|
return ok(undefined);
|
|
442
572
|
}
|
|
@@ -477,15 +607,16 @@ export class WorldComponentAccess {
|
|
|
477
607
|
/**
|
|
478
608
|
* Read component data from an entity.
|
|
479
609
|
*
|
|
480
|
-
* **
|
|
481
|
-
* `
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
* `
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
*
|
|
488
|
-
* `
|
|
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.
|
|
489
620
|
*
|
|
490
621
|
* @returns `Result<ShapeOf<S>, EcsError>` —
|
|
491
622
|
* `ok(ShapeOf<S>)` on success;
|
|
@@ -533,7 +664,13 @@ export class WorldComponentAccess {
|
|
|
533
664
|
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
534
665
|
}
|
|
535
666
|
|
|
536
|
-
|
|
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);
|
|
537
674
|
}
|
|
538
675
|
|
|
539
676
|
/**
|
|
@@ -568,9 +705,10 @@ export class WorldComponentAccess {
|
|
|
568
705
|
* Returns `undefined` when the entity is dead, the component is absent, the
|
|
569
706
|
* field does not exist, or the field is not an `array<...>` column.
|
|
570
707
|
*
|
|
571
|
-
* @internal Engine-internal fast path; AI users read
|
|
572
|
-
* `world.get
|
|
573
|
-
*
|
|
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.
|
|
574
712
|
*/
|
|
575
713
|
_getArrayView(
|
|
576
714
|
entity: EntityHandle,
|
|
@@ -586,6 +724,141 @@ export class WorldComponentAccess {
|
|
|
586
724
|
return this.storage.readArrayView(arch, component, this.tableRow(rec), fieldName);
|
|
587
725
|
}
|
|
588
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
|
+
|
|
589
862
|
/**
|
|
590
863
|
* Write (partial) component data to an entity.
|
|
591
864
|
*
|
|
@@ -632,6 +905,20 @@ export class WorldComponentAccess {
|
|
|
632
905
|
// F-02: set on missing component returns err instead of silent ignore
|
|
633
906
|
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
634
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
|
+
}
|
|
635
922
|
const valuePreflight = this.preflightComponentFieldValues(entity, {
|
|
636
923
|
component,
|
|
637
924
|
data: value,
|
|
@@ -875,58 +1162,6 @@ export class WorldComponentAccess {
|
|
|
875
1162
|
return ok(undefined);
|
|
876
1163
|
}
|
|
877
1164
|
|
|
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
1165
|
/**
|
|
931
1166
|
* Remove one variable-array element at a known slot. Relationship holders
|
|
932
1167
|
* supply the slot from their backpointer, so this is O(1) and never scans
|
|
@@ -1013,6 +1248,7 @@ export class WorldComponentAccess {
|
|
|
1013
1248
|
componentData: ComponentData<S>,
|
|
1014
1249
|
internal: boolean,
|
|
1015
1250
|
resolveRequirements = true,
|
|
1251
|
+
skipVariableArrayInitialization = false,
|
|
1016
1252
|
): Result<void, EcsError> {
|
|
1017
1253
|
const record = this.lookupAlive(entity, 'addComponent', componentData.component.name);
|
|
1018
1254
|
if (!record.ok) return record;
|
|
@@ -1068,6 +1304,27 @@ export class WorldComponentAccess {
|
|
|
1068
1304
|
return err(enumErr as unknown as EcsError);
|
|
1069
1305
|
}
|
|
1070
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
|
+
|
|
1071
1328
|
// Generic component requirements are resolved once at the structural
|
|
1072
1329
|
// boundary. Explicit data remains authoritative; only missing required
|
|
1073
1330
|
// identities are added before the requested component is migrated.
|
|
@@ -1085,9 +1342,13 @@ export class WorldComponentAccess {
|
|
|
1085
1342
|
// each member so malformed dependency cycles remain finite and the
|
|
1086
1343
|
// structural work still happens in one deterministic sequence.
|
|
1087
1344
|
const added = this._addComponentCore(entity, requirement as ComponentData, internal, false);
|
|
1088
|
-
if (!added.ok)
|
|
1345
|
+
if (!added.ok) {
|
|
1346
|
+
this.releaseRelationshipPreparation(relationshipPreparation);
|
|
1347
|
+
return added;
|
|
1348
|
+
}
|
|
1089
1349
|
srcArch = this.graph.archetypes[rec.archetypeId];
|
|
1090
1350
|
if (!srcArch) {
|
|
1351
|
+
this.releaseRelationshipPreparation(relationshipPreparation);
|
|
1091
1352
|
return err(
|
|
1092
1353
|
new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
|
|
1093
1354
|
operation: 'addComponent',
|
|
@@ -1101,44 +1362,27 @@ export class WorldComponentAccess {
|
|
|
1101
1362
|
}
|
|
1102
1363
|
|
|
1103
1364
|
// Check if entity already has this component (using World-local ID).
|
|
1104
|
-
const localId = componentId(componentData.component);
|
|
1105
1365
|
if (srcArch.components.some((candidate) => componentId(candidate) === localId)) {
|
|
1366
|
+
this.releaseRelationshipPreparation(relationshipPreparation);
|
|
1106
1367
|
// 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.
|
|
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.
|
|
1116
1372
|
const role = relationshipRole(componentData.component as Component);
|
|
1117
1373
|
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(
|
|
1374
|
+
return this.setRelationshipSource(
|
|
1124
1375
|
entity,
|
|
1125
1376
|
componentData.component as Component,
|
|
1126
|
-
|
|
1377
|
+
filled as Record<string, unknown>,
|
|
1378
|
+
rec,
|
|
1379
|
+
srcArch,
|
|
1380
|
+
true,
|
|
1127
1381
|
);
|
|
1128
|
-
if (!removeR.ok) return removeR;
|
|
1129
|
-
return this._addComponentCore(entity, componentData, false);
|
|
1130
1382
|
}
|
|
1131
1383
|
return err(new ComponentAlreadyPresentError(entity as number, componentData.component.name));
|
|
1132
1384
|
}
|
|
1133
1385
|
|
|
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
1386
|
// Get target archetype via edge cache.
|
|
1143
1387
|
const targetArch = getAddEdge(
|
|
1144
1388
|
this.graph,
|
|
@@ -1164,6 +1408,7 @@ export class WorldComponentAccess {
|
|
|
1164
1408
|
componentData.component,
|
|
1165
1409
|
this.tableRow(rec),
|
|
1166
1410
|
filled as ShapeOf<S>,
|
|
1411
|
+
skipVariableArrayInitialization ? { skipVariableArrayInitialization: true } : undefined,
|
|
1167
1412
|
);
|
|
1168
1413
|
}
|
|
1169
1414
|
this.markComponentAdded(entity, componentData.component as Component);
|
|
@@ -1173,8 +1418,12 @@ export class WorldComponentAccess {
|
|
|
1173
1418
|
entity,
|
|
1174
1419
|
componentData.component as Component,
|
|
1175
1420
|
filled as Record<string, unknown>,
|
|
1421
|
+
relationshipPreparation,
|
|
1176
1422
|
);
|
|
1177
|
-
if (!relationshipResult.ok)
|
|
1423
|
+
if (!relationshipResult.ok) {
|
|
1424
|
+
this.releaseRelationshipPreparation(relationshipPreparation);
|
|
1425
|
+
return relationshipResult;
|
|
1426
|
+
}
|
|
1178
1427
|
}
|
|
1179
1428
|
|
|
1180
1429
|
this.markStructureChanged();
|
|
@@ -1340,6 +1589,51 @@ export class WorldComponentAccess {
|
|
|
1340
1589
|
const record = this.records[slot];
|
|
1341
1590
|
if (!record || record.archetypeId !== -1) return ok(undefined);
|
|
1342
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
|
+
|
|
1343
1637
|
// Find or create target archetype (using World-local IDs).
|
|
1344
1638
|
const componentIds = componentDatas.map((cd) => componentId(cd.component));
|
|
1345
1639
|
const components = componentDatas.map((cd) => cd.component);
|
|
@@ -1371,15 +1665,21 @@ export class WorldComponentAccess {
|
|
|
1371
1665
|
this.storage.writeEntitySelf(arch, tableRow, entity);
|
|
1372
1666
|
|
|
1373
1667
|
// Publish relationship targets after all rows are written.
|
|
1374
|
-
for (
|
|
1668
|
+
for (let index = 0; index < componentDatas.length; index += 1) {
|
|
1669
|
+
const cd = componentDatas[index];
|
|
1670
|
+
if (cd === undefined) continue;
|
|
1375
1671
|
if (relationshipRole(cd.component as Component)?.kind === 'source') {
|
|
1376
1672
|
const filled = fillComponentDefaults(cd.component, cd.data as Record<string, unknown>);
|
|
1377
1673
|
const relationshipResult = this.relationshipOnInsert(
|
|
1378
1674
|
entity,
|
|
1379
1675
|
cd.component as Component,
|
|
1380
1676
|
filled as Record<string, unknown>,
|
|
1677
|
+
relationshipPreparations[index],
|
|
1381
1678
|
);
|
|
1382
1679
|
if (!relationshipResult.ok) {
|
|
1680
|
+
for (const reservation of relationshipPreparations) {
|
|
1681
|
+
this.releaseRelationshipPreparation(reservation);
|
|
1682
|
+
}
|
|
1383
1683
|
return relationshipResult;
|
|
1384
1684
|
}
|
|
1385
1685
|
}
|