@forgeax/engine-ecs 0.1.23 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +42 -1
  2. package/dist/buffer-pool.d.ts +4 -5
  3. package/dist/buffer-pool.d.ts.map +1 -1
  4. package/dist/component.d.ts +9 -6
  5. package/dist/component.d.ts.map +1 -1
  6. package/dist/errors/query-and-component-errors.d.ts +1 -1
  7. package/dist/errors/query-and-component-errors.d.ts.map +1 -1
  8. package/dist/externalization/index.mjs.map +1 -1
  9. package/dist/index.mjs +597 -152
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/internal.d.ts +3 -1
  12. package/dist/internal.d.ts.map +1 -1
  13. package/dist/internal.mjs +6 -1
  14. package/dist/internal.mjs.map +1 -1
  15. package/dist/projection/index.mjs.map +1 -1
  16. package/dist/query/derived-range-writer.d.ts +14 -0
  17. package/dist/query/derived-range-writer.d.ts.map +1 -1
  18. package/dist/query/query.d.ts.map +1 -1
  19. package/dist/shared.mjs.map +1 -1
  20. package/dist/world-component-access.d.ts +63 -18
  21. package/dist/world-component-access.d.ts.map +1 -1
  22. package/dist/world-component-storage.d.ts +26 -10
  23. package/dist/world-component-storage.d.ts.map +1 -1
  24. package/dist/world-entity-lifecycle.d.ts.map +1 -1
  25. package/dist/world-internal.d.ts +1 -1
  26. package/dist/world-internal.d.ts.map +1 -1
  27. package/dist/world.d.ts +5 -0
  28. package/dist/world.d.ts.map +1 -1
  29. package/package.json +4 -4
  30. package/src/__tests__/archetype.unit.test.ts +38 -10
  31. package/src/__tests__/relationship-index.test.ts +147 -1
  32. package/src/buffer-pool.ts +47 -74
  33. package/src/component.ts +9 -6
  34. package/src/errors/query-and-component-errors.ts +4 -1
  35. package/src/internal.ts +3 -0
  36. package/src/query/derived-range-writer.ts +111 -0
  37. package/src/query/query.ts +26 -1
  38. package/src/world-component-access.ts +427 -127
  39. package/src/world-component-storage.ts +104 -18
  40. package/src/world-entity-lifecycle.ts +49 -9
  41. package/src/world-internal.ts +5 -0
  42. package/src/world.ts +52 -3
@@ -114,6 +114,79 @@ export class ComponentStorage {
114
114
  return reinterpretSlotBytes(liveBytes, arrayMeta.elementType, elementCount);
115
115
  }
116
116
 
117
+ /**
118
+ * Read the live length of an array field without materialising a typed view.
119
+ * Relationship consumers use this narrow storage seam for hot reverse-list
120
+ * walks; the target array remains owned by the ECS column and no snapshot is
121
+ * created per entity.
122
+ */
123
+ readArrayLength(
124
+ arch: Archetype,
125
+ component: Component,
126
+ row: number,
127
+ fieldName: string,
128
+ ): number | undefined {
129
+ const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
130
+ if (fieldCols === undefined) return undefined;
131
+ const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
132
+ if (arrayMeta === undefined) return undefined;
133
+ if (arrayMeta.length !== undefined) return arrayMeta.length;
134
+ const count = fieldCols.get(arrayCountColumnName(fieldName))?.view[row];
135
+ return typeof count === 'number' ? count : 0;
136
+ }
137
+
138
+ /**
139
+ * Read one array element directly from its backing column/BufferPool slot.
140
+ * The relationship target vocabulary is `array<entity>`, so its hot path
141
+ * decodes the packed u32 in-place and does not allocate a TypedArray view.
142
+ */
143
+ readArrayElement(
144
+ arch: Archetype,
145
+ component: Component,
146
+ row: number,
147
+ fieldName: string,
148
+ index: number,
149
+ ): number | undefined {
150
+ if (!Number.isSafeInteger(index) || index < 0) return undefined;
151
+ const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
152
+ if (fieldCols === undefined) return undefined;
153
+ const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
154
+ if (arrayMeta === undefined) return undefined;
155
+ const length = this.readArrayLength(arch, component, row, fieldName);
156
+ if (length === undefined || index >= length) return undefined;
157
+ const col = fieldCols.get(fieldName);
158
+ if (col === undefined) return undefined;
159
+ if (arrayMeta.length !== undefined) {
160
+ return col.view[row * col.arity + index] as number;
161
+ }
162
+ const slotId = col.view[row] as number;
163
+ const bytes = this.bufferPool.view(slotId);
164
+ if (arrayMeta.elementType === 'entity') {
165
+ const byteOffset = index * 4;
166
+ if (byteOffset + 4 > bytes.byteLength) return undefined;
167
+ return (
168
+ ((bytes[byteOffset] ?? 0) |
169
+ ((bytes[byteOffset + 1] ?? 0) << 8) |
170
+ ((bytes[byteOffset + 2] ?? 0) << 16) |
171
+ ((bytes[byteOffset + 3] ?? 0) << 24)) >>>
172
+ 0
173
+ );
174
+ }
175
+ return this.readArrayElementAt(bytes, index, arrayMeta.elementType);
176
+ }
177
+
178
+ /** Read one scalar field without constructing a component shape. */
179
+ readFieldValue(
180
+ arch: Archetype,
181
+ component: Component,
182
+ row: number,
183
+ fieldName: string,
184
+ ): number | undefined {
185
+ return this.table(arch).storage.get(componentId(component))?.fields.get(fieldName)?.view[row] as
186
+ | number
187
+ | undefined;
188
+ }
189
+
117
190
  // ──────────────────────────────────────────────────────────────────────────
