@forgeax/engine-ecs 0.1.4 → 0.1.6

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 (35) hide show
  1. package/README.md +6 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/__tests__/component-nan-preflight.unit.test.d.ts +2 -0
  4. package/dist/__tests__/component-nan-preflight.unit.test.d.ts.map +1 -0
  5. package/dist/__tests__/externalization-render-read-lease.test-d.d.ts +2 -0
  6. package/dist/__tests__/externalization-render-read-lease.test-d.d.ts.map +1 -0
  7. package/dist/__tests__/externalization-render-read-lease.unit.test.d.ts +2 -0
  8. package/dist/__tests__/externalization-render-read-lease.unit.test.d.ts.map +1 -0
  9. package/dist/errors/validation-errors.d.ts +15 -0
  10. package/dist/errors/validation-errors.d.ts.map +1 -1
  11. package/dist/errors.d.ts +9 -2
  12. package/dist/errors.d.ts.map +1 -1
  13. package/dist/index.mjs +84 -16
  14. package/dist/index.mjs.map +1 -1
  15. package/dist/internal.mjs.map +1 -1
  16. package/dist/projection/index.d.ts +53 -0
  17. package/dist/projection/index.d.ts.map +1 -1
  18. package/dist/projection/index.mjs +99 -1
  19. package/dist/projection/index.mjs.map +1 -1
  20. package/dist/world-component-access.d.ts +1 -0
  21. package/dist/world-component-access.d.ts.map +1 -1
  22. package/dist/world.d.ts +2 -2
  23. package/dist/world.d.ts.map +1 -1
  24. package/package.json +4 -4
  25. package/src/__tests__/component-nan-preflight.unit.test.ts +186 -0
  26. package/src/__tests__/errors.unit.test.ts +4 -0
  27. package/src/__tests__/externalization-render-read-lease.test-d.ts +30 -0
  28. package/src/__tests__/externalization-render-read-lease.unit.test.ts +79 -0
  29. package/src/__tests__/query-trs-flat-column-ratio.perf.test.ts +1 -1
  30. package/src/__tests__/stale-error.test-d.ts +1 -0
  31. package/src/errors/validation-errors.ts +88 -0
  32. package/src/errors.ts +11 -0
  33. package/src/projection/index.ts +176 -0
  34. package/src/world-component-access.ts +28 -19
  35. package/src/world.ts +2 -0
@@ -2,6 +2,8 @@ import type { Result } from '@forgeax/engine-types';
2
2
  import type { Component } from '../component';
3
3
  import { componentId } from '../component';
4
4
  import type { EntityHandle } from '../entity-handle';
5
+ import type { SharedRefMutationRead } from '../shared-ref-store';
6
+ import type { WorldChangeRead } from '../storage/change-detection';
5
7
  import type { EcsError, World } from '../world';
6
8
  import { worldInternal } from '../world-internal';
7
9
 
@@ -59,6 +61,180 @@ export interface WorldProjectionOptions {
59
61
  readonly components?: readonly Component[];
60
62
  }
61
63
 
