@forgeax/engine-ecs 0.1.23 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +33 -1
  2. package/dist/component.d.ts +9 -6
  3. package/dist/component.d.ts.map +1 -1
  4. package/dist/errors/query-and-component-errors.d.ts +1 -1
  5. package/dist/errors/query-and-component-errors.d.ts.map +1 -1
  6. package/dist/externalization/index.mjs.map +1 -1
  7. package/dist/index.mjs +566 -131
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/internal.d.ts +3 -1
  10. package/dist/internal.d.ts.map +1 -1
  11. package/dist/internal.mjs +6 -1
  12. package/dist/internal.mjs.map +1 -1
  13. package/dist/projection/index.mjs.map +1 -1
  14. package/dist/query/derived-range-writer.d.ts +14 -0
  15. package/dist/query/derived-range-writer.d.ts.map +1 -1
  16. package/dist/query/query.d.ts.map +1 -1
  17. package/dist/shared.mjs.map +1 -1
  18. package/dist/world-component-access.d.ts +63 -18
  19. package/dist/world-component-access.d.ts.map +1 -1
  20. package/dist/world-component-storage.d.ts +26 -10
  21. package/dist/world-component-storage.d.ts.map +1 -1
  22. package/dist/world-entity-lifecycle.d.ts.map +1 -1
  23. package/dist/world-internal.d.ts +1 -1
  24. package/dist/world-internal.d.ts.map +1 -1
  25. package/dist/world.d.ts +5 -0
  26. package/dist/world.d.ts.map +1 -1
  27. package/package.json +4 -4
  28. package/src/__tests__/relationship-index.test.ts +147 -1
  29. package/src/component.ts +9 -6
  30. package/src/errors/query-and-component-errors.ts +4 -1
  31. package/src/internal.ts +3 -0
  32. package/src/query/derived-range-writer.ts +111 -0
  33. package/src/query/query.ts +26 -1
  34. package/src/world-component-access.ts +428 -127
  35. package/src/world-component-storage.ts +104 -18
  36. package/src/world-entity-lifecycle.ts +49 -9
  37. package/src/world-internal.ts +5 -0
  38. package/src/world.ts +52 -3
@@ -1,6 +1,9 @@
1
1
  import { err, ok, type Result } from '@forgeax/engine-types';
2
2
  import type { Component } from '../component';
3
3
  import * as componentOwner from '../component';
