@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.
- package/README.md +83 -17
- package/dist/__tests__/changed-block-summary.candidate.test.d.ts +2 -0
- package/dist/__tests__/changed-block-summary.candidate.test.d.ts.map +1 -0
- package/dist/__tests__/component-requirements.unit.test.d.ts +2 -0
- package/dist/__tests__/component-requirements.unit.test.d.ts.map +1 -0
- package/dist/__tests__/component-version-surface.test.d.ts +2 -0
- package/dist/__tests__/component-version-surface.test.d.ts.map +1 -0
- package/dist/__tests__/derived-range-writer.contract.test-d.d.ts +2 -0
- package/dist/__tests__/derived-range-writer.contract.test-d.d.ts.map +1 -0
- package/dist/__tests__/derived-range-writer.contract.test.d.ts +2 -0
- package/dist/__tests__/derived-range-writer.contract.test.d.ts.map +1 -0
- package/dist/__tests__/structural-evidence.contract.test-d.d.ts +2 -0
- package/dist/__tests__/structural-evidence.contract.test-d.d.ts.map +1 -0
- package/dist/__tests__/structural-evidence.contract.test.d.ts +2 -0
- package/dist/__tests__/structural-evidence.contract.test.d.ts.map +1 -0
- package/dist/component-schema.d.ts +17 -0
- package/dist/component-schema.d.ts.map +1 -1
- package/dist/component.d.ts +16 -7
- 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/errors/validation-errors.d.ts +24 -0
- package/dist/errors/validation-errors.d.ts.map +1 -1
- package/dist/errors.d.ts +14 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/externalization/index.mjs.map +1 -1
- package/dist/index.mjs +977 -255
- package/dist/index.mjs.map +1 -1
- package/dist/internal.d.ts +18 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.mjs +20 -2
- package/dist/internal.mjs.map +1 -1
- package/dist/projection/index.d.ts +27 -52
- package/dist/projection/index.d.ts.map +1 -1
- package/dist/projection/index.mjs +66 -76
- package/dist/projection/index.mjs.map +1 -1
- package/dist/query/derived-range-writer.d.ts +62 -0
- package/dist/query/derived-range-writer.d.ts.map +1 -0
- package/dist/query/query.d.ts +1 -1
- package/dist/query/query.d.ts.map +1 -1
- package/dist/relationship-index.d.ts +8 -0
- package/dist/relationship-index.d.ts.map +1 -1
- package/dist/shared-ref-store.d.ts +5 -9
- package/dist/shared-ref-store.d.ts.map +1 -1
- package/dist/shared.mjs.map +1 -1
- package/dist/storage/change-detection.d.ts +1 -3
- package/dist/storage/change-detection.d.ts.map +1 -1
- package/dist/storage/structural-evidence.d.ts +30 -0
- package/dist/storage/structural-evidence.d.ts.map +1 -0
- package/dist/world-component-access.d.ts +65 -19
- 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-core.d.ts +2 -2
- package/dist/world-core.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 +15 -14
- package/dist/world.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/changed-block-summary.candidate.test.ts +57 -0
- package/src/__tests__/component-requirements.unit.test.ts +76 -0
- package/src/__tests__/component-version-surface.test.ts +45 -0
- package/src/__tests__/derived-range-writer.contract.test-d.ts +35 -0
- package/src/__tests__/derived-range-writer.contract.test.ts +339 -0
- package/src/__tests__/errors.unit.test.ts +21 -5
- package/src/__tests__/externalization-render-read-lease.test-d.ts +1 -1
- package/src/__tests__/externalization-render-read-lease.unit.test.ts +15 -15
- package/src/__tests__/query-storage-trends.perf.test.ts +19 -0
- package/src/__tests__/query.unit.test.ts +10 -6
- package/src/__tests__/relationship-index.test.ts +147 -1
- package/src/__tests__/shared-ref-store.unit.test.ts +3 -8
- package/src/__tests__/structural-evidence.contract.test-d.ts +6 -0
- package/src/__tests__/structural-evidence.contract.test.ts +49 -0
- package/src/component-schema.ts +51 -0
- package/src/component.ts +21 -8
- package/src/errors/query-and-component-errors.ts +4 -1
- package/src/errors/validation-errors.ts +30 -0
- package/src/errors.ts +29 -5
- package/src/internal.ts +59 -0
- package/src/projection/index.ts +68 -149
- package/src/query/derived-range-writer.ts +443 -0
- package/src/query/query.ts +115 -16
- package/src/relationship-index.ts +13 -1
- package/src/shared-ref-store.ts +16 -24
- package/src/storage/change-detection.ts +0 -15
- package/src/storage/structural-evidence.ts +64 -0
- package/src/world-component-access.ts +479 -130
- package/src/world-component-storage.ts +104 -18
- package/src/world-core.ts +2 -2
- package/src/world-entity-lifecycle.ts +59 -10
- package/src/world-internal.ts +10 -5
- package/src/world.ts +97 -53
- package/dist/.tsbuildinfo +0 -1
- package/dist/__tests__/world-change-journal.unit.test.d.ts +0 -2
- package/dist/__tests__/world-change-journal.unit.test.d.ts.map +0 -1
- package/dist/__tests__/world-internal-identity.unit.test.d.ts +0 -2
- package/dist/__tests__/world-internal-identity.unit.test.d.ts.map +0 -1
- package/dist/world-change-journal.d.ts +0 -34
- package/dist/world-change-journal.d.ts.map +0 -1
- package/src/__tests__/world-change-journal.unit.test.ts +0 -97
- package/src/__tests__/world-internal-identity.unit.test.ts +0 -17
- 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
|
-
/**
|
|
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
|
|
170
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
/**
|
|
350
|
-
|
|
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<
|
|
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
|
|
374
|
-
|
|
375
|
-
false;
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
|
385
|
-
|
|
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
|
-
|
|
403
|
-
if (
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
-
* **
|
|
480
|
-
* `
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
* `
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
* `
|
|
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
|
-
|
|
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
|
|
571
|
-
* `world.get
|
|
572
|
-
*
|
|
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
|
-
|
|
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).
|
|
1074
|
-
//
|
|
1075
|
-
//
|
|
1076
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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 (
|
|
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
|
|