@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.
- package/README.md +6 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/__tests__/component-nan-preflight.unit.test.d.ts +2 -0
- package/dist/__tests__/component-nan-preflight.unit.test.d.ts.map +1 -0
- package/dist/__tests__/externalization-render-read-lease.test-d.d.ts +2 -0
- package/dist/__tests__/externalization-render-read-lease.test-d.d.ts.map +1 -0
- package/dist/__tests__/externalization-render-read-lease.unit.test.d.ts +2 -0
- package/dist/__tests__/externalization-render-read-lease.unit.test.d.ts.map +1 -0
- package/dist/errors/validation-errors.d.ts +15 -0
- package/dist/errors/validation-errors.d.ts.map +1 -1
- package/dist/errors.d.ts +9 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.mjs +84 -16
- package/dist/index.mjs.map +1 -1
- package/dist/internal.mjs.map +1 -1
- package/dist/projection/index.d.ts +53 -0
- package/dist/projection/index.d.ts.map +1 -1
- package/dist/projection/index.mjs +99 -1
- package/dist/projection/index.mjs.map +1 -1
- package/dist/world-component-access.d.ts +1 -0
- package/dist/world-component-access.d.ts.map +1 -1
- package/dist/world.d.ts +2 -2
- package/dist/world.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/component-nan-preflight.unit.test.ts +186 -0
- package/src/__tests__/errors.unit.test.ts +4 -0
- package/src/__tests__/externalization-render-read-lease.test-d.ts +30 -0
- package/src/__tests__/externalization-render-read-lease.unit.test.ts +79 -0
- package/src/__tests__/query-trs-flat-column-ratio.perf.test.ts +1 -1
- package/src/__tests__/stale-error.test-d.ts +1 -0
- package/src/errors/validation-errors.ts +88 -0
- package/src/errors.ts +11 -0
- package/src/projection/index.ts +176 -0
- package/src/world-component-access.ts +28 -19
- package/src/world.ts +2 -0
package/src/projection/index.ts
CHANGED
|
@@ -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
|
|
219
|
-
if (
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
if (
|
|
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
|
|
643
|
+
const enumError = validateEnumFieldValues(
|
|
633
644
|
component,
|
|
634
645
|
{ ...currentValue, ...(value as Record<string, unknown>) },
|
|
635
646
|
entity as number,
|
|
636
647
|
);
|
|
637
|
-
if (
|
|
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
|