@forgeax/engine-ecs 0.1.26 → 0.1.28
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 +94 -35
- package/dist/__tests__/world-read.unit.test.d.ts +2 -0
- package/dist/__tests__/world-read.unit.test.d.ts.map +1 -0
- package/dist/commands.d.ts +2 -0
- package/dist/commands.d.ts.map +1 -1
- package/dist/index.mjs +3471 -4263
- package/dist/index.mjs.map +1 -1
- package/dist/internal.d.ts +2 -3
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.mjs +3 -333
- package/dist/internal.mjs.map +1 -1
- package/dist/projection/index.mjs.map +1 -1
- package/dist/shared.mjs.map +1 -1
- package/dist/world-entity-lifecycle.d.ts +3 -14
- package/dist/world-entity-lifecycle.d.ts.map +1 -1
- package/dist/world-internal.d.ts +65 -5
- package/dist/world-internal.d.ts.map +1 -1
- package/dist/world-read.d.ts +16 -0
- package/dist/world-read.d.ts.map +1 -0
- package/dist/world-read.mjs +8 -0
- package/dist/world-read.mjs.map +1 -0
- package/dist/world-scheduling.d.ts +0 -4
- package/dist/world-scheduling.d.ts.map +1 -1
- package/dist/world-storage-primitives.d.ts +26 -0
- package/dist/world-storage-primitives.d.ts.map +1 -0
- package/dist/world.d.ts +352 -157
- package/dist/world.d.ts.map +1 -1
- package/package.json +8 -4
- package/src/__tests__/command-buffer.test.ts +29 -3
- package/src/__tests__/hierarchy.unit.test.ts +3 -3
- package/src/__tests__/world-health.contract.test.ts +195 -2
- package/src/__tests__/world-read.unit.test.ts +30 -0
- package/src/commands.ts +22 -9
- package/src/internal.ts +5 -3
- package/src/world-entity-lifecycle.ts +23 -252
- package/src/world-internal-augmentation.d.ts +11 -0
- package/src/world-internal.ts +114 -63
- package/src/world-read.ts +38 -0
- package/src/world-scheduling.ts +0 -26
- package/src/world-storage-primitives.ts +179 -0
- package/src/world.ts +2590 -509
- package/dist/world-component-access.d.ts +0 -311
- package/dist/world-component-access.d.ts.map +0 -1
- package/dist/world-component-storage.d.ts +0 -298
- package/dist/world-component-storage.d.ts.map +0 -1
- package/dist/world-core.d.ts +0 -39
- package/dist/world-core.d.ts.map +0 -1
- package/src/world-component-access.ts +0 -1769
- package/src/world-component-storage.ts +0 -1264
- package/src/world-core.ts +0 -74
package/src/world.ts
CHANGED
|
@@ -10,49 +10,84 @@
|
|
|
10
10
|
// build-time / infrastructure failures.
|
|
11
11
|
|
|
12
12
|
import type { Handle, Result } from '@forgeax/engine-types';
|
|
13
|
-
import { err, ok } from '@forgeax/engine-types';
|
|
14
|
-
import type { BufferPool } from './buffer-pool';
|
|
15
13
|
import {
|
|
14
|
+
BUILTIN_BASE,
|
|
15
|
+
err,
|
|
16
|
+
isRetiredSlot,
|
|
17
|
+
ok,
|
|
18
|
+
toShared,
|
|
19
|
+
toUnique,
|
|
20
|
+
unwrapHandle,
|
|
21
|
+
} from '@forgeax/engine-types';
|
|
22
|
+
import { BufferPool } from './buffer-pool';
|
|
23
|
+
import {
|
|
24
|
+
type ArrayMeta,
|
|
25
|
+
bufferFieldByteLength,
|
|
16
26
|
type Component,
|
|
17
27
|
ComponentCatalog,
|
|
18
28
|
type ComponentSchema,
|
|
19
29
|
componentId,
|
|
30
|
+
componentSchema,
|
|
20
31
|
type InputShapeOf,
|
|
32
|
+
isEntityField,
|
|
33
|
+
isManagedArrayField,
|
|
34
|
+
isManagedBufferField,
|
|
35
|
+
isManagedField,
|
|
21
36
|
type ShapeOf,
|
|
37
|
+
TYPE_METADATA,
|
|
22
38
|
} from './component';
|
|
23
|
-
import {
|
|
39
|
+
import { fillComponentDefaults, validateComponentDataKeys } from './component-default-fallback';
|
|
40
|
+
import { componentDefinition, expandComponentRequirements } from './component-schema';
|
|
41
|
+
import { validateManagedArrayValues, validateSharedFieldValues } from './component-value-validate';
|
|
42
|
+
import { Entity as EntityComponent } from './entity';
|
|
43
|
+
import {
|
|
44
|
+
ENTITY_MAX_INDEX,
|
|
45
|
+
ENTITY_NULL_RAW,
|
|
46
|
+
type EntityHandle,
|
|
47
|
+
encodeEntity,
|
|
48
|
+
entityGeneration,
|
|
49
|
+
entityIndex,
|
|
50
|
+
} from './entity-handle';
|
|
24
51
|
import type {
|
|
25
52
|
CommandFailedError,
|
|
26
|
-
ComponentAlreadyPresentError,
|
|
27
53
|
ComponentFieldInvalidValueError,
|
|
28
54
|
ComponentNotDefinedError,
|
|
29
|
-
ComponentNotPresentError,
|
|
30
55
|
ComponentNumericValueInvalidError,
|
|
31
56
|
DerivedRangeOutOfBoundsError,
|
|
32
|
-
|
|
57
|
+
ManagedArrayErrorEnvelope,
|
|
33
58
|
ManagedArrayInvalidValueError,
|
|
34
|
-
ManagedBufferOutOfBoundsError,
|
|
35
59
|
ManagedBufferShrinkNotSupportedError,
|
|
36
60
|
RelationshipDetachMismatchError,
|
|
37
61
|
RelationshipMirrorComponentNotRegisteredError,
|
|
38
62
|
RelationshipMirrorFieldTypeMismatchError,
|
|
39
|
-
RelationshipSelfCycleError,
|
|
40
|
-
RemoveEssentialComponentError,
|
|
41
63
|
ScheduleMutationError,
|
|
42
64
|
ScheduleScopeMismatchError,
|
|
43
65
|
SharedKernelEligibilityError,
|
|
44
66
|
SharedKernelFailureError,
|
|
45
|
-
StaleEntityError,
|
|
46
67
|
SystemFailedError,
|
|
47
68
|
SystemSetNotRegisteredError,
|
|
48
69
|
TimeConfigInvalidError,
|
|
49
70
|
TimeDeltaInvalidError,
|
|
50
71
|
UniqueRefDoubleReleaseError,
|
|
51
72
|
UniqueRefReleasedError,
|
|
73
|
+
} from './errors';
|
|
74
|
+
import {
|
|
75
|
+
ChangeEpochExhaustedError,
|
|
76
|
+
ComponentAlreadyPresentError,
|
|
77
|
+
ComponentNotPresentError,
|
|
78
|
+
EntityIndexOverflowError,
|
|
79
|
+
FixedSizeMismatchError,
|
|
80
|
+
ManagedBufferOutOfBoundsError,
|
|
81
|
+
RelationshipSelfCycleError,
|
|
82
|
+
RelationshipTargetReadonlyError,
|
|
83
|
+
RemoveEssentialComponentError,
|
|
84
|
+
StaleEntityError,
|
|
85
|
+
validateEnumFieldValues,
|
|
86
|
+
validateNumericFieldValues,
|
|
52
87
|
WorldPoisonedError,
|
|
53
88
|
} from './errors';
|
|
54
|
-
import { ChangeEpochExhaustedError, RelationshipTargetReadonlyError } from './errors';
|
|
55
89
|
import {
|
|
90
|
+
createWorldIdentity,
|
|
56
91
|
healthyWorldExecutionState,
|
|
57
92
|
poisonedWorldExecutionState,
|
|
58
93
|
type WorldExecutionFault,
|
|
@@ -62,38 +97,52 @@ import type { QueryDescriptor } from './query/query';
|
|
|
62
97
|
import { createQuery, type Query, type QueryCreationError } from './query/query';
|
|
63
98
|
import {
|
|
64
99
|
isRelationshipTarget,
|
|
100
|
+
RelationshipIndex,
|
|
65
101
|
type RelationshipTargetComponent,
|
|
102
|
+
relationshipMirror,
|
|
66
103
|
relationshipRole,
|
|
104
|
+
relationshipSource,
|
|
67
105
|
} from './relationship-index';
|
|
68
106
|
import { createResourceStore, type ResourceStore } from './resource';
|
|
69
|
-
import { createSchedule, type
|
|
107
|
+
import { createSchedule, type SystemDescriptor, type SystemSet } from './schedule';
|
|
70
108
|
import { FixedUpdate, Update } from './schedule-token';
|
|
71
109
|
import type { SharedRefStore } from './shared-ref-store';
|
|
72
|
-
import
|
|
73
|
-
import type
|
|
110
|
+
import { SharedRefStore as SharedRefStoreImpl } from './shared-ref-store';
|
|
111
|
+
import { type Archetype, appendArchetypeRow, removeArchetypeRow } from './storage/archetype';
|
|
112
|
+
import {
|
|
113
|
+
type ArchetypeGraph,
|
|
114
|
+
createArchetypeGraph,
|
|
115
|
+
getAddEdge,
|
|
116
|
+
getOrCreateArchetype,
|
|
117
|
+
getRemoveEdge,
|
|
118
|
+
getTable,
|
|
119
|
+
} from './storage/archetype-graph';
|
|
74
120
|
import {
|
|
75
121
|
type ChangeTicks,
|
|
122
|
+
copyComponentEpoch,
|
|
76
123
|
markComponentChanged,
|
|
77
124
|
markComponentsAdded,
|
|
78
125
|
readComponentChange,
|
|
126
|
+
removeSparseTag,
|
|
79
127
|
} from './storage/change-detection';
|
|
80
|
-
import type {
|
|
81
|
-
StructuralEvidenceInput,
|
|
82
|
-
StructuralEvidenceRing,
|
|
83
|
-
} from './storage/structural-evidence';
|
|
84
128
|
import {
|
|
85
|
-
|
|
129
|
+
arrayCountColumnName,
|
|
130
|
+
type Column,
|
|
131
|
+
type FieldView,
|
|
132
|
+
normalizeBufferWrite,
|
|
133
|
+
} from './storage/column';
|
|
134
|
+
import type { StructuralEvidenceInput } from './storage/structural-evidence';
|
|
135
|
+
import { StructuralEvidenceRing as StructuralEvidenceRingImpl } from './storage/structural-evidence';
|
|
136
|
+
import { appendTableRow, removeTableRow, type Table } from './storage/table';
|
|
137
|
+
import {
|
|
86
138
|
createWorldClock,
|
|
87
139
|
DEFAULT_TIME_POLICY,
|
|
88
140
|
FIXED_TIME_RESOURCE_KEY,
|
|
89
141
|
TIME_RESOURCE_KEY,
|
|
90
142
|
type WorldOptions,
|
|
91
143
|
} from './time';
|
|
92
|
-
import type
|
|
93
|
-
import { WorldComponentAccess } from './world-component-access';
|
|
94
|
-
import { WorldCore } from './world-core';
|
|
144
|
+
import { type UniqueRefStore, UniqueRefStore as UniqueRefStoreImpl } from './unique-ref-store';
|
|
95
145
|
import {
|
|
96
|
-
despawnCore,
|
|
97
146
|
spawnCore,
|
|
98
147
|
worldAddChild,
|
|
99
148
|
worldIterAncestors,
|
|
@@ -101,17 +150,15 @@ import {
|
|
|
101
150
|
worldRemoveChild,
|
|
102
151
|
worldReparent,
|
|
103
152
|
} from './world-entity-lifecycle';
|
|
104
|
-
import {
|
|
153
|
+
import { worldInternal } from './world-internal';
|
|
154
|
+
import { type WorldRead, worldRead } from './world-read';
|
|
105
155
|
import {
|
|
106
156
|
worldAddSystem,
|
|
107
157
|
worldAddSystems,
|
|
108
|
-
worldAllocSharedRef,
|
|
109
|
-
worldAllocUniqueRef,
|
|
110
158
|
worldGetResource,
|
|
111
159
|
worldHasResource,
|
|
112
160
|
worldInsertResource,
|
|
113
161
|
worldInspect,
|
|
114
|
-
worldInternSharedRef,
|
|
115
162
|
worldRemoveResource,
|
|
116
163
|
worldRemoveSystem,
|
|
117
164
|
worldReplaceSystem,
|
|
@@ -119,6 +166,14 @@ import {
|
|
|
119
166
|
worldScheduleUsesComponent,
|
|
120
167
|
worldUpdate,
|
|
121
168
|
} from './world-scheduling';
|
|
169
|
+
import {
|
|
170
|
+
detachWorldInspection,
|
|
171
|
+
elementByteSize,
|
|
172
|
+
readArrayElementAt,
|
|
173
|
+
reinterpretBufferRegion,
|
|
174
|
+
reinterpretSlotBytes,
|
|
175
|
+
writeArrayElementAt,
|
|
176
|
+
} from './world-storage-primitives';
|
|
122
177
|
|
|
123
178
|
/**
|
|
124
179
|
* Union of all EcsError types that World methods can return via Result.
|
|
@@ -174,6 +229,8 @@ export interface ComponentData<S extends ComponentSchema = ComponentSchema> {
|
|
|
174
229
|
|
|
175
230
|
type WritableComponent<C extends Component> = C extends RelationshipTargetComponent ? never : C;
|
|
176
231
|
|
|
232
|
+
type ErrorContext = { readonly systemName: string };
|
|
233
|
+
|
|
177
234
|
/**
|
|
178
235
|
* Per-archetype summary returned by `world.inspect()`. Sorted ComponentId key
|
|
179
236
|
* (always prefixed by the essential id=0 Entity column, e.g. "0+2+5+7"),
|
|
@@ -285,8 +342,10 @@ export interface WorldScheduleData {
|
|
|
285
342
|
* permanently (it is never pushed back to `freeIndices`). The single liveness
|
|
286
343
|
* predicate is therefore "handle gen matches AND archetypeId !== -1" -- see
|
|
287
344
|
* `World.recordIsLive`. A deferred-spawn allocation is "pending" when
|
|
288
|
-
* archetypeId === -1 (not yet materialized into an archetype row)
|
|
289
|
-
*
|
|
345
|
+
* archetypeId === -1 (not yet materialized into an archetype row). During a
|
|
346
|
+
* failed append, `archetypeId` is reserved before the first storage write and
|
|
347
|
+
* `archetypeRow` remains `-1`; that marker prevents command abort from
|
|
348
|
+
* reclaiming a reservation that may already have touched table storage.
|
|
290
349
|
*/
|
|
291
350
|
export interface EntityRecord {
|
|
292
351
|
generation: number;
|
|
@@ -295,57 +354,36 @@ export interface EntityRecord {
|
|
|
295
354
|
}
|
|
296
355
|
|
|
297
356
|
/**
|
|
298
|
-
* The World owns
|
|
299
|
-
*
|
|
300
|
-
* - all archetypes (via ArchetypeGraph);
|
|
301
|
-
* - the entity index table (records by index slot);
|
|
302
|
-
* - the free-list of recyclable entity slots.
|
|
357
|
+
* The World owns the component registry, archetype graph, entity index table,
|
|
358
|
+
* and recyclable free-list.
|
|
303
359
|
*/
|
|
304
360
|
export class World {
|
|
305
|
-
|
|
306
|
-
readonly [worldInternal]: WorldInternal;
|
|
361
|
+
readonly [worldRead]: WorldRead;
|
|
307
362
|
// ── Internal state ──
|
|
308
363
|
|
|
309
|
-
/**
|
|
310
|
-
private
|
|
364
|
+
/** World-local state is kept together so one owner closes each mutation. */
|
|
365
|
+
private executionState: WorldExecutionState = healthyWorldExecutionState(createWorldIdentity());
|
|
366
|
+
private readonly graph: ArchetypeGraph;
|
|
367
|
+
private readonly records: EntityRecord[] = [];
|
|
368
|
+
private readonly freeIndices: number[] = [];
|
|
369
|
+
/** BufferPool for schema-declared variable buffers and arrays. */
|
|
370
|
+
private readonly bufferPool = new BufferPool();
|
|
371
|
+
/** Per-World managed unique-ref store used by lifecycle mutations. */
|
|
372
|
+
private readonly uniqueRefs: UniqueRefStore = new UniqueRefStoreImpl();
|
|
373
|
+
/** Per-World shared-ref store; public read-only for direct handle operations. */
|
|
374
|
+
readonly sharedRefs: SharedRefStore = new SharedRefStoreImpl();
|
|
375
|
+
private readonly componentMutationEpochs: number[] = [];
|
|
376
|
+
private readonly structuralEvidence = new StructuralEvidenceRingImpl();
|
|
377
|
+
/** One packed reverse index per relationship source component. */
|
|
378
|
+
private readonly relationshipIndexes = new Map<number, RelationshipIndex>();
|
|
379
|
+
private mutationEpoch = 0;
|
|
380
|
+
private structureEpoch = 0;
|
|
381
|
+
/** Keep identity as a prototype getter; it is a diagnostic capability, not enumerable state. */
|
|
311
382
|
get identity() {
|
|
312
|
-
return this.
|
|
383
|
+
return this.executionState.identity;
|
|
313
384
|
}
|
|
314
385
|
/** Plugin-owned component discovery scoped to this World and removed through leases. */
|
|
315
386
|
readonly components = new ComponentCatalog((component) => this.componentIsInUse(component));
|
|
316
|
-
private executionState: WorldExecutionState;
|
|
317
|
-
/** Monotonic clock advanced exactly once per successful mutation. */
|
|
318
|
-
/** Monotonic revision for successful entity/component structure writes. */
|
|
319
|
-
/** Last mutation epoch for each component id, used by component-owned projections. */
|
|
320
|
-
/** Ordered, bounded evidence consumed by persistent engine-owned projections. */
|
|
321
|
-
private get mutationEpoch() {
|
|
322
|
-
return this.core.mutationEpoch;
|
|
323
|
-
}
|
|
324
|
-
private set mutationEpoch(value: number) {
|
|
325
|
-
this.core.mutationEpoch = value;
|
|
326
|
-
}
|
|
327
|
-
private get structureEpoch() {
|
|
328
|
-
return this.core.structureEpoch;
|
|
329
|
-
}
|
|
330
|
-
private set structureEpoch(value: number) {
|
|
331
|
-
this.core.structureEpoch = value;
|
|
332
|
-
}
|
|
333
|
-
private get componentMutationEpochs() {
|
|
334
|
-
return this.core.componentMutationEpochs;
|
|
335
|
-
}
|
|
336
|
-
/** Free index slots (LIFO stack). */
|
|
337
|
-
private get records() {
|
|
338
|
-
return this.core.records;
|
|
339
|
-
}
|
|
340
|
-
private get freeIndices() {
|
|
341
|
-
return this.core.freeIndices;
|
|
342
|
-
}
|
|
343
|
-
/**
|
|
344
|
-
* Relationship-sync reentry guard (feat-20260531 M2 / plan-strategy D-7).
|
|
345
|
-
/** The archetype graph: manages all archetypes + edge caching. */
|
|
346
|
-
private get graph(): ArchetypeGraph {
|
|
347
|
-
return this.core.graph;
|
|
348
|
-
}
|
|
349
387
|
/** DAG schedules for the two built-in execution scopes. */
|
|
350
388
|
private readonly schedules = new Map([
|
|
351
389
|
[Update, createSchedule(Update)],
|
|
@@ -356,69 +394,8 @@ export class World {
|
|
|
356
394
|
private readonly clock: ReturnType<typeof createWorldClock>;
|
|
357
395
|
/** Remainder carried between fixed-step runs. */
|
|
358
396
|
private fixedAccumulator = 0;
|
|
359
|
-
/**
|
|
360
|
-
* ECS-managed handle store (M1). Owned by the World - constructed eagerly
|
|
361
|
-
* so every spawn / despawn / set path can dispatch managed-ref releases
|
|
362
|
-
* without caller-side wiring. AI users obtain `Handle<T,'unique'>` values
|
|
363
|
-
* by accessing the store through internal channels (the surface is
|
|
364
|
-
* private; managed-ref-bearing fields read through `world.get`).
|
|
365
|
-
*/
|
|
366
|
-
// UniqueRefStore is type-erased at the storage layer (alloc/resolve are
|
|
367
|
-
// method-generic over `T`); World holds the single per-instance store and
|
|
368
|
-
// routes payload-agnostic release calls. Typed access flows through
|
|
369
|
-
// `UniqueRefStore.resolve<T>` at the consumer layer.
|
|
370
|
-
private get uniqueRefs(): UniqueRefStore {
|
|
371
|
-
return this.core.uniqueRefs;
|
|
372
|
-
}
|
|
373
|
-
/**
|
|
374
|
-
* Per-World `SharedRefStore` (feat-20260614 M3). Backs every `shared<T>`
|
|
375
|
-
* schema field + the `world.allocSharedRef` facade. Public read-only so AI
|
|
376
|
-
* users can `retain` / `release` / `resolve` user-tier handles directly off
|
|
377
|
-
* the world (the surface is small enough that hiding it behind another
|
|
378
|
-
* facade would be a phantom indirection - charter F1 single-entry
|
|
379
|
-
* indexability).
|
|
380
|
-
*
|
|
381
|
-
* Final release publishes structured evidence; there is no callback surface.
|
|
382
|
-
* M6 D-15: the store manages only user-tier slots
|
|
383
|
-
* (`>= BUILTIN_BASE`); builtin handles are process-static in their
|
|
384
|
-
* authoring package and never reference-counted.
|
|
385
|
-
*/
|
|
386
|
-
get sharedRefs(): SharedRefStore {
|
|
387
|
-
return this.core.sharedRefs;
|
|
388
|
-
}
|
|
389
|
-
/**
|
|
390
|
-
* BufferPool backing every `buffer:<N>` schema-vocab field (M2). Eagerly
|
|
391
|
-
* constructed (per-World, D-2). `spawn` allocs slots for buffer fields and
|
|
392
|
-
* stores the slot id in the u32 column; `despawn` / `removeComponent`
|
|
393
|
-
* release the slots; `set(e, C, { field: Uint8Array })` copies bytes into
|
|
394
|
-
* the live view without re-allocating (schema-declared byteLength is
|
|
395
|
-
* fixed in v1; runtime grow is reserved for the M4 carry-over path).
|
|
396
|
-
*/
|
|
397
|
-
private get bufferPool() {
|
|
398
|
-
return this.core.bufferPool;
|
|
399
|
-
}
|
|
400
|
-
private readonly componentAccess: WorldComponentAccess;
|
|
401
|
-
|
|
402
397
|
constructor(options: WorldOptions = {}) {
|
|
403
|
-
this.
|
|
404
|
-
this.executionState = healthyWorldExecutionState(this.identity);
|
|
405
|
-
this.componentAccess = new WorldComponentAccess({
|
|
406
|
-
graph: this.graph,
|
|
407
|
-
records: this.records,
|
|
408
|
-
freeIndices: this.freeIndices,
|
|
409
|
-
bufferPool: this.bufferPool,
|
|
410
|
-
uniqueRefs: this.uniqueRefs,
|
|
411
|
-
sharedRefs: this.sharedRefs,
|
|
412
|
-
relationshipIndexes: this.core.relationshipIndexes,
|
|
413
|
-
markComponentAdded: (entity, component) => this.internalmarkComponentAdded(entity, component),
|
|
414
|
-
markComponentsAdded: (entity, components) =>
|
|
415
|
-
this.internalmarkComponentsAdded(entity, components),
|
|
416
|
-
markComponentChanged: (entity, component) =>
|
|
417
|
-
this.internalmarkComponentChanged(entity, component),
|
|
418
|
-
markStructureChanged: () => this.internalmarkStructureChanged(),
|
|
419
|
-
recordStructuralEvidence: (evidence) => this.internalrecordStructuralEvidence(evidence),
|
|
420
|
-
routeError: (error, context) => this.internalrouteError(error as EcsError, context),
|
|
421
|
-
});
|
|
398
|
+
this.graph = createArchetypeGraph(options.storage === 'shared');
|
|
422
399
|
this.clock = createWorldClock({ ...DEFAULT_TIME_POLICY, ...options.time });
|
|
423
400
|
this.resources.entries.set(TIME_RESOURCE_KEY, {
|
|
424
401
|
value: this.clock.time,
|
|
@@ -431,63 +408,51 @@ export class World {
|
|
|
431
408
|
changed: 0,
|
|
432
409
|
});
|
|
433
410
|
this[worldInternal] = {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
getArrayView: this.internalgetArrayView.bind(this),
|
|
440
|
-
getArrayLength: this.internalgetArrayLength.bind(this),
|
|
441
|
-
getArrayElement: this.internalgetArrayElement.bind(this),
|
|
442
|
-
getFieldValue: this.internalgetFieldValue.bind(this),
|
|
443
|
-
getBufferPool: this.internalgetBufferPool.bind(this),
|
|
444
|
-
getClockWriter: this.internalgetClockWriter.bind(this),
|
|
411
|
+
allocatePendingEntity: this.allocatePendingEntity.bind(this),
|
|
412
|
+
cancelPendingEntity: this.cancelPendingEntity.bind(this),
|
|
413
|
+
getArrayView: this.getArrayView.bind(this),
|
|
414
|
+
getBufferPool: () => this.bufferPool,
|
|
415
|
+
getClockWriter: () => this.clock.writer,
|
|
445
416
|
getComponentChange: this.internalgetComponentChange.bind(this),
|
|
446
|
-
|
|
447
|
-
getComponentMutationEpochs: this.internalgetComponentMutationEpochs.bind(this),
|
|
417
|
+
getComponentMutationEpochs: () => this.componentMutationEpochs,
|
|
448
418
|
getEntityArchetype: this.internalgetEntityArchetype.bind(this),
|
|
449
|
-
getFixedAccumulator: this.
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
getRecords: this.
|
|
455
|
-
getRelationshipEpoch:
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
lookupAlive: this.
|
|
465
|
-
markComponentAdded: this.internalmarkComponentAdded.bind(this),
|
|
419
|
+
getFixedAccumulator: () => this.fixedAccumulator,
|
|
420
|
+
getGraph: () => this.graph,
|
|
421
|
+
getMutationEpoch: () => this.mutationEpoch,
|
|
422
|
+
getQueryRow: (entity, component) =>
|
|
423
|
+
this.get(entity, component) as Result<Record<string, unknown>, EcsError>,
|
|
424
|
+
getRecords: () => this.records,
|
|
425
|
+
getRelationshipEpoch: (component) =>
|
|
426
|
+
this.relationshipIndexes.get(componentId(component))?.epoch ?? 0,
|
|
427
|
+
getRelationshipTargetEntities: this.relationshipTargetEntries.bind(this),
|
|
428
|
+
getResources: () => this.resources,
|
|
429
|
+
getSchedule: (token) => this.schedules.get(token),
|
|
430
|
+
getSchedules: () => this.schedules,
|
|
431
|
+
getSharedRefs: () => this.sharedRefs,
|
|
432
|
+
getStructureEpoch: this.getStructureEpoch.bind(this),
|
|
433
|
+
getStructuralEvidence: () => this.structuralEvidence,
|
|
434
|
+
lookupAlive: this.lookupAlive.bind(this),
|
|
466
435
|
markComponentChanged: this.internalmarkComponentChanged.bind(this),
|
|
467
436
|
markComponentRangeChanged: this.internalmarkComponentRangeChanged.bind(this),
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
materializePendingEntity: this.internalmaterializePendingEntity.bind(this),
|
|
437
|
+
materializeEntity: this.materializeEntity.bind(this),
|
|
438
|
+
materializePendingEntity: this.materializePendingEntity.bind(this),
|
|
471
439
|
nextMutationEpoch: this.internalnextMutationEpoch.bind(this),
|
|
472
440
|
poisonExecution: this.internalpoisonExecution.bind(this),
|
|
473
|
-
prepareRelationshipInsert: this.internalprepareRelationshipInsert.bind(this),
|
|
474
441
|
publishDerivedRange: this.internalpublishDerivedRange.bind(this),
|
|
475
|
-
preflightComponentData: this.
|
|
476
|
-
readRow: this.
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
relationshipOnInsert: this.internalrelationshipOnInsert.bind(this),
|
|
480
|
-
relationshipOnRemove: this.internalrelationshipOnRemove.bind(this),
|
|
481
|
-
releaseManagedRefsOnRow: this.internalreleaseManagedRefsOnRow.bind(this),
|
|
482
|
-
releaseRelationshipPreparation: this.internalreleaseRelationshipPreparation.bind(this),
|
|
483
|
-
removeComponentCore: this.internalremoveComponentCore.bind(this),
|
|
484
|
-
routeError: this.internalrouteError.bind(this),
|
|
442
|
+
preflightComponentData: this.preflightComponentData.bind(this),
|
|
443
|
+
readRow: this.readRow.bind(this),
|
|
444
|
+
recordIsLive: this.recordIsLive.bind(this),
|
|
445
|
+
routeError: this.routeError.bind(this),
|
|
485
446
|
restoreMutationEpoch: this.internalrestoreMutationEpoch.bind(this),
|
|
486
|
-
setFixedAccumulator:
|
|
447
|
+
setFixedAccumulator: (value) => {
|
|
448
|
+
this.fixedAccumulator = value;
|
|
449
|
+
},
|
|
487
450
|
setQueryRow: this.internalsetQueryRow.bind(this),
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
451
|
+
};
|
|
452
|
+
this[worldRead] = {
|
|
453
|
+
getFieldValue: this.internalgetFieldValue.bind(this),
|
|
454
|
+
getArrayLength: this.internalgetArrayLength.bind(this),
|
|
455
|
+
getArrayElement: this.internalgetArrayElement.bind(this),
|
|
491
456
|
};
|
|
492
457
|
}
|
|
493
458
|
|
|
@@ -505,13 +470,24 @@ export class World {
|
|
|
505
470
|
return FixedUpdate;
|
|
506
471
|
}
|
|
507
472
|
|
|
508
|
-
/**
|
|
473
|
+
/** Seal the first execution fault; application code recovers with a new World. */
|
|
509
474
|
private internalpoisonExecution(fault: WorldExecutionFault): void {
|
|
510
475
|
if (this.executionState.health === 'healthy') {
|
|
511
476
|
this.executionState = poisonedWorldExecutionState(this.identity, fault);
|
|
512
477
|
}
|
|
513
478
|
}
|
|
514
479
|
|
|
480
|
+
/**
|
|
481
|
+
* A poisoned identity is diagnostic evidence, not a mutable recovery path.
|
|
482
|
+
* Public entity mutation therefore returns the same structured fence as
|
|
483
|
+
* `update()` instead of allocating a new reservation or touching a partial
|
|
484
|
+
* row. Recovery remains construction of a fresh World.
|
|
485
|
+
*/
|
|
486
|
+
private poisonedResult<T>(): Result<T, EcsError> | undefined {
|
|
487
|
+
if (this.executionState.health !== 'poisoned') return undefined;
|
|
488
|
+
return err(new WorldPoisonedError(this.identity, this.executionState.fault));
|
|
489
|
+
}
|
|
490
|
+
|
|
515
491
|
query<
|
|
516
492
|
const R extends readonly Component[] = readonly [],
|
|
517
493
|
const W extends readonly Component[] = readonly [],
|
|
@@ -524,11 +500,6 @@ export class World {
|
|
|
524
500
|
// Internal access — query engine
|
|
525
501
|
// ──────────────────────────────────────────────────────────────────────────
|
|
526
502
|
|
|
527
|
-
/** Expose archetype graph for query engine. Not part of public API. */
|
|
528
|
-
private internalgetGraph(): ArchetypeGraph {
|
|
529
|
-
return this.graph;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
503
|
private componentIsInUse(component: Component): boolean {
|
|
533
504
|
if (
|
|
534
505
|
this.graph.archetypes.some(
|
|
@@ -541,29 +512,14 @@ export class World {
|
|
|
541
512
|
return worldScheduleUsesComponent(this, component);
|
|
542
513
|
}
|
|
543
514
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
return this.mutationEpoch;
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
/** Structure snapshot used to invalidate borrowed query facades. */
|
|
550
|
-
private internalgetStructureEpoch(): number {
|
|
551
|
-
return this.structureEpoch;
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
/** Typed structural facts are produced by this World and consumed by projections. */
|
|
555
|
-
private internalgetStructuralEvidence(): StructuralEvidenceRing {
|
|
556
|
-
return this.core.structuralEvidence;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
private internalrecordStructuralEvidence(evidence: StructuralEvidenceInput): void {
|
|
560
|
-
this.core.structuralEvidence.append(evidence);
|
|
515
|
+
private recordStructuralEvidence(evidence: StructuralEvidenceInput): void {
|
|
516
|
+
this.structuralEvidence.append(evidence);
|
|
561
517
|
}
|
|
562
518
|
|
|
563
519
|
/** Resolve current logical identity for a packed entity handle. */
|
|
564
520
|
private internalgetEntityArchetype(entity: EntityHandle): Archetype | undefined {
|
|
565
521
|
const record = this.records[entityIndex(entity)];
|
|
566
|
-
if (!this.
|
|
522
|
+
if (!this.recordIsLive(record, entityGeneration(entity))) return undefined;
|
|
567
523
|
return this.graph.archetypes[record.archetypeId];
|
|
568
524
|
}
|
|
569
525
|
|
|
@@ -573,7 +529,7 @@ export class World {
|
|
|
573
529
|
componentId: number,
|
|
574
530
|
): ChangeTicks | undefined {
|
|
575
531
|
const record = this.records[entityIndex(entity)];
|
|
576
|
-
if (!this.
|
|
532
|
+
if (!this.recordIsLive(record, entityGeneration(entity))) return undefined;
|
|
577
533
|
return readComponentChange(this.graph, record, entity, componentId);
|
|
578
534
|
}
|
|
579
535
|
|
|
@@ -606,7 +562,7 @@ export class World {
|
|
|
606
562
|
}
|
|
607
563
|
|
|
608
564
|
/** Record one successful structural mutation. */
|
|
609
|
-
private
|
|
565
|
+
private advanceStructureEpoch(): void {
|
|
610
566
|
this.structureEpoch += 1;
|
|
611
567
|
}
|
|
612
568
|
|
|
@@ -615,15 +571,10 @@ export class World {
|
|
|
615
571
|
return this.structureEpoch;
|
|
616
572
|
}
|
|
617
573
|
|
|
618
|
-
/** Mark a component as both added and changed at the current tick. */
|
|
619
|
-
private internalmarkComponentAdded(entity: EntityHandle, componentId: number): void {
|
|
620
|
-
this.internalmarkComponentsAdded(entity, [componentId]);
|
|
621
|
-
}
|
|
622
|
-
|
|
623
574
|
/** Mark one mutation's component instances with a shared epoch. */
|
|
624
575
|
private internalmarkComponentsAdded(entity: EntityHandle, componentIds: readonly number[]): void {
|
|
625
576
|
const record = this.records[entityIndex(entity)];
|
|
626
|
-
if (!this.
|
|
577
|
+
if (!this.recordIsLive(record, entityGeneration(entity))) return;
|
|
627
578
|
const epoch = this.internalnextMutationEpoch();
|
|
628
579
|
markComponentsAdded(this.graph, record, entity, componentIds, epoch);
|
|
629
580
|
for (const componentId of componentIds) {
|
|
@@ -634,7 +585,7 @@ export class World {
|
|
|
634
585
|
/** Mark an existing component as changed at the current tick. */
|
|
635
586
|
private internalmarkComponentChanged(entity: EntityHandle, componentId: number): void {
|
|
636
587
|
const record = this.records[entityIndex(entity)];
|
|
637
|
-
if (!this.
|
|
588
|
+
if (!this.recordIsLive(record, entityGeneration(entity))) return;
|
|
638
589
|
let epoch: number | undefined;
|
|
639
590
|
markComponentChanged(this.graph, record, entity, componentId, () => {
|
|
640
591
|
epoch = this.internalnextMutationEpoch();
|
|
@@ -659,49 +610,17 @@ export class World {
|
|
|
659
610
|
this.componentMutationEpochs[componentId] = epoch;
|
|
660
611
|
}
|
|
661
612
|
|
|
662
|
-
/** Latest mutation token for one component-owned projection. */
|
|
663
|
-
private internalgetComponentMutationEpoch(componentId: number): number {
|
|
664
|
-
return this.componentMutationEpochs[componentId] ?? 0;
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
/** Borrow the component-version summary used to skip unrelated value columns. */
|
|
668
|
-
private internalgetComponentMutationEpochs(): readonly number[] {
|
|
669
|
-
return this.componentMutationEpochs;
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
/** Read a materialized relationship target in O(1 + k). */
|
|
673
|
-
private internalgetRelationshipTargetEntities(
|
|
674
|
-
source: Component,
|
|
675
|
-
target: EntityHandle,
|
|
676
|
-
): readonly EntityHandle[] {
|
|
677
|
-
return this.componentAccess.relationshipTargetEntries(source, target);
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
/** Monotonic epoch for the materialized relationship index. */
|
|
681
|
-
private internalgetRelationshipEpoch(source: Component): number {
|
|
682
|
-
return this.core.relationshipIndexes.get(componentId(source))?.epoch ?? 0;
|
|
683
|
-
}
|
|
684
|
-
|
|
685
613
|
/** Query facade write after the facade has already marked evidence. */
|
|
686
614
|
private internalsetQueryRow(
|
|
687
615
|
entity: EntityHandle,
|
|
688
616
|
component: Component,
|
|
689
617
|
value: Record<string, unknown>,
|
|
690
618
|
): Result<void, EcsError> {
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
component
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
);
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
/** Query facade read that does not re-enter the public World API. */
|
|
700
|
-
private internalgetQueryRow(
|
|
701
|
-
entity: EntityHandle,
|
|
702
|
-
component: Component,
|
|
703
|
-
): Result<Record<string, unknown>, EcsError> {
|
|
704
|
-
return this.componentAccess.get(entity, component) as Result<Record<string, unknown>, EcsError>;
|
|
619
|
+
const result = this.set(entity, component, value as never, false);
|
|
620
|
+
if (result.ok && relationshipRole(component)?.kind === 'source') {
|
|
621
|
+
this.markComponentChanged(entity, component);
|
|
622
|
+
}
|
|
623
|
+
return result;
|
|
705
624
|
}
|
|
706
625
|
|
|
707
626
|
/** Return resource change ticks for diagnostics and resource-driven systems. */
|
|
@@ -710,67 +629,13 @@ export class World {
|
|
|
710
629
|
return entry === undefined ? undefined : { added: entry.added, changed: entry.changed };
|
|
711
630
|
}
|
|
712
631
|
|
|
713
|
-
/**
|
|
714
|
-
|
|
715
|
-
* an engine-internal subsystem (e.g. RenderSystem extract stage, w15).
|
|
716
|
-
*
|
|
717
|
-
* Mirrors the private `errorHandler(err, ctx)` call sites inside `World`
|
|
718
|
-
* itself; the dedicated accessor avoids exposing `errorHandler` directly
|
|
719
|
-
* and keeps the routing contract under the `_xxx` `@internal` umbrella so
|
|
720
|
-
* AI users do not discover it through IDE autocomplete on `World`.
|
|
721
|
-
*
|
|
722
|
-
* Not part of the public API.
|
|
723
|
-
*/
|
|
724
|
-
private internalrouteError(err: EcsError, ctx?: { readonly systemName: string }): void {
|
|
632
|
+
/** Route an expected internal failure through the host-owned error channel. */
|
|
633
|
+
private routeError(err: unknown, ctx?: ErrorContext): void {
|
|
725
634
|
// Internal expected failures are reported without becoming a second
|
|
726
635
|
// schedule or terminal hook. The host owns fatal frame policy.
|
|
727
636
|
console.error(`[${ctx?.systemName ?? 'World'}]`, err);
|
|
728
637
|
}
|
|
729
638
|
|
|
730
|
-
// ──────────────────────────────────────────────────────────────────────────
|
|
731
|
-
// accessors — M1 extraction seam
|
|
732
|
-
// ──────────────────────────────────────────────────────────────────────────
|
|
733
|
-
|
|
734
|
-
/** */ private internalgetRecords(): EntityRecord[] {
|
|
735
|
-
return this.records;
|
|
736
|
-
}
|
|
737
|
-
/** */ private internalgetFreeIndices(): number[] {
|
|
738
|
-
return this.freeIndices;
|
|
739
|
-
}
|
|
740
|
-
/** */ private internalgetResources(): ResourceStore {
|
|
741
|
-
return this.resources;
|
|
742
|
-
}
|
|
743
|
-
/** */ private internalgetFixedAccumulator(): number {
|
|
744
|
-
return this.fixedAccumulator;
|
|
745
|
-
}
|
|
746
|
-
/** */ private internalsetFixedAccumulator(value: number): void {
|
|
747
|
-
this.fixedAccumulator = value;
|
|
748
|
-
}
|
|
749
|
-
/** */ private internalgetUniqueRefs(): UniqueRefStore {
|
|
750
|
-
return this.uniqueRefs;
|
|
751
|
-
}
|
|
752
|
-
/** */ private internalgetBufferPool(): BufferPool {
|
|
753
|
-
return this.bufferPool;
|
|
754
|
-
}
|
|
755
|
-
/** Scheduler-owned mutable clock capability. */
|
|
756
|
-
private internalgetClockWriter(): ClockWriter {
|
|
757
|
-
return this.clock.writer;
|
|
758
|
-
}
|
|
759
|
-
/** */ private internalgetSchedule(
|
|
760
|
-
token: import('./schedule-token').ScheduleToken,
|
|
761
|
-
): Schedule | undefined {
|
|
762
|
-
return this.schedules.get(token);
|
|
763
|
-
}
|
|
764
|
-
/** */ private internalgetSchedules(): ReadonlyMap<
|
|
765
|
-
import('./schedule-token').ScheduleToken,
|
|
766
|
-
Schedule
|
|
767
|
-
> {
|
|
768
|
-
return this.schedules;
|
|
769
|
-
}
|
|
770
|
-
/** */ private internalgetSharedRefs(): SharedRefStore {
|
|
771
|
-
return this.sharedRefs;
|
|
772
|
-
}
|
|
773
|
-
|
|
774
639
|
// ──────────────────────────────────────────────────────────────────────────
|
|
775
640
|
// System registration + update (M3)
|
|
776
641
|
// ──────────────────────────────────────────────────────────────────────────
|
|
@@ -1017,7 +882,7 @@ export class World {
|
|
|
1017
882
|
payload: T,
|
|
1018
883
|
onRelease?: (payload: T) => void,
|
|
1019
884
|
): Handle<Target, 'unique'> {
|
|
1020
|
-
return
|
|
885
|
+
return this.uniqueRefs.alloc(target, payload, onRelease);
|
|
1021
886
|
}
|
|
1022
887
|
|
|
1023
888
|
/**
|
|
@@ -1053,7 +918,7 @@ export class World {
|
|
|
1053
918
|
* ```
|
|
1054
919
|
*/
|
|
1055
920
|
allocSharedRef<Target extends string, T>(target: Target, payload: T): Handle<Target, 'shared'> {
|
|
1056
|
-
return
|
|
921
|
+
return this.sharedRefs.alloc(target, payload);
|
|
1057
922
|
}
|
|
1058
923
|
|
|
1059
924
|
/**
|
|
@@ -1067,11 +932,14 @@ export class World {
|
|
|
1067
932
|
target: Target,
|
|
1068
933
|
payload: T,
|
|
1069
934
|
): Handle<Target, 'shared'> {
|
|
1070
|
-
return
|
|
935
|
+
return this.sharedRefs.intern(target, payload);
|
|
1071
936
|
}
|
|
1072
937
|
|
|
1073
938
|
// ──────────────────────────────────────────────────────────────────────────
|
|
1074
|
-
//
|
|
939
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
940
|
+
// Component access and storage — World owns mutation state and coordination.
|
|
941
|
+
// Independent table/column algorithms remain private methods so all mutation
|
|
942
|
+
// paths share one state owner and publication boundary.
|
|
1075
943
|
// ──────────────────────────────────────────────────────────────────────────
|
|
1076
944
|
|
|
1077
945
|
private relationshipTargetWriteError(
|
|
@@ -1089,23 +957,7 @@ export class World {
|
|
|
1089
957
|
});
|
|
1090
958
|
}
|
|
1091
959
|
|
|
1092
|
-
|
|
1093
|
-
entity: EntityHandle,
|
|
1094
|
-
component: Component<string, S>,
|
|
1095
|
-
): Result<ShapeOf<S>, EcsError> {
|
|
1096
|
-
return this.componentAccess.get(entity, component);
|
|
1097
|
-
}
|
|
1098
|
-
|
|
1099
|
-
/**
|
|
1100
|
-
* Test live component presence without constructing a Result error.
|
|
1101
|
-
*
|
|
1102
|
-
* Read projections commonly need to branch on optional components for many
|
|
1103
|
-
* entities. Calling `get` for that branch allocates a structured
|
|
1104
|
-
* ComponentNotPresentError on every ordinary miss (and StaleEntityError for
|
|
1105
|
-
* a dangling handle). This predicate is deliberately non-throwing and
|
|
1106
|
-
* returns false for both cases; callers that need the detailed error should
|
|
1107
|
-
* continue to use `get`.
|
|
1108
|
-
*/
|
|
960
|
+
/** Test live component presence without constructing a Result error. */
|
|
1109
961
|
hasComponent(entity: EntityHandle, component: Component): boolean {
|
|
1110
962
|
const archetype = this.internalgetEntityArchetype(entity);
|
|
1111
963
|
return (
|
|
@@ -1115,174 +967,2434 @@ export class World {
|
|
|
1115
967
|
);
|
|
1116
968
|
}
|
|
1117
969
|
|
|
1118
|
-
private
|
|
1119
|
-
|
|
1120
|
-
component: Component,
|
|
1121
|
-
fieldName: string,
|
|
1122
|
-
): ArrayLike<number> | undefined {
|
|
1123
|
-
return this.componentAccess._getArrayView(entity, component, fieldName);
|
|
970
|
+
private table(archetype: Archetype): Table {
|
|
971
|
+
return getTable(this.graph, archetype.tableId);
|
|
1124
972
|
}
|
|
1125
973
|
|
|
1126
|
-
private
|
|
1127
|
-
|
|
1128
|
-
component: Component,
|
|
1129
|
-
fieldName: string,
|
|
1130
|
-
): number | undefined {
|
|
1131
|
-
return this.componentAccess._getArrayLength(entity, component, fieldName);
|
|
974
|
+
private tableRow(record: EntityRecord): number {
|
|
975
|
+
return this.graph.archetypes[record.archetypeId]?.rows[record.archetypeRow] ?? -1;
|
|
1132
976
|
}
|
|
1133
977
|
|
|
1134
|
-
private
|
|
1135
|
-
entity
|
|
1136
|
-
component: Component,
|
|
1137
|
-
fieldName: string,
|
|
1138
|
-
index: number,
|
|
1139
|
-
): number | undefined {
|
|
1140
|
-
return this.componentAccess._getArrayElement(entity, component, fieldName, index);
|
|
978
|
+
private markComponentChanged(entity: EntityHandle, component: Component): void {
|
|
979
|
+
this.internalmarkComponentChanged(entity, componentId(component));
|
|
1141
980
|
}
|
|
1142
981
|
|
|
1143
|
-
private
|
|
1144
|
-
|
|
982
|
+
private relationshipIndex(component: Component): RelationshipIndex | undefined {
|
|
983
|
+
if (relationshipRole(component)?.kind !== 'source') return undefined;
|
|
984
|
+
let index = this.relationshipIndexes.get(componentId(component));
|
|
985
|
+
if (index === undefined) {
|
|
986
|
+
index = new RelationshipIndex();
|
|
987
|
+
this.relationshipIndexes.set(componentId(component), index);
|
|
988
|
+
}
|
|
989
|
+
return index;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/** Read the World-owned materialized target array; never consults a shadow list. */
|
|
993
|
+
private relationshipTargetEntries(
|
|
994
|
+
source: Component,
|
|
995
|
+
target: EntityHandle,
|
|
996
|
+
): readonly EntityHandle[] {
|
|
997
|
+
const role = relationshipRole(source);
|
|
998
|
+
if (role?.kind !== 'source') return [];
|
|
999
|
+
const mirror = relationshipMirror(source);
|
|
1000
|
+
if (mirror === undefined) return [];
|
|
1001
|
+
// Internal relationship maintenance may read the live mirror view
|
|
1002
|
+
// directly; the public `get` path clones target arrays to keep them
|
|
1003
|
+
// read-only. Avoid allocating a component snapshot on every append or
|
|
1004
|
+
// removal while retaining the same World-owned storage authority.
|
|
1005
|
+
return (
|
|
1006
|
+
(this.getArrayView(target, mirror, role.targetField) as EntityHandle[] | undefined) ?? []
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/** Read a relationship target length without materialising its array view. */
|
|
1011
|
+
private relationshipTargetLength(source: Component, target: EntityHandle): number {
|
|
1012
|
+
const role = relationshipRole(source);
|
|
1013
|
+
if (role?.kind !== 'source') return 0;
|
|
1014
|
+
const mirror = relationshipMirror(source);
|
|
1015
|
+
if (mirror === undefined) return 0;
|
|
1016
|
+
return this.internalgetArrayLength(target, mirror, role.targetField) ?? 0;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
private relationshipTargetEntity(
|
|
1145
1020
|
component: Component,
|
|
1146
|
-
|
|
1147
|
-
):
|
|
1148
|
-
|
|
1021
|
+
value: Record<string, unknown>,
|
|
1022
|
+
): EntityHandle | null {
|
|
1023
|
+
for (const [fieldName, fieldType] of Object.entries(componentSchema(component))) {
|
|
1024
|
+
if (isEntityField(fieldType)) {
|
|
1025
|
+
const raw = value[fieldName];
|
|
1026
|
+
if (raw === null || raw === undefined) return null;
|
|
1027
|
+
const asNum = raw as number;
|
|
1028
|
+
if (asNum === ENTITY_NULL_RAW) return null;
|
|
1029
|
+
return asNum as EntityHandle;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
return null;
|
|
1149
1033
|
}
|
|
1150
1034
|
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
value: Partial<InputShapeOf<S>>,
|
|
1035
|
+
private preflightComponentFieldValues(
|
|
1036
|
+
holder: EntityHandle | null,
|
|
1037
|
+
componentData: ComponentData,
|
|
1155
1038
|
): Result<void, EcsError> {
|
|
1156
|
-
|
|
1157
|
-
|
|
1039
|
+
const data = componentData.data as Record<string, unknown>;
|
|
1040
|
+
const arrayError = validateManagedArrayValues(componentData.component, data);
|
|
1041
|
+
if (arrayError !== null) return err(arrayError as unknown as EcsError);
|
|
1042
|
+
const sharedError = validateSharedFieldValues(componentData.component, data);
|
|
1043
|
+
if (sharedError !== null) return err(sharedError as unknown as EcsError);
|
|
1044
|
+
const numericError = validateNumericFieldValues(
|
|
1045
|
+
componentData.component,
|
|
1046
|
+
data,
|
|
1047
|
+
holder === null ? undefined : (holder as number),
|
|
1048
|
+
);
|
|
1049
|
+
if (numericError !== null) return err(numericError as unknown as EcsError);
|
|
1050
|
+
return ok(undefined);
|
|
1158
1051
|
}
|
|
1159
1052
|
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1053
|
+
/**
|
|
1054
|
+
* Validate one structural component payload without touching archetypes,
|
|
1055
|
+
* columns, relationship mirrors, epochs, or managed-reference stores.
|
|
1056
|
+
* CommandBuffer uses this same owner-level gate as the direct World facade;
|
|
1057
|
+
* the optional pending set lets a batch refer to an entity reserved earlier
|
|
1058
|
+
* in that batch without mistaking it for a stale live handle.
|
|
1059
|
+
*/
|
|
1060
|
+
private preflightComponentData(
|
|
1061
|
+
holder: EntityHandle | null,
|
|
1062
|
+
componentData: ComponentData,
|
|
1063
|
+
pendingEntities?: ReadonlySet<number>,
|
|
1064
|
+
unavailableEntities?: ReadonlySet<number>,
|
|
1163
1065
|
): Result<void, EcsError> {
|
|
1066
|
+
const data = componentData.data as Record<string, unknown>;
|
|
1067
|
+
const keyError = validateComponentDataKeys(componentData.component, data);
|
|
1068
|
+
if (keyError !== null) return err(keyError as unknown as EcsError);
|
|
1069
|
+
const valuePreflight = this.preflightComponentFieldValues(holder, componentData);
|
|
1070
|
+
if (!valuePreflight.ok) return valuePreflight;
|
|
1164
1071
|
if (
|
|
1165
1072
|
isRelationshipTarget(componentData.component) &&
|
|
1166
|
-
this.relationshipTargetPayloadWrites(
|
|
1167
|
-
)
|
|
1168
|
-
return
|
|
1169
|
-
|
|
1170
|
-
}
|
|
1073
|
+
this.relationshipTargetPayloadWrites(data)
|
|
1074
|
+
) {
|
|
1075
|
+
return err(new RelationshipTargetReadonlyError(componentData.component.name, 'command'));
|
|
1076
|
+
}
|
|
1171
1077
|
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1078
|
+
const filled = fillComponentDefaults(componentData.component, data);
|
|
1079
|
+
const enumError = validateEnumFieldValues(
|
|
1080
|
+
componentData.component,
|
|
1081
|
+
filled,
|
|
1082
|
+
holder === null ? undefined : (holder as number),
|
|
1083
|
+
);
|
|
1084
|
+
if (enumError !== null) return err(enumError as unknown as EcsError);
|
|
1085
|
+
|
|
1086
|
+
const role = relationshipRole(componentData.component as Component);
|
|
1087
|
+
if (role?.kind !== 'source') return ok(undefined);
|
|
1088
|
+
const target = this.relationshipTargetEntity(componentData.component as Component, filled);
|
|
1089
|
+
if (target === null) return ok(undefined);
|
|
1090
|
+
|
|
1091
|
+
const targetRaw = target as unknown as number;
|
|
1092
|
+
if (unavailableEntities?.has(targetRaw) === true) {
|
|
1093
|
+
const targetRecord = this.records[entityIndex(target)];
|
|
1094
|
+
return err(
|
|
1095
|
+
new StaleEntityError(target as number, entityIndex(target), entityGeneration(target), {
|
|
1096
|
+
operation: 'relationship-insert',
|
|
1097
|
+
component: componentData.component.name,
|
|
1098
|
+
expectedGeneration: entityGeneration(target),
|
|
1099
|
+
actualGeneration: targetRecord?.generation ?? -1,
|
|
1100
|
+
}),
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
const targetIsPending = pendingEntities?.has(targetRaw) === true;
|
|
1104
|
+
const targetRecord = this.records[entityIndex(target)];
|
|
1105
|
+
const actualGeneration = targetRecord?.generation ?? -1;
|
|
1106
|
+
const targetLive = this.recordIsLive(targetRecord, entityGeneration(target));
|
|
1107
|
+
const holderIsPending =
|
|
1108
|
+
holder === null || pendingEntities?.has(holder as unknown as number) === true;
|
|
1109
|
+
if (!targetIsPending && !targetLive && !holderIsPending) {
|
|
1110
|
+
return err(
|
|
1111
|
+
new StaleEntityError(target as number, entityIndex(target), entityGeneration(target), {
|
|
1112
|
+
operation: 'relationship-insert',
|
|
1113
|
+
component: componentData.component.name,
|
|
1114
|
+
expectedGeneration: entityGeneration(target),
|
|
1115
|
+
actualGeneration,
|
|
1116
|
+
}),
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// A pending holder has no row to walk yet. Once materialized, its target
|
|
1121
|
+
// is still checked by the same source-side relationship callback.
|
|
1122
|
+
if (holder === null || pendingEntities?.has(holder as unknown as number) === true) {
|
|
1123
|
+
return ok(undefined);
|
|
1124
|
+
}
|
|
1125
|
+
const roleAllowsSelf = role?.kind === 'source' && role.allowSelf;
|
|
1126
|
+
if (holder === target && !roleAllowsSelf) {
|
|
1127
|
+
return err(
|
|
1128
|
+
new RelationshipSelfCycleError(
|
|
1129
|
+
componentData.component.name,
|
|
1130
|
+
holder as number,
|
|
1131
|
+
target as number,
|
|
1132
|
+
),
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
const cycleHit =
|
|
1137
|
+
holder === target && roleAllowsSelf
|
|
1138
|
+
? null
|
|
1139
|
+
: this.relationshipCycleHit(componentData.component as Component, target, holder);
|
|
1140
|
+
if (cycleHit !== null) {
|
|
1141
|
+
return err(
|
|
1142
|
+
new RelationshipSelfCycleError(
|
|
1143
|
+
componentData.component.name,
|
|
1144
|
+
holder as number,
|
|
1145
|
+
cycleHit as number,
|
|
1146
|
+
),
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
return ok(undefined);
|
|
1178
1150
|
}
|
|
1179
1151
|
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1152
|
+
private relationshipCycleHit(
|
|
1153
|
+
holderComponent: Component,
|
|
1154
|
+
start: EntityHandle,
|
|
1155
|
+
holder: EntityHandle,
|
|
1156
|
+
): EntityHandle | null {
|
|
1157
|
+
const visited = new Set<number>();
|
|
1158
|
+
let current = start;
|
|
1159
|
+
while (true) {
|
|
1160
|
+
if (current === holder) return current;
|
|
1161
|
+
const raw = current as unknown as number;
|
|
1162
|
+
if (visited.has(raw)) return null;
|
|
1163
|
+
visited.add(raw);
|
|
1164
|
+
const record = this.records[entityIndex(current)];
|
|
1165
|
+
if (!this.recordIsLive(record, entityGeneration(current))) return null;
|
|
1166
|
+
const archetype = this.graph.archetypes[record.archetypeId];
|
|
1167
|
+
if (
|
|
1168
|
+
!archetype?.components.some(
|
|
1169
|
+
(candidate) => componentId(candidate) === componentId(holderComponent),
|
|
1170
|
+
)
|
|
1171
|
+
) {
|
|
1172
|
+
return null;
|
|
1173
|
+
}
|
|
1174
|
+
const value = this.readRow(archetype, holderComponent, this.tableRow(record)) as Record<
|
|
1175
|
+
string,
|
|
1176
|
+
unknown
|
|
1177
|
+
>;
|
|
1178
|
+
const next = this.relationshipTargetEntity(holderComponent, value);
|
|
1179
|
+
if (next === null) return null;
|
|
1180
|
+
current = next;
|
|
1181
|
+
}
|
|
1187
1182
|
}
|
|
1188
1183
|
|
|
1189
|
-
|
|
1184
|
+
/**
|
|
1185
|
+
* Commit a relationship source through its owner-specific write path.
|
|
1186
|
+
* Relationship sources have one entity field, so dispatching before the
|
|
1187
|
+
* generic field loop avoids paying the ordinary component-field traversal on
|
|
1188
|
+
* every hierarchy reparent while keeping mirror/index publication here.
|
|
1189
|
+
*/
|
|
1190
|
+
private setRelationshipSource(
|
|
1190
1191
|
entity: EntityHandle,
|
|
1191
|
-
component: Component
|
|
1192
|
-
|
|
1192
|
+
component: Component,
|
|
1193
|
+
value: Record<string, unknown>,
|
|
1194
|
+
record: EntityRecord,
|
|
1195
|
+
arch: Archetype,
|
|
1196
|
+
markChanged: boolean,
|
|
1193
1197
|
): Result<void, EcsError> {
|
|
1194
|
-
|
|
1195
|
-
|
|
1198
|
+
const role = relationshipRole(component);
|
|
1199
|
+
if (role?.kind !== 'source') return ok(undefined);
|
|
1196
1200
|
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1201
|
+
const row = this.tableRow(record);
|
|
1202
|
+
const currentValue = this.readRow(arch, component, row) as Record<string, unknown>;
|
|
1203
|
+
const valuePreflight = this.preflightComponentFieldValues(entity, {
|
|
1204
|
+
component,
|
|
1205
|
+
data: value as Partial<InputShapeOf<ComponentSchema>>,
|
|
1206
|
+
});
|
|
1207
|
+
if (!valuePreflight.ok) return valuePreflight;
|
|
1208
|
+
|
|
1209
|
+
const mergedValue = {
|
|
1210
|
+
...currentValue,
|
|
1211
|
+
...value,
|
|
1212
|
+
};
|
|
1213
|
+
const enumError = validateEnumFieldValues(component, mergedValue, entity as number);
|
|
1214
|
+
if (enumError !== null) return err(enumError as unknown as EcsError);
|
|
1215
|
+
|
|
1216
|
+
const oldRelationshipTarget = this.relationshipTargetEntity(component, currentValue);
|
|
1217
|
+
const nextRelationshipTarget = this.relationshipTargetEntity(component, mergedValue);
|
|
1218
|
+
const relationshipChanged = oldRelationshipTarget !== nextRelationshipTarget;
|
|
1219
|
+
let preparedMirrorAdded: boolean | undefined;
|
|
1220
|
+
|
|
1221
|
+
if (relationshipChanged && nextRelationshipTarget !== null) {
|
|
1222
|
+
// Keep the hot source-write path equivalent to the public relationship
|
|
1223
|
+
// preflight, without re-running the generic component-data validator
|
|
1224
|
+
// over the already-read/merged source row.
|
|
1225
|
+
const targetRecord = this.records[entityIndex(nextRelationshipTarget)];
|
|
1226
|
+
const actualGeneration = targetRecord?.generation ?? -1;
|
|
1227
|
+
if (!this.recordIsLive(targetRecord, entityGeneration(nextRelationshipTarget))) {
|
|
1228
|
+
return err(
|
|
1229
|
+
new StaleEntityError(
|
|
1230
|
+
nextRelationshipTarget as number,
|
|
1231
|
+
entityIndex(nextRelationshipTarget),
|
|
1232
|
+
entityGeneration(nextRelationshipTarget),
|
|
1233
|
+
{
|
|
1234
|
+
operation: 'relationship-insert',
|
|
1235
|
+
component: component.name,
|
|
1236
|
+
expectedGeneration: entityGeneration(nextRelationshipTarget),
|
|
1237
|
+
actualGeneration,
|
|
1238
|
+
},
|
|
1239
|
+
),
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
if (entity === nextRelationshipTarget && !role.allowSelf) {
|
|
1243
|
+
return err(
|
|
1244
|
+
new RelationshipSelfCycleError(
|
|
1245
|
+
component.name,
|
|
1246
|
+
entity as number,
|
|
1247
|
+
nextRelationshipTarget as number,
|
|
1248
|
+
),
|
|
1249
|
+
);
|
|
1250
|
+
}
|
|
1251
|
+
const cycleHit =
|
|
1252
|
+
entity === nextRelationshipTarget
|
|
1253
|
+
? null
|
|
1254
|
+
: this.relationshipCycleHit(component, nextRelationshipTarget, entity);
|
|
1255
|
+
if (cycleHit !== null) {
|
|
1256
|
+
return err(
|
|
1257
|
+
new RelationshipSelfCycleError(component.name, entity as number, cycleHit as number),
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
const prepared = this.prepareRelationshipInsert(component, mergedValue);
|
|
1261
|
+
if (!prepared.ok) return prepared;
|
|
1262
|
+
preparedMirrorAdded = prepared.value;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// Prepare first, then detach/attach the mirror, and only then publish the
|
|
1266
|
+
// source scalar. A later relationship error can therefore poison the same
|
|
1267
|
+
// World without exposing a source value that disagrees with its mirror.
|
|
1268
|
+
if (relationshipChanged && oldRelationshipTarget !== null) {
|
|
1269
|
+
const removed = this.relationshipOnRemove(entity, component, currentValue);
|
|
1270
|
+
if (!removed.ok) {
|
|
1271
|
+
this.poisonAfterEntityMutation('World.set', removed.error);
|
|
1272
|
+
return removed;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
if (relationshipChanged && nextRelationshipTarget !== null) {
|
|
1276
|
+
const inserted = this.relationshipOnInsert(
|
|
1277
|
+
entity,
|
|
1278
|
+
component,
|
|
1279
|
+
mergedValue,
|
|
1280
|
+
preparedMirrorAdded,
|
|
1281
|
+
);
|
|
1282
|
+
if (!inserted.ok) {
|
|
1283
|
+
this.poisonAfterEntityMutation('World.set', inserted.error);
|
|
1284
|
+
return inserted;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
|
|
1289
|
+
const sourceColumn = fieldCols?.get(role.sourceField);
|
|
1290
|
+
if (sourceColumn === undefined) {
|
|
1291
|
+
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
1292
|
+
}
|
|
1293
|
+
sourceColumn.view[row] =
|
|
1294
|
+
nextRelationshipTarget === null ? ENTITY_NULL_RAW : (nextRelationshipTarget as number);
|
|
1295
|
+
if (markChanged) this.markComponentChanged(entity, component);
|
|
1296
|
+
return ok(undefined);
|
|
1202
1297
|
}
|
|
1203
1298
|
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1299
|
+
/** Prepare the target side before a source archetype mutation commits. */
|
|
1300
|
+
private prepareRelationshipInsert(
|
|
1301
|
+
component: Component,
|
|
1302
|
+
value: Record<string, unknown>,
|
|
1303
|
+
): Result<boolean, EcsError> {
|
|
1304
|
+
const role = relationshipRole(component);
|
|
1305
|
+
if (role?.kind !== 'source') return ok(false);
|
|
1306
|
+
const target = this.relationshipTargetEntity(component, value);
|
|
1307
|
+
if (target === null) return ok(false);
|
|
1308
|
+
const mirror = relationshipMirror(component);
|
|
1309
|
+
if (mirror === undefined) return ok(false);
|
|
1310
|
+
const targetRec = this.records[entityIndex(target)];
|
|
1311
|
+
const actualGeneration = targetRec?.generation ?? -1;
|
|
1312
|
+
if (!this.recordIsLive(targetRec, entityGeneration(target))) {
|
|
1313
|
+
return err(
|
|
1314
|
+
new StaleEntityError(target as number, entityIndex(target), entityGeneration(target), {
|
|
1315
|
+
operation: 'relationship-insert',
|
|
1316
|
+
component: component.name,
|
|
1317
|
+
expectedGeneration: entityGeneration(target),
|
|
1318
|
+
actualGeneration,
|
|
1319
|
+
}),
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
const targetArch = this.graph.archetypes[targetRec.archetypeId];
|
|
1323
|
+
const hasMirror =
|
|
1324
|
+
targetArch?.components.some((candidate) => componentId(candidate) === componentId(mirror)) ??
|
|
1325
|
+
false;
|
|
1326
|
+
let mirrorAdded = false;
|
|
1327
|
+
if (!hasMirror) {
|
|
1328
|
+
const added = this.addComponentCore(
|
|
1329
|
+
target,
|
|
1330
|
+
{ component: mirror, data: {} as Partial<ShapeOf<ComponentSchema>> },
|
|
1331
|
+
true,
|
|
1332
|
+
false,
|
|
1333
|
+
);
|
|
1334
|
+
if (!added.ok) return added;
|
|
1335
|
+
mirrorAdded = true;
|
|
1336
|
+
}
|
|
1337
|
+
const length = this.relationshipTargetLength(component, target);
|
|
1338
|
+
const capacity = this.ensureArrayCapacity(target, mirror, role.targetField, length + 1);
|
|
1339
|
+
if (capacity.ok) return ok(mirrorAdded);
|
|
1340
|
+
if (mirrorAdded) {
|
|
1341
|
+
// Mirror creation is part of preparation, not publication. If capacity
|
|
1342
|
+
// reservation fails, remove the newly-created target component through
|
|
1343
|
+
// the silent internal path so the caller sees no target, epoch, or
|
|
1344
|
+
// structural evidence side effect.
|
|
1345
|
+
const rolledBack = this.removeComponentCore(target, mirror, true);
|
|
1346
|
+
if (!rolledBack.ok) {
|
|
1347
|
+
this.poisonAfterEntityMutation('World.prepareRelationshipInsert', rolledBack.error);
|
|
1348
|
+
return rolledBack;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
return capacity;
|
|
1209
1352
|
}
|
|
1210
1353
|
|
|
1211
|
-
/**
|
|
1212
|
-
private
|
|
1213
|
-
holder: EntityHandle
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1354
|
+
/** Append `holder` to the materialized target list. */
|
|
1355
|
+
private relationshipOnInsert(
|
|
1356
|
+
holder: EntityHandle,
|
|
1357
|
+
component: Component,
|
|
1358
|
+
value: Record<string, unknown>,
|
|
1359
|
+
// addComponentCore may have prepared the target before migrating the
|
|
1360
|
+
// source. Carry that one-shot fact so the final commit can publish a
|
|
1361
|
+
// newly-created mirror exactly once instead of preparing a second time.
|
|
1362
|
+
preparedMirrorAdded?: boolean,
|
|
1217
1363
|
): Result<void, EcsError> {
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1364
|
+
const role = relationshipRole(component);
|
|
1365
|
+
if (role?.kind !== 'source') return ok(undefined);
|
|
1366
|
+
const target = this.relationshipTargetEntity(component, value);
|
|
1367
|
+
if (target === null) return ok(undefined);
|
|
1368
|
+
const mirror = relationshipMirror(component);
|
|
1369
|
+
/* istanbul ignore next -- defineComponent relationship validation guarantees mirror exists */
|
|
1370
|
+
if (mirror === undefined) return ok(undefined);
|
|
1371
|
+
|
|
1372
|
+
let mirrorAdded = preparedMirrorAdded ?? false;
|
|
1373
|
+
if (preparedMirrorAdded === undefined) {
|
|
1374
|
+
const prepared = this.prepareRelationshipInsert(component, value);
|
|
1375
|
+
if (!prepared.ok) {
|
|
1376
|
+
// A dangling source edge is still useful state: hierarchy/animation
|
|
1377
|
+
// projections report the missing target. The target mirror cannot be
|
|
1378
|
+
// updated, but insertion itself remains atomic and successful.
|
|
1379
|
+
if (prepared.error.code === 'stale-entity') return ok(undefined);
|
|
1380
|
+
return prepared;
|
|
1381
|
+
}
|
|
1382
|
+
mirrorAdded = prepared.value;
|
|
1383
|
+
}
|
|
1225
1384
|
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
this.
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1385
|
+
// Lazy-create the mirror component on the target when absent (D-3c).
|
|
1386
|
+
const targetSlot = entityIndex(target);
|
|
1387
|
+
const targetRec = this.records[targetSlot];
|
|
1388
|
+
if (!this.recordIsLive(targetRec, entityGeneration(target))) return ok(undefined);
|
|
1389
|
+
const targetArch = this.graph.archetypes[targetRec.archetypeId];
|
|
1390
|
+
const mirrorLocalId = componentId(mirror);
|
|
1391
|
+
const hasMirror =
|
|
1392
|
+
targetArch?.components.some((component) => componentId(component) === mirrorLocalId) ?? false;
|
|
1393
|
+
if (!hasMirror) {
|
|
1394
|
+
const added = this.addComponentCore(
|
|
1395
|
+
target,
|
|
1396
|
+
{
|
|
1397
|
+
component: mirror,
|
|
1398
|
+
data: {} as Partial<ShapeOf<ComponentSchema>>,
|
|
1399
|
+
},
|
|
1400
|
+
true,
|
|
1401
|
+
false,
|
|
1402
|
+
);
|
|
1403
|
+
if (!added.ok) return added;
|
|
1404
|
+
mirrorAdded = true;
|
|
1405
|
+
}
|
|
1406
|
+
const slot = this.relationshipTargetLength(component, target);
|
|
1407
|
+
const mirrored = this.appendArrayElement(target, mirror, role.targetField, holder);
|
|
1408
|
+
if (!mirrored.ok) return mirrored;
|
|
1409
|
+
this.relationshipIndex(component)?.attach(holder, target, slot);
|
|
1410
|
+
if (mirrorAdded) {
|
|
1411
|
+
this.internalmarkComponentsAdded(target, [mirrorLocalId]);
|
|
1412
|
+
this.advanceStructureEpoch();
|
|
1413
|
+
this.recordStructuralEvidence({
|
|
1414
|
+
kind: 'component-added',
|
|
1415
|
+
entity: target,
|
|
1416
|
+
componentId: mirrorLocalId,
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
return ok(undefined);
|
|
1259
1420
|
}
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1421
|
+
|
|
1422
|
+
/** Remove `holder` from the materialized target list. */
|
|
1423
|
+
private relationshipOnRemove(
|
|
1424
|
+
holder: EntityHandle,
|
|
1425
|
+
component: Component,
|
|
1426
|
+
oldValue: Record<string, unknown>,
|
|
1265
1427
|
): Result<void, EcsError> {
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
this.
|
|
1428
|
+
const role = relationshipRole(component);
|
|
1429
|
+
if (role?.kind !== 'source') return ok(undefined);
|
|
1430
|
+
const target = this.relationshipTargetEntity(component, oldValue);
|
|
1431
|
+
if (target === null) return ok(undefined);
|
|
1432
|
+
const mirror = relationshipMirror(component);
|
|
1433
|
+
/* istanbul ignore next -- defineComponent relationship validation guarantees mirror exists */
|
|
1434
|
+
if (mirror === undefined) return ok(undefined);
|
|
1435
|
+
const targetSlot = entityIndex(target);
|
|
1436
|
+
const targetRec = this.records[targetSlot];
|
|
1437
|
+
if (!this.recordIsLive(targetRec, entityGeneration(target))) return ok(undefined);
|
|
1438
|
+
|
|
1439
|
+
const index = this.relationshipIndex(component);
|
|
1440
|
+
if (index === undefined) return ok(undefined);
|
|
1441
|
+
const slot = index.slotOf(holder);
|
|
1442
|
+
if (slot === undefined || index.targetOf(holder) !== target) return ok(undefined);
|
|
1443
|
+
const mirrored = this.removeArrayElementAt(target, mirror, role.targetField, slot);
|
|
1444
|
+
if (!mirrored.ok) return mirrored;
|
|
1445
|
+
index.detach(holder);
|
|
1446
|
+
if (mirrored.value !== undefined) index.updateSlot(mirrored.value, target, slot);
|
|
1447
|
+
return ok(undefined);
|
|
1276
1448
|
}
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
return this.componentAccess.relationshipOnRemove(h, c, v);
|
|
1449
|
+
|
|
1450
|
+
private linkedSpawnMirrorField(mirror: Component): string | undefined {
|
|
1451
|
+
const source = relationshipSource(mirror);
|
|
1452
|
+
const role = source === undefined ? undefined : relationshipRole(source);
|
|
1453
|
+
return role?.kind === 'source' && role.linkedSpawn ? role.targetField : undefined;
|
|
1283
1454
|
}
|
|
1284
1455
|
|
|
1285
|
-
|
|
1456
|
+
private relationshipLinkedSpawnChildren(
|
|
1457
|
+
entity: EntityHandle,
|
|
1458
|
+
archetype: Archetype,
|
|
1459
|
+
): EntityHandle[] {
|
|
1460
|
+
const record = this.records[entityIndex(entity)];
|
|
1461
|
+
const row = record === undefined ? -1 : this.tableRow(record);
|
|
1462
|
+
const collected: EntityHandle[] = [];
|
|
1463
|
+
for (const component of archetype.components) {
|
|
1464
|
+
const mirrorField = this.linkedSpawnMirrorField(component);
|
|
1465
|
+
if (mirrorField === undefined) continue;
|
|
1466
|
+
const snapshot = this.readRow(archetype, component, row) as Record<string, unknown>;
|
|
1467
|
+
const list = snapshot[mirrorField];
|
|
1468
|
+
if (!(list instanceof Uint32Array)) continue;
|
|
1469
|
+
for (const raw of list) {
|
|
1470
|
+
if (raw !== ENTITY_NULL_RAW) collected.push(raw as EntityHandle);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
return collected;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/**
|
|
1477
|
+
* Read component data from an entity.
|
|
1478
|
+
*
|
|
1479
|
+
* **Transient view contract (feat-20260602):** for fixed-capacity
|
|
1480
|
+
* `array<T,N>` and `buffer<N>` fields, the returned `TypedArray` (and any
|
|
1481
|
+
* subarray of it) aliases the archetype column buffer directly. The view is
|
|
1482
|
+
* valid only until the next structural change (`spawn` / `despawn` /
|
|
1483
|
+
* `addComponent` / `removeComponent`). Holding a view across a structural
|
|
1484
|
+
* change is undefined behaviour -- the backing `ArrayBuffer` is detached on
|
|
1485
|
+
* column growth, and swap-remove at the same row index points to the wrong
|
|
1486
|
+
* entity. **Re-fetch `world.get(e, C)` on every access.** See
|
|
1487
|
+
* `packages/ecs/README.md` Transient view contract section.
|
|
1488
|
+
*
|
|
1489
|
+
* @returns `Result<ShapeOf<S>, EcsError>` —
|
|
1490
|
+
* `ok(ShapeOf<S>)` on success;
|
|
1491
|
+
* `err(StaleEntityError)` (`.code = 'stale-entity'`) if entity is dead;
|
|
1492
|
+
* `err(ComponentNotPresentError)` (`.code = 'component-not-present'`) if
|
|
1493
|
+
* the entity does not have the component (a never-present component on
|
|
1494
|
+
* this entity degrades to the same `component-not-present` path — there is
|
|
1495
|
+
* no separate "not registered" failure; components are global at
|
|
1496
|
+
* `defineComponent` time).
|
|
1497
|
+
*
|
|
1498
|
+
* @example
|
|
1499
|
+
* ```ts
|
|
1500
|
+
* const Position = defineComponent('Position', { x: 'f32', y: 'f32' });
|
|
1501
|
+
* const world = new World();
|
|
1502
|
+
* const e = world.spawn({ component: Position, data: { x: 1, y: 2 } }).unwrap();
|
|
1503
|
+
* const r = world.get(e, Position);
|
|
1504
|
+
* if (!r.ok) { return; } // r.error.code === 'stale-entity' on dead handle
|
|
1505
|
+
* const pos = r.value;
|
|
1506
|
+
* ```
|
|
1507
|
+
*/
|
|
1508
|
+
get<S extends ComponentSchema>(
|
|
1509
|
+
entity: EntityHandle,
|
|
1510
|
+
component: Component<string, S>,
|
|
1511
|
+
): Result<ShapeOf<S>, EcsError> {
|
|
1512
|
+
const record = this.lookupAlive(entity, 'get', component.name);
|
|
1513
|
+
if (!record.ok) return record;
|
|
1514
|
+
|
|
1515
|
+
const rec = record.value;
|
|
1516
|
+
const arch = this.graph.archetypes[rec.archetypeId];
|
|
1517
|
+
/* istanbul ignore next -- defensive: alive record always has valid archetypeId */
|
|
1518
|
+
if (!arch) {
|
|
1519
|
+
return err(
|
|
1520
|
+
new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
|
|
1521
|
+
operation: 'get',
|
|
1522
|
+
component: component.name,
|
|
1523
|
+
expectedGeneration: entityGeneration(entity),
|
|
1524
|
+
actualGeneration: rec.generation,
|
|
1525
|
+
}),
|
|
1526
|
+
);
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// Check if this archetype has the component (using World-local ID).
|
|
1530
|
+
const localId = componentId(component);
|
|
1531
|
+
if (!arch.components.some((candidate) => componentId(candidate) === localId)) {
|
|
1532
|
+
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
return ok(this.readRow(arch, component, this.tableRow(rec)));
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
/**
|
|
1539
|
+
* Column-level zero-copy view of an `array<T, N>` / `array<T>` field.
|
|
1540
|
+
*
|
|
1541
|
+
* Resolves the live byte region for `(entity, component, fieldName)`
|
|
1542
|
+
* directly at the column level and returns the element-typed TypedArray
|
|
1543
|
+
* aliasing it (`view.buffer` is the SSOT byte region; mutations route
|
|
1544
|
+
* through `world.set`). Unlike `get`, this does NOT build the
|
|
1545
|
+
* `{}` whole-component object nor walk every schema field. Per-frame
|
|
1546
|
+
* consumers that need one column (the resolved world mat4) take this path to
|
|
1547
|
+
* avoid the `get` overhead (1 `{}` alloc + N-field readRow walk).
|
|
1548
|
+
*
|
|
1549
|
+
* Fixed `array<T,N>` columns (feat-20260602) store their elements inline, so
|
|
1550
|
+
* the view aliases the archetype column buffer directly (no BufferPool
|
|
1551
|
+
* indirection); variable `array<T>` columns still alias the BufferPool slot.
|
|
1552
|
+
* The returned view's element type follows the schema element type
|
|
1553
|
+
* (`array<entity,N>` -> `Uint32Array`, `array<f32,N>` -> `Float32Array`,
|
|
1554
|
+
* etc.) -- the prior f32-only early-return gate is removed.
|
|
1555
|
+
*
|
|
1556
|
+
* **Transient view contract:** the returned `TypedArray` aliases the column
|
|
1557
|
+
* buffer and is valid only until the next structural change (`spawn` /
|
|
1558
|
+
* `despawn` / `addComponent` / `removeComponent`). Column growth
|
|
1559
|
+
* (`growColumn`) detaches the old `ArrayBuffer` via `transfer()`; a
|
|
1560
|
+
* swap-remove at the same row index leaves the view pointing to the wrong
|
|
1561
|
+
* entity. **Callers must re-fetch `getArrayView` on every access** and must
|
|
1562
|
+
* not hold the view across any operation that may cause archetype migration.
|
|
1563
|
+
* All existing per-frame consumers (`propagateTransforms` / `render-extract`
|
|
1564
|
+
* / `pick`) already conform -- they fetch the view inside a single pass with
|
|
1565
|
+
* no intervening structural changes.
|
|
1566
|
+
*
|
|
1567
|
+
* Returns `undefined` when the entity is dead, the component is absent, the
|
|
1568
|
+
* field does not exist, or the field is not an `array<...>` column.
|
|
1569
|
+
*
|
|
1570
|
+
* Engine-internal fast path; AI users read the typed view through
|
|
1571
|
+
* `world.get(e, GlobalTransform).world`. The accessor is the zero-materialization
|
|
1572
|
+
* route the propagate kernel and render walk use.
|
|
1573
|
+
*/
|
|
1574
|
+
private getArrayView(
|
|
1575
|
+
entity: EntityHandle,
|
|
1576
|
+
component: Component,
|
|
1577
|
+
fieldName: string,
|
|
1578
|
+
): FieldView | undefined {
|
|
1579
|
+
const record = this.lookupAlive(entity, 'getArrayView', component.name);
|
|
1580
|
+
if (!record.ok) return undefined;
|
|
1581
|
+
|
|
1582
|
+
const rec = record.value;
|
|
1583
|
+
const arch = this.graph.archetypes[rec.archetypeId];
|
|
1584
|
+
if (!arch) return undefined;
|
|
1585
|
+
return this.readArrayView(arch, component, this.tableRow(rec), fieldName);
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
/**
|
|
1589
|
+
* Write (partial) component data to an entity.
|
|
1590
|
+
*
|
|
1591
|
+
* @returns `Result<void, EcsError>` —
|
|
1592
|
+
* `ok(void)` on success;
|
|
1593
|
+
* `err(StaleEntityError)` (`.code = 'stale-entity'`) if entity is dead;
|
|
1594
|
+
* `err(ComponentNotPresentError)` (`.code = 'component-not-present'`) if
|
|
1595
|
+
* entity does not have the component (F-02: no longer silently ignores).
|
|
1596
|
+
*
|
|
1597
|
+
* @example
|
|
1598
|
+
* ```ts
|
|
1599
|
+
* const Position = defineComponent('Position', { x: 'f32', y: 'f32' });
|
|
1600
|
+
* const world = new World();
|
|
1601
|
+
* const e = world.spawn({ component: Position, data: { x: 0, y: 0 } }).unwrap();
|
|
1602
|
+
* const r = world.set(e, Position, { x: 10 });
|
|
1603
|
+
* if (!r.ok) { return; } // r.error.code === 'stale-entity' on dead handle
|
|
1604
|
+
* r.unwrap();
|
|
1605
|
+
* ```
|
|
1606
|
+
*/
|
|
1607
|
+
set<S extends ComponentSchema, C extends Component<string, S>>(
|
|
1608
|
+
entity: EntityHandle,
|
|
1609
|
+
component: C & WritableComponent<C>,
|
|
1610
|
+
value: Partial<InputShapeOf<S>>,
|
|
1611
|
+
markChanged = true,
|
|
1612
|
+
): Result<void, EcsError> {
|
|
1613
|
+
const poisoned = this.poisonedResult<void>();
|
|
1614
|
+
if (poisoned !== undefined) return poisoned;
|
|
1615
|
+
if (isRelationshipTarget(component)) return this.relationshipTargetWriteError(component, 'set');
|
|
1616
|
+
const record = this.lookupAlive(entity, 'set', component.name);
|
|
1617
|
+
if (!record.ok) return record;
|
|
1618
|
+
|
|
1619
|
+
const rec = record.value;
|
|
1620
|
+
const arch = this.graph.archetypes[rec.archetypeId];
|
|
1621
|
+
/* istanbul ignore next -- defensive: alive record always has valid archetypeId */
|
|
1622
|
+
if (!arch) {
|
|
1623
|
+
return err(
|
|
1624
|
+
new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
|
|
1625
|
+
operation: 'set',
|
|
1626
|
+
component: component.name,
|
|
1627
|
+
expectedGeneration: entityGeneration(entity),
|
|
1628
|
+
actualGeneration: rec.generation,
|
|
1629
|
+
}),
|
|
1630
|
+
);
|
|
1631
|
+
}
|
|
1632
|
+
const row = this.tableRow(rec);
|
|
1633
|
+
const localId = componentId(component);
|
|
1634
|
+
if (!arch.components.some((candidate) => componentId(candidate) === localId)) {
|
|
1635
|
+
// F-02: set on missing component returns err instead of silent ignore
|
|
1636
|
+
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
1637
|
+
}
|
|
1638
|
+
const relationship = relationshipRole(component);
|
|
1639
|
+
if (relationship?.kind === 'source') {
|
|
1640
|
+
return this.setRelationshipSource(
|
|
1641
|
+
entity,
|
|
1642
|
+
component,
|
|
1643
|
+
value as Record<string, unknown>,
|
|
1644
|
+
rec,
|
|
1645
|
+
arch,
|
|
1646
|
+
markChanged,
|
|
1647
|
+
);
|
|
1648
|
+
}
|
|
1649
|
+
const valuePreflight = this.preflightComponentFieldValues(entity, {
|
|
1650
|
+
component,
|
|
1651
|
+
data: value,
|
|
1652
|
+
});
|
|
1653
|
+
if (!valuePreflight.ok) return valuePreflight;
|
|
1654
|
+
const currentValue = this.readRow(arch, component, row) as Record<string, unknown>;
|
|
1655
|
+
const mergedValue = {
|
|
1656
|
+
...currentValue,
|
|
1657
|
+
...(value as Record<string, unknown>),
|
|
1658
|
+
};
|
|
1659
|
+
const enumError = validateEnumFieldValues(component, mergedValue, entity as number);
|
|
1660
|
+
if (enumError !== null) return err(enumError as unknown as EcsError);
|
|
1661
|
+
|
|
1662
|
+
if (component.storage === 'sparse') {
|
|
1663
|
+
if (markChanged) this.markComponentChanged(entity, component);
|
|
1664
|
+
return ok(undefined);
|
|
1665
|
+
}
|
|
1666
|
+
const fieldCols = this.table(arch).storage.get(localId)?.fields;
|
|
1667
|
+
if (fieldCols === undefined) {
|
|
1668
|
+
throw new Error(`Table storage for ${component.name} does not exist.`);
|
|
1669
|
+
}
|
|
1670
|
+
for (const fieldName of Object.keys(value)) {
|
|
1671
|
+
const col = fieldCols.get(fieldName);
|
|
1672
|
+
if (!col) {
|
|
1673
|
+
continue;
|
|
1674
|
+
}
|
|
1675
|
+
const fieldType = (componentSchema(component) as Record<string, string>)[fieldName] ?? '';
|
|
1676
|
+
// M1/M2 release loop (set path): release the prior managed value
|
|
1677
|
+
// BEFORE writing the new one. Single SSOT helper `releaseManagedFieldOnRow`
|
|
1678
|
+
// (feat-20260614 D-2) covers every managed-field family (`ref<T>` /
|
|
1679
|
+
// `string` / `buffer` / variable `array<T>`); it self-skips fields that
|
|
1680
|
+
// do not match `isManagedField` here, but for set-ref/string we already
|
|
1681
|
+
// gated on it so the call is hot. Zeroes the column when applicable.
|
|
1682
|
+
if (isManagedField(fieldType)) {
|
|
1683
|
+
this.releaseManagedFieldOnRow(arch, component, row, fieldName);
|
|
1684
|
+
}
|
|
1685
|
+
const raw = (value as Record<string, unknown>)[fieldName];
|
|
1686
|
+
if (fieldType === 'bool') {
|
|
1687
|
+
col.view[row] = raw ? 1 : 0;
|
|
1688
|
+
} else if (isEntityField(fieldType)) {
|
|
1689
|
+
// M3 entity field overwrite: encode null as ENTITY_NULL_RAW;
|
|
1690
|
+
// otherwise store the Entity bit pattern (slot+gen).
|
|
1691
|
+
col.view[row] = raw === null || raw === undefined ? ENTITY_NULL_RAW : (raw as number);
|
|
1692
|
+
} else if (isManagedBufferField(fieldType)) {
|
|
1693
|
+
// M2 set path: collapsed-vocab keyword family `'buffer'` (variable) +
|
|
1694
|
+
// `'buffer<N>'` (fixed). The two shapes diverge here:
|
|
1695
|
+
// - `buffer<N>` — schema-declared byteLength is fixed; raw must be a
|
|
1696
|
+
// `Uint8Array` whose `byteLength === N`. Mismatched payloads route
|
|
1697
|
+
// `FixedSizeMismatchError` via Result.err so AI users observe an
|
|
1698
|
+
// explicit failure instead of silent truncation (verify round 1
|
|
1699
|
+
// B1 fix; charter P3 — explicit failure > silent acceptance).
|
|
1700
|
+
// - `'buffer'` — variable capacity; release the prior slot then
|
|
1701
|
+
// alloc a fresh one sized to the new payload's byteLength (mirrors
|
|
1702
|
+
// the `array<T>` set path's release-then-alloc D-5 ordering).
|
|
1703
|
+
// raw is normalized from any AllowSharedBufferSource view to a
|
|
1704
|
+
// Uint8Array over its bytes (feat-20260621 V2 / AC-A4). Non-buffer
|
|
1705
|
+
// raw (a forced cast feeding e.g. a number) normalizes to null and
|
|
1706
|
+
// is treated as a no-op (column slot stays unchanged).
|
|
1707
|
+
const isFixedBuffer = fieldType !== 'buffer';
|
|
1708
|
+
const bytes = normalizeBufferWrite(raw);
|
|
1709
|
+
if (bytes !== null) {
|
|
1710
|
+
if (isFixedBuffer) {
|
|
1711
|
+
// feat-20260602: fixed `buffer<N>` lives inline as a stride-N u8
|
|
1712
|
+
// column (arity = N bytes). Write the payload straight into the
|
|
1713
|
+
// row window -- no BufferPool slot.
|
|
1714
|
+
const expected = bufferFieldByteLength(fieldType);
|
|
1715
|
+
if (bytes.byteLength !== expected) {
|
|
1716
|
+
return err(new FixedSizeMismatchError(fieldName, expected, bytes.byteLength));
|
|
1717
|
+
}
|
|
1718
|
+
const arity = col.arity;
|
|
1719
|
+
(col.view as Uint8Array).set(bytes.subarray(0, arity), row * arity);
|
|
1720
|
+
} else {
|
|
1721
|
+
// Variable `'buffer'` set: release prior slot via SSOT helper
|
|
1722
|
+
// (feat-20260614 D-2) then alloc fresh sized to the new payload
|
|
1723
|
+
// (verify round 1 B2 fix path). The helper zeroes the column on
|
|
1724
|
+
// release; sentinel slot id 0 is a no-op.
|
|
1725
|
+
this.releaseManagedFieldOnRow(arch, component, row, fieldName);
|
|
1726
|
+
const allocR = this.bufferPool.alloc(bytes.byteLength);
|
|
1727
|
+
if (!allocR.ok) {
|
|
1728
|
+
const ctx: ErrorContext = {
|
|
1729
|
+
systemName: `World.set (${component.name}.${fieldName})`,
|
|
1730
|
+
};
|
|
1731
|
+
this.routeError(allocR.error as EcsError, ctx);
|
|
1732
|
+
col.view[row] = 0;
|
|
1733
|
+
continue;
|
|
1734
|
+
}
|
|
1735
|
+
const slot = allocR.value;
|
|
1736
|
+
slot.view.set(bytes);
|
|
1737
|
+
col.view[row] = slot.id;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
} else if (fieldType === 'string') {
|
|
1741
|
+
// M1 string-field set path (AC-05 path 3): the prior handle was
|
|
1742
|
+
// already released by the unified `isManagedField` pre-write block
|
|
1743
|
+
// above (D-R3) -- here we just alloc the new handle and store the
|
|
1744
|
+
// u32. Mirrors the array<T> release-then-alloc pattern (D-5) so AI
|
|
1745
|
+
// users observe the UniqueRefStore _liveCount net-zero invariant
|
|
1746
|
+
// on field overwrite. Missing / non-string raw -> '' fallback
|
|
1747
|
+
// (AC-06).
|
|
1748
|
+
const text = typeof raw === 'string' ? raw : '';
|
|
1749
|
+
const handle = this.uniqueRefs.alloc<'String'>('String', text);
|
|
1750
|
+
col.view[row] = unwrapHandle(handle);
|
|
1751
|
+
} else {
|
|
1752
|
+
const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
|
|
1753
|
+
if (arrayMeta !== undefined) {
|
|
1754
|
+
// M1 set path for array<T> / array<T,N> fields (feat-20260614 D-3
|
|
1755
|
+
// calling convention). The set semantics mirror spawn: release the
|
|
1756
|
+
// prior slot via the SSOT helper, then alloc a fresh one sized to
|
|
1757
|
+
// the new value, copy bytes verbatim, store slot id (+ count for
|
|
1758
|
+
// variable). Fixed `array<T,N>` is inline — the helper short-
|
|
1759
|
+
// circuits and writeArrayField writes directly into the row's
|
|
1760
|
+
// stride window with no pool traffic.
|
|
1761
|
+
this.releaseManagedFieldOnRow(arch, component, row, fieldName);
|
|
1762
|
+
this.writeArrayField(arch, component, row, fieldName, fieldType, arrayMeta, raw);
|
|
1763
|
+
} else {
|
|
1764
|
+
// The pre-write `releaseManagedFieldOnRow` block above already
|
|
1765
|
+
// released the prior `'shared<T>'` rc via SharedRefStore.release;
|
|
1766
|
+
// here we retain the new value so net rc delta is +1 / 0 / -1 per
|
|
1767
|
+
// M4 invariant (set: -1+1=0; spawn: 0+1=+1; despawn: -1).
|
|
1768
|
+
col.view[row] = raw as number;
|
|
1769
|
+
if (fieldType.startsWith('shared<') && (raw as number) !== 0) {
|
|
1770
|
+
this.retainSharedScalarHandle(raw as number, component.name, fieldName);
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
if (markChanged) this.markComponentChanged(entity, component);
|
|
1776
|
+
return ok(undefined);
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
1780
|
+
// Internal relationship array maintenance. Public array mutation is always
|
|
1781
|
+
// expressed as one `world.set` payload; these helpers only implement the
|
|
1782
|
+
// engine-owned target projection and backpointer swap-remove path.
|
|
1783
|
+
//
|
|
1784
|
+
// Append/remove are engine-owned relationship maintenance only.
|
|
1785
|
+
//
|
|
1786
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
1787
|
+
|
|
1788
|
+
/**
|
|
1789
|
+
* Append `value` to the variable `array<T>` field `fieldName` on `entity`.
|
|
1790
|
+
*
|
|
1791
|
+
* BufferPool grow is amortized O(1) via the size-class freelist (research
|
|
1792
|
+
* Finding 5). Relationship target arrays grow byte-wise.
|
|
1793
|
+
*
|
|
1794
|
+
* @returns `Result<void, EcsError>` with the normal stale/component errors.
|
|
1795
|
+
*
|
|
1796
|
+
* The helper is called only by relationship synchronization.
|
|
1797
|
+
*/
|
|
1798
|
+
private appendArrayElement(
|
|
1799
|
+
entity: EntityHandle,
|
|
1800
|
+
component: Component,
|
|
1801
|
+
fieldName: string,
|
|
1802
|
+
value: EntityHandle,
|
|
1803
|
+
): Result<void, EcsError> {
|
|
1804
|
+
const record = this.lookupAlive(entity, 'relationship-append', component.name);
|
|
1805
|
+
if (!record.ok) return record;
|
|
1806
|
+
const rec = record.value;
|
|
1807
|
+
const arch = this.graph.archetypes[rec.archetypeId];
|
|
1808
|
+
/* istanbul ignore next -- alive record always has a valid archetype */
|
|
1809
|
+
if (!arch) {
|
|
1810
|
+
return err(
|
|
1811
|
+
new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
|
|
1812
|
+
operation: 'relationship-append',
|
|
1813
|
+
component: component.name,
|
|
1814
|
+
expectedGeneration: entityGeneration(entity),
|
|
1815
|
+
actualGeneration: rec.generation,
|
|
1816
|
+
}),
|
|
1817
|
+
);
|
|
1818
|
+
}
|
|
1819
|
+
const row = this.tableRow(rec);
|
|
1820
|
+
const localId = componentId(component);
|
|
1821
|
+
const fieldCols = this.table(arch).storage.get(localId)?.fields;
|
|
1822
|
+
if (!fieldCols) {
|
|
1823
|
+
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
1824
|
+
}
|
|
1825
|
+
const col = fieldCols.get(fieldName);
|
|
1826
|
+
if (!col) return err(new ComponentNotPresentError(entity as number, component.name));
|
|
1827
|
+
const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
|
|
1828
|
+
if (arrayMeta === undefined) {
|
|
1829
|
+
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
1830
|
+
}
|
|
1831
|
+
const meta = TYPE_METADATA[arrayMeta.elementType];
|
|
1832
|
+
/* istanbul ignore next -- arrayMeta.elementType is guaranteed in TYPE_METADATA */
|
|
1833
|
+
if (!meta) return err(new ComponentNotPresentError(entity as number, component.name));
|
|
1834
|
+
// biome-ignore lint/style/noNonNullAssertion: every array element type has a byte size
|
|
1835
|
+
const elementBytes = meta.byteSize!;
|
|
1836
|
+
const slotId = col.view[row] as number;
|
|
1837
|
+
|
|
1838
|
+
const countCol = fieldCols.get(arrayCountColumnName(fieldName));
|
|
1839
|
+
/* istanbul ignore next -- variable arrays always allocate the count column */
|
|
1840
|
+
if (countCol === undefined) {
|
|
1841
|
+
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
1842
|
+
}
|
|
1843
|
+
const count = countCol.view[row] as number;
|
|
1844
|
+
const newCount = count + 1;
|
|
1845
|
+
const newByteLength = newCount * elementBytes;
|
|
1846
|
+
|
|
1847
|
+
let liveSlotId = slotId;
|
|
1848
|
+
if (liveSlotId === 0) {
|
|
1849
|
+
// Empty/unallocated slot — alloc fresh.
|
|
1850
|
+
const allocR = this.bufferPool.alloc(newByteLength);
|
|
1851
|
+
if (!allocR.ok) return err(allocR.error);
|
|
1852
|
+
liveSlotId = allocR.value.id;
|
|
1853
|
+
col.view[row] = liveSlotId;
|
|
1854
|
+
} else {
|
|
1855
|
+
// A previously-allocated slot may have drained below its high-water
|
|
1856
|
+
// mark: swap-remove (`_removeArrayElementByValue`) and `pop` only lower
|
|
1857
|
+
// the count column, never shrink the managed buffer. When the refilled
|
|
1858
|
+
// length still fits inside the slot's current logical length, reuse the
|
|
1859
|
+
// buffer in place -- routing through `grow` would hit the (correct, but
|
|
1860
|
+
// here irrelevant) shrink-not-supported guard and strand the field
|
|
1861
|
+
// (e.g. `Children.entities` never repopulating after a full drain).
|
|
1862
|
+
if (newByteLength > this.bufferPool.view(liveSlotId).byteLength) {
|
|
1863
|
+
const growR = this.bufferPool.grow(liveSlotId, newByteLength);
|
|
1864
|
+
if (!growR.ok) return err(growR.error);
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
const liveBytes = this.bufferPool.view(liveSlotId);
|
|
1868
|
+
// Reinterpret the slot bytes as the element-typed view and write at the
|
|
1869
|
+
// tail index. Entity values are stored as their u32 bit pattern.
|
|
1870
|
+
writeArrayElementAt(liveBytes, count, arrayMeta.elementType, value as number);
|
|
1871
|
+
countCol.view[row] = newCount;
|
|
1872
|
+
this.markComponentChanged(entity, component);
|
|
1873
|
+
return ok(undefined);
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
private ensureArrayCapacity(
|
|
1877
|
+
entity: EntityHandle,
|
|
1878
|
+
component: Component,
|
|
1879
|
+
fieldName: string,
|
|
1880
|
+
minimum: number,
|
|
1881
|
+
): Result<void, EcsError> {
|
|
1882
|
+
const record = this.lookupAlive(entity, 'relationship-capacity', component.name);
|
|
1883
|
+
if (!record.ok) return record;
|
|
1884
|
+
const rec = record.value;
|
|
1885
|
+
const arch = this.graph.archetypes[rec.archetypeId];
|
|
1886
|
+
if (!arch) {
|
|
1887
|
+
return err(
|
|
1888
|
+
new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
|
|
1889
|
+
operation: 'relationship-capacity',
|
|
1890
|
+
component: component.name,
|
|
1891
|
+
expectedGeneration: entityGeneration(entity),
|
|
1892
|
+
actualGeneration: rec.generation,
|
|
1893
|
+
}),
|
|
1894
|
+
);
|
|
1895
|
+
}
|
|
1896
|
+
const row = this.tableRow(rec);
|
|
1897
|
+
const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
|
|
1898
|
+
if (!fieldCols) return err(new ComponentNotPresentError(entity as number, component.name));
|
|
1899
|
+
const col = fieldCols.get(fieldName);
|
|
1900
|
+
if (!col) return err(new ComponentNotPresentError(entity as number, component.name));
|
|
1901
|
+
const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
|
|
1902
|
+
if (arrayMeta === undefined) {
|
|
1903
|
+
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
1904
|
+
}
|
|
1905
|
+
const meta = TYPE_METADATA[arrayMeta.elementType];
|
|
1906
|
+
if (!meta?.byteSize) {
|
|
1907
|
+
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
1908
|
+
}
|
|
1909
|
+
const maximum = Math.floor(262_144 / meta.byteSize);
|
|
1910
|
+
if (!Number.isSafeInteger(minimum) || minimum < 0 || minimum > maximum) {
|
|
1911
|
+
return err(new ManagedBufferOutOfBoundsError(minimum, maximum));
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
const byteLength = minimum * meta.byteSize;
|
|
1915
|
+
const slotId = col.view[row] as number;
|
|
1916
|
+
if (slotId === 0) {
|
|
1917
|
+
if (minimum === 0) return ok(undefined);
|
|
1918
|
+
const allocated = this.bufferPool.alloc(byteLength);
|
|
1919
|
+
if (!allocated.ok) return allocated;
|
|
1920
|
+
col.view[row] = allocated.value.id;
|
|
1921
|
+
return ok(undefined);
|
|
1922
|
+
}
|
|
1923
|
+
if (this.bufferPool.view(slotId).byteLength >= byteLength) return ok(undefined);
|
|
1924
|
+
const grown = this.bufferPool.grow(slotId, byteLength);
|
|
1925
|
+
return grown.ok ? ok(undefined) : grown;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
/**
|
|
1929
|
+
* Remove one variable-array element at a known slot. Relationship holders
|
|
1930
|
+
* supply the slot from their backpointer, so this is O(1) and never scans
|
|
1931
|
+
* the materialized target array.
|
|
1932
|
+
*/
|
|
1933
|
+
private removeArrayElementAt(
|
|
1934
|
+
entity: EntityHandle,
|
|
1935
|
+
component: Component<string, ComponentSchema>,
|
|
1936
|
+
fieldName: string,
|
|
1937
|
+
slot: number,
|
|
1938
|
+
): Result<EntityHandle | undefined, EcsError> {
|
|
1939
|
+
const record = this.lookupAlive(entity, 'removeArrayElementAt', component.name);
|
|
1940
|
+
if (!record.ok) return record;
|
|
1941
|
+
const rec = record.value;
|
|
1942
|
+
const arch = this.graph.archetypes[rec.archetypeId];
|
|
1943
|
+
if (!arch) return err(new ComponentNotPresentError(entity as number, component.name));
|
|
1944
|
+
const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
|
|
1945
|
+
if (!fieldCols) return err(new ComponentNotPresentError(entity as number, component.name));
|
|
1946
|
+
const col = fieldCols.get(fieldName);
|
|
1947
|
+
const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
|
|
1948
|
+
const countCol = fieldCols.get(arrayCountColumnName(fieldName));
|
|
1949
|
+
if (!col || !arrayMeta || arrayMeta.length !== undefined || !countCol) {
|
|
1950
|
+
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
1951
|
+
}
|
|
1952
|
+
const row = this.tableRow(rec);
|
|
1953
|
+
const count = countCol.view[row] as number;
|
|
1954
|
+
if (slot < 0 || slot >= count) return ok(undefined);
|
|
1955
|
+
const slotId = col.view[row] as number;
|
|
1956
|
+
if (slotId === 0) return ok(undefined);
|
|
1957
|
+
const liveBytes = this.bufferPool.view(slotId);
|
|
1958
|
+
const last = count - 1;
|
|
1959
|
+
const moved =
|
|
1960
|
+
slot === last
|
|
1961
|
+
? undefined
|
|
1962
|
+
: (readArrayElementAt(liveBytes, last, arrayMeta.elementType) as EntityHandle);
|
|
1963
|
+
if (slot !== last) {
|
|
1964
|
+
writeArrayElementAt(liveBytes, slot, arrayMeta.elementType, moved as number);
|
|
1965
|
+
}
|
|
1966
|
+
countCol.view[row] = last;
|
|
1967
|
+
this.markComponentChanged(entity, component);
|
|
1968
|
+
return ok(moved);
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
1972
|
+
// addComponent / removeComponent (archetype migration via edges, AC-07)
|
|
1973
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
1974
|
+
|
|
1975
|
+
/**
|
|
1976
|
+
* Add a component to an existing entity, triggering archetype migration.
|
|
1977
|
+
*
|
|
1978
|
+
* @returns `Result<void, EcsError>` —
|
|
1979
|
+
* `ok(void)` on success;
|
|
1980
|
+
* `err(StaleEntityError)` (`.code = 'stale-entity'`) if entity is dead;
|
|
1981
|
+
* `err(ComponentAlreadyPresentError)` (`.code = 'component-already-present'`)
|
|
1982
|
+
* if entity already has the component (E-03).
|
|
1983
|
+
*
|
|
1984
|
+
* @example
|
|
1985
|
+
* ```ts
|
|
1986
|
+
* const Position = defineComponent('Position', { x: 'f32', y: 'f32' });
|
|
1987
|
+
* const Velocity = defineComponent('Velocity', { dx: 'f32', dy: 'f32' });
|
|
1988
|
+
* const world = new World();
|
|
1989
|
+
* const e = world.spawn({ component: Position, data: { x: 0, y: 0 } }).unwrap();
|
|
1990
|
+
* const r = world.addComponent(e, { component: Velocity, data: { dx: 1, dy: 0 } });
|
|
1991
|
+
* if (!r.ok) { return; } // r.error.code === 'stale-entity' on dead handle
|
|
1992
|
+
* r.unwrap();
|
|
1993
|
+
* ```
|
|
1994
|
+
*/
|
|
1995
|
+
addComponent<S extends ComponentSchema, C extends Component<string, S>>(
|
|
1996
|
+
entity: EntityHandle,
|
|
1997
|
+
componentData: ComponentData<S> & { component: C & WritableComponent<C> },
|
|
1998
|
+
): Result<void, EcsError> {
|
|
1999
|
+
const poisoned = this.poisonedResult<void>();
|
|
2000
|
+
if (poisoned !== undefined) return poisoned;
|
|
2001
|
+
if (
|
|
2002
|
+
isRelationshipTarget(componentData.component) &&
|
|
2003
|
+
this.relationshipTargetPayloadWrites(componentData.data as Record<string, unknown>)
|
|
2004
|
+
)
|
|
2005
|
+
return this.relationshipTargetWriteError(componentData.component, 'addComponent');
|
|
2006
|
+
return this.addComponentCore(entity, componentData, false);
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
/**
|
|
2010
|
+
* Core implementation of `addComponent` with reentry guard.
|
|
2011
|
+
*
|
|
2012
|
+
* @param internal — `true` when called from relationship maintenance
|
|
2013
|
+
* (lazy mirror create or exclusive reparent).
|
|
2014
|
+
*/
|
|
2015
|
+
private addComponentCore<S extends ComponentSchema>(
|
|
2016
|
+
entity: EntityHandle,
|
|
2017
|
+
componentData: ComponentData<S>,
|
|
2018
|
+
internal: boolean,
|
|
2019
|
+
resolveRequirements = true,
|
|
2020
|
+
// A relationship preflight can create a target mirror before the source
|
|
2021
|
+
// migration. Preserve whether that preflight created it through the
|
|
2022
|
+
// exclusive-reparent recursion.
|
|
2023
|
+
preparedMirrorAdded?: boolean,
|
|
2024
|
+
): Result<void, EcsError> {
|
|
2025
|
+
const record = this.lookupAlive(entity, 'addComponent', componentData.component.name);
|
|
2026
|
+
if (!record.ok) return record;
|
|
2027
|
+
|
|
2028
|
+
const rec = record.value;
|
|
2029
|
+
let srcArch = this.graph.archetypes[rec.archetypeId];
|
|
2030
|
+
/* istanbul ignore next -- defensive: alive record always has valid archetypeId */
|
|
2031
|
+
if (!srcArch) {
|
|
2032
|
+
return err(
|
|
2033
|
+
new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
|
|
2034
|
+
operation: 'addComponent',
|
|
2035
|
+
component: componentData.component.name,
|
|
2036
|
+
expectedGeneration: entityGeneration(entity),
|
|
2037
|
+
actualGeneration: rec.generation,
|
|
2038
|
+
}),
|
|
2039
|
+
);
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
const preflight = this.preflightComponentData(entity, componentData);
|
|
2043
|
+
if (!preflight.ok) return preflight;
|
|
2044
|
+
|
|
2045
|
+
const filled = fillComponentDefaults(
|
|
2046
|
+
componentData.component,
|
|
2047
|
+
componentData.data as Record<string, unknown>,
|
|
2048
|
+
);
|
|
2049
|
+
|
|
2050
|
+
// Reserve a source relationship's target before adding any required
|
|
2051
|
+
// components. A capacity failure must not leave a required component on
|
|
2052
|
+
// the holder, nor a lazily-created mirror on the target. Existing
|
|
2053
|
+
// exclusive sources keep their dedicated reparent path below.
|
|
2054
|
+
let relationshipMirrorAdded = preparedMirrorAdded;
|
|
2055
|
+
const componentAlreadyPresent = srcArch.components.some(
|
|
2056
|
+
(candidate) => componentId(candidate) === componentId(componentData.component),
|
|
2057
|
+
);
|
|
2058
|
+
if (
|
|
2059
|
+
!componentAlreadyPresent &&
|
|
2060
|
+
!internal &&
|
|
2061
|
+
relationshipRole(componentData.component as Component)?.kind === 'source' &&
|
|
2062
|
+
relationshipMirrorAdded === undefined
|
|
2063
|
+
) {
|
|
2064
|
+
const prepared = this.prepareRelationshipInsert(
|
|
2065
|
+
componentData.component as Component,
|
|
2066
|
+
filled as Record<string, unknown>,
|
|
2067
|
+
);
|
|
2068
|
+
if (!prepared.ok) return prepared;
|
|
2069
|
+
relationshipMirrorAdded = prepared.value;
|
|
2070
|
+
// Preparing a relationship may materialize the mirror on the same
|
|
2071
|
+
// entity (self-targeting relationships are valid when `allowSelf` is
|
|
2072
|
+
// enabled). That nested add migrates `rec`, so the source archetype
|
|
2073
|
+
// captured above is no longer authoritative. Refresh it before the
|
|
2074
|
+
// requested component migration instead of removing a row from the
|
|
2075
|
+
// stale, already-empty table.
|
|
2076
|
+
srcArch = this.graph.archetypes[rec.archetypeId];
|
|
2077
|
+
if (srcArch === undefined) {
|
|
2078
|
+
return err(
|
|
2079
|
+
new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
|
|
2080
|
+
operation: 'addComponent',
|
|
2081
|
+
component: componentData.component.name,
|
|
2082
|
+
expectedGeneration: rec.generation,
|
|
2083
|
+
actualGeneration: rec.generation,
|
|
2084
|
+
}),
|
|
2085
|
+
);
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
// Generic component requirements are resolved once at the structural
|
|
2090
|
+
// boundary. Explicit data remains authoritative; only missing required
|
|
2091
|
+
// identities are added before the requested component is migrated.
|
|
2092
|
+
if (resolveRequirements) {
|
|
2093
|
+
const required = expandComponentRequirements([componentData]).slice(1);
|
|
2094
|
+
for (const requirement of required) {
|
|
2095
|
+
if (
|
|
2096
|
+
srcArch.components.some(
|
|
2097
|
+
(candidate) => componentId(candidate) === componentId(requirement.component),
|
|
2098
|
+
)
|
|
2099
|
+
) {
|
|
2100
|
+
continue;
|
|
2101
|
+
}
|
|
2102
|
+
// The closure is expanded once above. Bypass requirement expansion for
|
|
2103
|
+
// each member so malformed dependency cycles remain finite and the
|
|
2104
|
+
// structural work still happens in one deterministic sequence.
|
|
2105
|
+
const added = this.addComponentCore(entity, requirement as ComponentData, internal, false);
|
|
2106
|
+
if (!added.ok) return added;
|
|
2107
|
+
srcArch = this.graph.archetypes[rec.archetypeId];
|
|
2108
|
+
if (!srcArch) {
|
|
2109
|
+
return err(
|
|
2110
|
+
new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
|
|
2111
|
+
operation: 'addComponent',
|
|
2112
|
+
component: componentData.component.name,
|
|
2113
|
+
expectedGeneration: rec.generation,
|
|
2114
|
+
actualGeneration: rec.generation,
|
|
2115
|
+
}),
|
|
2116
|
+
);
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
// Check if entity already has this component (using World-local ID).
|
|
2122
|
+
const localId = componentId(componentData.component);
|
|
2123
|
+
if (srcArch.components.some((candidate) => componentId(candidate) === localId)) {
|
|
2124
|
+
// M2 exclusive relationship: re-adding the holder with a (possibly new)
|
|
2125
|
+
// target auto-reparents instead of failing (AC-12). Prune the old side
|
|
2126
|
+
// first (removeComponent prunes the old target), then fall through to
|
|
2127
|
+
// the normal add (which appends the new target). The two steps keep the
|
|
2128
|
+
// materialized target list consistent (AC-13);
|
|
2129
|
+
// removeComponent + addComponent each touch the mirror exactly once and
|
|
2130
|
+
// the mirror component carries no relationship of its own, so there is
|
|
2131
|
+
// no recursion. Reparent only fires for top-level user calls
|
|
2132
|
+
// (!internal); engine-internal lazy create / append
|
|
2133
|
+
// never re-add an existing relationship component.
|
|
2134
|
+
const role = relationshipRole(componentData.component as Component);
|
|
2135
|
+
if (role?.kind === 'source' && role.exclusive && !internal) {
|
|
2136
|
+
const prepared = this.prepareRelationshipInsert(
|
|
2137
|
+
componentData.component as Component,
|
|
2138
|
+
filled as Record<string, unknown>,
|
|
2139
|
+
);
|
|
2140
|
+
if (!prepared.ok) return prepared;
|
|
2141
|
+
const removeR = this.removeComponentCore(
|
|
2142
|
+
entity,
|
|
2143
|
+
componentData.component as Component,
|
|
2144
|
+
false,
|
|
2145
|
+
);
|
|
2146
|
+
if (!removeR.ok) return removeR;
|
|
2147
|
+
return this.addComponentCore(entity, componentData, false, true, prepared.value);
|
|
2148
|
+
}
|
|
2149
|
+
return err(new ComponentAlreadyPresentError(entity as number, componentData.component.name));
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
// Get target archetype via edge cache.
|
|
2153
|
+
const targetArch = getAddEdge(
|
|
2154
|
+
this.graph,
|
|
2155
|
+
srcArch,
|
|
2156
|
+
localId,
|
|
2157
|
+
componentData.component as Component,
|
|
2158
|
+
);
|
|
2159
|
+
|
|
2160
|
+
if (componentData.component.storage === 'sparse') {
|
|
2161
|
+
this.moveEntityArchetype(rec, srcArch, targetArch);
|
|
2162
|
+
} else {
|
|
2163
|
+
this.migrateEntity(rec, srcArch, targetArch);
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
// Write the new component's data. Apply layer-2 + layer-3 silent
|
|
2167
|
+
// fallback so addComponent shares the SAME default-resolution path
|
|
2168
|
+
// as spawn / SceneAsset.instantiate (feat-20260517 / M2 / AC-04
|
|
2169
|
+
// research §F4 auto-symmetry; ComponentData<S>['data'] is the
|
|
2170
|
+
// physical bridge).
|
|
2171
|
+
if (componentData.component.storage === 'table') {
|
|
2172
|
+
this.writeRow(targetArch, componentData.component, this.tableRow(rec), filled as ShapeOf<S>);
|
|
2173
|
+
}
|
|
2174
|
+
// Relationship sync: append to the materialized target list.
|
|
2175
|
+
if (!internal && relationshipRole(componentData.component as Component)?.kind === 'source') {
|
|
2176
|
+
const relationshipResult = this.relationshipOnInsert(
|
|
2177
|
+
entity,
|
|
2178
|
+
componentData.component as Component,
|
|
2179
|
+
filled as Record<string, unknown>,
|
|
2180
|
+
relationshipMirrorAdded,
|
|
2181
|
+
);
|
|
2182
|
+
if (!relationshipResult.ok) {
|
|
2183
|
+
this.poisonAfterEntityMutation('World.addComponent', relationshipResult.error);
|
|
2184
|
+
return relationshipResult;
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
if (!internal) {
|
|
2189
|
+
// Relationship insertion is part of the same publication boundary. A
|
|
2190
|
+
// target-side storage failure poisons the World; it must not leave a
|
|
2191
|
+
// components-added epoch/evidence record that claims the edge exists.
|
|
2192
|
+
this.internalmarkComponentsAdded(entity, [componentId(componentData.component)]);
|
|
2193
|
+
this.advanceStructureEpoch();
|
|
2194
|
+
this.recordStructuralEvidence({
|
|
2195
|
+
kind: 'component-added',
|
|
2196
|
+
entity,
|
|
2197
|
+
componentId: localId,
|
|
2198
|
+
});
|
|
2199
|
+
}
|
|
2200
|
+
return ok(undefined);
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
/**
|
|
2204
|
+
* Remove a component from an existing entity, triggering archetype migration.
|
|
2205
|
+
*
|
|
2206
|
+
* @returns `Result<void, EcsError>` —
|
|
2207
|
+
* `ok(void)` on success;
|
|
2208
|
+
* `err(StaleEntityError)` (`.code = 'stale-entity'`) if entity is dead;
|
|
2209
|
+
* `err(ComponentNotPresentError)` (`.code = 'component-not-present'`)
|
|
2210
|
+
* if entity doesn't have the component (E-04).
|
|
2211
|
+
*
|
|
2212
|
+
* @example
|
|
2213
|
+
* ```ts
|
|
2214
|
+
* const Position = defineComponent('Position', { x: 'f32', y: 'f32' });
|
|
2215
|
+
* const world = new World();
|
|
2216
|
+
* const e = world.spawn({ component: Position, data: { x: 0, y: 0 } }).unwrap();
|
|
2217
|
+
* const r = world.removeComponent(e, Position);
|
|
2218
|
+
* if (!r.ok) { return; } // r.error.code === 'stale-entity' on dead handle
|
|
2219
|
+
* r.unwrap();
|
|
2220
|
+
* ```
|
|
2221
|
+
*/
|
|
2222
|
+
removeComponent<S extends ComponentSchema, C extends Component<string, S>>(
|
|
2223
|
+
entity: EntityHandle,
|
|
2224
|
+
component: C & WritableComponent<C>,
|
|
2225
|
+
): Result<void, EcsError> {
|
|
2226
|
+
const poisoned = this.poisonedResult<void>();
|
|
2227
|
+
if (poisoned !== undefined) return poisoned;
|
|
2228
|
+
if (isRelationshipTarget(component))
|
|
2229
|
+
return this.relationshipTargetWriteError(component, 'removeComponent');
|
|
2230
|
+
return this.removeComponentCore(entity, component, false);
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
/**
|
|
2234
|
+
* Core implementation of `removeComponent` with reentry guard.
|
|
2235
|
+
*
|
|
2236
|
+
* @param internal — `true` when called from relationship maintenance
|
|
2237
|
+
* (exclusive reparent).
|
|
2238
|
+
*/
|
|
2239
|
+
private removeComponentCore<S extends ComponentSchema>(
|
|
2240
|
+
entity: EntityHandle,
|
|
2241
|
+
component: Component<string, S>,
|
|
2242
|
+
internal: boolean,
|
|
2243
|
+
): Result<void, EcsError> {
|
|
2244
|
+
// Essential-component hard reject (feat-20260602 / plan-strategy D-3): the
|
|
2245
|
+
// id=0 `Entity` component is carried by every archetype unconditionally (it
|
|
2246
|
+
// is the row's own packed handle) and cannot be removed. Reject before any
|
|
2247
|
+
// liveness lookup so the rejection is structural, not entity-state-dependent.
|
|
2248
|
+
if (componentId(component) === componentId(EntityComponent)) {
|
|
2249
|
+
return err(new RemoveEssentialComponentError(component.name));
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
const record = this.lookupAlive(entity, 'removeComponent', component.name);
|
|
2253
|
+
if (!record.ok) return record;
|
|
2254
|
+
|
|
2255
|
+
const rec = record.value;
|
|
2256
|
+
const srcArch = this.graph.archetypes[rec.archetypeId];
|
|
2257
|
+
/* istanbul ignore next -- defensive: alive record always has valid archetypeId */
|
|
2258
|
+
if (!srcArch) {
|
|
2259
|
+
return err(
|
|
2260
|
+
new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
|
|
2261
|
+
operation: 'removeComponent',
|
|
2262
|
+
component: component.name,
|
|
2263
|
+
expectedGeneration: entityGeneration(entity),
|
|
2264
|
+
actualGeneration: rec.generation,
|
|
2265
|
+
}),
|
|
2266
|
+
);
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
// Check if entity has this component (using World-local ID).
|
|
2270
|
+
const localId = componentId(component);
|
|
2271
|
+
if (!srcArch.components.some((candidate) => componentId(candidate) === localId)) {
|
|
2272
|
+
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
// Capture the old relationship value before column removal so the
|
|
2276
|
+
// materialized target list can be pruned.
|
|
2277
|
+
const role = relationshipRole(component as Component);
|
|
2278
|
+
const needsOldValue = role?.kind === 'source' && !internal;
|
|
2279
|
+
if (needsOldValue) {
|
|
2280
|
+
const oldValue = this.readRow(srcArch, component as Component, this.tableRow(rec)) as Record<
|
|
2281
|
+
string,
|
|
2282
|
+
unknown
|
|
2283
|
+
>;
|
|
2284
|
+
// Relationship sync: prune the holder from the target's materialized list.
|
|
2285
|
+
if (role?.kind === 'source' && !internal) {
|
|
2286
|
+
const relation = this.relationshipOnRemove(entity, component as Component, oldValue);
|
|
2287
|
+
if (!relation.ok) {
|
|
2288
|
+
this.poisonAfterEntityMutation('World.removeComponent', relation.error);
|
|
2289
|
+
return relation;
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
// M1 release loop (removeComponent path): release every `ref<T>` field
|
|
2295
|
+
// on the component being removed before migration drops the row.
|
|
2296
|
+
if (component.storage === 'table') {
|
|
2297
|
+
this.releaseManagedRefsOnRow(srcArch, component as Component, this.tableRow(rec));
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
// Get target archetype via edge cache.
|
|
2301
|
+
const targetArch = getRemoveEdge(this.graph, srcArch, localId);
|
|
2302
|
+
|
|
2303
|
+
if (component.storage === 'sparse') {
|
|
2304
|
+
this.moveEntityArchetype(rec, srcArch, targetArch);
|
|
2305
|
+
const set = this.graph.sparseTags.get(componentId(component));
|
|
2306
|
+
if (set !== undefined) removeSparseTag(set, entity);
|
|
2307
|
+
} else {
|
|
2308
|
+
this.migrateEntity(rec, srcArch, targetArch);
|
|
2309
|
+
}
|
|
2310
|
+
if (!internal) {
|
|
2311
|
+
this.advanceStructureEpoch();
|
|
2312
|
+
this.recordStructuralEvidence({
|
|
2313
|
+
kind: 'component-removed',
|
|
2314
|
+
entity,
|
|
2315
|
+
componentId: localId,
|
|
2316
|
+
});
|
|
2317
|
+
}
|
|
2318
|
+
return ok(undefined);
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
2322
|
+
// Internal — deferred command support (CommandBuffer interface)
|
|
2323
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
2324
|
+
|
|
2325
|
+
/**
|
|
2326
|
+
* Allocate a pending entity for deferred spawn.
|
|
2327
|
+
* Returns an Entity handle. The entity is "pending" because
|
|
2328
|
+
* archetypeId === -1 (set by allocateIndex); no separate flag needed.
|
|
2329
|
+
*/
|
|
2330
|
+
private allocatePendingEntity(): EntityHandle {
|
|
2331
|
+
const indexSlot = this.allocateIndex();
|
|
2332
|
+
// biome-ignore lint/style/noNonNullAssertion: allocateIndex guarantees a valid slot with an initialized record
|
|
2333
|
+
return encodeEntity(indexSlot, this.records[indexSlot]!.generation);
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
/**
|
|
2337
|
+
* Return a deferred-spawn reservation to the free-list without publishing a
|
|
2338
|
+
* row or advancing an epoch. CommandBuffer.abort is the sole caller; a
|
|
2339
|
+
* materialized entity is intentionally left untouched so an unexpected
|
|
2340
|
+
* post-write failure poisons the World instead of attempting an unsafe undo.
|
|
2341
|
+
*/
|
|
2342
|
+
private cancelPendingEntity(entity: EntityHandle): void {
|
|
2343
|
+
const slot = entityIndex(entity);
|
|
2344
|
+
const record = this.records[slot];
|
|
2345
|
+
if (record === undefined || record.generation !== entityGeneration(entity)) return;
|
|
2346
|
+
if (record.archetypeId !== -1 || record.archetypeRow !== -1) return;
|
|
2347
|
+
record.generation += 1;
|
|
2348
|
+
if (!isRetiredSlot(record.generation)) this.freeIndices.push(slot);
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
/**
|
|
2352
|
+
* Materialize one already-reserved entity. Synchronous spawn and deferred
|
|
2353
|
+
* command flush share this exact insertion/publication path; only the
|
|
2354
|
+
* caller's validation and reservation boundary differs.
|
|
2355
|
+
*
|
|
2356
|
+
*/
|
|
2357
|
+
private materializeEntity(
|
|
2358
|
+
entity: EntityHandle,
|
|
2359
|
+
componentDatas: ComponentData[],
|
|
2360
|
+
): Result<void, EcsError> {
|
|
2361
|
+
const poisoned = this.poisonedResult<void>();
|
|
2362
|
+
if (poisoned !== undefined) return poisoned;
|
|
2363
|
+
const slot = entityIndex(entity);
|
|
2364
|
+
const record = this.records[slot];
|
|
2365
|
+
if (!record || record.archetypeId !== -1) return ok(undefined);
|
|
2366
|
+
|
|
2367
|
+
let storageTouched = false;
|
|
2368
|
+
try {
|
|
2369
|
+
// Find or create target archetype (using World-local IDs).
|
|
2370
|
+
const componentIds = componentDatas.map((cd) => componentId(cd.component));
|
|
2371
|
+
const components = componentDatas.map((cd) => cd.component);
|
|
2372
|
+
const arch = getOrCreateArchetype(this.graph, componentIds, components);
|
|
2373
|
+
|
|
2374
|
+
// Mark the reservation as mutation-owned before either append can write.
|
|
2375
|
+
// If appendTableRow succeeds and appendArchetypeRow fails, command abort
|
|
2376
|
+
// must not reclaim an entity whose table storage may already be touched.
|
|
2377
|
+
// A poisoned World is the recovery boundary for that partial state.
|
|
2378
|
+
record.archetypeId = arch.id;
|
|
2379
|
+
storageTouched = true;
|
|
2380
|
+
const table = this.table(arch);
|
|
2381
|
+
const tableRow = appendTableRow(table, entity);
|
|
2382
|
+
const archetypeRow = appendArchetypeRow(arch, tableRow);
|
|
2383
|
+
record.archetypeRow = archetypeRow;
|
|
2384
|
+
|
|
2385
|
+
// Write initial data. Apply the same default-resolution path as the
|
|
2386
|
+
// synchronous World.spawn / addComponent operations.
|
|
2387
|
+
for (const cd of componentDatas) {
|
|
2388
|
+
const filled = fillComponentDefaults(cd.component, cd.data as Record<string, unknown>);
|
|
2389
|
+
this.writeRow(arch, cd.component, tableRow, filled as ShapeOf<ComponentSchema>);
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
// The reserved handle is the source of truth for the essential Entity
|
|
2393
|
+
// self column. Relationship maintenance is part of the write boundary;
|
|
2394
|
+
// component/epoch publication waits until it has completed.
|
|
2395
|
+
this.writeEntitySelf(arch, tableRow, entity);
|
|
2396
|
+
|
|
2397
|
+
// Publish relationship targets after all rows and managed writes are
|
|
2398
|
+
// ready. A non-stale error here is a possible partial write. No
|
|
2399
|
+
// components-added epoch or structural evidence has been published yet.
|
|
2400
|
+
for (const cd of componentDatas) {
|
|
2401
|
+
if (relationshipRole(cd.component as Component)?.kind !== 'source') continue;
|
|
2402
|
+
const filled = fillComponentDefaults(cd.component, cd.data as Record<string, unknown>);
|
|
2403
|
+
const relationshipResult = this.relationshipOnInsert(
|
|
2404
|
+
entity,
|
|
2405
|
+
cd.component as Component,
|
|
2406
|
+
filled as Record<string, unknown>,
|
|
2407
|
+
);
|
|
2408
|
+
if (!relationshipResult.ok) {
|
|
2409
|
+
this.poisonAfterEntityMutation('World.materializeEntity', relationshipResult.error);
|
|
2410
|
+
return relationshipResult;
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
this.internalmarkComponentsAdded(entity, [
|
|
2414
|
+
componentId(EntityComponent),
|
|
2415
|
+
...componentDatas.map((cd) => componentId(cd.component)),
|
|
2416
|
+
]);
|
|
2417
|
+
this.advanceStructureEpoch();
|
|
2418
|
+
this.recordStructuralEvidence({ kind: 'spawn', entity });
|
|
2419
|
+
return ok(undefined);
|
|
2420
|
+
} catch (error) {
|
|
2421
|
+
if (storageTouched) this.poisonAfterEntityMutation('World.materializeEntity', error);
|
|
2422
|
+
throw error;
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
private poisonAfterEntityMutation(kernelName: string, cause: unknown): void {
|
|
2427
|
+
this.internalpoisonExecution({
|
|
2428
|
+
code: 'shared-kernel-failed',
|
|
2429
|
+
kernelName,
|
|
2430
|
+
cause,
|
|
2431
|
+
partialWrite: true,
|
|
2432
|
+
retryable: false,
|
|
2433
|
+
});
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
/** Deferred commands use the common materialization owner. */
|
|
2437
|
+
private materializePendingEntity(
|
|
2438
|
+
entity: EntityHandle,
|
|
2439
|
+
componentDatas: ComponentData[],
|
|
2440
|
+
): Result<void, EcsError> {
|
|
2441
|
+
return this.materializeEntity(entity, expandComponentRequirements(componentDatas));
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
2445
|
+
// Internal — entity index allocation
|
|
2446
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
2447
|
+
|
|
2448
|
+
private allocateIndex(): number {
|
|
2449
|
+
if (this.executionState.health === 'poisoned') {
|
|
2450
|
+
throw new WorldPoisonedError(this.identity, this.executionState.fault);
|
|
2451
|
+
}
|
|
2452
|
+
const recycled = this.freeIndices.pop();
|
|
2453
|
+
if (recycled !== undefined) {
|
|
2454
|
+
return recycled;
|
|
2455
|
+
}
|
|
2456
|
+
const slot = this.records.length;
|
|
2457
|
+
if (slot > ENTITY_MAX_INDEX) {
|
|
2458
|
+
throw new EntityIndexOverflowError(slot);
|
|
2459
|
+
}
|
|
2460
|
+
this.records.push({ generation: 0, archetypeId: -1, archetypeRow: -1 });
|
|
2461
|
+
return slot;
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
/**
|
|
2465
|
+
* Single liveness predicate (feat-20260602 / plan-strategy D-4): a slot is
|
|
2466
|
+
* live for a given handle generation iff the record exists, its generation
|
|
2467
|
+
* still matches the handle (despawn bumps generation, so a stale or recycled
|
|
2468
|
+
* handle fails here), and the slot is materialized into an archetype
|
|
2469
|
+
* (archetypeId !== -1). Replaces the former `record.alive && record.generation
|
|
2470
|
+
* === gen` conjunction and the intermediate `!record.pending` clause. An
|
|
2471
|
+
* append in progress keeps `archetypeRow === -1` until both storage indexes
|
|
2472
|
+
* exist.
|
|
2473
|
+
*/
|
|
2474
|
+
private recordIsLive(record: EntityRecord | undefined, gen: number): record is EntityRecord {
|
|
2475
|
+
return (
|
|
2476
|
+
record !== undefined &&
|
|
2477
|
+
record.generation === gen &&
|
|
2478
|
+
record.archetypeId !== -1 &&
|
|
2479
|
+
record.archetypeRow !== -1
|
|
2480
|
+
);
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
private lookupAlive(
|
|
2484
|
+
entity: EntityHandle,
|
|
2485
|
+
operation: string,
|
|
2486
|
+
component?: string,
|
|
2487
|
+
): Result<EntityRecord, EcsError> {
|
|
2488
|
+
const slot = entityIndex(entity);
|
|
2489
|
+
const gen = entityGeneration(entity);
|
|
2490
|
+
const record = this.records[slot];
|
|
2491
|
+
if (!this.recordIsLive(record, gen)) {
|
|
2492
|
+
return err(
|
|
2493
|
+
new StaleEntityError(entity as number, slot, gen, {
|
|
2494
|
+
operation,
|
|
2495
|
+
...(component !== undefined ? { component } : {}),
|
|
2496
|
+
expectedGeneration: gen,
|
|
2497
|
+
actualGeneration: this.records[slot]?.generation ?? -1,
|
|
2498
|
+
}),
|
|
2499
|
+
);
|
|
2500
|
+
}
|
|
2501
|
+
return ok(record);
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
private readArrayView(
|
|
2505
|
+
arch: Archetype,
|
|
2506
|
+
component: Component,
|
|
2507
|
+
row: number,
|
|
2508
|
+
fieldName: string,
|
|
2509
|
+
): FieldView | undefined {
|
|
2510
|
+
const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
|
|
2511
|
+
if (!fieldCols) return undefined;
|
|
2512
|
+
|
|
2513
|
+
const fieldType = componentSchema(component)[fieldName];
|
|
2514
|
+
if (fieldType === undefined) return undefined;
|
|
2515
|
+
// Component reflection already parses and freezes array metadata at
|
|
2516
|
+
// registration time. Reusing it here keeps the per-entity zero-copy path
|
|
2517
|
+
// parse-free; this accessor is called once for every renderable every
|
|
2518
|
+
// frame. The field lookup also preserves the existing undefined result
|
|
2519
|
+
// for non-array fields without reparsing arbitrary schema strings.
|
|
2520
|
+
const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
|
|
2521
|
+
if (arrayMeta === undefined) return undefined;
|
|
2522
|
+
|
|
2523
|
+
const col = fieldCols.get(fieldName);
|
|
2524
|
+
if (!col) return undefined;
|
|
2525
|
+
|
|
2526
|
+
const elementCount =
|
|
2527
|
+
arrayMeta.length ??
|
|
2528
|
+
(fieldCols.get(arrayCountColumnName(fieldName))?.view[row] as number | undefined) ??
|
|
2529
|
+
0;
|
|
2530
|
+
return this.materializeArrayView(col, row, arrayMeta, elementCount);
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
/** Read one scalar column without constructing a component snapshot. */
|
|
2534
|
+
private internalgetFieldValue(
|
|
2535
|
+
entity: EntityHandle,
|
|
2536
|
+
component: Component,
|
|
2537
|
+
fieldName: string,
|
|
2538
|
+
): number | undefined {
|
|
2539
|
+
const record = this.lookupAlive(entity, 'world-read', component.name);
|
|
2540
|
+
if (!record.ok) return undefined;
|
|
2541
|
+
const arch = this.graph.archetypes[record.value.archetypeId];
|
|
2542
|
+
if (arch === undefined) return undefined;
|
|
2543
|
+
const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
|
|
2544
|
+
return fieldCols?.get(fieldName)?.view[this.tableRow(record.value)] as number | undefined;
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
/** Read an array's live logical length without allocating a TypedArray. */
|
|
2548
|
+
private internalgetArrayLength(
|
|
2549
|
+
entity: EntityHandle,
|
|
2550
|
+
component: Component,
|
|
2551
|
+
fieldName: string,
|
|
2552
|
+
): number | undefined {
|
|
2553
|
+
const record = this.lookupAlive(entity, 'world-read', component.name);
|
|
2554
|
+
if (!record.ok) return undefined;
|
|
2555
|
+
const arch = this.graph.archetypes[record.value.archetypeId];
|
|
2556
|
+
if (arch === undefined) return undefined;
|
|
2557
|
+
const row = this.tableRow(record.value);
|
|
2558
|
+
const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
|
|
2559
|
+
if (fieldCols === undefined) return undefined;
|
|
2560
|
+
const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
|
|
2561
|
+
if (arrayMeta === undefined) return undefined;
|
|
2562
|
+
if (arrayMeta.length !== undefined) return arrayMeta.length;
|
|
2563
|
+
const count = fieldCols.get(arrayCountColumnName(fieldName))?.view[row];
|
|
2564
|
+
return typeof count === 'number' ? count : 0;
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
/** Read one array element directly from its column or BufferPool slot. */
|
|
2568
|
+
private internalgetArrayElement(
|
|
2569
|
+
entity: EntityHandle,
|
|
2570
|
+
component: Component,
|
|
2571
|
+
fieldName: string,
|
|
2572
|
+
index: number,
|
|
2573
|
+
): number | undefined {
|
|
2574
|
+
if (!Number.isSafeInteger(index) || index < 0) return undefined;
|
|
2575
|
+
const record = this.lookupAlive(entity, 'world-read', component.name);
|
|
2576
|
+
if (!record.ok) return undefined;
|
|
2577
|
+
const arch = this.graph.archetypes[record.value.archetypeId];
|
|
2578
|
+
if (arch === undefined) return undefined;
|
|
2579
|
+
const row = this.tableRow(record.value);
|
|
2580
|
+
const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
|
|
2581
|
+
if (fieldCols === undefined) return undefined;
|
|
2582
|
+
const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
|
|
2583
|
+
if (arrayMeta === undefined) return undefined;
|
|
2584
|
+
const length =
|
|
2585
|
+
arrayMeta.length ??
|
|
2586
|
+
(fieldCols.get(arrayCountColumnName(fieldName))?.view[row] as number | undefined);
|
|
2587
|
+
if (length === undefined || index >= length) return undefined;
|
|
2588
|
+
const col = fieldCols.get(fieldName);
|
|
2589
|
+
if (col === undefined) return undefined;
|
|
2590
|
+
if (arrayMeta.length !== undefined) return col.view[row * col.arity + index] as number;
|
|
2591
|
+
|
|
2592
|
+
const slotId = col.view[row] as number;
|
|
2593
|
+
const bytes = this.bufferPool.view(slotId);
|
|
2594
|
+
if (arrayMeta.elementType === 'entity') {
|
|
2595
|
+
const byteOffset = index * 4;
|
|
2596
|
+
if (byteOffset + 4 > bytes.byteLength) return undefined;
|
|
2597
|
+
return (
|
|
2598
|
+
((bytes[byteOffset] ?? 0) |
|
|
2599
|
+
((bytes[byteOffset + 1] ?? 0) << 8) |
|
|
2600
|
+
((bytes[byteOffset + 2] ?? 0) << 16) |
|
|
2601
|
+
((bytes[byteOffset + 3] ?? 0) << 24)) >>>
|
|
2602
|
+
0
|
|
2603
|
+
);
|
|
2604
|
+
}
|
|
2605
|
+
return readArrayElementAt(bytes, index, arrayMeta.elementType);
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
2609
|
+
// Internal — archetype data read/write
|
|
2610
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
2611
|
+
|
|
2612
|
+
private readRow<S extends ComponentSchema>(
|
|
2613
|
+
arch: Archetype,
|
|
2614
|
+
component: Component<string, S>,
|
|
2615
|
+
row: number,
|
|
2616
|
+
): ShapeOf<S> {
|
|
2617
|
+
const localId = componentId(component);
|
|
2618
|
+
const fieldCols = this.table(arch).storage.get(localId)?.fields;
|
|
2619
|
+
const out = {} as ShapeOf<S>;
|
|
2620
|
+
/* istanbul ignore next -- defensive: component is registered and arch has it */
|
|
2621
|
+
if (!fieldCols) {
|
|
2622
|
+
return out;
|
|
2623
|
+
}
|
|
2624
|
+
for (const [fieldName, fieldType] of Object.entries(componentSchema(component))) {
|
|
2625
|
+
const col = fieldCols.get(fieldName);
|
|
2626
|
+
if (!col) {
|
|
2627
|
+
continue;
|
|
2628
|
+
}
|
|
2629
|
+
const raw = col.view[row];
|
|
2630
|
+
if (fieldType === 'bool') {
|
|
2631
|
+
(out as Record<string, unknown>)[fieldName] = raw === 1;
|
|
2632
|
+
} else if (isEntityField(fieldType)) {
|
|
2633
|
+
// Entity field: decode the stored raw u32 verbatim back to Entity
|
|
2634
|
+
// (or null when the slot carries the ENTITY_NULL_RAW sentinel). No
|
|
2635
|
+
// liveness validation happens here -- a slot referencing a despawned
|
|
2636
|
+
// target returns its original raw encoding unchanged; the consumer is
|
|
2637
|
+
// responsible for checking liveness (e.g. `world.get(ref, Entity)`).
|
|
2638
|
+
(out as Record<string, unknown>)[fieldName] = raw === ENTITY_NULL_RAW ? null : raw;
|
|
2639
|
+
} else if (isManagedBufferField(fieldType)) {
|
|
2640
|
+
if (fieldType !== 'buffer') {
|
|
2641
|
+
// feat-20260602: fixed `buffer<N>` lives inline (stride-N u8 column,
|
|
2642
|
+
// arity = N). Return the row's byte window directly -- no pool slot.
|
|
2643
|
+
const arity = col.arity;
|
|
2644
|
+
(out as Record<string, unknown>)[fieldName] = (col.view as Uint8Array).subarray(
|
|
2645
|
+
row * arity,
|
|
2646
|
+
row * arity + arity,
|
|
2647
|
+
);
|
|
2648
|
+
} else {
|
|
2649
|
+
// Variable `'buffer'`: column stores slot id; the live view is
|
|
2650
|
+
// resolved on demand so post-grow callers always see the refreshed
|
|
2651
|
+
// Uint8Array.
|
|
2652
|
+
(out as Record<string, unknown>)[fieldName] = this.bufferPool.view(raw as number);
|
|
2653
|
+
}
|
|
2654
|
+
} else if (fieldType === 'string') {
|
|
2655
|
+
// M1 string-field read path (AC-03 / AC-09): resolve the column
|
|
2656
|
+
// u32 handle through UniqueRefStore -- same dispatch arm as the
|
|
2657
|
+
// 'unique<T>' read (D-R3). Returns the native JS string payload by
|
|
2658
|
+
// strong reference; identity is stable across reads until the
|
|
2659
|
+
// next set or release (AC-03 read-side identity contract).
|
|
2660
|
+
// Released / sentinel handles surface as unique-ref-released via
|
|
2661
|
+
// resolve; we fall back to '' rather than propagate the error so
|
|
2662
|
+
// the read shape (`out.value: string`) stays total -- AI users
|
|
2663
|
+
// never see undefined or wrapper objects.
|
|
2664
|
+
const resolveR = this.uniqueRefs.resolve<'String'>(toUnique<'String'>(raw as number));
|
|
2665
|
+
(out as Record<string, unknown>)[fieldName] = resolveR.ok ? resolveR.value : '';
|
|
2666
|
+
} else {
|
|
2667
|
+
const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
|
|
2668
|
+
if (arrayMeta !== undefined) {
|
|
2669
|
+
// M1 read path: materialise a fresh TypedArray snapshot each call
|
|
2670
|
+
// (D-4 no cache; plan-strategy §2.2 read-only contract). The
|
|
2671
|
+
// snapshot aliases the BufferPool slot bytes; mutations route
|
|
2672
|
+
// through the World mutation API.
|
|
2673
|
+
const elementCount =
|
|
2674
|
+
arrayMeta.length ??
|
|
2675
|
+
(fieldCols.get(arrayCountColumnName(fieldName))?.view[row] as number | undefined) ??
|
|
2676
|
+
0;
|
|
2677
|
+
const materialized = this.materializeArrayView(col, row, arrayMeta, elementCount);
|
|
2678
|
+
// Relationship target arrays are a read-only projection. Keep the
|
|
2679
|
+
// public component snapshot detached so mutating the returned view
|
|
2680
|
+
// cannot bypass the World-owned mirror/index path.
|
|
2681
|
+
(out as Record<string, unknown>)[fieldName] = isRelationshipTarget(component)
|
|
2682
|
+
? materialized.slice()
|
|
2683
|
+
: materialized;
|
|
2684
|
+
} else {
|
|
2685
|
+
(out as Record<string, unknown>)[fieldName] = raw;
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
return out;
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
/**
|
|
2693
|
+
* Write the full packed entity handle into the row's essential id=0 `Entity`
|
|
2694
|
+
* column (`self` field). Called by `spawn` / `materializePendingEntity`
|
|
2695
|
+
* after the row is appended (feat-20260602 / plan-strategy D-3). The column
|
|
2696
|
+
* always exists -- `createArchetype` folds the Entity column into every
|
|
2697
|
+
* archetype -- so this is a direct u32 store, no readRow/writeRow walk.
|
|
2698
|
+
*/
|
|
2699
|
+
private writeEntitySelf(arch: Archetype, row: number, handle: EntityHandle): void {
|
|
2700
|
+
const col = this.table(arch).storage.get(componentId(EntityComponent))?.fields.get('self');
|
|
2701
|
+
/* istanbul ignore next -- defensive: Entity column is folded into every archetype */
|
|
2702
|
+
if (!col) return;
|
|
2703
|
+
col.view[row] = handle as unknown as number;
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
private writeRow<S extends ComponentSchema>(
|
|
2707
|
+
arch: Archetype,
|
|
2708
|
+
component: Component<string, S>,
|
|
2709
|
+
row: number,
|
|
2710
|
+
value: ShapeOf<S>,
|
|
2711
|
+
): void {
|
|
2712
|
+
const localId = componentId(component);
|
|
2713
|
+
const fieldCols = this.table(arch).storage.get(localId)?.fields;
|
|
2714
|
+
/* istanbul ignore next -- defensive: component is registered and arch has it */
|
|
2715
|
+
if (!fieldCols) {
|
|
2716
|
+
return;
|
|
2717
|
+
}
|
|
2718
|
+
for (const [fieldName, fieldType] of Object.entries(componentSchema(component))) {
|
|
2719
|
+
const col = fieldCols.get(fieldName);
|
|
2720
|
+
/* istanbul ignore next -- defensive: schema fields always have columns */
|
|
2721
|
+
if (!col) {
|
|
2722
|
+
continue;
|
|
2723
|
+
}
|
|
2724
|
+
const raw = (value as Record<string, unknown>)[fieldName];
|
|
2725
|
+
if (fieldType === 'bool') {
|
|
2726
|
+
col.view[row] = raw ? 1 : 0;
|
|
2727
|
+
} else if (isEntityField(fieldType)) {
|
|
2728
|
+
// M3 entity field: encode (slot, gen) into u32 column. `null`
|
|
2729
|
+
// / undefined map to ENTITY_NULL_RAW sentinel.
|
|
2730
|
+
col.view[row] = raw === null || raw === undefined ? ENTITY_NULL_RAW : (raw as number);
|
|
2731
|
+
} else if (isManagedBufferField(fieldType)) {
|
|
2732
|
+
// M2 spawn path: collapsed-vocab keyword family `'buffer'` (variable)
|
|
2733
|
+
// + `'buffer<N>'` (fixed):
|
|
2734
|
+
// - `buffer<N>` (feat-20260602) — lives inline as a stride-N u8
|
|
2735
|
+
// column (arity = N). Copy any provided payload straight into the
|
|
2736
|
+
// row window (truncate to N); no BufferPool slot.
|
|
2737
|
+
// - `'buffer'` — variable capacity; alloc one BufferPool slot sized
|
|
2738
|
+
// to the provided payload's byteLength. Missing / non-buffer raw
|
|
2739
|
+
// -> alloc(0) zero-length live view (verify round 1 B2 fix path;
|
|
2740
|
+
// pre-fix the bare keyword routed `bufferFieldByteLength('buffer')`
|
|
2741
|
+
// -> NaN -> alloc(NaN) -> managed-buffer-out-of-bounds, dropping
|
|
2742
|
+
// the payload bytes silently). Failures route to Layer 3
|
|
2743
|
+
// error channel; column slot stays at 0 (sentinel) so subsequent
|
|
2744
|
+
// release short-circuits.
|
|
2745
|
+
// raw is normalized from any AllowSharedBufferSource view to a
|
|
2746
|
+
// Uint8Array over its bytes (feat-20260621 V2 / AC-A4).
|
|
2747
|
+
const bytes = normalizeBufferWrite(raw);
|
|
2748
|
+
if (fieldType !== 'buffer') {
|
|
2749
|
+
const arity = col.arity;
|
|
2750
|
+
if (bytes !== null) {
|
|
2751
|
+
const copyLen = Math.min(bytes.byteLength, arity);
|
|
2752
|
+
(col.view as Uint8Array).set(bytes.subarray(0, copyLen), row * arity);
|
|
2753
|
+
}
|
|
2754
|
+
} else {
|
|
2755
|
+
const allocBytes = bytes !== null ? bytes.byteLength : 0;
|
|
2756
|
+
const allocR = this.bufferPool.alloc(allocBytes);
|
|
2757
|
+
if (!allocR.ok) {
|
|
2758
|
+
const ctx: ErrorContext = {
|
|
2759
|
+
systemName: `World.spawn (${component.name}.${fieldName})`,
|
|
2760
|
+
};
|
|
2761
|
+
this.routeError(allocR.error, ctx);
|
|
2762
|
+
col.view[row] = 0;
|
|
2763
|
+
continue;
|
|
2764
|
+
}
|
|
2765
|
+
const slot = allocR.value;
|
|
2766
|
+
if (bytes !== null) {
|
|
2767
|
+
// allocBytes is the payload's exact byteLength so no truncation.
|
|
2768
|
+
const copyLen = Math.min(bytes.byteLength, slot.view.byteLength);
|
|
2769
|
+
slot.view.set(bytes.subarray(0, copyLen));
|
|
2770
|
+
}
|
|
2771
|
+
col.view[row] = slot.id;
|
|
2772
|
+
}
|
|
2773
|
+
} else if (fieldType === 'string') {
|
|
2774
|
+
// M1 string-field spawn path (AC-04 / AC-06): route the JS string
|
|
2775
|
+
// payload through `uniqueRefs.alloc('String', text)` -- the same
|
|
2776
|
+
// UniqueRefStore the `ref<T>` arm uses (D-R3 single-arm dispatch).
|
|
2777
|
+
// The store holds the immutable string by strong reference so
|
|
2778
|
+
// identity is stable across reads (AC-03). Missing / non-string raw
|
|
2779
|
+
// falls back to '' so AI users always see a readable string on
|
|
2780
|
+
// get (no nullable handling).
|
|
2781
|
+
const text = typeof raw === 'string' ? raw : '';
|
|
2782
|
+
const handle = this.uniqueRefs.alloc<'String'>('String', text);
|
|
2783
|
+
col.view[row] = unwrapHandle(handle);
|
|
2784
|
+
} else {
|
|
2785
|
+
const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
|
|
2786
|
+
if (arrayMeta !== undefined) {
|
|
2787
|
+
// M1 spawn path for array<T> / array<T,N> fields (D-3 double-
|
|
2788
|
+
// column for variable; single column for fixed).
|
|
2789
|
+
// Spawn path: no prior-slot release (fresh rows carry stale debris
|
|
2790
|
+
// owned by the migrated entity in the new archetype) — feat-20260614
|
|
2791
|
+
// D-3 calling convention.
|
|
2792
|
+
this.writeArrayField(arch, component, row, fieldName, fieldType, arrayMeta, raw);
|
|
2793
|
+
} else {
|
|
2794
|
+
col.view[row] = raw as number;
|
|
2795
|
+
// feat-20260614 M5 / D-5: scalar 'shared<T>' spawn retain. The
|
|
2796
|
+
// alloc-grant rc=1 stays held by the producer (e.g. AssetRegistry);
|
|
2797
|
+
// each ECS holder bumps rc via this retain so despawn / overwrite
|
|
2798
|
+
// releases bring rc back symmetrically. Sentinel slot 0 is a no-op.
|
|
2799
|
+
if (fieldType.startsWith('shared<') && (raw as number) !== 0) {
|
|
2800
|
+
this.retainSharedScalarHandle(raw as number, component.name, fieldName);
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
2808
|
+
// Internal — managed-ref + managed-buffer release loop (M1 / M2)
|
|
2809
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
2810
|
+
|
|
2811
|
+
/** Release all ECS-owned field handles before a row is removed or overwritten. */
|
|
2812
|
+
private releaseManagedRefsOnRow(arch: Archetype, component: Component, row: number): void {
|
|
2813
|
+
const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
|
|
2814
|
+
if (!fieldCols) return;
|
|
2815
|
+
for (const fieldName of Object.keys(componentSchema(component))) {
|
|
2816
|
+
this.releaseManagedFieldOnRow(arch, component, row, fieldName);
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
/**
|
|
2821
|
+
* Release one ECS-owned field according to its schema. Inline buffers have
|
|
2822
|
+
* no pool slot; shared-array elements still release their handles. Store
|
|
2823
|
+
* failures use the host error channel so row cleanup remains total.
|
|
2824
|
+
*/
|
|
2825
|
+
private releaseManagedFieldOnRow(
|
|
2826
|
+
arch: Archetype,
|
|
2827
|
+
component: Component,
|
|
2828
|
+
row: number,
|
|
2829
|
+
fieldName: string,
|
|
2830
|
+
): void {
|
|
2831
|
+
const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
|
|
2832
|
+
if (!fieldCols) return;
|
|
2833
|
+
const col = fieldCols.get(fieldName);
|
|
2834
|
+
if (!col) return;
|
|
2835
|
+
const fieldType = (componentSchema(component) as Record<string, string>)[fieldName] ?? '';
|
|
2836
|
+
if (isManagedField(fieldType)) {
|
|
2837
|
+
// Sub-dispatch by the schema-vocab keyword (feat-20260614 M4 / AC-08):
|
|
2838
|
+
// - 'shared<T>' scalar -> SharedRefStore.release (rc--; drop on rc=0)
|
|
2839
|
+
// - 'unique<T>' / 'string' -> UniqueRefStore.release (direct slot drop)
|
|
2840
|
+
// Both column shapes are u32 handles; the lookup store differs.
|
|
2841
|
+
// Keeping both arms inside the unified `isManagedField` block is
|
|
2842
|
+
// intentional: meta key (TYPE_METADATA `'shared'` vs `'ref'`) decides
|
|
2843
|
+
// the store, not a separate top-level branch (architecture-principles
|
|
2844
|
+
// §1 SSOT — meta key = release semantics).
|
|
2845
|
+
const handleU32 = col.view[row] as number;
|
|
2846
|
+
if (fieldType.startsWith('shared<')) {
|
|
2847
|
+
this.releaseSharedRefHandle(handleU32, component.name, fieldName);
|
|
2848
|
+
return;
|
|
2849
|
+
}
|
|
2850
|
+
this.releaseManagedRefHandle(handleU32, component.name, fieldName);
|
|
2851
|
+
return;
|
|
2852
|
+
}
|
|
2853
|
+
if (isManagedBufferField(fieldType)) {
|
|
2854
|
+
if (fieldType === 'buffer') {
|
|
2855
|
+
const slotId = col.view[row] as number;
|
|
2856
|
+
this.releaseManagedBufferSlot(slotId);
|
|
2857
|
+
col.view[row] = 0;
|
|
2858
|
+
}
|
|
2859
|
+
return;
|
|
2860
|
+
}
|
|
2861
|
+
if (isManagedArrayField(fieldType)) {
|
|
2862
|
+
// Use the pre-parsed arrayMeta cached on the component descriptor at
|
|
2863
|
+
// registration (AC-03c parse-free hot path); reaching for
|
|
2864
|
+
// `parseManagedArraySchema` here would violate the parse-free
|
|
2865
|
+
// invariant exercised by hierarchy.unit.test.ts §w5 AC-03(a).
|
|
2866
|
+
const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
|
|
2867
|
+
if (arrayMeta === undefined) return;
|
|
2868
|
+
const isSharedElement = arrayMeta.elementType.startsWith('shared<');
|
|
2869
|
+
if (arrayMeta.length === undefined) {
|
|
2870
|
+
// Variable `array<T>`: BufferPool slot id in primary column + live
|
|
2871
|
+
// count in `<fieldName>:count` sidecar. For `array<shared<T>>`,
|
|
2872
|
+
// walk live elements and release each shared handle BEFORE
|
|
2873
|
+
// releasing the slot bytes (feat-20260614 M4 / D-3 — slot bytes
|
|
2874
|
+
// are only valid until the slot is recycled).
|
|
2875
|
+
const slotId = col.view[row] as number;
|
|
2876
|
+
const countCol = fieldCols.get(arrayCountColumnName(fieldName));
|
|
2877
|
+
if (isSharedElement && slotId !== 0) {
|
|
2878
|
+
const liveCount = countCol !== undefined ? (countCol.view[row] as number) : 0;
|
|
2879
|
+
const slotView = liveCount > 0 ? this.bufferPool.view(slotId) : null;
|
|
2880
|
+
if (slotView !== null && slotView.byteLength > 0) {
|
|
2881
|
+
this.releaseSharedArrayElements(slotView, liveCount);
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
this.releaseManagedBufferSlot(slotId);
|
|
2885
|
+
col.view[row] = 0;
|
|
2886
|
+
if (countCol !== undefined) countCol.view[row] = 0;
|
|
2887
|
+
return;
|
|
2888
|
+
}
|
|
2889
|
+
// Fixed `array<T,N>` (feat-20260602): inline stride-N column, no
|
|
2890
|
+
// BufferPool slot to release. For `array<shared<T>,N>`, walk the N
|
|
2891
|
+
// inline elements and release each shared handle. Zero the row
|
|
2892
|
+
// window so subsequent writes do not double-release.
|
|
2893
|
+
if (isSharedElement) {
|
|
2894
|
+
const arity = col.arity;
|
|
2895
|
+
const elementBytes = (TYPE_METADATA.shared?.byteSize ?? 4) as number;
|
|
2896
|
+
const rowByteOffset = col.view.byteOffset + row * arity * elementBytes;
|
|
2897
|
+
const rowBytes = new Uint8Array(col.view.buffer, rowByteOffset, arity * elementBytes);
|
|
2898
|
+
this.releaseSharedArrayElements(rowBytes, arity);
|
|
2899
|
+
rowBytes.fill(0);
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
/** Release one unique-ref handle; sentinel 0 is ignored. */
|
|
2905
|
+
private releaseManagedRefHandle(
|
|
2906
|
+
handleU32: number,
|
|
2907
|
+
componentName: string,
|
|
2908
|
+
fieldName: string,
|
|
2909
|
+
): void {
|
|
2910
|
+
if (handleU32 === 0) return; // sentinel: skip silently.
|
|
2911
|
+
const r = this.uniqueRefs.release(handleU32 as Handle<string, 'unique'>);
|
|
2912
|
+
if (r.ok) return;
|
|
2913
|
+
// Layer 3 routing: surface double-release as a structured error so AI
|
|
2914
|
+
// users see {code, hint, expected, detail} on their handler. Severity
|
|
2915
|
+
// defaults to Error so the chain continues; matchSeverity prints to
|
|
2916
|
+
// console.error rather than throw.
|
|
2917
|
+
const ctx: ErrorContext = {
|
|
2918
|
+
systemName: `World.release (${componentName}.${fieldName})`,
|
|
2919
|
+
};
|
|
2920
|
+
this.routeError(r.error, ctx);
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
/** Release one shared-ref handle, preserving builtin slots and refcounts. */
|
|
2924
|
+
private releaseSharedRefHandle(
|
|
2925
|
+
handleU32: number,
|
|
2926
|
+
componentName: string,
|
|
2927
|
+
fieldName: string,
|
|
2928
|
+
): void {
|
|
2929
|
+
// feat-20260614 M6 D-15 / R-14: builtin slots (< BUILTIN_BASE, including the
|
|
2930
|
+
// sentinel 0) are process-static and never reference-counted -> short-circuit
|
|
2931
|
+
// before touching SharedRefStore. This single guard is the SSOT for both the
|
|
2932
|
+
// scalar arm (here) and the array-element arm (releaseSharedArrayElements).
|
|
2933
|
+
if (handleU32 < BUILTIN_BASE) return;
|
|
2934
|
+
const r = this.sharedRefs.release(toShared<string>(handleU32));
|
|
2935
|
+
if (r.ok) return;
|
|
2936
|
+
const ctx: ErrorContext = {
|
|
2937
|
+
systemName: `World.release (${componentName}.${fieldName})`,
|
|
2938
|
+
};
|
|
2939
|
+
this.routeError(r.error, ctx);
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
/** Retain one shared-ref scalar handle for a World-owned field. */
|
|
2943
|
+
private retainSharedScalarHandle(
|
|
2944
|
+
handleU32: number,
|
|
2945
|
+
componentName: string,
|
|
2946
|
+
fieldName: string,
|
|
2947
|
+
): void {
|
|
2948
|
+
// feat-20260614 M6 D-15 / R-14: builtin slots (< BUILTIN_BASE, including the
|
|
2949
|
+
// sentinel 0) short-circuit — process-static, never reference-counted. SSOT
|
|
2950
|
+
// guard shared with the array-element arm (retainSharedArrayElements).
|
|
2951
|
+
if (handleU32 < BUILTIN_BASE) return;
|
|
2952
|
+
const r = this.sharedRefs.retain(toShared<string>(handleU32));
|
|
2953
|
+
if (r.ok) return;
|
|
2954
|
+
const ctx: ErrorContext = {
|
|
2955
|
+
systemName: `World.write (${componentName}.${fieldName} shared scalar retain)`,
|
|
2956
|
+
};
|
|
2957
|
+
this.routeError(r.error, ctx);
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
/** Release one variable-buffer slot; id 0 is the unallocated sentinel. */
|
|
2961
|
+
private releaseManagedBufferSlot(slotId: number): void {
|
|
2962
|
+
if (slotId === 0) return; // sentinel: skip silently.
|
|
2963
|
+
this.bufferPool.release(slotId);
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
2967
|
+
// Internal — array<T> / array<T,N> spawn / set helpers (M1 / w7)
|
|
2968
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
2969
|
+
|
|
2970
|
+
/**
|
|
2971
|
+
* Attach field context to a `ManagedArrayErrorEnvelope`.
|
|
2972
|
+
* The envelope shape (`code / hint / expected / detail`) already mirrors
|
|
2973
|
+
* the EcsError contract; this helper only attaches the systemName context
|
|
2974
|
+
* so AI users can correlate the error with the holder component / field.
|
|
2975
|
+
*/
|
|
2976
|
+
private routeArrayError(
|
|
2977
|
+
err: ManagedArrayErrorEnvelope,
|
|
2978
|
+
componentName: string,
|
|
2979
|
+
fieldName: string,
|
|
2980
|
+
): void {
|
|
2981
|
+
const ctx: ErrorContext = {
|
|
2982
|
+
systemName: `World.write (${componentName}.${fieldName})`,
|
|
2983
|
+
};
|
|
2984
|
+
this.routeError(err, ctx);
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
/**
|
|
2988
|
+
* Write an array field for spawn or set. Fixed arrays stay inline; variable
|
|
2989
|
+
* arrays use one BufferPool slot plus a live-count sidecar.
|
|
2990
|
+
*/
|
|
2991
|
+
private writeArrayField(
|
|
2992
|
+
arch: Archetype,
|
|
2993
|
+
component: Component,
|
|
2994
|
+
row: number,
|
|
2995
|
+
fieldName: string,
|
|
2996
|
+
_fieldType: string,
|
|
2997
|
+
arrayMeta: ArrayMeta,
|
|
2998
|
+
raw: unknown,
|
|
2999
|
+
): void {
|
|
3000
|
+
const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
|
|
3001
|
+
/* istanbul ignore next -- writeArrayField caller validated the column map */
|
|
3002
|
+
if (!fieldCols) return;
|
|
3003
|
+
const col = fieldCols.get(fieldName);
|
|
3004
|
+
/* istanbul ignore next -- writeArrayField caller validated the column */
|
|
3005
|
+
if (!col) return;
|
|
3006
|
+
|
|
3007
|
+
const elementType = arrayMeta.elementType;
|
|
3008
|
+
// Normalize parametrised element-type template literals to the family
|
|
3009
|
+
// key for TYPE_METADATA lookup; the column stores plain u32 handles
|
|
3010
|
+
// either way:
|
|
3011
|
+
// - `shared<X>` -> 'shared' (feat-20260614 M4 / D-3 -- element-level
|
|
3012
|
+
// retain/release semantics route via the dedicated `'shared'` arm
|
|
3013
|
+
// below)
|
|
3014
|
+
const metaKey = elementType.startsWith('shared<') ? 'shared' : (elementType as string);
|
|
3015
|
+
const meta = TYPE_METADATA[metaKey];
|
|
3016
|
+
/* istanbul ignore next -- arrayMeta.elementType is guaranteed in TYPE_METADATA */
|
|
3017
|
+
if (!meta) return;
|
|
3018
|
+
// biome-ignore lint/style/noNonNullAssertion: every array element type has a byte size
|
|
3019
|
+
const elementBytes = meta.byteSize!;
|
|
3020
|
+
|
|
3021
|
+
const isVariable = arrayMeta.length === undefined;
|
|
3022
|
+
const fixedLength = arrayMeta.length ?? 0;
|
|
3023
|
+
|
|
3024
|
+
// Determine the payload's logical element count. Accept any TypedArray
|
|
3025
|
+
// (Float32Array / Uint32Array / etc.) plus plain numeric arrays; an
|
|
3026
|
+
// undefined / missing payload is treated as a length-0 init. Bytes are
|
|
3027
|
+
// copied from the source's underlying ArrayBuffer when present.
|
|
3028
|
+
let payloadCount = 0;
|
|
3029
|
+
let payloadBytes: Uint8Array | null = null;
|
|
3030
|
+
if (raw !== null && raw !== undefined) {
|
|
3031
|
+
if (
|
|
3032
|
+
raw instanceof Float32Array ||
|
|
3033
|
+
raw instanceof Float64Array ||
|
|
3034
|
+
raw instanceof Int32Array ||
|
|
3035
|
+
raw instanceof Uint32Array ||
|
|
3036
|
+
raw instanceof Int16Array ||
|
|
3037
|
+
raw instanceof Uint16Array ||
|
|
3038
|
+
raw instanceof Int8Array ||
|
|
3039
|
+
raw instanceof Uint8Array
|
|
3040
|
+
) {
|
|
3041
|
+
payloadCount = raw.length;
|
|
3042
|
+
payloadBytes = new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength);
|
|
3043
|
+
} else if (Array.isArray(raw)) {
|
|
3044
|
+
payloadCount = raw.length;
|
|
3045
|
+
// Plain JS array: pack each element through the declared element
|
|
3046
|
+
// type's TypedArray constructor (`meta.viewCtor`) so the numeric
|
|
3047
|
+
// VALUE is encoded, not its integer bit pattern. Dispatching on
|
|
3048
|
+
// `viewCtor` (the type SSOT) rather than byte size is what keeps
|
|
3049
|
+
// `array<f32,N>` distinct from `array<u32,N>` -- both are 4 bytes,
|
|
3050
|
+
// so a size-keyed setter would store an f32 `1.0` as the u32 bits
|
|
3051
|
+
// `0x00000001` (reads back ~1.4e-45). The TypedArray then exposes
|
|
3052
|
+
// its little-endian bytes for the shared copy path below.
|
|
3053
|
+
if (payloadCount > 0 && meta.viewCtor !== undefined) {
|
|
3054
|
+
const typed = new meta.viewCtor(payloadCount);
|
|
3055
|
+
for (let i = 0; i < payloadCount; i++) {
|
|
3056
|
+
const val = raw[i];
|
|
3057
|
+
typed[i] = typeof val === 'number' ? val : 0;
|
|
3058
|
+
}
|
|
3059
|
+
payloadBytes = new Uint8Array(typed.buffer, typed.byteOffset, typed.byteLength);
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
|
|
3064
|
+
// Effective count for variable arrays = payload count; for fixed
|
|
3065
|
+
// arrays = schema-declared N (the payload's length is advisory — we
|
|
3066
|
+
// copy up to N elements and pad the rest with zero).
|
|
3067
|
+
const effectiveCount = isVariable ? payloadCount : fixedLength;
|
|
3068
|
+
|
|
3069
|
+
// Fixed `array<T,N>` (feat-20260602): the column is an inline stride-N
|
|
3070
|
+
// view (`col.arity === N`), so write the payload bytes directly into the
|
|
3071
|
+
// row's stride window — no BufferPool slot, no slot-id store, no
|
|
3072
|
+
// prior-slot release. The byte window starts at `row * arity * elementBytes`
|
|
3073
|
+
// and spans N elements; payloads shorter than N copy a prefix and leave
|
|
3074
|
+
// the tail at its current value (spawn rows are zero-initialised by the
|
|
3075
|
+
// fresh column buffer; the swap-pop migration copies the whole block).
|
|
3076
|
+
if (!isVariable) {
|
|
3077
|
+
const arity = col.arity;
|
|
3078
|
+
const rowByteOffset = col.view.byteOffset + row * arity * elementBytes;
|
|
3079
|
+
const rowBytes = new Uint8Array(col.view.buffer, rowByteOffset, arity * elementBytes);
|
|
3080
|
+
const copyLen =
|
|
3081
|
+
payloadBytes === null ? 0 : Math.min(payloadBytes.byteLength, rowBytes.byteLength);
|
|
3082
|
+
if (copyLen > 0 && payloadBytes !== null) {
|
|
3083
|
+
rowBytes.set(payloadBytes.subarray(0, copyLen));
|
|
3084
|
+
}
|
|
3085
|
+
// Zero the tail past the copied prefix so a short / missing payload
|
|
3086
|
+
// matches the prior fresh-slot semantics (the old pool path always
|
|
3087
|
+
// alloc'd a zeroed slot, so unwritten elements read back as 0).
|
|
3088
|
+
if (copyLen < rowBytes.byteLength) {
|
|
3089
|
+
rowBytes.fill(0, copyLen);
|
|
3090
|
+
}
|
|
3091
|
+
// feat-20260614 M4 / D-3: `array<shared<T>,N>` element-level retain.
|
|
3092
|
+
// Walk the copied prefix as u32 handles and retain each non-sentinel
|
|
3093
|
+
// element. Caller releases priors via `releaseManagedFieldOnRow` on
|
|
3094
|
+
// the set path (D-3 calling convention); the spawn path's fresh row
|
|
3095
|
+
// is zero-initialised so no priors exist.
|
|
3096
|
+
if (metaKey === 'shared' && copyLen > 0) {
|
|
3097
|
+
this.retainSharedArrayElements(rowBytes, copyLen >>> 2);
|
|
3098
|
+
}
|
|
3099
|
+
return;
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
// Variable `array<T>`: prior-slot release lives at the caller (set path)
|
|
3103
|
+
// — D-3 calling convention. Spawn path's fresh rows carry stale swap-pop
|
|
3104
|
+
// debris which MUST NOT be released here.
|
|
3105
|
+
const byteLength = effectiveCount * elementBytes;
|
|
3106
|
+
const allocR = this.bufferPool.alloc(byteLength);
|
|
3107
|
+
if (!allocR.ok) {
|
|
3108
|
+
this.routeArrayError(
|
|
3109
|
+
{
|
|
3110
|
+
code: allocR.error.code,
|
|
3111
|
+
hint: allocR.error.hint,
|
|
3112
|
+
expected: allocR.error.expected,
|
|
3113
|
+
detail: allocR.error.detail,
|
|
3114
|
+
} as ManagedArrayErrorEnvelope,
|
|
3115
|
+
component.name,
|
|
3116
|
+
fieldName,
|
|
3117
|
+
);
|
|
3118
|
+
col.view[row] = 0;
|
|
3119
|
+
if (isVariable) {
|
|
3120
|
+
const countCol = fieldCols.get(arrayCountColumnName(fieldName));
|
|
3121
|
+
if (countCol !== undefined) countCol.view[row] = 0;
|
|
3122
|
+
}
|
|
3123
|
+
return;
|
|
3124
|
+
}
|
|
3125
|
+
const slot = allocR.value;
|
|
3126
|
+
if (payloadBytes !== null) {
|
|
3127
|
+
const copyLen = Math.min(payloadBytes.byteLength, slot.view.byteLength);
|
|
3128
|
+
slot.view.set(payloadBytes.subarray(0, copyLen));
|
|
3129
|
+
}
|
|
3130
|
+
col.view[row] = slot.id;
|
|
3131
|
+
if (isVariable) {
|
|
3132
|
+
const countCol = fieldCols.get(arrayCountColumnName(fieldName));
|
|
3133
|
+
/* istanbul ignore else -- count column allocated by createArchetype */
|
|
3134
|
+
if (countCol !== undefined) countCol.view[row] = effectiveCount;
|
|
3135
|
+
}
|
|
3136
|
+
// feat-20260614 M4 / D-3: variable `array<shared<T>>` element-level
|
|
3137
|
+
// retain. Walk the live element prefix (effectiveCount u32 handles) and
|
|
3138
|
+
// retain each non-sentinel handle. Prior elements were released by the
|
|
3139
|
+
// caller via `releaseManagedFieldOnRow` on the set path (D-3 calling
|
|
3140
|
+
// convention); the spawn path has no priors.
|
|
3141
|
+
if (metaKey === 'shared' && effectiveCount > 0) {
|
|
3142
|
+
this.retainSharedArrayElements(slot.view, effectiveCount);
|
|
3143
|
+
}
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3146
|
+
/**
|
|
3147
|
+
* Walk the first `count` u32 handles in `bytes` and call
|
|
3148
|
+
* `SharedRefStore.retain` on each non-sentinel slot id (feat-20260614 M4 /
|
|
3149
|
+
* D-3). Failures route via the error channel so the write chain stays
|
|
3150
|
+
* total; charter explicit-failure boundary lets AI users see structured
|
|
3151
|
+
* `shared-ref-released` payloads when retaining a stale handle.
|
|
3152
|
+
*
|
|
3153
|
+
* Helper-internal -- only called from `writeArrayField`'s `'shared'` arm.
|
|
3154
|
+
*/
|
|
3155
|
+
private retainSharedArrayElements(bytes: Uint8Array, count: number): void {
|
|
3156
|
+
const view = new Uint32Array(bytes.buffer, bytes.byteOffset, count);
|
|
3157
|
+
for (let i = 0; i < count; i++) {
|
|
3158
|
+
const raw = view[i];
|
|
3159
|
+
if (raw === undefined) continue;
|
|
3160
|
+
// R-14: route through the scalar SSOT helper so the `< BUILTIN_BASE`
|
|
3161
|
+
// short-circuit (builtin slots + sentinel 0) lives in exactly one place.
|
|
3162
|
+
this.retainSharedScalarHandle(raw, 'array<shared<T>>', 'element');
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3166
|
+
/**
|
|
3167
|
+
* Walk the first `count` u32 handles in `bytes` and call
|
|
3168
|
+
* `SharedRefStore.release` on each non-sentinel slot id (feat-20260614 M4 /
|
|
3169
|
+
* D-3). Mirrors `retainSharedArrayElements`; called from
|
|
3170
|
+
* `releaseManagedFieldOnRow`'s array arm BEFORE the BufferPool slot is
|
|
3171
|
+
* released so the underlying bytes are still valid.
|
|
3172
|
+
*/
|
|
3173
|
+
private releaseSharedArrayElements(bytes: Uint8Array, count: number): void {
|
|
3174
|
+
const view = new Uint32Array(bytes.buffer, bytes.byteOffset, count);
|
|
3175
|
+
for (let i = 0; i < count; i++) {
|
|
3176
|
+
const raw = view[i];
|
|
3177
|
+
if (raw === undefined) continue;
|
|
3178
|
+
// R-14: route through the scalar SSOT helper so the `< BUILTIN_BASE`
|
|
3179
|
+
// short-circuit (builtin slots + sentinel 0) lives in exactly one place.
|
|
3180
|
+
this.releaseSharedRefHandle(raw, 'array<shared<T>>', 'element');
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3184
|
+
/**
|
|
3185
|
+
* Materialize an array snapshot. Fixed arrays alias their inline column;
|
|
3186
|
+
* variable arrays alias the live BufferPool slot and use the count sidecar.
|
|
3187
|
+
* Both views are transient and must not be held across structural changes.
|
|
3188
|
+
*/
|
|
3189
|
+
private materializeArrayView(
|
|
3190
|
+
col: Column,
|
|
3191
|
+
row: number,
|
|
3192
|
+
arrayMeta: ArrayMeta,
|
|
3193
|
+
elementCount: number,
|
|
3194
|
+
): FieldView {
|
|
3195
|
+
if (arrayMeta.length !== undefined) {
|
|
3196
|
+
// Fixed `array<T,N>` (feat-20260602): the elements live INLINE in the
|
|
3197
|
+
// stride-N column. Reinterpret the row's byte window directly — no
|
|
3198
|
+
// BufferPool indirection.
|
|
3199
|
+
const elementBytes = elementByteSize(arrayMeta.elementType);
|
|
3200
|
+
const arity = col.arity;
|
|
3201
|
+
const rowByteOffset = col.view.byteOffset + row * arity * elementBytes;
|
|
3202
|
+
return reinterpretBufferRegion(
|
|
3203
|
+
col.view.buffer,
|
|
3204
|
+
rowByteOffset,
|
|
3205
|
+
arrayMeta.elementType,
|
|
3206
|
+
arrayMeta.length,
|
|
3207
|
+
);
|
|
3208
|
+
}
|
|
3209
|
+
return reinterpretSlotBytes(
|
|
3210
|
+
this.bufferPool.view(col.view[row] as number),
|
|
3211
|
+
arrayMeta.elementType,
|
|
3212
|
+
elementCount,
|
|
3213
|
+
);
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3216
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
3217
|
+
// Internal — archetype migration
|
|
3218
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
3219
|
+
|
|
3220
|
+
/**
|
|
3221
|
+
* Copy surviving component columns into the target row, then swap-remove
|
|
3222
|
+
* the source row. Managed handles and variable-array sidecars are copied
|
|
3223
|
+
* verbatim; release remains the responsibility of remove/despawn paths.
|
|
3224
|
+
*/
|
|
3225
|
+
private migrateEntity(record: EntityRecord, srcArch: Archetype, targetArch: Archetype): void {
|
|
3226
|
+
const oldArchetypeRow = record.archetypeRow;
|
|
3227
|
+
const oldTableRow = srcArch.rows[oldArchetypeRow] ?? 0;
|
|
3228
|
+
const srcTable = this.table(srcArch);
|
|
3229
|
+
const targetTable = this.table(targetArch);
|
|
3230
|
+
const entity = (srcTable.storage.get(componentId(EntityComponent))?.fields.get('self')?.view[
|
|
3231
|
+
oldTableRow
|
|
3232
|
+
] ?? 0) as EntityHandle;
|
|
3233
|
+
const newTableRow = appendTableRow(targetTable, entity);
|
|
3234
|
+
const newArchetypeRow = appendArchetypeRow(targetArch, newTableRow);
|
|
3235
|
+
|
|
3236
|
+
// Copy shared component data.
|
|
3237
|
+
for (const [compId, srcComponentStorage] of srcTable.storage) {
|
|
3238
|
+
const srcFieldCols = srcComponentStorage.fields;
|
|
3239
|
+
const targetComponentStorage = targetTable.storage.get(compId);
|
|
3240
|
+
const targetFieldCols = targetComponentStorage?.fields;
|
|
3241
|
+
if (!targetFieldCols) {
|
|
3242
|
+
continue; // Component was removed — skip.
|
|
3243
|
+
}
|
|
3244
|
+
for (const [fieldName, srcCol] of srcFieldCols) {
|
|
3245
|
+
const targetCol = targetFieldCols.get(fieldName);
|
|
3246
|
+
if (!targetCol) {
|
|
3247
|
+
continue;
|
|
3248
|
+
}
|
|
3249
|
+
// Copy the whole stride-N block per row. Scalar / variable / `:count`
|
|
3250
|
+
// columns have arity 1 (single-element copy, byte-identical to the
|
|
3251
|
+
// prior `view[newRow] = view[oldRow]` form); fixed inline
|
|
3252
|
+
// `array<T,N>` / `buffer<N>` columns carry their N elements inline and
|
|
3253
|
+
// must migrate the entire block (feat-20260602).
|
|
3254
|
+
const arity = srcCol.arity;
|
|
3255
|
+
targetCol.view.set(
|
|
3256
|
+
srcCol.view.subarray(oldTableRow * arity, oldTableRow * arity + arity),
|
|
3257
|
+
newTableRow * arity,
|
|
3258
|
+
);
|
|
3259
|
+
}
|
|
3260
|
+
if (targetComponentStorage !== undefined) {
|
|
3261
|
+
copyComponentEpoch(
|
|
3262
|
+
srcComponentStorage.epochs,
|
|
3263
|
+
oldTableRow,
|
|
3264
|
+
targetComponentStorage.epochs,
|
|
3265
|
+
newTableRow,
|
|
3266
|
+
);
|
|
3267
|
+
}
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
const archetypeSwap = removeArchetypeRow(srcArch, oldArchetypeRow);
|
|
3271
|
+
if (archetypeSwap !== null) {
|
|
3272
|
+
const movedEntity = (srcTable.storage.get(componentId(EntityComponent))?.fields.get('self')
|
|
3273
|
+
?.view[archetypeSwap.movedTableRow] ?? 0) as EntityHandle;
|
|
3274
|
+
const movedRecord = this.records[entityIndex(movedEntity)];
|
|
3275
|
+
if (movedRecord?.generation === entityGeneration(movedEntity)) {
|
|
3276
|
+
movedRecord.archetypeRow = archetypeSwap.newRow;
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
const tableSwap = removeTableRow(srcTable, oldTableRow);
|
|
3280
|
+
if (tableSwap !== null) {
|
|
3281
|
+
const movedRecord = this.records[entityIndex(tableSwap.movedEntity)];
|
|
3282
|
+
if (movedRecord?.generation === entityGeneration(tableSwap.movedEntity)) {
|
|
3283
|
+
const movedArchetype = this.graph.archetypes[movedRecord.archetypeId];
|
|
3284
|
+
if (movedArchetype !== undefined) {
|
|
3285
|
+
movedArchetype.rows[movedRecord.archetypeRow] = tableSwap.newRow;
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
}
|
|
3289
|
+
|
|
3290
|
+
record.archetypeId = targetArch.id;
|
|
3291
|
+
record.archetypeRow = newArchetypeRow;
|
|
3292
|
+
}
|
|
3293
|
+
|
|
3294
|
+
private moveEntityArchetype(
|
|
3295
|
+
record: EntityRecord,
|
|
3296
|
+
srcArch: Archetype,
|
|
3297
|
+
targetArch: Archetype,
|
|
3298
|
+
): void {
|
|
3299
|
+
const table = this.table(srcArch);
|
|
3300
|
+
if (srcArch.tableId !== targetArch.tableId) {
|
|
3301
|
+
throw new Error('Logical archetype move requires a shared Table.');
|
|
3302
|
+
}
|
|
3303
|
+
const oldArchetypeRow = record.archetypeRow;
|
|
3304
|
+
const tableRow = srcArch.rows[oldArchetypeRow] ?? 0;
|
|
3305
|
+
const archetypeSwap = removeArchetypeRow(srcArch, oldArchetypeRow);
|
|
3306
|
+
if (archetypeSwap !== null) {
|
|
3307
|
+
const movedEntity = (table.storage.get(componentId(EntityComponent))?.fields.get('self')
|
|
3308
|
+
?.view[archetypeSwap.movedTableRow] ?? 0) as EntityHandle;
|
|
3309
|
+
const movedRecord = this.records[entityIndex(movedEntity)];
|
|
3310
|
+
if (movedRecord?.generation === entityGeneration(movedEntity)) {
|
|
3311
|
+
movedRecord.archetypeRow = archetypeSwap.newRow;
|
|
3312
|
+
}
|
|
3313
|
+
}
|
|
3314
|
+
record.archetypeId = targetArch.id;
|
|
3315
|
+
record.archetypeRow = appendArchetypeRow(targetArch, tableRow);
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
/**
|
|
3319
|
+
* Retire one live entity and any linked-spawn descendants. The complete
|
|
3320
|
+
* row/relationship/managed-data mutation stays on World so a failure after
|
|
3321
|
+
* the first write can poison this identity instead of crossing an extraction
|
|
3322
|
+
* owner boundary.
|
|
3323
|
+
*/
|
|
3324
|
+
private despawnEntity(entity: EntityHandle, internal: boolean): Result<void, EcsError> {
|
|
3325
|
+
const slot = entityIndex(entity);
|
|
3326
|
+
const generation = entityGeneration(entity);
|
|
3327
|
+
const record = this.records[slot];
|
|
3328
|
+
if (!this.recordIsLive(record, generation)) return ok(undefined);
|
|
3329
|
+
|
|
3330
|
+
const archetype = this.graph.archetypes[record.archetypeId];
|
|
3331
|
+
const linkedChildren =
|
|
3332
|
+
archetype === undefined ? [] : this.relationshipLinkedSpawnChildren(entity, archetype);
|
|
3333
|
+
let mutationStarted = false;
|
|
3334
|
+
try {
|
|
3335
|
+
if (archetype !== undefined) {
|
|
3336
|
+
const table = this.table(archetype);
|
|
3337
|
+
const archetypeRow = record.archetypeRow;
|
|
3338
|
+
const tableRow = archetype.rows[archetypeRow] ?? 0;
|
|
3339
|
+
mutationStarted = true;
|
|
3340
|
+
for (const component of archetype.components) {
|
|
3341
|
+
const role = relationshipRole(component);
|
|
3342
|
+
if (role?.kind === 'source' && !internal) {
|
|
3343
|
+
const oldValue = this.readRow(archetype, component, tableRow) as Record<
|
|
3344
|
+
string,
|
|
3345
|
+
unknown
|
|
3346
|
+
>;
|
|
3347
|
+
const relation = this.relationshipOnRemove(entity, component, oldValue);
|
|
3348
|
+
if (!relation.ok) {
|
|
3349
|
+
this.poisonAfterEntityMutation('World.despawn', relation.error);
|
|
3350
|
+
return relation;
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
this.releaseManagedRefsOnRow(archetype, component, tableRow);
|
|
3354
|
+
}
|
|
3355
|
+
for (const component of archetype.components) {
|
|
3356
|
+
if (component.storage !== 'sparse') continue;
|
|
3357
|
+
const sparse = this.graph.sparseTags.get(componentId(component));
|
|
3358
|
+
if (sparse !== undefined) removeSparseTag(sparse, entity);
|
|
3359
|
+
}
|
|
3360
|
+
const archetypeSwap = removeArchetypeRow(archetype, archetypeRow);
|
|
3361
|
+
if (archetypeSwap !== null) {
|
|
3362
|
+
const movedEntity = (table.storage.get(componentId(EntityComponent))?.fields.get('self')
|
|
3363
|
+
?.view[archetypeSwap.movedTableRow] ?? 0) as EntityHandle;
|
|
3364
|
+
const movedRecord = this.records[entityIndex(movedEntity)];
|
|
3365
|
+
if (movedRecord?.generation === entityGeneration(movedEntity)) {
|
|
3366
|
+
movedRecord.archetypeRow = archetypeSwap.newRow;
|
|
3367
|
+
}
|
|
3368
|
+
}
|
|
3369
|
+
const tableSwap = removeTableRow(table, tableRow);
|
|
3370
|
+
if (tableSwap !== null) {
|
|
3371
|
+
const movedRecord = this.records[entityIndex(tableSwap.movedEntity)];
|
|
3372
|
+
if (movedRecord?.generation === entityGeneration(tableSwap.movedEntity)) {
|
|
3373
|
+
const movedArchetype = this.graph.archetypes[movedRecord.archetypeId];
|
|
3374
|
+
if (movedArchetype !== undefined) {
|
|
3375
|
+
movedArchetype.rows[movedRecord.archetypeRow] = tableSwap.newRow;
|
|
3376
|
+
}
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
this.recordStructuralEvidence({ kind: 'despawn', entity });
|
|
3381
|
+
record.archetypeId = -1;
|
|
3382
|
+
record.archetypeRow = -1;
|
|
3383
|
+
record.generation += 1;
|
|
3384
|
+
if (!isRetiredSlot(record.generation)) this.freeIndices.push(slot);
|
|
3385
|
+
for (const child of linkedChildren) {
|
|
3386
|
+
const childResult = this.despawnEntity(child, true);
|
|
3387
|
+
if (!childResult.ok) return childResult;
|
|
3388
|
+
}
|
|
3389
|
+
this.advanceStructureEpoch();
|
|
3390
|
+
return ok(undefined);
|
|
3391
|
+
} catch (error) {
|
|
3392
|
+
if (mutationStarted) this.poisonAfterEntityMutation('World.despawn', error);
|
|
3393
|
+
throw error;
|
|
3394
|
+
}
|
|
3395
|
+
}
|
|
3396
|
+
|
|
3397
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
1286
3398
|
// Spawn
|
|
1287
3399
|
// ──────────────────────────────────────────────────────────────────────────
|
|
1288
3400
|
|
|
@@ -1311,26 +3423,15 @@ export class World {
|
|
|
1311
3423
|
}
|
|
1312
3424
|
): Result<EntityHandle, EcsError>;
|
|
1313
3425
|
spawn(...componentDatas: ComponentData[]): Result<EntityHandle, EcsError> {
|
|
3426
|
+
const poisoned = this.poisonedResult<EntityHandle>();
|
|
3427
|
+
if (poisoned !== undefined) return poisoned;
|
|
1314
3428
|
const target = componentDatas.find(
|
|
1315
3429
|
(data) =>
|
|
1316
3430
|
isRelationshipTarget(data.component) &&
|
|
1317
3431
|
this.relationshipTargetPayloadWrites(data.data as Record<string, unknown>),
|
|
1318
3432
|
);
|
|
1319
3433
|
if (target !== undefined) return this.relationshipTargetWriteError(target.component, 'spawn');
|
|
1320
|
-
return spawnCore(this, componentDatas
|
|
1321
|
-
}
|
|
1322
|
-
|
|
1323
|
-
/**
|
|
1324
|
-
* Core implementation of `spawn` with reentry guard.
|
|
1325
|
-
*
|
|
1326
|
-
* @param internal — `true` when called from relationship maintenance
|
|
1327
|
-
* (lazy mirror create or exclusive reparent).
|
|
1328
|
-
*/
|
|
1329
|
-
private internalspawnCore(
|
|
1330
|
-
componentDatas: ComponentData[],
|
|
1331
|
-
internal: boolean,
|
|
1332
|
-
): Result<EntityHandle, EcsError> {
|
|
1333
|
-
return spawnCore(this, componentDatas, internal);
|
|
3434
|
+
return spawnCore(this, componentDatas);
|
|
1334
3435
|
}
|
|
1335
3436
|
|
|
1336
3437
|
// ──────────────────────────────────────────────────────────────────────────
|
|
@@ -1353,11 +3454,15 @@ export class World {
|
|
|
1353
3454
|
* ```
|
|
1354
3455
|
*/
|
|
1355
3456
|
despawn(entity: EntityHandle): Result<void, EcsError> {
|
|
1356
|
-
|
|
3457
|
+
const poisoned = this.poisonedResult<void>();
|
|
3458
|
+
if (poisoned !== undefined) return poisoned;
|
|
3459
|
+
return this.despawnEntity(entity, false);
|
|
1357
3460
|
}
|
|
1358
3461
|
|
|
1359
3462
|
/** Despawn every live entity through the normal lifecycle and ref cleanup path. */
|
|
1360
3463
|
despawnAll(): Result<void, EcsError> {
|
|
3464
|
+
const poisoned = this.poisonedResult<void>();
|
|
3465
|
+
if (poisoned !== undefined) return poisoned;
|
|
1361
3466
|
const entities: EntityHandle[] = [];
|
|
1362
3467
|
for (let index = 0; index < this.records.length; index += 1) {
|
|
1363
3468
|
const record = this.records[index];
|
|
@@ -1372,21 +3477,9 @@ export class World {
|
|
|
1372
3477
|
return ok(undefined);
|
|
1373
3478
|
}
|
|
1374
3479
|
|
|
1375
|
-
/**
|
|
1376
|
-
* Core implementation of `despawn` with reentry guard.
|
|
1377
|
-
*
|
|
1378
|
-
* @param internal — `true` when called from within linkedSpawn cascade.
|
|
1379
|
-
* Nested despawn skips relationship pruning after the parent is retired;
|
|
1380
|
-
* the linkedSpawn collection still walks the subtree so grandchildren
|
|
1381
|
-
* cascade correctly (tweak-20260714 M2, R-6).
|
|
1382
|
-
*/
|
|
1383
|
-
private internaldespawnCore(entity: EntityHandle, internal: boolean): Result<void, EcsError> {
|
|
1384
|
-
return despawnCore(this, entity, internal);
|
|
1385
|
-
}
|
|
1386
|
-
|
|
1387
3480
|
// ──────────────────────────────────────────────────────────────────────────
|
|
1388
3481
|
// Hierarchy facade — lifecycle orchestration lives in world-entity-lifecycle.
|
|
1389
|
-
//
|
|
3482
|
+
// World owns the component storage and relationship mutation primitives.
|
|
1390
3483
|
// ──────────────────────────────────────────────────────────────────────────
|
|
1391
3484
|
|
|
1392
3485
|
addChild<S extends ComponentSchema>(
|
|
@@ -1395,6 +3488,8 @@ export class World {
|
|
|
1395
3488
|
component: Component<string, S>,
|
|
1396
3489
|
data: Partial<InputShapeOf<S>>,
|
|
1397
3490
|
): Result<void, EcsError> {
|
|
3491
|
+
const poisoned = this.poisonedResult<void>();
|
|
3492
|
+
if (poisoned !== undefined) return poisoned;
|
|
1398
3493
|
return worldAddChild(this, parent, child, component, data);
|
|
1399
3494
|
}
|
|
1400
3495
|
|
|
@@ -1403,6 +3498,8 @@ export class World {
|
|
|
1403
3498
|
child: EntityHandle,
|
|
1404
3499
|
component: Component<string, S>,
|
|
1405
3500
|
): Result<void, EcsError> {
|
|
3501
|
+
const poisoned = this.poisonedResult<void>();
|
|
3502
|
+
if (poisoned !== undefined) return poisoned;
|
|
1406
3503
|
return worldRemoveChild(this, parent, child, component);
|
|
1407
3504
|
}
|
|
1408
3505
|
|
|
@@ -1412,6 +3509,8 @@ export class World {
|
|
|
1412
3509
|
component: Component<string, S>,
|
|
1413
3510
|
data: Partial<InputShapeOf<S>>,
|
|
1414
3511
|
): Result<void, EcsError> {
|
|
3512
|
+
const poisoned = this.poisonedResult<void>();
|
|
3513
|
+
if (poisoned !== undefined) return poisoned;
|
|
1415
3514
|
return worldReparent(this, child, newParent, component, data);
|
|
1416
3515
|
}
|
|
1417
3516
|
|
|
@@ -1423,21 +3522,3 @@ export class World {
|
|
|
1423
3522
|
return worldIterDescendants(this, entity);
|
|
1424
3523
|
}
|
|
1425
3524
|
}
|
|
1426
|
-
|
|
1427
|
-
/** Freeze the detached POD produced by World.inspect(). */
|
|
1428
|
-
function detachWorldInspection<T extends object>(snapshot: T): Readonly<T> {
|
|
1429
|
-
return freezeInspection(snapshot);
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
|
-
function freezeInspection<T>(value: T): Readonly<T> {
|
|
1433
|
-
if (value === null || (typeof value !== 'object' && typeof value !== 'function')) {
|
|
1434
|
-
return value as Readonly<T>;
|
|
1435
|
-
}
|
|
1436
|
-
for (const key of Reflect.ownKeys(value as object)) {
|
|
1437
|
-
const child = (value as Record<PropertyKey, unknown>)[key];
|
|
1438
|
-
if (child !== null && (typeof child === 'object' || typeof child === 'function')) {
|
|
1439
|
-
freezeInspection(child);
|
|
1440
|
-
}
|
|
1441
|
-
}
|
|
1442
|
-
return Object.freeze(value) as Readonly<T>;
|
|
1443
|
-
}
|