118
191
  // Internal — archetype data read/write
119
192
  // ──────────────────────────────────────────────────────────────────────────
@@ -175,10 +248,10 @@ export class ComponentStorage {
175
248
  } else {
176
249
  const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
177
250
  if (arrayMeta !== undefined) {
178
- // M1 read path: materialise a fresh TypedArray snapshot each call
179
- // (D-4 no cache; plan-strategy §2.2 read-only contract). The
180
- // snapshot aliases the BufferPool slot bytes; mutations route
181
- // through `world.set` / `world.push` / `world.pop`.
251
+ // Internal row path: materialise a fresh TypedArray view each call
252
+ // without copying its backing bytes. Public World.get detaches a
253
+ // relationship target array after this read; ECS relationship and
254
+ // Scene owners use this readRow seam as zero-copy storage access.
182
255
  (out as Record<string, unknown>)[fieldName] = this.materializeArrayView(
183
256
  arch,
184
257
  component,
@@ -215,6 +288,7 @@ export class ComponentStorage {
215
288
  component: Component<string, S>,
216
289
  row: number,
217
290
  value: ShapeOf<S>,
291
+ options?: { readonly skipVariableArrayInitialization?: boolean },
218
292
  ): void {
219
293
  const localId = componentId(component);
220
294
  const fieldCols = this.table(arch).storage.get(localId)?.fields;
@@ -291,6 +365,16 @@ export class ComponentStorage {
291
365
  } else {
292
366
  const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
293
367
  if (arrayMeta !== undefined) {
368
+ if (options?.skipVariableArrayInitialization === true && arrayMeta.length === undefined) {
369
+ // Relationship preparation can reserve the first target slot
370
+ // before the mirror archetype is created. Leave the fresh row at
371
+ // the sentinel until the owner installs that reservation; this
372
+ // keeps an injected alloc failure out of the structural path.
373
+ col.view[row] = 0;
374
+ const countCol = fieldCols.get(arrayCountColumnName(fieldName));
375
+ if (countCol !== undefined) countCol.view[row] = 0;
376
+ continue;
377
+ }
294
378
  // M1 spawn path for array<T> / array<T,N> fields (D-3 double-
295
379
  // column for variable; single column for fixed).
296
380
  // Spawn path: no prior-slot release (fresh rows carry stale debris
@@ -810,20 +894,19 @@ export class ComponentStorage {
810
894
  }
811
895
 
812
896
  /**
813
- * Materialise a fresh `TypedArray` snapshot for an `array<T,N>` /
814
- * `array<T>` field at `row` (plan-strategy §2.2 -- read-only snapshot
815
- * contract; mutations route through `world.set` / `world.push` /
816
- * `world.pop`).
897
+ * Materialise a fresh `TypedArray` view for an `array<T,N>` / `array<T>`
898
+ * field at `row`. This is the internal row-storage path: the returned view
899
+ * aliases the live column or BufferPool slot. `WorldComponentAccess.get`
900
+ * detaches relationship target arrays at the public boundary; internal ECS
901
+ * relationship maintenance and Scene traversal keep this zero-copy path.
817
902
  *
818
903
  * **Transient view contract (feat-20260602):** for fixed `array<T,N>`
819
904
  * columns the returned `TypedArray` aliases the inline column buffer
820
905
  * directly (`col.view.subarray(row * arity, ...)`); for variable
821
906
  * `array<T>` columns it aliases the live `BufferPool` slot bytes
822
- * (zero-copy; `pool.view(slotId)` is the SSOT byte region). In both
823
- * cases the view is valid only until the next structural change. Writing
824
- * into the snapshot is undefined behaviour -- the contract is read-only
825
- * and the implementation may switch to a copy in the future without
826
- * breaking AI users who consume only `length` / index reads.
907
+ * (zero-copy; `pool.view(slotId)` is the SSOT byte region). In both cases
908
+ * the view is valid only until the next structural change. Internal callers
909
+ * must not write through it except via the owner mutation helpers.
827
910
  *
828
911
  * For variable arrays the typed-array length matches the live count from
829
912
  * the sidecar `<fieldName>:count` column; for fixed arrays it matches the
@@ -1011,11 +1094,14 @@ export class ComponentStorage {
1011
1094
 
1012
1095
  /**
1013
1096
  * Reinterpret a `BufferPool` slot's `Uint8Array` byte region as the typed
1014
- * view for `elementType`, sliced to `elementCount` elements. Backs the
1015
- * `world.get(e, C).<arrayField>` read path: the returned typed array
1016
- * aliases live slot bytes (zero-copy) but is contractually a read-only
1017
- * snapshot (plan-strategy §2.2). `entity` element fields surface as
1018
- * `Uint32Array` (Entity packs slot+gen into u32).
1097
+ * view for `elementType`, sliced to `elementCount` elements. This is the
1098
+ * internal materialization used by `readRow`/`materializeArrayView`: the
1099
+ * returned view aliases live slot bytes. `WorldComponentAccess.get` detaches
1100
+ * relationship-target `array<entity>` values at the public boundary; other
1101
+ * public array fields retain the existing transient live-view contract.
1102
+ * `_getArrayView` and other internal owners always retain zero-copy access.
1103
+ * `entity` element fields surface as `Uint32Array` (Entity packs slot+gen
1104
+ * into u32).
1019
1105
  */
1020
1106
  /**
1021
1107
  * Element-byte-width for a managed-array element type, read off the global
@@ -65,6 +65,34 @@ export function spawnCore(
65
65
  if (enumErr !== null) return err(enumErr as unknown as EcsError);
66
66
  filledData.push(filled as Record<string, unknown>);
67
67
  }
68
+ // Reserve every relationship target before allocating the new source row.
69
+ // In particular, an injected BufferPool failure must not leave a source
70
+ // entity, mirror component, or mutation epoch behind.
71
+ const relationshipPreparations: unknown[] = [];
72
+ if (!internal) {
73
+ for (let index = 0; index < componentDatas.length; index += 1) {
74
+ const componentData = componentDatas[index];
75
+ const value = filledData[index];
76
+ if (
77
+ componentData === undefined ||
78
+ value === undefined ||
79
+ relationshipRole(componentData.component as Component)?.kind !== 'source'
80
+ ) {
81
+ continue;
82
+ }
83
+ const prepared = world[worldInternal].prepareRelationshipInsert(
84
+ componentData.component as Component,
85
+ value,
86
+ );
87
+ if (!prepared.ok) {
88
+ for (const reservation of relationshipPreparations) {
89
+ world[worldInternal].releaseRelationshipPreparation(reservation);
90
+ }
91
+ return prepared;
92
+ }
93
+ relationshipPreparations[index] = prepared.value;
94
+ }
95
+ }
68
96
  const indexSlot = world[worldInternal].allocateIndex();
69
97
  const record = world[worldInternal].getRecords()[indexSlot];
70
98
  if (record === undefined)
@@ -101,8 +129,14 @@ export function spawnCore(
101
129
  spawnedEntity,
102
130
  cd.component as Component,
103
131
  filled,
132
+ relationshipPreparations[i],
104
133
  );
105
- if (!relation.ok) return relation;
134
+ if (!relation.ok) {
135
+ for (const reservation of relationshipPreparations) {
136
+ world[worldInternal].releaseRelationshipPreparation(reservation);
137
+ }
138
+ return relation;
139
+ }
106
140
  }
107
141
  }
108
142
  world[worldInternal].markStructureChanged();
@@ -199,7 +233,8 @@ export function worldAddChild<S extends ComponentSchema>(
199
233
  data: Partial<InputShapeOf<S>>,
200
234
  ): Result<void, EcsError> {
201
235
  const holderComp = component as Component;
202
- if (relationshipRole(holderComp)?.kind !== 'source') {
236
+ const role = relationshipRole(holderComp);
237
+ if (role?.kind !== 'source') {
203
238
  return err(new ComponentNotPresentError(child as number, component.name));
204
239
  }
205
240
 
@@ -231,7 +266,6 @@ export function worldAddChild<S extends ComponentSchema>(
231
266
  );
232
267
  }
233
268
 
234
- const role = relationshipRole(holderComp);
235
269
  if (child === parent && !(role?.kind === 'source' && role.allowSelf)) {
236
270
  return err(new RelationshipSelfCycleError(component.name, child as number, child as number));
237
271
  }
@@ -308,10 +342,10 @@ export function worldReparent<S extends ComponentSchema>(
308
342
  data: Partial<InputShapeOf<S>>,
309
343
  ): Result<void, EcsError> {
310
344
  const holderComp = component as Component;
311
- if (relationshipRole(holderComp)?.kind !== 'source') {
345
+ const role = relationshipRole(holderComp);
346
+ if (role?.kind !== 'source') {
312
347
  return err(new ComponentNotPresentError(child as number, component.name));
313
348
  }
314
- const role = relationshipRole(holderComp);
315
349
  if (child === newParent && !(role?.kind === 'source' && role.allowSelf)) {
316
350
  return err(
317
351
  new RelationshipSelfCycleError(component.name, child as number, newParent as number),
@@ -348,13 +382,19 @@ export function worldReparent<S extends ComponentSchema>(
348
382
  }),
349
383
  );
350
384
  }
385
+ const payload = {
386
+ ...(data as Record<string, unknown>),
387
+ [role.sourceField]: newParent,
388
+ } as Partial<InputShapeOf<S>>;
351
389
  if (
352
- childArch.components.some((component) => componentId(component) === componentId(holderComp))
390
+ childArch.components.some((candidate) => componentId(candidate) === componentId(holderComp))
353
391
  ) {
354
- const removeResult = world.removeComponent(child, component);
355
- if (!removeResult.ok) return removeResult;
392
+ // Existing exclusive sources are updated through the same owner-level
393
+ // write barrier as `world.set`; remove+add would expose a partial mirror
394
+ // state and would invalidate unrelated query spans.
395
+ return world.set(child, component as never, payload as never);
356
396
  }
357
- return world.addComponent(child, { component, data });
397
+ return world.addComponent(child, { component, data: payload });
358
398
  }
359
399
 
360
400
  /** Iterate ancestors in child-to-root order while safely terminating corrupt cycles. */
@@ -22,6 +22,9 @@ type InternalName =
22
22
  | 'cancelPendingEntity'
23
23
  | 'despawnCore'
24
24
  | 'getArrayView'
25
+ | 'getArrayLength'
26
+ | 'getArrayElement'
27
+ | 'getFieldValue'
25
28
  | 'getBufferPool'
26
29
  | 'getClockWriter'
27
30
  | 'getComponentChange'
@@ -53,6 +56,7 @@ type InternalName =
53
56
  | 'nextMutationEpoch'
54
57
  | 'poisonExecution'
55
58
  | 'publishDerivedRange'
59
+ | 'prepareRelationshipInsert'
56
60
  | 'preflightComponentData'
57
61
  | 'readRow'
58
62
  | 'recordStructuralEvidence'
@@ -60,6 +64,7 @@ type InternalName =
60
64
  | 'relationshipOnInsert'
61
65
  | 'relationshipOnRemove'
62
66
  | 'releaseManagedRefsOnRow'
67
+ | 'releaseRelationshipPreparation'
63
68
  | 'removeComponentCore'
64
69
  | 'routeError'
65
70
  | 'restoreMutationEpoch'
package/src/world.ts CHANGED
@@ -60,7 +60,11 @@ import {
60
60
  } from './execution/shared-kernel';
61
61
  import type { QueryDescriptor } from './query/query';
62
62
  import { createQuery, type Query, type QueryCreationError } from './query/query';
63
- import { isRelationshipTarget, type RelationshipTargetComponent } from './relationship-index';
63
+ import {
64
+ isRelationshipTarget,
65
+ type RelationshipTargetComponent,
66
+ relationshipRole,
67
+ } from './relationship-index';
64
68
  import { createResourceStore, type ResourceStore } from './resource';
65
69
  import { createSchedule, type Schedule, type SystemDescriptor, type SystemSet } from './schedule';
66
70
  import { FixedUpdate, Update } from './schedule-token';
@@ -433,6 +437,9 @@ export class World {
433
437
  cancelPendingEntity: this.internalcancelPendingEntity.bind(this),
434
438
  despawnCore: this.internaldespawnCore.bind(this),
435
439
  getArrayView: this.internalgetArrayView.bind(this),
440
+ getArrayLength: this.internalgetArrayLength.bind(this),
441
+ getArrayElement: this.internalgetArrayElement.bind(this),
442
+ getFieldValue: this.internalgetFieldValue.bind(this),
436
443
  getBufferPool: this.internalgetBufferPool.bind(this),
437
444
  getClockWriter: this.internalgetClockWriter.bind(this),
438
445
  getComponentChange: this.internalgetComponentChange.bind(this),
@@ -463,6 +470,7 @@ export class World {
463
470
  materializePendingEntity: this.internalmaterializePendingEntity.bind(this),
464
471
  nextMutationEpoch: this.internalnextMutationEpoch.bind(this),
465
472
  poisonExecution: this.internalpoisonExecution.bind(this),
473
+ prepareRelationshipInsert: this.internalprepareRelationshipInsert.bind(this),
466
474
  publishDerivedRange: this.internalpublishDerivedRange.bind(this),
467
475
  preflightComponentData: this.internalpreflightComponentData.bind(this),
468
476
  readRow: this.internalreadRow.bind(this),
@@ -471,6 +479,7 @@ export class World {
471
479
  relationshipOnInsert: this.internalrelationshipOnInsert.bind(this),
472
480
  relationshipOnRemove: this.internalrelationshipOnRemove.bind(this),
473
481
  releaseManagedRefsOnRow: this.internalreleaseManagedRefsOnRow.bind(this),
482
+ releaseRelationshipPreparation: this.internalreleaseRelationshipPreparation.bind(this),
474
483
  removeComponentCore: this.internalremoveComponentCore.bind(this),
475
484
  routeError: this.internalrouteError.bind(this),
476
485
  restoreMutationEpoch: this.internalrestoreMutationEpoch.bind(this),
@@ -679,7 +688,12 @@ export class World {
679
688
  component: Component,
680
689
  value: Record<string, unknown>,
681
690
  ): Result<void, EcsError> {
682
- return this.componentAccess.set(entity, component, value as never, false);
691
+ return this.componentAccess.set(
692
+ entity,
693
+ component,
694
+ value as never,
695
+ relationshipRole(component)?.kind === 'source',
696
+ );
683
697
  }
684
698
 
685
699
  /** Query facade read that does not re-enter the public World API. */
@@ -1109,6 +1123,31 @@ export class World {
1109
1123
  return this.componentAccess._getArrayView(entity, component, fieldName);
1110
1124
  }
1111
1125
 
1126
+ private internalgetArrayLength(
1127
+ entity: EntityHandle,
1128
+ component: Component,
1129
+ fieldName: string,
1130
+ ): number | undefined {
1131
+ return this.componentAccess._getArrayLength(entity, component, fieldName);
1132
+ }
1133
+
1134
+ private internalgetArrayElement(
1135
+ entity: EntityHandle,
1136
+ component: Component,
1137
+ fieldName: string,
1138
+ index: number,
1139
+ ): number | undefined {
1140
+ return this.componentAccess._getArrayElement(entity, component, fieldName, index);
1141
+ }
1142
+
1143
+ private internalgetFieldValue(
1144
+ entity: EntityHandle,
1145
+ component: Component,
1146
+ fieldName: string,
1147
+ ): number | undefined {
1148
+ return this.componentAccess._getFieldValue(entity, component, fieldName);
1149
+ }
1150
+
1112
1151
  set<S extends ComponentSchema, C extends Component<string, S>>(
1113
1152
  entity: EntityHandle,
1114
1153
  component: C & WritableComponent<C>,
@@ -1222,8 +1261,18 @@ export class World {
1222
1261
  h: EntityHandle,
1223
1262
  c: Component,
1224
1263
  v: Record<string, unknown>,
1264
+ preparation?: unknown,
1225
1265
  ): Result<void, EcsError> {
1226
- return this.componentAccess.relationshipOnInsert(h, c, v);
1266
+ return this.componentAccess.relationshipOnInsert(h, c, v, preparation as never);
1267
+ }
1268
+ /** */ private internalprepareRelationshipInsert(
1269
+ c: Component,
1270
+ v: Record<string, unknown>,
1271
+ ): Result<unknown, EcsError> {
1272
+ return this.componentAccess.prepareRelationshipInsert(c, v);
1273
+ }
1274
+ /** */ private internalreleaseRelationshipPreparation(preparation: unknown): void {
1275
+ this.componentAccess.releaseRelationshipPreparation(preparation as never);
1227
1276
  }
1228
1277
  /** */ private internalrelationshipOnRemove(
1229
1278
  h: EntityHandle,