4
+ import { componentId } from '../component';
5
+ import { Entity } from '../entity';
6
+ import type { EntityHandle } from '../entity-handle';
4
7
  import {
5
8
  DerivedRangeOutOfBoundsError,
6
9
  SharedKernelFailureError,
@@ -13,11 +16,21 @@ import type { MutableColumnShape, ReadonlyColumnShape } from './query';
13
16
 
14
17
  /** Package-internal whole-column storage owned by one dense query table. */
15
18
  export interface DerivedColumnBinding<R extends Component, C extends Component> {
19
+ /** Physical table identity for package-internal projection consumers. */
20
+ readonly tableId: number;
21
+ /** Whole entity column; every dense table carries the essential Entity row. */
22
+ readonly entities: Readonly<Uint32Array>;
16
23
  readonly read: ReadonlyColumnShape<R>;
17
24
  readonly write: MutableColumnShape<C>;
18
25
  readonly rowCapacity: number;
19
26
  }
20
27
 
28
+ /** Reusable identity cursor for O(1) dense-table lookup without a row facade. */
29
+ export interface DerivedRangeCursor {
30
+ bindingIndex: number;
31
+ row: number;
32
+ }
33
+
21
34
  export type DerivedRangeKernel<R extends Component, C extends Component> = (
22
35
  binding: DerivedColumnBinding<R, C>,
23
36
  base: number,
@@ -97,6 +110,10 @@ function countAllocation(name: keyof DerivedRangeAllocationTrace): void {
97
110
 
98
111
  export interface DerivedRangeWriter<R extends Component, C extends Component> {
99
112
  readonly bindings: readonly DerivedColumnBinding<R, C>[];
113
+ /** Locate a live entity in its bound table and return its physical row. */
114
+ locateEntity(entity: EntityHandle, cursor: DerivedRangeCursor): boolean;
115
+ /** Publish rows whose values were written directly into a derived column. */
116
+ publishChangedRows(bindingIndex: number, changed: Uint8Array): Result<void, EcsError>;
100
117
  writeRange(
101
118
  bindingIndex: number,
102
119
  base: number,
@@ -130,6 +147,7 @@ export function createDerivedRangeWriter<R extends Component, C extends Componen
130
147
  let boundEpoch = -1;
131
148
  let bindingTables: readonly Table[] = [];
132
149
  let bindings: readonly DerivedColumnBinding<R, C>[] = [];
150
+ let bindingByTable = new Map<number, number>();
133
151
  let runStartBuffers: readonly Int32Array[] = [];
134
152
  let runCountBuffers: readonly Int32Array[] = [];
135
153
 
@@ -141,6 +159,9 @@ export function createDerivedRangeWriter<R extends Component, C extends Componen
141
159
  const nextRunCountBuffers: Int32Array[] = [];
142
160
  for (const table of tables) {
143
161
  nextBindings.push({
162
+ tableId: table.id,
163
+ entities: (table.storage.get(componentId(Entity))?.fields.get('self')?.view ??
164
+ new Uint32Array(0)) as Uint32Array,
144
165
  read: buildWholeColumnShape(table, source.readComponents) as ReadonlyColumnShape<R>,
145
166
  write: buildWholeColumnShape(table, source.writeComponents) as MutableColumnShape<C>,
146
167
  rowCapacity: table.size,
@@ -153,6 +174,11 @@ export function createDerivedRangeWriter<R extends Component, C extends Componen
153
174
  }
154
175
  bindingTables = tables;
155
176
  bindings = nextBindings;
177
+ bindingByTable = new Map<number, number>();
178
+ for (let index = 0; index < nextBindings.length; index += 1) {
179
+ const binding = nextBindings[index];
180
+ if (binding !== undefined) bindingByTable.set(binding.tableId, index);
181
+ }
156
182
  runStartBuffers = nextRunStartBuffers;
157
183
  runCountBuffers = nextRunCountBuffers;
158
184
  boundEpoch = source.structureEpoch();
@@ -165,6 +191,91 @@ export function createDerivedRangeWriter<R extends Component, C extends Componen
165
191
  if (boundEpoch !== source.structureEpoch()) rebind();
166
192
  return bindings;
167
193
  },
194
+ locateEntity(entity, cursor) {
195
+ if (world.execution.health === 'poisoned') return false;
196
+ if (boundEpoch !== source.structureEpoch()) rebind();
197
+ const archetype = world[worldInternal].getEntityArchetype(entity);
198
+ if (archetype === undefined) return false;
199
+ const bindingIndex = bindingByTable.get(archetype.tableId);
200
+ if (bindingIndex === undefined) return false;
201
+ const record = world[worldInternal].getRecords()[(entity as unknown as number) & 0x00ffffff];
202
+ if (record === undefined || record.archetypeId !== archetype.id) return false;
203
+ const row = archetype.rows[record.archetypeRow];
204
+ const binding = bindings[bindingIndex];
205
+ if (binding === undefined || row === undefined || row < 0 || row >= binding.rowCapacity) {
206
+ return false;
207
+ }
208
+ cursor.bindingIndex = bindingIndex;
209
+ cursor.row = row;
210
+ return true;
211
+ },
212
+ publishChangedRows(bindingIndex, changed) {
213
+ if (world.execution.health === 'poisoned') {
214
+ return err(new WorldPoisonedError(world.identity, world.execution.fault));
215
+ }
216
+ if (boundEpoch !== source.structureEpoch()) rebind();
217
+ const binding = bindings[bindingIndex];
218
+ const table = bindingTables[bindingIndex];
219
+ const runStarts = runStartBuffers[bindingIndex];
220
+ const runCounts = runCountBuffers[bindingIndex];
221
+ if (
222
+ binding === undefined ||
223
+ table === undefined ||
224
+ runStarts === undefined ||
225
+ runCounts === undefined ||
226
+ !Number.isSafeInteger(bindingIndex) ||
227
+ bindingIndex < 0 ||
228
+ changed.length < binding.rowCapacity ||
229
+ table.storage.get(componentOwner.componentId(component)) === undefined
230
+ ) {
231
+ return err(new DerivedRangeOutOfBoundsError(0, changed.length, binding?.rowCapacity ?? 0));
232
+ }
233
+ let runCount = 0;
234
+ let runStart = -1;
235
+ for (let row = 0; row < binding.rowCapacity; row += 1) {
236
+ if ((changed[row] ?? 0) !== 0) {
237
+ if (runStart < 0) runStart = row;
238
+ } else if (runStart >= 0) {
239
+ runStarts[runCount] = runStart;
240
+ runCounts[runCount] = row - runStart;
241
+ runCount += 1;
242
+ runStart = -1;
243
+ }
244
+ }
245
+ if (runStart >= 0) {
246
+ runStarts[runCount] = runStart;
247
+ runCounts[runCount] = binding.rowCapacity - runStart;
248
+ runCount += 1;
249
+ }
250
+ if (runCount === 0) return ok(undefined);
251
+ const previousEpoch = world[worldInternal].getMutationEpoch();
252
+ let epoch: number;
253
+ try {
254
+ epoch = world[worldInternal].nextMutationEpoch();
255
+ const componentIdentifier = componentOwner.componentId(component);
256
+ for (let index = 0; index < runCount; index += 1) {
257
+ world[worldInternal].publishDerivedRange(
258
+ table,
259
+ componentIdentifier,
260
+ runStarts[index] ?? 0,
261
+ runCounts[index] ?? 0,
262
+ epoch,
263
+ );
264
+ }
265
+ changed.fill(0, 0, binding.rowCapacity);
266
+ return ok(undefined);
267
+ } catch (cause) {
268
+ world[worldInternal].restoreMutationEpoch(previousEpoch);
269
+ world[worldInternal].poisonExecution({
270
+ code: 'shared-kernel-failed',
271
+ kernelName: `derived-range:${component.name}:publish`,
272
+ cause,
273
+ partialWrite: true,
274
+ retryable: false,
275
+ });
276
+ return err(new SharedKernelFailureError(component.name, world.identity, cause, true));
277
+ }
278
+ },
168
279
  writeRange(bindingIndex, base, start, count, kernel, context) {
169
280
  if (world.execution.health === 'poisoned') {
170
281
  return err(new WorldPoisonedError(world.identity, world.execution.fault));
@@ -18,8 +18,10 @@ import {
18
18
  QueryIterationInvalidatedError,
19
19
  QuerySpanUnavailableError,
20
20
  type QuerySpanUnavailableReason,
21
+ RelationshipTargetReadonlyError,
21
22
  } from '../errors';
22
23
  import { DERIVED_WRITER } from '../internal';
24
+ import { isRelationshipTarget, relationshipRole } from '../relationship-index';
23
25
  import type { Archetype, ArchetypeId } from '../storage/archetype';
24
26
  import type { ArchetypeGraph } from '../storage/archetype-graph';
25
27
  import { sparseTagIndex } from '../storage/change-detection';
@@ -229,7 +231,17 @@ class QueryRowFacade<
229
231
  mut<C extends W[number]>(component: C): MutableRowShape<C> {
230
232
  const current = this.get(component);
231
233
  if (current === undefined) throw new Error(`Query row lacks ${component.name}.`);
232
- this.world[worldInternal].markComponentChanged(this.entity, componentId(component));
234
+ if (isRelationshipTarget(component)) {
235
+ throw new RelationshipTargetReadonlyError(component.name, 'query row');
236
+ }
237
+ // Relationship sources must let the owner validate before recording
238
+ // authored evidence. A stale/cyclic target therefore has zero mutation
239
+ // side effects, while ordinary components retain the existing eager
240
+ // `mut()` evidence semantics.
241
+ const relationshipSource = relationshipRole(component)?.kind === 'source';
242
+ if (!relationshipSource) {
243
+ this.world[worldInternal].markComponentChanged(this.entity, componentId(component));
244
+ }
233
245
  return new Proxy(current, {
234
246
  set: (target, property, value) => {
235
247
  if (typeof property !== 'string') return false;
@@ -275,6 +287,12 @@ class QuerySpanFacade<R extends readonly Component[], W extends readonly Compone
275
287
  }
276
288
 
277
289
  mut<C extends W[number]>(component: C): MutableColumnShape<C> {
290
+ // A span exposes live column storage. Relationship source and target
291
+ // columns must never obtain that raw write capability: source writes need
292
+ // target-side maintenance and target writes are engine-owned projections.
293
+ if (relationshipRole(component) !== undefined) {
294
+ throw new RelationshipTargetReadonlyError(component.name, 'query span');
295
+ }
278
296
  this.world[worldInternal].markComponentRangeChanged(
279
297
  this.table,
280
298
  componentId(component),
@@ -662,6 +680,13 @@ class ExecutableQuery<
662
680
  private spanUnavailableReason(): QuerySpanUnavailableReason | undefined {
663
681
  if (this.compiled.sparseRoute) return 'sparse-component';
664
682
  if ((this.compiled.descriptor.optional?.length ?? 0) > 0) return 'optional-data';
683
+ // A writable relationship source would bypass the source owner (and its
684
+ // materialized target/backpointer), while a target is an ECS-owned
685
+ // projection. Keep read-only relationship inputs usable by Scene's
686
+ // derived writer; only the descriptor's writable role is ineligible.
687
+ if ((this.compiled.descriptor.write ?? []).some((component) => relationshipRole(component))) {
688
+ return 'relationship-component';
689
+ }
665
690
  return undefined;
666
691
  }
667
692
  }