64
+ export interface RenderProjectionComponentRequest {
65
+ readonly component: Component;
66
+ readonly fields: readonly string[];
67
+ }
68
+
69
+ export interface RenderProjectionRequest {
70
+ readonly components: readonly RenderProjectionComponentRequest[];
71
+ }
72
+
73
+ export interface RenderProjectionSpan {
74
+ readonly length: number;
75
+ readonly fields: Readonly<Record<string, ArrayLike<number>>>;
76
+ }
77
+
78
+ export interface RenderProjectionSpans {
79
+ readonly generation: number;
80
+ readonly sharedRefEpoch: number;
81
+ readonly spans: readonly RenderProjectionSpan[];
82
+ }
83
+
84
+ export interface RenderChangeBatchOk {
85
+ readonly status: 'ok';
86
+ readonly cursor: number;
87
+ readonly world: Extract<WorldChangeRead, { readonly status: 'ok' }>;
88
+ readonly sharedRefs: Extract<SharedRefMutationRead, { readonly status: 'ok' }>;
89
+ }
90
+
91
+ export interface RenderChangeBatchOverflow {
92
+ readonly status: 'overflow';
93
+ readonly cursor: number;
94
+ readonly resync: true;
95
+ readonly oldestAvailable: number;
96
+ readonly world: WorldChangeRead;
97
+ readonly sharedRefs: SharedRefMutationRead;
98
+ }
99
+
100
+ export type RenderChangeBatch = RenderChangeBatchOk | RenderChangeBatchOverflow;
101
+
102
+ export interface RenderReadLease {
103
+ readonly worldIdentity: string;
104
+ readonly generation: number;
105
+ readChanges(cursor: number): RenderChangeBatch;
106
+ querySpans(request: RenderProjectionRequest): RenderProjectionSpans;
107
+ inspectCursor(): number;
108
+ dispose(): void;
109
+ }
110
+
111
+ /**
112
+ * Read one array field through the render projection boundary without
113
+ * materialising the component object. Render extraction uses this for hot
114
+ * transform/instance columns; the World internals remain owned by ECS.
115
+ */
116
+ export function readRenderArrayView(
117
+ world: World,
118
+ entity: EntityHandle,
119
+ component: Component,
120
+ fieldName: string,
121
+ ): ArrayLike<number> | undefined {
122
+ return world[worldInternal].getArrayView(entity, component, fieldName) as
123
+ | ArrayLike<number>
124
+ | undefined;
125
+ }
126
+
127
+ interface ProjectionCursor {
128
+ readonly world: number;
129
+ readonly sharedRefs: number;
130
+ }
131
+
132
+ function readProjectionSpans(
133
+ world: World,
134
+ generation: number,
135
+ request: RenderProjectionRequest,
136
+ ): RenderProjectionSpans {
137
+ const queryResult = world.query({ read: request.components.map((entry) => entry.component) });
138
+ if (!queryResult.ok) throw new Error(queryResult.error.message);
139
+ const spansResult = queryResult.value.spans();
140
+ if (!spansResult.ok) throw new Error(spansResult.error.message);
141
+ const spans: RenderProjectionSpan[] = [];
142
+ for (const span of spansResult.value) {
143
+ const fields: Record<string, ArrayLike<number>> = {};
144
+ for (const entry of request.components) {
145
+ const shape = span.get(entry.component) as unknown as Record<string, ArrayLike<number>>;
146
+ for (const fieldName of entry.fields) {
147
+ const field = shape[fieldName];
148
+ if (field === undefined) {
149
+ throw new Error(
150
+ `Render projection field '${entry.component.name}.${fieldName}' is unavailable.`,
151
+ );
152
+ }
153
+ fields[`${entry.component.name}.${fieldName}`] = field;
154
+ if (request.components.length === 1) fields[fieldName] = field;
155
+ }
156
+ }
157
+ spans.push({ length: span.length, fields: Object.freeze(fields) });
158
+ }
159
+ return {
160
+ generation,
161
+ sharedRefEpoch: world[worldInternal].getSharedRefs().getMutationEpoch(),
162
+ spans: Object.freeze(spans),
163
+ };
164
+ }
165
+
166
+ /** Create the render-owned lease from the ECS projection boundary. */
167
+ export function createRenderReadLease(world: World, token: object = {}): RenderReadLease {
168
+ void token;
169
+ const sharedRefs = world[worldInternal].getSharedRefs();
170
+ const generation = Math.max(1, world[worldInternal].getStructureEpoch());
171
+ let disposed = false;
172
+ let nextCursor = 1;
173
+ const cursors = new Map<number, ProjectionCursor>([
174
+ [
175
+ 0,
176
+ { world: world[worldInternal].getChangeCursor(), sharedRefs: sharedRefs.getMutationEpoch() },
177
+ ],
178
+ ]);
179
+
180
+ const assertLive = (): void => {
181
+ if (disposed) throw new Error('RenderReadLease is disposed.');
182
+ };
183
+
184
+ const captureCursor = (): number => {
185
+ const cursor = nextCursor++;
186
+ cursors.set(cursor, {
187
+ world: world[worldInternal].getChangeCursor(),
188
+ sharedRefs: sharedRefs.getMutationEpoch(),
189
+ });
190
+ if (cursors.size > 8) {
191
+ const oldest = cursors.keys().next().value;
192
+ if (typeof oldest === 'number' && oldest !== cursor) cursors.delete(oldest);
193
+ }
194
+ return cursor;
195
+ };
196
+
197
+ return {
198
+ worldIdentity: world.identity,
199
+ generation,
200
+ inspectCursor(): number {
201
+ assertLive();
202
+ return captureCursor();
203
+ },
204
+ readChanges(cursor: number): RenderChangeBatch {
205
+ assertLive();
206
+ const start = cursors.get(cursor);
207
+ if (start === undefined) throw new RangeError(`Unknown RenderReadLease cursor ${cursor}.`);
208
+ const worldRead = world[worldInternal].readChangesSince(start.world) as WorldChangeRead;
209
+ const sharedRead = sharedRefs.readChangesSince(start.sharedRefs);
210
+ const next = captureCursor();
211
+ if (worldRead.status === 'overflow' || sharedRead.status === 'overflow') {
212
+ const oldestAvailable = Math.min(
213
+ worldRead.status === 'overflow' ? worldRead.oldestAvailable : Number.MAX_SAFE_INTEGER,
214
+ sharedRead.status === 'overflow' ? sharedRead.oldestAvailable : Number.MAX_SAFE_INTEGER,
215
+ );
216
+ return {
217
+ status: 'overflow',
218
+ cursor: next,
219
+ resync: true,
220
+ oldestAvailable,
221
+ world: worldRead,
222
+ sharedRefs: sharedRead,
223
+ };
224
+ }
225
+ return { status: 'ok', cursor: next, world: worldRead, sharedRefs: sharedRead };
226
+ },
227
+ querySpans(request: RenderProjectionRequest): RenderProjectionSpans {
228
+ assertLive();
229
+ return readProjectionSpans(world, generation, request);
230
+ },
231
+ dispose(): void {
232
+ disposed = true;
233
+ cursors.clear();
234
+ },
235
+ };
236
+ }
237
+
62
238
  /**
63
239
  * Publish one owner-derived component value without confusing it with an
64
240
  * authored mutation. Owner packages use this narrow seam for fields such as
@@ -44,6 +44,7 @@ import {
44
44
  RemoveEssentialComponentError,
45
45
  StaleEntityError,
46
46
  validateEnumFieldValues,
47
+ validateNumericFieldValues,
47
48
  } from './errors';
48
49
  import {
49
50
  isRelationshipTarget,
@@ -199,6 +200,24 @@ export class WorldComponentAccess {
199
200
  return null;
200
201
  }
201
202
 
203
+ private preflightComponentFieldValues(
204
+ holder: EntityHandle | null,
205
+ componentData: ComponentData,
206
+ ): Result<void, EcsError> {
207
+ const data = componentData.data as Record<string, unknown>;
208
+ const arrayError = validateManagedArrayValues(componentData.component, data);
209
+ if (arrayError !== null) return err(arrayError as unknown as EcsError);
210
+ const sharedError = validateSharedFieldValues(componentData.component, data);
211
+ if (sharedError !== null) return err(sharedError as unknown as EcsError);
212
+ const numericError = validateNumericFieldValues(
213
+ componentData.component,
214
+ data,
215
+ holder === null ? undefined : (holder as number),
216
+ );
217
+ if (numericError !== null) return err(numericError as unknown as EcsError);
218
+ return ok(undefined);
219
+ }
220
+
202
221
  /**
203
222
  * Validate one structural component payload without touching archetypes,
204
223
  * columns, relationship mirrors, epochs, or managed-reference stores.
@@ -215,10 +234,8 @@ export class WorldComponentAccess {
215
234
  const data = componentData.data as Record<string, unknown>;
216
235
  const keyError = validateComponentDataKeys(componentData.component, data);
217
236
  if (keyError !== null) return err(keyError as unknown as EcsError);
218
- const arrayError = validateManagedArrayValues(componentData.component, data);
219
- if (arrayError !== null) return err(arrayError as unknown as EcsError);
220
- const sharedError = validateSharedFieldValues(componentData.component, data);
221
- if (sharedError !== null) return err(sharedError as unknown as EcsError);
237
+ const valuePreflight = this.preflightComponentFieldValues(holder, componentData);
238
+ if (!valuePreflight.ok) return valuePreflight;
222
239
  if (isRelationshipTarget(componentData.component) && relationshipPayloadWrites(data)) {
223
240
  return err(new RelationshipTargetReadonlyError(componentData.component.name, 'command'));
224
241
  }
@@ -614,29 +631,21 @@ export class WorldComponentAccess {
614
631
  // F-02: set on missing component returns err instead of silent ignore
615
632
  return err(new ComponentNotPresentError(entity as number, component.name));
616
633
  }
617
- // feat-20260713 M2 / w9: P3 shared-field value gate (see _spawnCore). Runs
618
- // before any column write so a mis-bound GUID aborts before the scalar /
619
- // array packer would zero the field — the set never partially lands.
620
- const arrayErr = validateManagedArrayValues(component, value as Record<string, unknown>);
621
- if (arrayErr !== null) {
622
- return err(arrayErr as unknown as EcsError);
623
- }
624
- const sharedErr = validateSharedFieldValues(component, value as Record<string, unknown>);
625
- if (sharedErr !== null) {
626
- return err(sharedErr as unknown as EcsError);
627
- }
634
+ const valuePreflight = this.preflightComponentFieldValues(entity, {
635
+ component,
636
+ data: value,
637
+ });
638
+ if (!valuePreflight.ok) return valuePreflight;
628
639
  const currentValue = this.storage.readRow(arch, component, this.tableRow(rec)) as Record<
629
640
  string,
630
641
  unknown
631
642
  >;
632
- const enumErr = validateEnumFieldValues(
643
+ const enumError = validateEnumFieldValues(
633
644
  component,
634
645
  { ...currentValue, ...(value as Record<string, unknown>) },
635
646
  entity as number,
636
647
  );
637
- if (enumErr !== null) {
638
- return err(enumErr as unknown as EcsError);
639
- }
648
+ if (enumError !== null) return err(enumError as unknown as EcsError);
640
649
  if (component.storage === 'sparse') {
641
650
  if (markChanged) this.markComponentChanged(entity, component);
642
651
  return ok(undefined);
package/src/world.ts CHANGED
@@ -27,6 +27,7 @@ import type {
27
27
  ComponentFieldInvalidValueError,
28
28
  ComponentNotDefinedError,
29
29
  ComponentNotPresentError,
30
+ ComponentNumericValueInvalidError,
30
31
  FixedSizeMismatchError,
31
32
  ManagedArrayInvalidValueError,
32
33
  ManagedBufferOutOfBoundsError,
@@ -122,6 +123,7 @@ export type EcsError =
122
123
  | ComponentNotPresentError
123
124
  | ComponentAlreadyPresentError
124
125
  | ComponentFieldInvalidValueError
126
+ | ComponentNumericValueInvalidError
125
127
  | ManagedArrayInvalidValueError
126
128
  | UniqueRefReleasedError
127
129
  | UniqueRefDoubleReleaseError