@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
|
@@ -1,1769 +0,0 @@
|
|
|
1
|
-
// @forgeax/engine-ecs — world-component-access: component storage and access.
|
|
2
|
-
//
|
|
3
|
-
// This module owns component rows, managed storage, array operations, archetype
|
|
4
|
-
// migration, and the relationship callbacks that mutate component storage. World
|
|
5
|
-
// remains the typed facade and supplies one narrow per-World state capability.
|
|
6
|
-
|
|
7
|
-
import { err, isRetiredSlot, ok, type Result, unwrapHandle } from '@forgeax/engine-types';
|
|
8
|
-
import type { BufferPool } from './buffer-pool';
|
|
9
|
-
import {
|
|
10
|
-
bufferFieldByteLength,
|
|
11
|
-
type Component,
|
|
12
|
-
type ComponentSchema,
|
|
13
|
-
componentId,
|
|
14
|
-
componentSchema,
|
|
15
|
-
type InputShapeOf,
|
|
16
|
-
isEntityField,
|
|
17
|
-
isManagedBufferField,
|
|
18
|
-
isManagedField,
|
|
19
|
-
type ManagedArrayElementType,
|
|
20
|
-
type ManagedArrayElementValue,
|
|
21
|
-
type ShapeOf,
|
|
22
|
-
TYPE_METADATA,
|
|
23
|
-
} from './component';
|
|
24
|
-
import { fillComponentDefaults, validateComponentDataKeys } from './component-default-fallback';
|
|
25
|
-
import { componentDefinition, expandComponentRequirements } from './component-schema';
|
|
26
|
-
import { validateManagedArrayValues, validateSharedFieldValues } from './component-value-validate';
|
|
27
|
-
import { Entity as EntityComponent } from './entity';
|
|
28
|
-
import {
|
|
29
|
-
ENTITY_MAX_INDEX,
|
|
30
|
-
ENTITY_NULL_RAW,
|
|
31
|
-
type EntityHandle,
|
|
32
|
-
encodeEntity,
|
|
33
|
-
entityGeneration,
|
|
34
|
-
entityIndex,
|
|
35
|
-
} from './entity-handle';
|
|
36
|
-
import {
|
|
37
|
-
ComponentAlreadyPresentError,
|
|
38
|
-
ComponentNotPresentError,
|
|
39
|
-
EntityIndexOverflowError,
|
|
40
|
-
FixedSizeMismatchError,
|
|
41
|
-
ManagedBufferOutOfBoundsError,
|
|
42
|
-
RelationshipSelfCycleError,
|
|
43
|
-
RelationshipTargetReadonlyError,
|
|
44
|
-
RemoveEssentialComponentError,
|
|
45
|
-
StaleEntityError,
|
|
46
|
-
validateEnumFieldValues,
|
|
47
|
-
validateNumericFieldValues,
|
|
48
|
-
} from './errors';
|
|
49
|
-
import {
|
|
50
|
-
isRelationshipTarget,
|
|
51
|
-
RelationshipIndex,
|
|
52
|
-
relationshipMirror,
|
|
53
|
-
relationshipRole,
|
|
54
|
-
} from './relationship-index';
|
|
55
|
-
import type { SharedRefStore } from './shared-ref-store';
|
|
56
|
-
import { type Archetype, appendArchetypeRow } from './storage/archetype';
|
|
57
|
-
import {
|
|
58
|
-
type ArchetypeGraph,
|
|
59
|
-
getAddEdge,
|
|
60
|
-
getOrCreateArchetype,
|
|
61
|
-
getRemoveEdge,
|
|
62
|
-
getTable,
|
|
63
|
-
} from './storage/archetype-graph';
|
|
64
|
-
import { removeSparseTag } from './storage/change-detection';
|
|
65
|
-
import { arrayCountColumnName, type FieldView, normalizeBufferWrite } from './storage/column';
|
|
66
|
-
import type { StructuralEvidenceInput } from './storage/structural-evidence';
|
|
67
|
-
import { appendTableRow, type Table } from './storage/table';
|
|
68
|
-
import type { UniqueRefStore } from './unique-ref-store';
|
|
69
|
-
import type { ComponentData, EcsError, EntityRecord } from './world';
|
|
70
|
-
import { ComponentStorage } from './world-component-storage';
|
|
71
|
-
|
|
72
|
-
type ErrorContext = { readonly systemName: string };
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Prepared target-side work for one relationship source write.
|
|
76
|
-
*
|
|
77
|
-
* A reservation is deliberately kept outside the World columns until the
|
|
78
|
-
* source operation is ready to commit. That lets direct, spawn, and deferred
|
|
79
|
-
* source writes observe BufferPool failures before adding a mirror component
|
|
80
|
-
* or advancing any ECS epoch.
|
|
81
|
-
*/
|
|
82
|
-
interface RelationshipPreparation {
|
|
83
|
-
readonly target: EntityHandle;
|
|
84
|
-
readonly mirror: Component;
|
|
85
|
-
readonly fieldName: string;
|
|
86
|
-
readonly mirrorPresent: boolean;
|
|
87
|
-
reservedSlotId: number | undefined;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
type ArrayFieldsOf<S extends ComponentSchema> = {
|
|
91
|
-
[K in keyof S]: S[K] extends
|
|
92
|
-
| `array<${ManagedArrayElementType}>`
|
|
93
|
-
| `array<${ManagedArrayElementType}, ${number}>`
|
|
94
|
-
? K
|
|
95
|
-
: never;
|
|
96
|
-
}[keyof S];
|
|
97
|
-
|
|
98
|
-
type ArrayFieldElementValue<
|
|
99
|
-
S extends ComponentSchema,
|
|
100
|
-
K extends keyof S,
|
|
101
|
-
> = S[K] extends `array<${infer Elem extends ManagedArrayElementType}>`
|
|
102
|
-
? ManagedArrayElementValue<Elem>
|
|
103
|
-
: S[K] extends `array<${infer Elem extends ManagedArrayElementType}, ${number}>`
|
|
104
|
-
? ManagedArrayElementValue<Elem>
|
|
105
|
-
: never;
|
|
106
|
-
|
|
107
|
-
function relationshipPayloadWrites(data: Readonly<Record<string, unknown>>): boolean {
|
|
108
|
-
return Object.values(data).some((value) => {
|
|
109
|
-
if (Array.isArray(value)) return value.length > 0;
|
|
110
|
-
if (ArrayBuffer.isView(value)) return value.byteLength > 0;
|
|
111
|
-
return true;
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Relationship target arrays are public read-only projections. Keep the
|
|
117
|
-
* detached-copy rule at the World.get boundary while internal relationship
|
|
118
|
-
* owners continue to borrow the live storage through `_getArrayView`.
|
|
119
|
-
*/
|
|
120
|
-
function detachRelationshipTargetArray(value: unknown): unknown {
|
|
121
|
-
return value instanceof Uint32Array ? value.slice() : value;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export interface ComponentAccessState {
|
|
125
|
-
readonly graph: ArchetypeGraph;
|
|
126
|
-
readonly records: EntityRecord[];
|
|
127
|
-
readonly freeIndices: number[];
|
|
128
|
-
readonly bufferPool: BufferPool;
|
|
129
|
-
readonly uniqueRefs: UniqueRefStore;
|
|
130
|
-
readonly sharedRefs: SharedRefStore;
|
|
131
|
-
readonly relationshipIndexes: Map<number, RelationshipIndex>;
|
|
132
|
-
readonly markComponentAdded: (entity: EntityHandle, componentId: number) => void;
|
|
133
|
-
readonly markComponentsAdded: (entity: EntityHandle, componentIds: readonly number[]) => void;
|
|
134
|
-
readonly markComponentChanged: (entity: EntityHandle, componentId: number) => void;
|
|
135
|
-
readonly markStructureChanged: () => void;
|
|
136
|
-
readonly recordStructuralEvidence: (evidence: StructuralEvidenceInput) => void;
|
|
137
|
-
routeError(err: unknown, ctx: ErrorContext): void;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export class WorldComponentAccess {
|
|
141
|
-
private readonly storage: ComponentStorage;
|
|
142
|
-
|
|
143
|
-
constructor(private readonly state: ComponentAccessState) {
|
|
144
|
-
this.storage = new ComponentStorage(state);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
private get graph(): ArchetypeGraph {
|
|
148
|
-
return this.state.graph;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
private get records(): EntityRecord[] {
|
|
152
|
-
return this.state.records;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
private table(archetype: Archetype): Table {
|
|
156
|
-
return getTable(this.graph, archetype.tableId);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
private tableRow(record: EntityRecord): number {
|
|
160
|
-
return this.graph.archetypes[record.archetypeId]?.rows[record.archetypeRow] ?? -1;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
private get freeIndices(): number[] {
|
|
164
|
-
return this.state.freeIndices;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
private get bufferPool(): BufferPool {
|
|
168
|
-
return this.state.bufferPool;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
private get uniqueRefs(): UniqueRefStore {
|
|
172
|
-
return this.state.uniqueRefs;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
private routeError(err: unknown, ctx: ErrorContext): void {
|
|
176
|
-
this.state.routeError(err, ctx);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
private relationshipIndex(component: Component): RelationshipIndex | undefined {
|
|
180
|
-
if (relationshipRole(component)?.kind !== 'source') return undefined;
|
|
181
|
-
let index = this.state.relationshipIndexes.get(componentId(component));
|
|
182
|
-
if (index === undefined) {
|
|
183
|
-
index = new RelationshipIndex();
|
|
184
|
-
this.state.relationshipIndexes.set(componentId(component), index);
|
|
185
|
-
}
|
|
186
|
-
return index;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Read the World-owned materialized target array without a public snapshot.
|
|
191
|
-
* This is an internal relationship-owner path: it borrows the live
|
|
192
|
-
* `Uint32Array` so attach/detach stays zero-copy. Public `World.get` detaches
|
|
193
|
-
* target arrays before returning them to callers.
|
|
194
|
-
*/
|
|
195
|
-
relationshipTargetEntries(source: Component, target: EntityHandle): readonly EntityHandle[] {
|
|
196
|
-
const role = relationshipRole(source);
|
|
197
|
-
if (role?.kind !== 'source') return [];
|
|
198
|
-
const mirror = relationshipMirror(source);
|
|
199
|
-
if (mirror === undefined) return [];
|
|
200
|
-
const entries = this._getArrayView(target, mirror, role.targetField);
|
|
201
|
-
return entries === undefined ? [] : (entries as unknown as readonly EntityHandle[]);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
private markComponentAdded(entity: EntityHandle, component: Component): void {
|
|
205
|
-
this.state.markComponentAdded(entity, componentId(component));
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
private markComponentChanged(entity: EntityHandle, component: Component): void {
|
|
209
|
-
this.state.markComponentChanged(entity, componentId(component));
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
private markStructureChanged(): void {
|
|
213
|
-
this.state.markStructureChanged();
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
relationshipTargetEntity(
|
|
217
|
-
component: Component,
|
|
218
|
-
value: Record<string, unknown>,
|
|
219
|
-
): EntityHandle | null {
|
|
220
|
-
for (const [fieldName, fieldType] of Object.entries(componentSchema(component))) {
|
|
221
|
-
if (isEntityField(fieldType)) {
|
|
222
|
-
const raw = value[fieldName];
|
|
223
|
-
if (raw === null || raw === undefined) return null;
|
|
224
|
-
const asNum = raw as number;
|
|
225
|
-
if (asNum === ENTITY_NULL_RAW) return null;
|
|
226
|
-
return asNum as EntityHandle;
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
return null;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
private preflightComponentFieldValues(
|
|
233
|
-
holder: EntityHandle | null,
|
|
234
|
-
componentData: ComponentData,
|
|
235
|
-
): Result<void, EcsError> {
|
|
236
|
-
const data = componentData.data as Record<string, unknown>;
|
|
237
|
-
const arrayError = validateManagedArrayValues(componentData.component, data);
|
|
238
|
-
if (arrayError !== null) return err(arrayError as unknown as EcsError);
|
|
239
|
-
const sharedError = validateSharedFieldValues(componentData.component, data);
|
|
240
|
-
if (sharedError !== null) return err(sharedError as unknown as EcsError);
|
|
241
|
-
const numericError = validateNumericFieldValues(
|
|
242
|
-
componentData.component,
|
|
243
|
-
data,
|
|
244
|
-
holder === null ? undefined : (holder as number),
|
|
245
|
-
);
|
|
246
|
-
if (numericError !== null) return err(numericError as unknown as EcsError);
|
|
247
|
-
return ok(undefined);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* Validate one structural component payload without touching archetypes,
|
|
252
|
-
* columns, relationship mirrors, epochs, or managed-reference stores.
|
|
253
|
-
* CommandBuffer uses this same owner-level gate as the direct World facade;
|
|
254
|
-
* the optional pending set lets a batch refer to an entity reserved earlier
|
|
255
|
-
* in that batch without mistaking it for a stale live handle.
|
|
256
|
-
*/
|
|
257
|
-
preflightComponentData(
|
|
258
|
-
holder: EntityHandle | null,
|
|
259
|
-
componentData: ComponentData,
|
|
260
|
-
pendingEntities?: ReadonlySet<number>,
|
|
261
|
-
unavailableEntities?: ReadonlySet<number>,
|
|
262
|
-
): Result<void, EcsError> {
|
|
263
|
-
const data = componentData.data as Record<string, unknown>;
|
|
264
|
-
const keyError = validateComponentDataKeys(componentData.component, data);
|
|
265
|
-
if (keyError !== null) return err(keyError as unknown as EcsError);
|
|
266
|
-
const valuePreflight = this.preflightComponentFieldValues(holder, componentData);
|
|
267
|
-
if (!valuePreflight.ok) return valuePreflight;
|
|
268
|
-
if (isRelationshipTarget(componentData.component) && relationshipPayloadWrites(data)) {
|
|
269
|
-
return err(new RelationshipTargetReadonlyError(componentData.component.name, 'command'));
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const filled = fillComponentDefaults(componentData.component, data);
|
|
273
|
-
const enumError = validateEnumFieldValues(
|
|
274
|
-
componentData.component,
|
|
275
|
-
filled,
|
|
276
|
-
holder === null ? undefined : (holder as number),
|
|
277
|
-
);
|
|
278
|
-
if (enumError !== null) return err(enumError as unknown as EcsError);
|
|
279
|
-
|
|
280
|
-
const role = relationshipRole(componentData.component as Component);
|
|
281
|
-
if (role?.kind !== 'source') return ok(undefined);
|
|
282
|
-
const target = this.relationshipTargetEntity(componentData.component as Component, filled);
|
|
283
|
-
if (target === null) return ok(undefined);
|
|
284
|
-
|
|
285
|
-
const targetRaw = target as unknown as number;
|
|
286
|
-
if (unavailableEntities?.has(targetRaw) === true) {
|
|
287
|
-
const targetRecord = this.records[entityIndex(target)];
|
|
288
|
-
return err(
|
|
289
|
-
new StaleEntityError(target as number, entityIndex(target), entityGeneration(target), {
|
|
290
|
-
operation: 'relationship-insert',
|
|
291
|
-
component: componentData.component.name,
|
|
292
|
-
expectedGeneration: entityGeneration(target),
|
|
293
|
-
actualGeneration: targetRecord?.generation ?? -1,
|
|
294
|
-
}),
|
|
295
|
-
);
|
|
296
|
-
}
|
|
297
|
-
const targetIsPending = pendingEntities?.has(targetRaw) === true;
|
|
298
|
-
const targetRecord = this.records[entityIndex(target)];
|
|
299
|
-
const actualGeneration = targetRecord?.generation ?? -1;
|
|
300
|
-
const targetLive = this.recordIsLive(targetRecord, entityGeneration(target));
|
|
301
|
-
// A source edge is never allowed to publish a dangling target. Pending
|
|
302
|
-
// targets are the only exception, and are admitted only for the deferred
|
|
303
|
-
// command batch that reserved that exact handle.
|
|
304
|
-
if (!targetIsPending && !targetLive) {
|
|
305
|
-
return err(
|
|
306
|
-
new StaleEntityError(target as number, entityIndex(target), entityGeneration(target), {
|
|
307
|
-
operation: 'relationship-insert',
|
|
308
|
-
component: componentData.component.name,
|
|
309
|
-
expectedGeneration: entityGeneration(target),
|
|
310
|
-
actualGeneration,
|
|
311
|
-
}),
|
|
312
|
-
);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// A pending holder has no row to walk yet. Once materialized, its target
|
|
316
|
-
// is still checked by the same source-side relationship callback.
|
|
317
|
-
if (holder === null || pendingEntities?.has(holder as unknown as number) === true) {
|
|
318
|
-
return ok(undefined);
|
|
319
|
-
}
|
|
320
|
-
const roleAllowsSelf = role?.kind === 'source' && role.allowSelf;
|
|
321
|
-
if (holder === target && !roleAllowsSelf) {
|
|
322
|
-
return err(
|
|
323
|
-
new RelationshipSelfCycleError(
|
|
324
|
-
componentData.component.name,
|
|
325
|
-
holder as number,
|
|
326
|
-
target as number,
|
|
327
|
-
),
|
|
328
|
-
);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
const cycleHit =
|
|
332
|
-
holder === target && roleAllowsSelf
|
|
333
|
-
? null
|
|
334
|
-
: this.relationshipCycleHit(componentData.component as Component, target, holder);
|
|
335
|
-
if (cycleHit !== null) {
|
|
336
|
-
return err(
|
|
337
|
-
new RelationshipSelfCycleError(
|
|
338
|
-
componentData.component.name,
|
|
339
|
-
holder as number,
|
|
340
|
-
cycleHit as number,
|
|
341
|
-
),
|
|
342
|
-
);
|
|
343
|
-
}
|
|
344
|
-
return ok(undefined);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
private relationshipCycleHit(
|
|
348
|
-
holderComponent: Component,
|
|
349
|
-
start: EntityHandle,
|
|
350
|
-
holder: EntityHandle,
|
|
351
|
-
): EntityHandle | null {
|
|
352
|
-
const visited = new Set<number>();
|
|
353
|
-
let current = start;
|
|
354
|
-
while (true) {
|
|
355
|
-
if (current === holder) return current;
|
|
356
|
-
const raw = current as unknown as number;
|
|
357
|
-
if (visited.has(raw)) return null;
|
|
358
|
-
visited.add(raw);
|
|
359
|
-
const record = this.records[entityIndex(current)];
|
|
360
|
-
if (!this.recordIsLive(record, entityGeneration(current))) return null;
|
|
361
|
-
const archetype = this.graph.archetypes[record.archetypeId];
|
|
362
|
-
if (
|
|
363
|
-
!archetype?.components.some(
|
|
364
|
-
(candidate) => componentId(candidate) === componentId(holderComponent),
|
|
365
|
-
)
|
|
366
|
-
) {
|
|
367
|
-
return null;
|
|
368
|
-
}
|
|
369
|
-
const value = this.readRow(archetype, holderComponent, this.tableRow(record)) as Record<
|
|
370
|
-
string,
|
|
371
|
-
unknown
|
|
372
|
-
>;
|
|
373
|
-
const next = this.relationshipTargetEntity(holderComponent, value);
|
|
374
|
-
if (next === null) return null;
|
|
375
|
-
current = next;
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/**
|
|
380
|
-
* Reserve target-side relationship capacity without touching World columns.
|
|
381
|
-
*
|
|
382
|
-
* A missing mirror reserves its first slot before the mirror archetype is
|
|
383
|
-
* created. An existing mirror either reserves an empty slot or grows its
|
|
384
|
-
* existing BufferPool slot; both failure paths return before any component,
|
|
385
|
-
* relationship index, or ECS epoch changes.
|
|
386
|
-
*/
|
|
387
|
-
prepareRelationshipInsert(
|
|
388
|
-
component: Component,
|
|
389
|
-
value: Record<string, unknown>,
|
|
390
|
-
): Result<RelationshipPreparation | undefined, EcsError> {
|
|
391
|
-
const role = relationshipRole(component);
|
|
392
|
-
if (role?.kind !== 'source') return ok(undefined);
|
|
393
|
-
const target = this.relationshipTargetEntity(component, value);
|
|
394
|
-
if (target === null) return ok(undefined);
|
|
395
|
-
const mirror = relationshipMirror(component);
|
|
396
|
-
if (mirror === undefined) return ok(undefined);
|
|
397
|
-
const targetRec = this.records[entityIndex(target)];
|
|
398
|
-
const actualGeneration = targetRec?.generation ?? -1;
|
|
399
|
-
if (!this.recordIsLive(targetRec, entityGeneration(target))) {
|
|
400
|
-
return err(
|
|
401
|
-
new StaleEntityError(target as number, entityIndex(target), entityGeneration(target), {
|
|
402
|
-
operation: 'relationship-insert',
|
|
403
|
-
component: component.name,
|
|
404
|
-
expectedGeneration: entityGeneration(target),
|
|
405
|
-
actualGeneration,
|
|
406
|
-
}),
|
|
407
|
-
);
|
|
408
|
-
}
|
|
409
|
-
const targetArch = this.graph.archetypes[targetRec.archetypeId];
|
|
410
|
-
const mirrorLocalId = componentId(mirror);
|
|
411
|
-
const mirrorPresent =
|
|
412
|
-
targetArch?.components.some((candidate) => componentId(candidate) === mirrorLocalId) ?? false;
|
|
413
|
-
const fieldName = role.targetField;
|
|
414
|
-
const arrayMeta = componentDefinition(mirror).fields[fieldName]?.arrayMeta;
|
|
415
|
-
if (arrayMeta === undefined) {
|
|
416
|
-
return err(new ComponentNotPresentError(target as number, mirror.name));
|
|
417
|
-
}
|
|
418
|
-
const meta = TYPE_METADATA[arrayMeta.elementType];
|
|
419
|
-
if (meta?.byteSize === undefined) {
|
|
420
|
-
return err(new ComponentNotPresentError(target as number, mirror.name));
|
|
421
|
-
}
|
|
422
|
-
let currentLength = 0;
|
|
423
|
-
let slotId = 0;
|
|
424
|
-
if (mirrorPresent) {
|
|
425
|
-
currentLength = this._getArrayLength(target, mirror, fieldName) ?? 0;
|
|
426
|
-
const fieldCols = this.table(targetArch as Archetype).storage.get(mirrorLocalId)?.fields;
|
|
427
|
-
const column = fieldCols?.get(fieldName);
|
|
428
|
-
if (column === undefined) {
|
|
429
|
-
return err(new ComponentNotPresentError(target as number, mirror.name));
|
|
430
|
-
}
|
|
431
|
-
const row = this.tableRow(targetRec);
|
|
432
|
-
slotId = column.view[row] as number;
|
|
433
|
-
}
|
|
434
|
-
const requiredBytes = (currentLength + 1) * meta.byteSize;
|
|
435
|
-
if (!Number.isSafeInteger(requiredBytes)) {
|
|
436
|
-
return err(new ManagedBufferOutOfBoundsError(requiredBytes, Number.MAX_SAFE_INTEGER));
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
const preparation: RelationshipPreparation = {
|
|
440
|
-
target,
|
|
441
|
-
mirror,
|
|
442
|
-
fieldName,
|
|
443
|
-
mirrorPresent,
|
|
444
|
-
reservedSlotId: undefined,
|
|
445
|
-
};
|
|
446
|
-
if (!mirrorPresent || slotId === 0) {
|
|
447
|
-
const allocated = this.bufferPool.alloc(requiredBytes);
|
|
448
|
-
if (!allocated.ok) return allocated;
|
|
449
|
-
preparation.reservedSlotId = allocated.value.id;
|
|
450
|
-
} else if (this.bufferPool.view(slotId).byteLength < requiredBytes) {
|
|
451
|
-
const grown = this.bufferPool.grow(slotId, requiredBytes);
|
|
452
|
-
if (!grown.ok) return grown;
|
|
453
|
-
}
|
|
454
|
-
return ok(preparation);
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/** Release a target-capacity reservation that did not reach commit. */
|
|
458
|
-
releaseRelationshipPreparation(preparation: RelationshipPreparation | undefined): void {
|
|
459
|
-
if (preparation === undefined || preparation.reservedSlotId === undefined) return;
|
|
460
|
-
const slotId = preparation.reservedSlotId;
|
|
461
|
-
this.bufferPool.release(slotId);
|
|
462
|
-
preparation.reservedSlotId = undefined;
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
/** Install a reserved slot after the target mirror archetype exists. */
|
|
466
|
-
private installRelationshipPreparation(
|
|
467
|
-
preparation: RelationshipPreparation,
|
|
468
|
-
): Result<void, EcsError> {
|
|
469
|
-
const record = this.lookupAlive(
|
|
470
|
-
preparation.target,
|
|
471
|
-
'relationship-capacity',
|
|
472
|
-
preparation.mirror.name,
|
|
473
|
-
);
|
|
474
|
-
if (!record.ok) return record;
|
|
475
|
-
const arch = this.graph.archetypes[record.value.archetypeId];
|
|
476
|
-
if (arch === undefined) {
|
|
477
|
-
return err(
|
|
478
|
-
new ComponentNotPresentError(preparation.target as number, preparation.mirror.name),
|
|
479
|
-
);
|
|
480
|
-
}
|
|
481
|
-
const fieldCols = this.table(arch).storage.get(componentId(preparation.mirror))?.fields;
|
|
482
|
-
const column = fieldCols?.get(preparation.fieldName);
|
|
483
|
-
if (column === undefined) {
|
|
484
|
-
return err(
|
|
485
|
-
new ComponentNotPresentError(preparation.target as number, preparation.mirror.name),
|
|
486
|
-
);
|
|
487
|
-
}
|
|
488
|
-
const slotId = preparation.reservedSlotId;
|
|
489
|
-
if (slotId !== undefined) {
|
|
490
|
-
column.view[this.tableRow(record.value)] = slotId;
|
|
491
|
-
preparation.reservedSlotId = undefined;
|
|
492
|
-
}
|
|
493
|
-
return ok(undefined);
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
/** Append `holder` to the materialized target list. */
|
|
497
|
-
relationshipOnInsert(
|
|
498
|
-
holder: EntityHandle,
|
|
499
|
-
component: Component,
|
|
500
|
-
value: Record<string, unknown>,
|
|
501
|
-
preparation?: RelationshipPreparation,
|
|
502
|
-
): Result<void, EcsError> {
|
|
503
|
-
const role = relationshipRole(component);
|
|
504
|
-
if (role?.kind !== 'source') return ok(undefined);
|
|
505
|
-
const target = this.relationshipTargetEntity(component, value);
|
|
506
|
-
if (target === null) return ok(undefined);
|
|
507
|
-
const mirror = relationshipMirror(component);
|
|
508
|
-
/* istanbul ignore next -- defineComponent relationship validation guarantees mirror exists */
|
|
509
|
-
if (mirror === undefined) return ok(undefined);
|
|
510
|
-
|
|
511
|
-
let prepared = preparation;
|
|
512
|
-
if (prepared === undefined) {
|
|
513
|
-
const preparedResult = this.prepareRelationshipInsert(component, value);
|
|
514
|
-
if (!preparedResult.ok) return preparedResult;
|
|
515
|
-
prepared = preparedResult.value;
|
|
516
|
-
}
|
|
517
|
-
if (prepared === undefined) return ok(undefined);
|
|
518
|
-
|
|
519
|
-
// Lazy-create the mirror component on the target when absent (D-3c).
|
|
520
|
-
const targetSlot = entityIndex(target);
|
|
521
|
-
const targetRec = this.records[targetSlot];
|
|
522
|
-
const actualGeneration = targetRec?.generation ?? -1;
|
|
523
|
-
if (!this.recordIsLive(targetRec, entityGeneration(target))) {
|
|
524
|
-
return err(
|
|
525
|
-
new StaleEntityError(target as number, targetSlot, entityGeneration(target), {
|
|
526
|
-
operation: 'relationship-insert',
|
|
527
|
-
component: component.name,
|
|
528
|
-
expectedGeneration: entityGeneration(target),
|
|
529
|
-
actualGeneration: actualGeneration,
|
|
530
|
-
}),
|
|
531
|
-
);
|
|
532
|
-
}
|
|
533
|
-
const targetArch = this.graph.archetypes[targetRec.archetypeId];
|
|
534
|
-
const mirrorLocalId = componentId(mirror);
|
|
535
|
-
const hasMirror =
|
|
536
|
-
targetArch?.components.some((component) => componentId(component) === mirrorLocalId) ?? false;
|
|
537
|
-
if (!hasMirror) {
|
|
538
|
-
const added = this._addComponentCore(
|
|
539
|
-
target,
|
|
540
|
-
{
|
|
541
|
-
component: mirror,
|
|
542
|
-
data: {} as Partial<ShapeOf<ComponentSchema>>,
|
|
543
|
-
},
|
|
544
|
-
true,
|
|
545
|
-
false,
|
|
546
|
-
true,
|
|
547
|
-
);
|
|
548
|
-
if (!added.ok) {
|
|
549
|
-
this.releaseRelationshipPreparation(prepared);
|
|
550
|
-
return added;
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
const installed = this.installRelationshipPreparation(prepared);
|
|
554
|
-
if (!installed.ok) {
|
|
555
|
-
this.releaseRelationshipPreparation(prepared);
|
|
556
|
-
return installed;
|
|
557
|
-
}
|
|
558
|
-
const targetEntries = this.relationshipTargetEntries(component, target);
|
|
559
|
-
const slot = targetEntries.length;
|
|
560
|
-
const mirrored = this.appendArrayElement(
|
|
561
|
-
target,
|
|
562
|
-
mirror as Component<string, ComponentSchema>,
|
|
563
|
-
role.targetField as never,
|
|
564
|
-
holder as never,
|
|
565
|
-
);
|
|
566
|
-
if (!mirrored.ok) {
|
|
567
|
-
this.releaseRelationshipPreparation(prepared);
|
|
568
|
-
return mirrored;
|
|
569
|
-
}
|
|
570
|
-
this.relationshipIndex(component)?.attach(holder, target, slot);
|
|
571
|
-
return ok(undefined);
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
/** Remove `holder` from the materialized target list. */
|
|
575
|
-
relationshipOnRemove(
|
|
576
|
-
holder: EntityHandle,
|
|
577
|
-
component: Component,
|
|
578
|
-
oldValue: Record<string, unknown>,
|
|
579
|
-
): Result<void, EcsError> {
|
|
580
|
-
const role = relationshipRole(component);
|
|
581
|
-
if (role?.kind !== 'source') return ok(undefined);
|
|
582
|
-
const target = this.relationshipTargetEntity(component, oldValue);
|
|
583
|
-
if (target === null) return ok(undefined);
|
|
584
|
-
const mirror = relationshipMirror(component);
|
|
585
|
-
/* istanbul ignore next -- defineComponent relationship validation guarantees mirror exists */
|
|
586
|
-
if (mirror === undefined) return ok(undefined);
|
|
587
|
-
const targetSlot = entityIndex(target);
|
|
588
|
-
const targetRec = this.records[targetSlot];
|
|
589
|
-
if (!this.recordIsLive(targetRec, entityGeneration(target))) return ok(undefined);
|
|
590
|
-
|
|
591
|
-
const index = this.relationshipIndex(component);
|
|
592
|
-
if (index === undefined) return ok(undefined);
|
|
593
|
-
const slot = index.slotOf(holder);
|
|
594
|
-
if (slot === undefined || index.targetOf(holder) !== target) return ok(undefined);
|
|
595
|
-
const mirrored = this.removeArrayElementAt(
|
|
596
|
-
target,
|
|
597
|
-
mirror as Component<string, ComponentSchema>,
|
|
598
|
-
role.targetField as never,
|
|
599
|
-
slot,
|
|
600
|
-
);
|
|
601
|
-
if (!mirrored.ok) return mirrored;
|
|
602
|
-
index.detach(holder);
|
|
603
|
-
if (mirrored.value !== undefined) index.updateSlot(mirrored.value, target, slot);
|
|
604
|
-
return ok(undefined);
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
/**
|
|
608
|
-
* Read component data from an entity.
|
|
609
|
-
*
|
|
610
|
-
* **Public array contract:** relationship target `array<entity>` fields are
|
|
611
|
-
* detached `Uint32Array` copies. Mutating that returned array cannot alter
|
|
612
|
-
* the materialized target, relationship index, or source. Other array
|
|
613
|
-
* fields retain the existing transient view contract: fixed-capacity
|
|
614
|
-
* `array<T,N>` and `buffer<N>` fields alias the archetype column buffer
|
|
615
|
-
* directly, while variable managed arrays alias their BufferPool slot. Those
|
|
616
|
-
* views are valid only until the next structural change (`spawn` /
|
|
617
|
-
* `despawn` / `addComponent` / `removeComponent`); callers must re-fetch
|
|
618
|
-
* `world.get(e, C)` on every access. Internal owners use `readRow` and
|
|
619
|
-
* `_getArrayView` directly and retain zero-copy access.
|
|
620
|
-
*
|
|
621
|
-
* @returns `Result<ShapeOf<S>, EcsError>` —
|
|
622
|
-
* `ok(ShapeOf<S>)` on success;
|
|
623
|
-
* `err(StaleEntityError)` (`.code = 'stale-entity'`) if entity is dead;
|
|
624
|
-
* `err(ComponentNotPresentError)` (`.code = 'component-not-present'`) if
|
|
625
|
-
* the entity does not have the component (a never-present component on
|
|
626
|
-
* this entity degrades to the same `component-not-present` path — there is
|
|
627
|
-
* no separate "not registered" failure; components are global at
|
|
628
|
-
* `defineComponent` time).
|
|
629
|
-
*
|
|
630
|
-
* @example
|
|
631
|
-
* ```ts
|
|
632
|
-
* const Position = defineComponent('Position', { x: 'f32', y: 'f32' });
|
|
633
|
-
* const world = new World();
|
|
634
|
-
* const e = world.spawn({ component: Position, data: { x: 1, y: 2 } }).unwrap();
|
|
635
|
-
* const r = world.get(e, Position);
|
|
636
|
-
* if (!r.ok) { return; } // r.error.code === 'stale-entity' on dead handle
|
|
637
|
-
* const pos = r.value;
|
|
638
|
-
* ```
|
|
639
|
-
*/
|
|
640
|
-
get<S extends ComponentSchema>(
|
|
641
|
-
entity: EntityHandle,
|
|
642
|
-
component: Component<string, S>,
|
|
643
|
-
): Result<ShapeOf<S>, EcsError> {
|
|
644
|
-
const record = this.lookupAlive(entity, 'get', component.name);
|
|
645
|
-
if (!record.ok) return record;
|
|
646
|
-
|
|
647
|
-
const rec = record.value;
|
|
648
|
-
const arch = this.graph.archetypes[rec.archetypeId];
|
|
649
|
-
/* istanbul ignore next -- defensive: alive record always has valid archetypeId */
|
|
650
|
-
if (!arch) {
|
|
651
|
-
return err(
|
|
652
|
-
new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
|
|
653
|
-
operation: 'get',
|
|
654
|
-
component: component.name,
|
|
655
|
-
expectedGeneration: entityGeneration(entity),
|
|
656
|
-
actualGeneration: rec.generation,
|
|
657
|
-
}),
|
|
658
|
-
);
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
// Check if this archetype has the component (using World-local ID).
|
|
662
|
-
const localId = componentId(component);
|
|
663
|
-
if (!arch.components.some((candidate) => componentId(candidate) === localId)) {
|
|
664
|
-
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
const value = this.storage.readRow(arch, component, this.tableRow(rec));
|
|
668
|
-
const role = relationshipRole(component);
|
|
669
|
-
if (role?.kind === 'target') {
|
|
670
|
-
const targetValue = value as Record<string, unknown>;
|
|
671
|
-
targetValue[role.targetField] = detachRelationshipTargetArray(targetValue[role.targetField]);
|
|
672
|
-
}
|
|
673
|
-
return ok(value);
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
/**
|
|
677
|
-
* Column-level zero-copy view of an `array<T, N>` / `array<T>` field.
|
|
678
|
-
*
|
|
679
|
-
* Resolves the live byte region for `(entity, component, fieldName)`
|
|
680
|
-
* directly at the column level and returns the element-typed TypedArray
|
|
681
|
-
* aliasing it (`view.buffer` is the SSOT byte region; mutations route
|
|
682
|
-
* through `world.set`). Unlike `get`, this does NOT build the
|
|
683
|
-
* `{}` whole-component object nor walk every schema field. Per-frame
|
|
684
|
-
* consumers that need one column (the resolved world mat4) take this path to
|
|
685
|
-
* avoid the `get` overhead (1 `{}` alloc + N-field readRow walk).
|
|
686
|
-
*
|
|
687
|
-
* Fixed `array<T,N>` columns (feat-20260602) store their elements inline, so
|
|
688
|
-
* the view aliases the archetype column buffer directly (no BufferPool
|
|
689
|
-
* indirection); variable `array<T>` columns still alias the BufferPool slot.
|
|
690
|
-
* The returned view's element type follows the schema element type
|
|
691
|
-
* (`array<entity,N>` -> `Uint32Array`, `array<f32,N>` -> `Float32Array`,
|
|
692
|
-
* etc.) -- the prior f32-only early-return gate is removed.
|
|
693
|
-
*
|
|
694
|
-
* **Transient view contract:** the returned `TypedArray` aliases the column
|
|
695
|
-
* buffer and is valid only until the next structural change (`spawn` /
|
|
696
|
-
* `despawn` / `addComponent` / `removeComponent`). Column growth
|
|
697
|
-
* (`growColumn`) detaches the old `ArrayBuffer` via `transfer()`; a
|
|
698
|
-
* swap-remove at the same row index leaves the view pointing to the wrong
|
|
699
|
-
* entity. **Callers must re-fetch `_getArrayView` on every access** and must
|
|
700
|
-
* not hold the view across any operation that may cause archetype migration.
|
|
701
|
-
* All existing per-frame consumers (`propagateTransforms` / `render-extract`
|
|
702
|
-
* / `pick`) already conform -- they fetch the view inside a single pass with
|
|
703
|
-
* no intervening structural changes.
|
|
704
|
-
*
|
|
705
|
-
* Returns `undefined` when the entity is dead, the component is absent, the
|
|
706
|
-
* field does not exist, or the field is not an `array<...>` column.
|
|
707
|
-
*
|
|
708
|
-
* @internal Engine-internal fast path; AI users read public component values
|
|
709
|
-
* through `world.get`. This accessor is the zero-materialization route the
|
|
710
|
-
* propagate kernel, relationship owner, and render walk use; it bypasses
|
|
711
|
-
* the detached public relationship-target snapshot.
|
|
712
|
-
*/
|
|
713
|
-
_getArrayView(
|
|
714
|
-
entity: EntityHandle,
|
|
715
|
-
component: Component,
|
|
716
|
-
fieldName: string,
|
|
717
|
-
): FieldView | undefined {
|
|
718
|
-
const record = this.lookupAlive(entity, '_getArrayView', component.name);
|
|
719
|
-
if (!record.ok) return undefined;
|
|
720
|
-
|
|
721
|
-
const rec = record.value;
|
|
722
|
-
const arch = this.graph.archetypes[rec.archetypeId];
|
|
723
|
-
if (!arch) return undefined;
|
|
724
|
-
return this.storage.readArrayView(arch, component, this.tableRow(rec), fieldName);
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
/** Internal zero-materialisation read for ECS-owned relationship lists. */
|
|
728
|
-
_getArrayLength(
|
|
729
|
-
entity: EntityHandle,
|
|
730
|
-
component: Component,
|
|
731
|
-
fieldName: string,
|
|
732
|
-
): number | undefined {
|
|
733
|
-
const record = this.lookupAlive(entity, 'relationship-read', component.name);
|
|
734
|
-
if (!record.ok) return undefined;
|
|
735
|
-
const arch = this.graph.archetypes[record.value.archetypeId];
|
|
736
|
-
if (arch === undefined) return undefined;
|
|
737
|
-
return this.storage.readArrayLength(arch, component, this.tableRow(record.value), fieldName);
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
/** Internal zero-materialisation read for one ECS-owned array element. */
|
|
741
|
-
_getArrayElement(
|
|
742
|
-
entity: EntityHandle,
|
|
743
|
-
component: Component,
|
|
744
|
-
fieldName: string,
|
|
745
|
-
index: number,
|
|
746
|
-
): number | undefined {
|
|
747
|
-
const record = this.lookupAlive(entity, 'relationship-read', component.name);
|
|
748
|
-
if (!record.ok) return undefined;
|
|
749
|
-
const arch = this.graph.archetypes[record.value.archetypeId];
|
|
750
|
-
if (arch === undefined) return undefined;
|
|
751
|
-
return this.storage.readArrayElement(
|
|
752
|
-
arch,
|
|
753
|
-
component,
|
|
754
|
-
this.tableRow(record.value),
|
|
755
|
-
fieldName,
|
|
756
|
-
index,
|
|
757
|
-
);
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
/** Internal scalar-column read used by parent-first hierarchy traversal. */
|
|
761
|
-
_getFieldValue(
|
|
762
|
-
entity: EntityHandle,
|
|
763
|
-
component: Component,
|
|
764
|
-
fieldName: string,
|
|
765
|
-
): number | undefined {
|
|
766
|
-
const record = this.lookupAlive(entity, 'relationship-read', component.name);
|
|
767
|
-
if (!record.ok) return undefined;
|
|
768
|
-
const arch = this.graph.archetypes[record.value.archetypeId];
|
|
769
|
-
if (arch === undefined) return undefined;
|
|
770
|
-
return this.storage.readFieldValue(arch, component, this.tableRow(record.value), fieldName);
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
/**
|
|
774
|
-
* Converge one writable relationship source mutation through the source
|
|
775
|
-
* owner. The source scalar and its materialized target list are committed as
|
|
776
|
-
* one operation; no caller receives a raw source column view that could
|
|
777
|
-
* bypass the mirror/index maintenance.
|
|
778
|
-
*/
|
|
779
|
-
private setRelationshipSource(
|
|
780
|
-
entity: EntityHandle,
|
|
781
|
-
component: Component,
|
|
782
|
-
value: Record<string, unknown>,
|
|
783
|
-
record: EntityRecord,
|
|
784
|
-
arch: Archetype,
|
|
785
|
-
markChanged: boolean,
|
|
786
|
-
): Result<void, EcsError> {
|
|
787
|
-
const role = relationshipRole(component);
|
|
788
|
-
if (role?.kind !== 'source') return ok(undefined);
|
|
789
|
-
const row = this.tableRow(record);
|
|
790
|
-
const current = this.storage.readRow(arch, component, row) as Record<string, unknown>;
|
|
791
|
-
const valuePreflight = this.preflightComponentFieldValues(entity, {
|
|
792
|
-
component,
|
|
793
|
-
data: value as never,
|
|
794
|
-
});
|
|
795
|
-
if (!valuePreflight.ok) return valuePreflight;
|
|
796
|
-
const merged = { ...current, ...value };
|
|
797
|
-
const enumError = validateEnumFieldValues(component, merged, entity as number);
|
|
798
|
-
if (enumError !== null) return err(enumError as unknown as EcsError);
|
|
799
|
-
|
|
800
|
-
const oldTarget = this.relationshipTargetEntity(component, current);
|
|
801
|
-
const target = this.relationshipTargetEntity(component, merged);
|
|
802
|
-
if (target !== null) {
|
|
803
|
-
const targetRecord = this.records[entityIndex(target)];
|
|
804
|
-
const actualGeneration = targetRecord?.generation ?? -1;
|
|
805
|
-
if (!this.recordIsLive(targetRecord, entityGeneration(target))) {
|
|
806
|
-
return err(
|
|
807
|
-
new StaleEntityError(target as number, entityIndex(target), entityGeneration(target), {
|
|
808
|
-
operation: 'relationship-insert',
|
|
809
|
-
component: component.name,
|
|
810
|
-
expectedGeneration: entityGeneration(target),
|
|
811
|
-
actualGeneration,
|
|
812
|
-
}),
|
|
813
|
-
);
|
|
814
|
-
}
|
|
815
|
-
const roleAllowsSelf = role.allowSelf;
|
|
816
|
-
if (entity === target && !roleAllowsSelf) {
|
|
817
|
-
return err(
|
|
818
|
-
new RelationshipSelfCycleError(component.name, entity as number, target as number),
|
|
819
|
-
);
|
|
820
|
-
}
|
|
821
|
-
const cycleHit =
|
|
822
|
-
entity === target && roleAllowsSelf
|
|
823
|
-
? null
|
|
824
|
-
: this.relationshipCycleHit(component, target, entity);
|
|
825
|
-
if (cycleHit !== null) {
|
|
826
|
-
return err(
|
|
827
|
-
new RelationshipSelfCycleError(component.name, entity as number, cycleHit as number),
|
|
828
|
-
);
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
// A same-target source write still counts as authored evidence but does
|
|
833
|
-
// not churn the target mirror or relationship index.
|
|
834
|
-
if (oldTarget !== target) {
|
|
835
|
-
const prepared = this.prepareRelationshipInsert(component, merged);
|
|
836
|
-
if (!prepared.ok) return prepared;
|
|
837
|
-
const preparation = prepared.value;
|
|
838
|
-
if (oldTarget !== null) {
|
|
839
|
-
const detached = this.relationshipOnRemove(entity, component, current);
|
|
840
|
-
if (!detached.ok) {
|
|
841
|
-
this.releaseRelationshipPreparation(preparation);
|
|
842
|
-
return detached;
|
|
843
|
-
}
|
|
844
|
-
}
|
|
845
|
-
const attached = this.relationshipOnInsert(entity, component, merged, preparation);
|
|
846
|
-
if (!attached.ok) {
|
|
847
|
-
this.releaseRelationshipPreparation(preparation);
|
|
848
|
-
return attached;
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
|
|
853
|
-
const sourceColumn = fieldCols?.get(role.sourceField);
|
|
854
|
-
if (sourceColumn === undefined) {
|
|
855
|
-
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
856
|
-
}
|
|
857
|
-
sourceColumn.view[row] = target === null ? ENTITY_NULL_RAW : (target as number);
|
|
858
|
-
if (markChanged) this.markComponentChanged(entity, component);
|
|
859
|
-
return ok(undefined);
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
/**
|
|
863
|
-
* Write (partial) component data to an entity.
|
|
864
|
-
*
|
|
865
|
-
* @returns `Result<void, EcsError>` —
|
|
866
|
-
* `ok(void)` on success;
|
|
867
|
-
* `err(StaleEntityError)` (`.code = 'stale-entity'`) if entity is dead;
|
|
868
|
-
* `err(ComponentNotPresentError)` (`.code = 'component-not-present'`) if
|
|
869
|
-
* entity does not have the component (F-02: no longer silently ignores).
|
|
870
|
-
*
|
|
871
|
-
* @example
|
|
872
|
-
* ```ts
|
|
873
|
-
* const Position = defineComponent('Position', { x: 'f32', y: 'f32' });
|
|
874
|
-
* const world = new World();
|
|
875
|
-
* const e = world.spawn({ component: Position, data: { x: 0, y: 0 } }).unwrap();
|
|
876
|
-
* const r = world.set(e, Position, { x: 10 });
|
|
877
|
-
* if (!r.ok) { return; } // r.error.code === 'stale-entity' on dead handle
|
|
878
|
-
* r.unwrap();
|
|
879
|
-
* ```
|
|
880
|
-
*/
|
|
881
|
-
set<S extends ComponentSchema>(
|
|
882
|
-
entity: EntityHandle,
|
|
883
|
-
component: Component<string, S>,
|
|
884
|
-
value: Partial<InputShapeOf<S>>,
|
|
885
|
-
markChanged = true,
|
|
886
|
-
): Result<void, EcsError> {
|
|
887
|
-
const record = this.lookupAlive(entity, 'set', component.name);
|
|
888
|
-
if (!record.ok) return record;
|
|
889
|
-
|
|
890
|
-
const rec = record.value;
|
|
891
|
-
const arch = this.graph.archetypes[rec.archetypeId];
|
|
892
|
-
/* istanbul ignore next -- defensive: alive record always has valid archetypeId */
|
|
893
|
-
if (!arch) {
|
|
894
|
-
return err(
|
|
895
|
-
new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
|
|
896
|
-
operation: 'set',
|
|
897
|
-
component: component.name,
|
|
898
|
-
expectedGeneration: entityGeneration(entity),
|
|
899
|
-
actualGeneration: rec.generation,
|
|
900
|
-
}),
|
|
901
|
-
);
|
|
902
|
-
}
|
|
903
|
-
const localId = componentId(component);
|
|
904
|
-
if (!arch.components.some((candidate) => componentId(candidate) === localId)) {
|
|
905
|
-
// F-02: set on missing component returns err instead of silent ignore
|
|
906
|
-
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
907
|
-
}
|
|
908
|
-
const role = relationshipRole(component as Component);
|
|
909
|
-
if (role?.kind === 'target') {
|
|
910
|
-
return err(new RelationshipTargetReadonlyError(component.name, 'set'));
|
|
911
|
-
}
|
|
912
|
-
if (role?.kind === 'source') {
|
|
913
|
-
return this.setRelationshipSource(
|
|
914
|
-
entity,
|
|
915
|
-
component,
|
|
916
|
-
value as Record<string, unknown>,
|
|
917
|
-
rec,
|
|
918
|
-
arch,
|
|
919
|
-
markChanged,
|
|
920
|
-
);
|
|
921
|
-
}
|
|
922
|
-
const valuePreflight = this.preflightComponentFieldValues(entity, {
|
|
923
|
-
component,
|
|
924
|
-
data: value,
|
|
925
|
-
});
|
|
926
|
-
if (!valuePreflight.ok) return valuePreflight;
|
|
927
|
-
const currentValue = this.storage.readRow(arch, component, this.tableRow(rec)) as Record<
|
|
928
|
-
string,
|
|
929
|
-
unknown
|
|
930
|
-
>;
|
|
931
|
-
const enumError = validateEnumFieldValues(
|
|
932
|
-
component,
|
|
933
|
-
{ ...currentValue, ...(value as Record<string, unknown>) },
|
|
934
|
-
entity as number,
|
|
935
|
-
);
|
|
936
|
-
if (enumError !== null) return err(enumError as unknown as EcsError);
|
|
937
|
-
if (component.storage === 'sparse') {
|
|
938
|
-
if (markChanged) this.markComponentChanged(entity, component);
|
|
939
|
-
return ok(undefined);
|
|
940
|
-
}
|
|
941
|
-
const fieldCols = this.table(arch).storage.get(localId)?.fields;
|
|
942
|
-
if (fieldCols === undefined) {
|
|
943
|
-
throw new Error(`Table storage for ${component.name} does not exist.`);
|
|
944
|
-
}
|
|
945
|
-
for (const fieldName of Object.keys(value)) {
|
|
946
|
-
const col = fieldCols.get(fieldName);
|
|
947
|
-
if (!col) {
|
|
948
|
-
continue;
|
|
949
|
-
}
|
|
950
|
-
const fieldType = (componentSchema(component) as Record<string, string>)[fieldName] ?? '';
|
|
951
|
-
// M1/M2 release loop (set path): release the prior managed value
|
|
952
|
-
// BEFORE writing the new one. Single SSOT helper `releaseManagedFieldOnRow`
|
|
953
|
-
// (feat-20260614 D-2) covers every managed-field family (`ref<T>` /
|
|
954
|
-
// `string` / `buffer` / variable `array<T>`); it self-skips fields that
|
|
955
|
-
// do not match `isManagedField` here, but for set-ref/string we already
|
|
956
|
-
// gated on it so the call is hot. Zeroes the column when applicable.
|
|
957
|
-
if (isManagedField(fieldType)) {
|
|
958
|
-
this.storage.releaseManagedFieldOnRow(arch, component, this.tableRow(rec), fieldName);
|
|
959
|
-
}
|
|
960
|
-
const raw = (value as Record<string, unknown>)[fieldName];
|
|
961
|
-
if (fieldType === 'bool') {
|
|
962
|
-
col.view[this.tableRow(rec)] = raw ? 1 : 0;
|
|
963
|
-
} else if (isEntityField(fieldType)) {
|
|
964
|
-
// M3 entity field overwrite: encode null as ENTITY_NULL_RAW;
|
|
965
|
-
// otherwise store the Entity bit pattern (slot+gen).
|
|
966
|
-
col.view[this.tableRow(rec)] =
|
|
967
|
-
raw === null || raw === undefined ? ENTITY_NULL_RAW : (raw as number);
|
|
968
|
-
} else if (isManagedBufferField(fieldType)) {
|
|
969
|
-
// M2 set path: collapsed-vocab keyword family `'buffer'` (variable) +
|
|
970
|
-
// `'buffer<N>'` (fixed). The two shapes diverge here:
|
|
971
|
-
// - `buffer<N>` — schema-declared byteLength is fixed; raw must be a
|
|
972
|
-
// `Uint8Array` whose `byteLength === N`. Mismatched payloads route
|
|
973
|
-
// `FixedSizeMismatchError` via Result.err so AI users observe an
|
|
974
|
-
// explicit failure instead of silent truncation (verify round 1
|
|
975
|
-
// B1 fix; charter P3 — explicit failure > silent acceptance).
|
|
976
|
-
// - `'buffer'` — variable capacity; release the prior slot then
|
|
977
|
-
// alloc a fresh one sized to the new payload's byteLength (mirrors
|
|
978
|
-
// the `array<T>` set path's release-then-alloc D-5 ordering).
|
|
979
|
-
// raw is normalized from any AllowSharedBufferSource view to a
|
|
980
|
-
// Uint8Array over its bytes (feat-20260621 V2 / AC-A4). Non-buffer
|
|
981
|
-
// raw (a forced cast feeding e.g. a number) normalizes to null and
|
|
982
|
-
// is treated as a no-op (column slot stays unchanged).
|
|
983
|
-
const isFixedBuffer = fieldType !== 'buffer';
|
|
984
|
-
const bytes = normalizeBufferWrite(raw);
|
|
985
|
-
if (bytes !== null) {
|
|
986
|
-
if (isFixedBuffer) {
|
|
987
|
-
// feat-20260602: fixed `buffer<N>` lives inline as a stride-N u8
|
|
988
|
-
// column (arity = N bytes). Write the payload straight into the
|
|
989
|
-
// row window -- no BufferPool slot.
|
|
990
|
-
const expected = bufferFieldByteLength(fieldType);
|
|
991
|
-
if (bytes.byteLength !== expected) {
|
|
992
|
-
return err(new FixedSizeMismatchError(fieldName, expected, bytes.byteLength));
|
|
993
|
-
}
|
|
994
|
-
const arity = col.arity;
|
|
995
|
-
(col.view as Uint8Array).set(bytes.subarray(0, arity), this.tableRow(rec) * arity);
|
|
996
|
-
} else {
|
|
997
|
-
// Variable `'buffer'` set: release prior slot via SSOT helper
|
|
998
|
-
// (feat-20260614 D-2) then alloc fresh sized to the new payload
|
|
999
|
-
// (verify round 1 B2 fix path). The helper zeroes the column on
|
|
1000
|
-
// release; sentinel slot id 0 is a no-op.
|
|
1001
|
-
this.storage.releaseManagedFieldOnRow(arch, component, this.tableRow(rec), fieldName);
|
|
1002
|
-
const allocR = this.bufferPool.alloc(bytes.byteLength);
|
|
1003
|
-
if (!allocR.ok) {
|
|
1004
|
-
const ctx: ErrorContext = {
|
|
1005
|
-
systemName: `World.set (${component.name}.${fieldName})`,
|
|
1006
|
-
};
|
|
1007
|
-
this.routeError(allocR.error, ctx);
|
|
1008
|
-
col.view[this.tableRow(rec)] = 0;
|
|
1009
|
-
continue;
|
|
1010
|
-
}
|
|
1011
|
-
const slot = allocR.value;
|
|
1012
|
-
slot.view.set(bytes);
|
|
1013
|
-
col.view[this.tableRow(rec)] = slot.id;
|
|
1014
|
-
}
|
|
1015
|
-
}
|
|
1016
|
-
} else if (fieldType === 'string') {
|
|
1017
|
-
// M1 string-field set path (AC-05 path 3): the prior handle was
|
|
1018
|
-
// already released by the unified `isManagedField` pre-write block
|
|
1019
|
-
// above (D-R3) -- here we just alloc the new handle and store the
|
|
1020
|
-
// u32. Mirrors the array<T> release-then-alloc pattern (D-5) so AI
|
|
1021
|
-
// users observe the UniqueRefStore _liveCount net-zero invariant
|
|
1022
|
-
// on field overwrite. Missing / non-string raw -> '' fallback
|
|
1023
|
-
// (AC-06).
|
|
1024
|
-
const text = typeof raw === 'string' ? raw : '';
|
|
1025
|
-
const handle = this.uniqueRefs.alloc<'String'>('String', text);
|
|
1026
|
-
col.view[this.tableRow(rec)] = unwrapHandle(handle);
|
|
1027
|
-
} else {
|
|
1028
|
-
const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
|
|
1029
|
-
if (arrayMeta !== undefined) {
|
|
1030
|
-
// M1 set path for array<T> / array<T,N> fields (feat-20260614 D-3
|
|
1031
|
-
// calling convention). The set semantics mirror spawn: release the
|
|
1032
|
-
// prior slot via the SSOT helper, then alloc a fresh one sized to
|
|
1033
|
-
// the new value, copy bytes verbatim, store slot id (+ count for
|
|
1034
|
-
// variable). Fixed `array<T,N>` is inline — the helper short-
|
|
1035
|
-
// circuits and writeArrayField writes directly into the row's
|
|
1036
|
-
// stride window with no pool traffic.
|
|
1037
|
-
this.storage.releaseManagedFieldOnRow(arch, component, this.tableRow(rec), fieldName);
|
|
1038
|
-
this.storage.writeArrayField(
|
|
1039
|
-
arch,
|
|
1040
|
-
component,
|
|
1041
|
-
this.tableRow(rec),
|
|
1042
|
-
fieldName,
|
|
1043
|
-
fieldType,
|
|
1044
|
-
arrayMeta,
|
|
1045
|
-
raw,
|
|
1046
|
-
);
|
|
1047
|
-
} else {
|
|
1048
|
-
// The pre-write `releaseManagedFieldOnRow` block above already
|
|
1049
|
-
// released the prior `'shared<T>'` rc via SharedRefStore.release;
|
|
1050
|
-
// here we retain the new value so net rc delta is +1 / 0 / -1 per
|
|
1051
|
-
// M4 invariant (set: -1+1=0; spawn: 0+1=+1; despawn: -1).
|
|
1052
|
-
col.view[this.tableRow(rec)] = raw as number;
|
|
1053
|
-
if (fieldType.startsWith('shared<') && (raw as number) !== 0) {
|
|
1054
|
-
this.storage.retainSharedScalarHandle(raw as number, component.name, fieldName);
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
}
|
|
1058
|
-
}
|
|
1059
|
-
if (markChanged) this.markComponentChanged(entity, component);
|
|
1060
|
-
return ok(undefined);
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
// ──────────────────────────────────────────────────────────────────────────
|
|
1064
|
-
// Internal relationship array maintenance. Public array mutation is always
|
|
1065
|
-
// expressed as one `world.set` payload; these helpers only implement the
|
|
1066
|
-
// engine-owned target projection and backpointer swap-remove path.
|
|
1067
|
-
//
|
|
1068
|
-
// Append/remove are engine-owned relationship maintenance only.
|
|
1069
|
-
//
|
|
1070
|
-
// The `fieldName` parameter is typed `ArrayFieldsOf<S>` so cross-shape
|
|
1071
|
-
// access (entity / buffer / string / scalar field names) is rejected at
|
|
1072
|
-
// compile time -- AI users see a TS error well before any runtime path.
|
|
1073
|
-
// ──────────────────────────────────────────────────────────────────────────
|
|
1074
|
-
|
|
1075
|
-
/**
|
|
1076
|
-
* Append `value` to the variable `array<T>` field `fieldName` on `entity`.
|
|
1077
|
-
*
|
|
1078
|
-
* BufferPool grow is amortized O(1) via the size-class freelist (research
|
|
1079
|
-
* Finding 5). Relationship target arrays grow byte-wise.
|
|
1080
|
-
*
|
|
1081
|
-
* @returns `Result<void, EcsError>` with the normal stale/component errors.
|
|
1082
|
-
*
|
|
1083
|
-
* The helper is called only by relationship synchronization.
|
|
1084
|
-
*/
|
|
1085
|
-
private appendArrayElement<S extends ComponentSchema, K extends ArrayFieldsOf<S>>(
|
|
1086
|
-
entity: EntityHandle,
|
|
1087
|
-
component: Component<string, S>,
|
|
1088
|
-
fieldName: K,
|
|
1089
|
-
value: ArrayFieldElementValue<S, K>,
|
|
1090
|
-
): Result<void, EcsError> {
|
|
1091
|
-
const record = this.lookupAlive(entity, 'relationship-append', component.name);
|
|
1092
|
-
if (!record.ok) return record;
|
|
1093
|
-
const rec = record.value;
|
|
1094
|
-
const arch = this.graph.archetypes[rec.archetypeId];
|
|
1095
|
-
/* istanbul ignore next -- alive record always has a valid archetype */
|
|
1096
|
-
if (!arch) {
|
|
1097
|
-
return err(
|
|
1098
|
-
new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
|
|
1099
|
-
operation: 'relationship-append',
|
|
1100
|
-
component: component.name,
|
|
1101
|
-
expectedGeneration: entityGeneration(entity),
|
|
1102
|
-
actualGeneration: rec.generation,
|
|
1103
|
-
}),
|
|
1104
|
-
);
|
|
1105
|
-
}
|
|
1106
|
-
const localId = componentId(component);
|
|
1107
|
-
const fieldCols = this.table(arch).storage.get(localId)?.fields;
|
|
1108
|
-
if (!fieldCols) {
|
|
1109
|
-
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
1110
|
-
}
|
|
1111
|
-
const fieldNameStr = fieldName as string;
|
|
1112
|
-
const col = fieldCols.get(fieldNameStr);
|
|
1113
|
-
/* istanbul ignore next -- ArrayFieldsOf filter ensures the column exists */
|
|
1114
|
-
if (!col) return err(new ComponentNotPresentError(entity as number, component.name));
|
|
1115
|
-
const arrayMeta = componentDefinition(component).fields[fieldNameStr]?.arrayMeta;
|
|
1116
|
-
/* istanbul ignore next -- ArrayFieldsOf filter guarantees array<*> */
|
|
1117
|
-
if (arrayMeta === undefined) {
|
|
1118
|
-
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
1119
|
-
}
|
|
1120
|
-
const meta = TYPE_METADATA[arrayMeta.elementType];
|
|
1121
|
-
/* istanbul ignore next -- arrayMeta.elementType is guaranteed in TYPE_METADATA */
|
|
1122
|
-
if (!meta) return err(new ComponentNotPresentError(entity as number, component.name));
|
|
1123
|
-
// biome-ignore lint/style/noNonNullAssertion: ManagedArrayElementType always scalar -> byteSize present
|
|
1124
|
-
const elementBytes = meta.byteSize!;
|
|
1125
|
-
const slotId = col.view[this.tableRow(rec)] as number;
|
|
1126
|
-
|
|
1127
|
-
const countCol = fieldCols.get(arrayCountColumnName(fieldNameStr));
|
|
1128
|
-
/* istanbul ignore next -- variable arrays always allocate the count column */
|
|
1129
|
-
if (countCol === undefined) {
|
|
1130
|
-
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
1131
|
-
}
|
|
1132
|
-
const count = countCol.view[this.tableRow(rec)] as number;
|
|
1133
|
-
const newCount = count + 1;
|
|
1134
|
-
const newByteLength = newCount * elementBytes;
|
|
1135
|
-
|
|
1136
|
-
let liveSlotId = slotId;
|
|
1137
|
-
if (liveSlotId === 0) {
|
|
1138
|
-
// Empty/unallocated slot — alloc fresh.
|
|
1139
|
-
const allocR = this.bufferPool.alloc(newByteLength);
|
|
1140
|
-
if (!allocR.ok) return err(allocR.error);
|
|
1141
|
-
liveSlotId = allocR.value.id;
|
|
1142
|
-
col.view[this.tableRow(rec)] = liveSlotId;
|
|
1143
|
-
} else {
|
|
1144
|
-
// A previously-allocated slot may have drained below its high-water
|
|
1145
|
-
// mark: swap-remove (`_removeArrayElementByValue`) and `pop` only lower
|
|
1146
|
-
// the count column, never shrink the managed buffer. When the refilled
|
|
1147
|
-
// length still fits inside the slot's current logical length, reuse the
|
|
1148
|
-
// buffer in place -- routing through `grow` would hit the (correct, but
|
|
1149
|
-
// here irrelevant) shrink-not-supported guard and strand the field
|
|
1150
|
-
// (e.g. `Children.entities` never repopulating after a full drain).
|
|
1151
|
-
if (newByteLength > this.bufferPool.view(liveSlotId).byteLength) {
|
|
1152
|
-
const growR = this.bufferPool.grow(liveSlotId, newByteLength);
|
|
1153
|
-
if (!growR.ok) return err(growR.error);
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1156
|
-
const liveBytes = this.bufferPool.view(liveSlotId);
|
|
1157
|
-
// Reinterpret the slot bytes as the element-typed view and write at the
|
|
1158
|
-
// tail index. Entity values are stored as their u32 bit pattern.
|
|
1159
|
-
this.storage.writeArrayElementAt(liveBytes, count, arrayMeta.elementType, value as number);
|
|
1160
|
-
countCol.view[this.tableRow(rec)] = newCount;
|
|
1161
|
-
this.markComponentChanged(entity, component);
|
|
1162
|
-
return ok(undefined);
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
/**
|
|
1166
|
-
* Remove one variable-array element at a known slot. Relationship holders
|
|
1167
|
-
* supply the slot from their backpointer, so this is O(1) and never scans
|
|
1168
|
-
* the materialized target array.
|
|
1169
|
-
*/
|
|
1170
|
-
private removeArrayElementAt(
|
|
1171
|
-
entity: EntityHandle,
|
|
1172
|
-
component: Component<string, ComponentSchema>,
|
|
1173
|
-
fieldName: string,
|
|
1174
|
-
slot: number,
|
|
1175
|
-
): Result<EntityHandle | undefined, EcsError> {
|
|
1176
|
-
const record = this.lookupAlive(entity, 'removeArrayElementAt', component.name);
|
|
1177
|
-
if (!record.ok) return record;
|
|
1178
|
-
const rec = record.value;
|
|
1179
|
-
const arch = this.graph.archetypes[rec.archetypeId];
|
|
1180
|
-
if (!arch) return err(new ComponentNotPresentError(entity as number, component.name));
|
|
1181
|
-
const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
|
|
1182
|
-
if (!fieldCols) return err(new ComponentNotPresentError(entity as number, component.name));
|
|
1183
|
-
const col = fieldCols.get(fieldName);
|
|
1184
|
-
const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
|
|
1185
|
-
const countCol = fieldCols.get(arrayCountColumnName(fieldName));
|
|
1186
|
-
if (!col || !arrayMeta || arrayMeta.length !== undefined || !countCol) {
|
|
1187
|
-
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
1188
|
-
}
|
|
1189
|
-
const row = this.tableRow(rec);
|
|
1190
|
-
const count = countCol.view[row] as number;
|
|
1191
|
-
if (slot < 0 || slot >= count) return ok(undefined);
|
|
1192
|
-
const slotId = col.view[row] as number;
|
|
1193
|
-
if (slotId === 0) return ok(undefined);
|
|
1194
|
-
const liveBytes = this.bufferPool.view(slotId);
|
|
1195
|
-
const last = count - 1;
|
|
1196
|
-
const moved =
|
|
1197
|
-
slot === last
|
|
1198
|
-
? undefined
|
|
1199
|
-
: (this.storage.readArrayElementAt(liveBytes, last, arrayMeta.elementType) as EntityHandle);
|
|
1200
|
-
if (slot !== last) {
|
|
1201
|
-
this.storage.writeArrayElementAt(liveBytes, slot, arrayMeta.elementType, moved as number);
|
|
1202
|
-
}
|
|
1203
|
-
countCol.view[row] = last;
|
|
1204
|
-
this.markComponentChanged(entity, component);
|
|
1205
|
-
return ok(moved);
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
// ──────────────────────────────────────────────────────────────────────────
|
|
1209
|
-
// addComponent / removeComponent (archetype migration via edges, AC-07)
|
|
1210
|
-
// ──────────────────────────────────────────────────────────────────────────
|
|
1211
|
-
|
|
1212
|
-
/**
|
|
1213
|
-
* Add a component to an existing entity, triggering archetype migration.
|
|
1214
|
-
*
|
|
1215
|
-
* @returns `Result<void, EcsError>` —
|
|
1216
|
-
* `ok(void)` on success;
|
|
1217
|
-
* `err(StaleEntityError)` (`.code = 'stale-entity'`) if entity is dead;
|
|
1218
|
-
* `err(ComponentAlreadyPresentError)` (`.code = 'component-already-present'`)
|
|
1219
|
-
* if entity already has the component (E-03).
|
|
1220
|
-
*
|
|
1221
|
-
* @example
|
|
1222
|
-
* ```ts
|
|
1223
|
-
* const Position = defineComponent('Position', { x: 'f32', y: 'f32' });
|
|
1224
|
-
* const Velocity = defineComponent('Velocity', { dx: 'f32', dy: 'f32' });
|
|
1225
|
-
* const world = new World();
|
|
1226
|
-
* const e = world.spawn({ component: Position, data: { x: 0, y: 0 } }).unwrap();
|
|
1227
|
-
* const r = world.addComponent(e, { component: Velocity, data: { dx: 1, dy: 0 } });
|
|
1228
|
-
* if (!r.ok) { return; } // r.error.code === 'stale-entity' on dead handle
|
|
1229
|
-
* r.unwrap();
|
|
1230
|
-
* ```
|
|
1231
|
-
*/
|
|
1232
|
-
addComponent<S extends ComponentSchema>(
|
|
1233
|
-
entity: EntityHandle,
|
|
1234
|
-
componentData: ComponentData<S>,
|
|
1235
|
-
): Result<void, EcsError> {
|
|
1236
|
-
return this._addComponentCore(entity, componentData, false);
|
|
1237
|
-
}
|
|
1238
|
-
|
|
1239
|
-
/**
|
|
1240
|
-
* Core implementation of `addComponent` with reentry guard.
|
|
1241
|
-
*
|
|
1242
|
-
* @param internal — `true` when called from relationship maintenance
|
|
1243
|
-
* (lazy mirror create or exclusive reparent).
|
|
1244
|
-
* @internal
|
|
1245
|
-
*/
|
|
1246
|
-
_addComponentCore<S extends ComponentSchema>(
|
|
1247
|
-
entity: EntityHandle,
|
|
1248
|
-
componentData: ComponentData<S>,
|
|
1249
|
-
internal: boolean,
|
|
1250
|
-
resolveRequirements = true,
|
|
1251
|
-
skipVariableArrayInitialization = false,
|
|
1252
|
-
): Result<void, EcsError> {
|
|
1253
|
-
const record = this.lookupAlive(entity, 'addComponent', componentData.component.name);
|
|
1254
|
-
if (!record.ok) return record;
|
|
1255
|
-
|
|
1256
|
-
const rec = record.value;
|
|
1257
|
-
let srcArch = this.graph.archetypes[rec.archetypeId];
|
|
1258
|
-
/* istanbul ignore next -- defensive: alive record always has valid archetypeId */
|
|
1259
|
-
if (!srcArch) {
|
|
1260
|
-
return err(
|
|
1261
|
-
new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
|
|
1262
|
-
operation: 'addComponent',
|
|
1263
|
-
component: componentData.component.name,
|
|
1264
|
-
expectedGeneration: entityGeneration(entity),
|
|
1265
|
-
actualGeneration: rec.generation,
|
|
1266
|
-
}),
|
|
1267
|
-
);
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
const preflight = this.preflightComponentData(entity, componentData);
|
|
1271
|
-
if (!preflight.ok) return preflight;
|
|
1272
|
-
|
|
1273
|
-
// bug-20260615: unknown-key fail-fast BEFORE archetype mutation so a
|
|
1274
|
-
// typo aborts cleanly without partial state (mirrors _spawnCore).
|
|
1275
|
-
const keyErr = validateComponentDataKeys(
|
|
1276
|
-
componentData.component,
|
|
1277
|
-
componentData.data as Record<string, unknown>,
|
|
1278
|
-
);
|
|
1279
|
-
if (keyErr !== null) {
|
|
1280
|
-
return err(keyErr as unknown as EcsError);
|
|
1281
|
-
}
|
|
1282
|
-
const arrayErr = validateManagedArrayValues(
|
|
1283
|
-
componentData.component,
|
|
1284
|
-
componentData.data as Record<string, unknown>,
|
|
1285
|
-
);
|
|
1286
|
-
if (arrayErr !== null) {
|
|
1287
|
-
return err(arrayErr as unknown as EcsError);
|
|
1288
|
-
}
|
|
1289
|
-
// feat-20260713 M2 / w9: P3 shared-field value gate (see _spawnCore). Runs
|
|
1290
|
-
// before archetype mutation so a mis-bound GUID aborts cleanly.
|
|
1291
|
-
const sharedErr = validateSharedFieldValues(
|
|
1292
|
-
componentData.component,
|
|
1293
|
-
componentData.data as Record<string, unknown>,
|
|
1294
|
-
);
|
|
1295
|
-
if (sharedErr !== null) {
|
|
1296
|
-
return err(sharedErr as unknown as EcsError);
|
|
1297
|
-
}
|
|
1298
|
-
const filled = fillComponentDefaults(
|
|
1299
|
-
componentData.component,
|
|
1300
|
-
componentData.data as Record<string, unknown>,
|
|
1301
|
-
);
|
|
1302
|
-
const enumErr = validateEnumFieldValues(componentData.component, filled, entity as number);
|
|
1303
|
-
if (enumErr !== null) {
|
|
1304
|
-
return err(enumErr as unknown as EcsError);
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
const localId = componentId(componentData.component);
|
|
1308
|
-
let relationshipPreparation: RelationshipPreparation | undefined;
|
|
1309
|
-
const componentAlreadyPresent = srcArch.components.some(
|
|
1310
|
-
(candidate) => componentId(candidate) === localId,
|
|
1311
|
-
);
|
|
1312
|
-
// Reserve relationship target storage before resolving required source
|
|
1313
|
-
// components. A failed mirror allocation must not leave a requirement
|
|
1314
|
-
// component (or its epochs) behind on the source entity.
|
|
1315
|
-
if (
|
|
1316
|
-
!internal &&
|
|
1317
|
-
!componentAlreadyPresent &&
|
|
1318
|
-
relationshipRole(componentData.component as Component)?.kind === 'source'
|
|
1319
|
-
) {
|
|
1320
|
-
const prepared = this.prepareRelationshipInsert(
|
|
1321
|
-
componentData.component as Component,
|
|
1322
|
-
filled as Record<string, unknown>,
|
|
1323
|
-
);
|
|
1324
|
-
if (!prepared.ok) return prepared;
|
|
1325
|
-
relationshipPreparation = prepared.value;
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
// Generic component requirements are resolved once at the structural
|
|
1329
|
-
// boundary. Explicit data remains authoritative; only missing required
|
|
1330
|
-
// identities are added before the requested component is migrated.
|
|
1331
|
-
if (resolveRequirements) {
|
|
1332
|
-
const required = expandComponentRequirements([componentData]).slice(1);
|
|
1333
|
-
for (const requirement of required) {
|
|
1334
|
-
if (
|
|
1335
|
-
srcArch.components.some(
|
|
1336
|
-
(candidate) => componentId(candidate) === componentId(requirement.component),
|
|
1337
|
-
)
|
|
1338
|
-
) {
|
|
1339
|
-
continue;
|
|
1340
|
-
}
|
|
1341
|
-
// The closure is expanded once above. Bypass requirement expansion for
|
|
1342
|
-
// each member so malformed dependency cycles remain finite and the
|
|
1343
|
-
// structural work still happens in one deterministic sequence.
|
|
1344
|
-
const added = this._addComponentCore(entity, requirement as ComponentData, internal, false);
|
|
1345
|
-
if (!added.ok) {
|
|
1346
|
-
this.releaseRelationshipPreparation(relationshipPreparation);
|
|
1347
|
-
return added;
|
|
1348
|
-
}
|
|
1349
|
-
srcArch = this.graph.archetypes[rec.archetypeId];
|
|
1350
|
-
if (!srcArch) {
|
|
1351
|
-
this.releaseRelationshipPreparation(relationshipPreparation);
|
|
1352
|
-
return err(
|
|
1353
|
-
new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
|
|
1354
|
-
operation: 'addComponent',
|
|
1355
|
-
component: componentData.component.name,
|
|
1356
|
-
expectedGeneration: rec.generation,
|
|
1357
|
-
actualGeneration: rec.generation,
|
|
1358
|
-
}),
|
|
1359
|
-
);
|
|
1360
|
-
}
|
|
1361
|
-
}
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
// Check if entity already has this component (using World-local ID).
|
|
1365
|
-
if (srcArch.components.some((candidate) => componentId(candidate) === localId)) {
|
|
1366
|
-
this.releaseRelationshipPreparation(relationshipPreparation);
|
|
1367
|
-
// M2 exclusive relationship: re-adding the holder with a (possibly new)
|
|
1368
|
-
// target auto-reparents instead of failing (AC-12). Route the complete
|
|
1369
|
-
// source mutation through one owner operation so the old mirror, new
|
|
1370
|
-
// mirror, source scalar, and backpointer converge atomically. Engine
|
|
1371
|
-
// internal mirror maintenance never re-adds a source component.
|
|
1372
|
-
const role = relationshipRole(componentData.component as Component);
|
|
1373
|
-
if (role?.kind === 'source' && role.exclusive && !internal) {
|
|
1374
|
-
return this.setRelationshipSource(
|
|
1375
|
-
entity,
|
|
1376
|
-
componentData.component as Component,
|
|
1377
|
-
filled as Record<string, unknown>,
|
|
1378
|
-
rec,
|
|
1379
|
-
srcArch,
|
|
1380
|
-
true,
|
|
1381
|
-
);
|
|
1382
|
-
}
|
|
1383
|
-
return err(new ComponentAlreadyPresentError(entity as number, componentData.component.name));
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1386
|
-
// Get target archetype via edge cache.
|
|
1387
|
-
const targetArch = getAddEdge(
|
|
1388
|
-
this.graph,
|
|
1389
|
-
srcArch,
|
|
1390
|
-
localId,
|
|
1391
|
-
componentData.component as Component,
|
|
1392
|
-
);
|
|
1393
|
-
|
|
1394
|
-
if (componentData.component.storage === 'sparse') {
|
|
1395
|
-
this.storage.moveEntityArchetype(rec, srcArch, targetArch);
|
|
1396
|
-
} else {
|
|
1397
|
-
this.storage.migrateEntity(rec, srcArch, targetArch);
|
|
1398
|
-
}
|
|
1399
|
-
|
|
1400
|
-
// Write the new component's data. Apply layer-2 + layer-3 silent
|
|
1401
|
-
// fallback so addComponent shares the SAME default-resolution path
|
|
1402
|
-
// as spawn / SceneAsset.instantiate (feat-20260517 / M2 / AC-04
|
|
1403
|
-
// research §F4 auto-symmetry; ComponentData<S>['data'] is the
|
|
1404
|
-
// physical bridge).
|
|
1405
|
-
if (componentData.component.storage === 'table') {
|
|
1406
|
-
this.storage.writeRow(
|
|
1407
|
-
targetArch,
|
|
1408
|
-
componentData.component,
|
|
1409
|
-
this.tableRow(rec),
|
|
1410
|
-
filled as ShapeOf<S>,
|
|
1411
|
-
skipVariableArrayInitialization ? { skipVariableArrayInitialization: true } : undefined,
|
|
1412
|
-
);
|
|
1413
|
-
}
|
|
1414
|
-
this.markComponentAdded(entity, componentData.component as Component);
|
|
1415
|
-
// Relationship sync: append to the materialized target list.
|
|
1416
|
-
if (!internal && relationshipRole(componentData.component as Component)?.kind === 'source') {
|
|
1417
|
-
const relationshipResult = this.relationshipOnInsert(
|
|
1418
|
-
entity,
|
|
1419
|
-
componentData.component as Component,
|
|
1420
|
-
filled as Record<string, unknown>,
|
|
1421
|
-
relationshipPreparation,
|
|
1422
|
-
);
|
|
1423
|
-
if (!relationshipResult.ok) {
|
|
1424
|
-
this.releaseRelationshipPreparation(relationshipPreparation);
|
|
1425
|
-
return relationshipResult;
|
|
1426
|
-
}
|
|
1427
|
-
}
|
|
1428
|
-
|
|
1429
|
-
this.markStructureChanged();
|
|
1430
|
-
this.state.recordStructuralEvidence({
|
|
1431
|
-
kind: 'component-added',
|
|
1432
|
-
entity,
|
|
1433
|
-
componentId: localId,
|
|
1434
|
-
});
|
|
1435
|
-
return ok(undefined);
|
|
1436
|
-
}
|
|
1437
|
-
|
|
1438
|
-
/**
|
|
1439
|
-
* Remove a component from an existing entity, triggering archetype migration.
|
|
1440
|
-
*
|
|
1441
|
-
* @returns `Result<void, EcsError>` —
|
|
1442
|
-
* `ok(void)` on success;
|
|
1443
|
-
* `err(StaleEntityError)` (`.code = 'stale-entity'`) if entity is dead;
|
|
1444
|
-
* `err(ComponentNotPresentError)` (`.code = 'component-not-present'`)
|
|
1445
|
-
* if entity doesn't have the component (E-04).
|
|
1446
|
-
*
|
|
1447
|
-
* @example
|
|
1448
|
-
* ```ts
|
|
1449
|
-
* const Position = defineComponent('Position', { x: 'f32', y: 'f32' });
|
|
1450
|
-
* const world = new World();
|
|
1451
|
-
* const e = world.spawn({ component: Position, data: { x: 0, y: 0 } }).unwrap();
|
|
1452
|
-
* const r = world.removeComponent(e, Position);
|
|
1453
|
-
* if (!r.ok) { return; } // r.error.code === 'stale-entity' on dead handle
|
|
1454
|
-
* r.unwrap();
|
|
1455
|
-
* ```
|
|
1456
|
-
*/
|
|
1457
|
-
removeComponent<S extends ComponentSchema>(
|
|
1458
|
-
entity: EntityHandle,
|
|
1459
|
-
component: Component<string, S>,
|
|
1460
|
-
): Result<void, EcsError> {
|
|
1461
|
-
return this._removeComponentCore(entity, component, false);
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
|
-
/**
|
|
1465
|
-
* Core implementation of `removeComponent` with reentry guard.
|
|
1466
|
-
*
|
|
1467
|
-
* @param internal — `true` when called from relationship maintenance
|
|
1468
|
-
* (exclusive reparent).
|
|
1469
|
-
* @internal
|
|
1470
|
-
*/
|
|
1471
|
-
_removeComponentCore<S extends ComponentSchema>(
|
|
1472
|
-
entity: EntityHandle,
|
|
1473
|
-
component: Component<string, S>,
|
|
1474
|
-
internal: boolean,
|
|
1475
|
-
): Result<void, EcsError> {
|
|
1476
|
-
// Essential-component hard reject (feat-20260602 / plan-strategy D-3): the
|
|
1477
|
-
// id=0 `Entity` component is carried by every archetype unconditionally (it
|
|
1478
|
-
// is the row's own packed handle) and cannot be removed. Reject before any
|
|
1479
|
-
// liveness lookup so the rejection is structural, not entity-state-dependent.
|
|
1480
|
-
if (componentId(component) === componentId(EntityComponent)) {
|
|
1481
|
-
return err(new RemoveEssentialComponentError(component.name));
|
|
1482
|
-
}
|
|
1483
|
-
|
|
1484
|
-
const record = this.lookupAlive(entity, 'removeComponent', component.name);
|
|
1485
|
-
if (!record.ok) return record;
|
|
1486
|
-
|
|
1487
|
-
const rec = record.value;
|
|
1488
|
-
const srcArch = this.graph.archetypes[rec.archetypeId];
|
|
1489
|
-
/* istanbul ignore next -- defensive: alive record always has valid archetypeId */
|
|
1490
|
-
if (!srcArch) {
|
|
1491
|
-
return err(
|
|
1492
|
-
new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
|
|
1493
|
-
operation: 'removeComponent',
|
|
1494
|
-
component: component.name,
|
|
1495
|
-
expectedGeneration: entityGeneration(entity),
|
|
1496
|
-
actualGeneration: rec.generation,
|
|
1497
|
-
}),
|
|
1498
|
-
);
|
|
1499
|
-
}
|
|
1500
|
-
|
|
1501
|
-
// Check if entity has this component (using World-local ID).
|
|
1502
|
-
const localId = componentId(component);
|
|
1503
|
-
if (!srcArch.components.some((candidate) => componentId(candidate) === localId)) {
|
|
1504
|
-
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
1505
|
-
}
|
|
1506
|
-
|
|
1507
|
-
// Capture the old relationship value before column removal so the
|
|
1508
|
-
// materialized target list can be pruned.
|
|
1509
|
-
const role = relationshipRole(component as Component);
|
|
1510
|
-
const needsOldValue = role?.kind === 'source' && !internal;
|
|
1511
|
-
if (needsOldValue) {
|
|
1512
|
-
const oldValue = this.storage.readRow(
|
|
1513
|
-
srcArch,
|
|
1514
|
-
component as Component,
|
|
1515
|
-
this.tableRow(rec),
|
|
1516
|
-
) as Record<string, unknown>;
|
|
1517
|
-
// Relationship sync: prune the holder from the target's materialized list.
|
|
1518
|
-
if (role?.kind === 'source' && !internal) {
|
|
1519
|
-
const relation = this.relationshipOnRemove(entity, component as Component, oldValue);
|
|
1520
|
-
if (!relation.ok) return relation;
|
|
1521
|
-
}
|
|
1522
|
-
}
|
|
1523
|
-
|
|
1524
|
-
// M1 release loop (removeComponent path): release every `ref<T>` field
|
|
1525
|
-
// on the component being removed before migration drops the row.
|
|
1526
|
-
if (component.storage === 'table') {
|
|
1527
|
-
this.storage.releaseManagedRefsOnRow(srcArch, component as Component, this.tableRow(rec));
|
|
1528
|
-
}
|
|
1529
|
-
|
|
1530
|
-
// Get target archetype via edge cache.
|
|
1531
|
-
const targetArch = getRemoveEdge(this.graph, srcArch, localId);
|
|
1532
|
-
|
|
1533
|
-
if (component.storage === 'sparse') {
|
|
1534
|
-
this.storage.moveEntityArchetype(rec, srcArch, targetArch);
|
|
1535
|
-
const set = this.graph.sparseTags.get(componentId(component));
|
|
1536
|
-
if (set !== undefined) removeSparseTag(set, entity);
|
|
1537
|
-
} else {
|
|
1538
|
-
this.storage.migrateEntity(rec, srcArch, targetArch);
|
|
1539
|
-
}
|
|
1540
|
-
this.markStructureChanged();
|
|
1541
|
-
this.state.recordStructuralEvidence({
|
|
1542
|
-
kind: 'component-removed',
|
|
1543
|
-
entity,
|
|
1544
|
-
componentId: localId,
|
|
1545
|
-
});
|
|
1546
|
-
return ok(undefined);
|
|
1547
|
-
}
|
|
1548
|
-
|
|
1549
|
-
// ──────────────────────────────────────────────────────────────────────────
|
|
1550
|
-
// Internal — deferred command support (CommandBuffer interface)
|
|
1551
|
-
// ──────────────────────────────────────────────────────────────────────────
|
|
1552
|
-
|
|
1553
|
-
/**
|
|
1554
|
-
* @internal Allocate a pending entity for deferred spawn.
|
|
1555
|
-
* Returns an Entity handle. The entity is "pending" because
|
|
1556
|
-
* archetypeId === -1 (set by allocateIndex); no separate flag needed.
|
|
1557
|
-
*/
|
|
1558
|
-
_allocatePendingEntity(): EntityHandle {
|
|
1559
|
-
const indexSlot = this.allocateIndex();
|
|
1560
|
-
// biome-ignore lint/style/noNonNullAssertion: allocateIndex guarantees a valid slot with an initialized record
|
|
1561
|
-
return encodeEntity(indexSlot, this.records[indexSlot]!.generation);
|
|
1562
|
-
}
|
|
1563
|
-
|
|
1564
|
-
/**
|
|
1565
|
-
* Return a deferred-spawn reservation to the free-list without publishing a
|
|
1566
|
-
* row or advancing an epoch. CommandBuffer.abort is the sole caller; a
|
|
1567
|
-
* materialized entity is intentionally left untouched so an unexpected
|
|
1568
|
-
* post-write failure poisons the World instead of attempting an unsafe undo.
|
|
1569
|
-
*/
|
|
1570
|
-
_cancelPendingEntity(entity: EntityHandle): void {
|
|
1571
|
-
const slot = entityIndex(entity);
|
|
1572
|
-
const record = this.records[slot];
|
|
1573
|
-
if (record === undefined || record.generation !== entityGeneration(entity)) return;
|
|
1574
|
-
if (record.archetypeId !== -1 || record.archetypeRow !== -1) return;
|
|
1575
|
-
record.generation += 1;
|
|
1576
|
-
if (!isRetiredSlot(record.generation)) this.freeIndices.push(slot);
|
|
1577
|
-
}
|
|
1578
|
-
|
|
1579
|
-
/**
|
|
1580
|
-
* @internal Materialize a pending entity: actually place it into an archetype.
|
|
1581
|
-
* Idempotent: a record with archetypeId !== -1 is already materialized.
|
|
1582
|
-
*/
|
|
1583
|
-
_materializePendingEntity(
|
|
1584
|
-
entity: EntityHandle,
|
|
1585
|
-
componentDatas: ComponentData[],
|
|
1586
|
-
): Result<void, EcsError> {
|
|
1587
|
-
componentDatas = expandComponentRequirements(componentDatas);
|
|
1588
|
-
const slot = entityIndex(entity);
|
|
1589
|
-
const record = this.records[slot];
|
|
1590
|
-
if (!record || record.archetypeId !== -1) return ok(undefined);
|
|
1591
|
-
|
|
1592
|
-
// Reserve target-side relationship storage before materializing this
|
|
1593
|
-
// pending row. The command preflight has already checked the batch graph;
|
|
1594
|
-
// this owner-level reservation closes the remaining managed-capacity
|
|
1595
|
-
// failure window without adding a mirror component or advancing an epoch.
|
|
1596
|
-
const relationshipPreparations: (RelationshipPreparation | undefined)[] = [];
|
|
1597
|
-
for (let index = 0; index < componentDatas.length; index += 1) {
|
|
1598
|
-
const componentData = componentDatas[index];
|
|
1599
|
-
if (
|
|
1600
|
-
componentData === undefined ||
|
|
1601
|
-
relationshipRole(componentData.component as Component)?.kind !== 'source'
|
|
1602
|
-
) {
|
|
1603
|
-
continue;
|
|
1604
|
-
}
|
|
1605
|
-
const filled = fillComponentDefaults(
|
|
1606
|
-
componentData.component,
|
|
1607
|
-
componentData.data as Record<string, unknown>,
|
|
1608
|
-
);
|
|
1609
|
-
const target = this.relationshipTargetEntity(
|
|
1610
|
-
componentData.component as Component,
|
|
1611
|
-
filled as Record<string, unknown>,
|
|
1612
|
-
);
|
|
1613
|
-
const targetRecord = target === null ? undefined : this.records[entityIndex(target)];
|
|
1614
|
-
// A pending target is materialized by an earlier command in the normal
|
|
1615
|
-
// supported order. Leave it to the existing relationship callback when
|
|
1616
|
-
// that target row becomes live; current-world targets are fully
|
|
1617
|
-
// prepared before this source row is appended.
|
|
1618
|
-
if (
|
|
1619
|
-
target !== null &&
|
|
1620
|
-
targetRecord !== undefined &&
|
|
1621
|
-
this.recordIsLive(targetRecord, entityGeneration(target))
|
|
1622
|
-
) {
|
|
1623
|
-
const prepared = this.prepareRelationshipInsert(
|
|
1624
|
-
componentData.component as Component,
|
|
1625
|
-
filled as Record<string, unknown>,
|
|
1626
|
-
);
|
|
1627
|
-
if (!prepared.ok) {
|
|
1628
|
-
for (const reservation of relationshipPreparations) {
|
|
1629
|
-
this.releaseRelationshipPreparation(reservation);
|
|
1630
|
-
}
|
|
1631
|
-
return prepared;
|
|
1632
|
-
}
|
|
1633
|
-
relationshipPreparations[index] = prepared.value;
|
|
1634
|
-
}
|
|
1635
|
-
}
|
|
1636
|
-
|
|
1637
|
-
// Find or create target archetype (using World-local IDs).
|
|
1638
|
-
const componentIds = componentDatas.map((cd) => componentId(cd.component));
|
|
1639
|
-
const components = componentDatas.map((cd) => cd.component);
|
|
1640
|
-
const arch = getOrCreateArchetype(this.graph, componentIds, components);
|
|
1641
|
-
|
|
1642
|
-
// Append entity row.
|
|
1643
|
-
const table = this.table(arch);
|
|
1644
|
-
const tableRow = appendTableRow(table, entity);
|
|
1645
|
-
const archetypeRow = appendArchetypeRow(arch, tableRow);
|
|
1646
|
-
record.archetypeId = arch.id;
|
|
1647
|
-
record.archetypeRow = archetypeRow;
|
|
1648
|
-
|
|
1649
|
-
// Write initial data. Apply layer-2 + layer-3 silent fallback so
|
|
1650
|
-
// deferred-spawn (Commands.spawn) shares the SAME default-resolution
|
|
1651
|
-
// path as the synchronous `world.spawn` / `addComponent` /
|
|
1652
|
-
// SceneAsset.instantiate (feat-20260517 / M2 / AC-04 + AC-09).
|
|
1653
|
-
this.state.markComponentsAdded(entity, [
|
|
1654
|
-
componentId(EntityComponent),
|
|
1655
|
-
...componentDatas.map((cd) => componentId(cd.component)),
|
|
1656
|
-
]);
|
|
1657
|
-
for (const cd of componentDatas) {
|
|
1658
|
-
const filled = fillComponentDefaults(cd.component, cd.data as Record<string, unknown>);
|
|
1659
|
-
this.storage.writeRow(arch, cd.component, tableRow, filled as ShapeOf<ComponentSchema>);
|
|
1660
|
-
}
|
|
1661
|
-
|
|
1662
|
-
// Essential id=0 `Entity` column write (feat-20260602 / plan-strategy D-3),
|
|
1663
|
-
// mirroring the synchronous `spawn` path: the deferred handle was minted at
|
|
1664
|
-
// `_allocatePendingEntity` time and is passed in here.
|
|
1665
|
-
this.storage.writeEntitySelf(arch, tableRow, entity);
|
|
1666
|
-
|
|
1667
|
-
// Publish relationship targets after all rows are written.
|
|
1668
|
-
for (let index = 0; index < componentDatas.length; index += 1) {
|
|
1669
|
-
const cd = componentDatas[index];
|
|
1670
|
-
if (cd === undefined) continue;
|
|
1671
|
-
if (relationshipRole(cd.component as Component)?.kind === 'source') {
|
|
1672
|
-
const filled = fillComponentDefaults(cd.component, cd.data as Record<string, unknown>);
|
|
1673
|
-
const relationshipResult = this.relationshipOnInsert(
|
|
1674
|
-
entity,
|
|
1675
|
-
cd.component as Component,
|
|
1676
|
-
filled as Record<string, unknown>,
|
|
1677
|
-
relationshipPreparations[index],
|
|
1678
|
-
);
|
|
1679
|
-
if (!relationshipResult.ok) {
|
|
1680
|
-
for (const reservation of relationshipPreparations) {
|
|
1681
|
-
this.releaseRelationshipPreparation(reservation);
|
|
1682
|
-
}
|
|
1683
|
-
return relationshipResult;
|
|
1684
|
-
}
|
|
1685
|
-
}
|
|
1686
|
-
}
|
|
1687
|
-
this.markStructureChanged();
|
|
1688
|
-
this.state.recordStructuralEvidence({
|
|
1689
|
-
kind: 'spawn',
|
|
1690
|
-
entity,
|
|
1691
|
-
});
|
|
1692
|
-
return ok(undefined);
|
|
1693
|
-
}
|
|
1694
|
-
|
|
1695
|
-
// ──────────────────────────────────────────────────────────────────────────
|
|
1696
|
-
// Internal — entity index allocation
|
|
1697
|
-
// ──────────────────────────────────────────────────────────────────────────
|
|
1698
|
-
|
|
1699
|
-
allocateIndex(): number {
|
|
1700
|
-
const recycled = this.freeIndices.pop();
|
|
1701
|
-
if (recycled !== undefined) {
|
|
1702
|
-
return recycled;
|
|
1703
|
-
}
|
|
1704
|
-
const slot = this.records.length;
|
|
1705
|
-
if (slot > ENTITY_MAX_INDEX) {
|
|
1706
|
-
throw new EntityIndexOverflowError(slot);
|
|
1707
|
-
}
|
|
1708
|
-
this.records.push({ generation: 0, archetypeId: -1, archetypeRow: -1 });
|
|
1709
|
-
return slot;
|
|
1710
|
-
}
|
|
1711
|
-
|
|
1712
|
-
/**
|
|
1713
|
-
* Single liveness predicate (feat-20260602 / plan-strategy D-4): a slot is
|
|
1714
|
-
* live for a given handle generation iff the record exists, its generation
|
|
1715
|
-
* still matches the handle (despawn bumps generation, so a stale or recycled
|
|
1716
|
-
* handle fails here), and the slot is materialized into an archetype
|
|
1717
|
-
* (archetypeId !== -1). Replaces the former `record.alive && record.generation
|
|
1718
|
-
* === gen` conjunction and the intermediate `!record.pending` clause.
|
|
1719
|
-
*/
|
|
1720
|
-
recordIsLive(record: EntityRecord | undefined, gen: number): record is EntityRecord {
|
|
1721
|
-
return record !== undefined && record.generation === gen && record.archetypeId !== -1;
|
|
1722
|
-
}
|
|
1723
|
-
|
|
1724
|
-
lookupAlive(
|
|
1725
|
-
entity: EntityHandle,
|
|
1726
|
-
operation: string,
|
|
1727
|
-
component?: string,
|
|
1728
|
-
): Result<EntityRecord, EcsError> {
|
|
1729
|
-
const slot = entityIndex(entity);
|
|
1730
|
-
const gen = entityGeneration(entity);
|
|
1731
|
-
const record = this.records[slot];
|
|
1732
|
-
if (!this.recordIsLive(record, gen)) {
|
|
1733
|
-
return err(
|
|
1734
|
-
new StaleEntityError(entity as number, slot, gen, {
|
|
1735
|
-
operation,
|
|
1736
|
-
...(component !== undefined ? { component } : {}),
|
|
1737
|
-
expectedGeneration: gen,
|
|
1738
|
-
actualGeneration: this.records[slot]?.generation ?? -1,
|
|
1739
|
-
}),
|
|
1740
|
-
);
|
|
1741
|
-
}
|
|
1742
|
-
return ok(record);
|
|
1743
|
-
}
|
|
1744
|
-
|
|
1745
|
-
readRow<S extends ComponentSchema>(
|
|
1746
|
-
arch: Archetype,
|
|
1747
|
-
component: Component<string, S>,
|
|
1748
|
-
row: number,
|
|
1749
|
-
): ShapeOf<S> {
|
|
1750
|
-
return this.storage.readRow(arch, component, row);
|
|
1751
|
-
}
|
|
1752
|
-
|
|
1753
|
-
writeEntitySelf(arch: Archetype, row: number, handle: EntityHandle): void {
|
|
1754
|
-
this.storage.writeEntitySelf(arch, row, handle);
|
|
1755
|
-
}
|
|
1756
|
-
|
|
1757
|
-
writeRow<S extends ComponentSchema>(
|
|
1758
|
-
arch: Archetype,
|
|
1759
|
-
component: Component<string, S>,
|
|
1760
|
-
row: number,
|
|
1761
|
-
value: ShapeOf<S>,
|
|
1762
|
-
): void {
|
|
1763
|
-
this.storage.writeRow(arch, component, row, value);
|
|
1764
|
-
}
|
|
1765
|
-
|
|
1766
|
-
releaseManagedRefsOnRow(arch: Archetype, component: Component, row: number): void {
|
|
1767
|
-
this.storage.releaseManagedRefsOnRow(arch, component, row);
|
|
1768
|
-
}
|
|
1769
|
-
}
|