@forgeax/engine-ecs 0.1.32 → 0.1.34
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 +35 -18
- package/dist/__tests__/query-idle.unit.test.d.ts +2 -0
- package/dist/__tests__/query-idle.unit.test.d.ts.map +1 -0
- package/dist/__tests__/set-allocation.unit.test.d.ts +2 -0
- package/dist/__tests__/set-allocation.unit.test.d.ts.map +1 -0
- package/dist/__tests__/state-projection.unit.test.d.ts +2 -0
- package/dist/__tests__/state-projection.unit.test.d.ts.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +579 -623
- package/dist/index.mjs.map +1 -1
- package/dist/projection/index.d.ts +2 -18
- package/dist/projection/index.d.ts.map +1 -1
- package/dist/projection/index.mjs +433 -23
- package/dist/projection/index.mjs.map +1 -1
- package/dist/projection/state-projection.d.ts +37 -0
- package/dist/projection/state-projection.d.ts.map +1 -0
- package/dist/query/query.d.ts.map +1 -1
- package/dist/shared-ref-store.d.ts +0 -24
- package/dist/shared-ref-store.d.ts.map +1 -1
- package/dist/shared.mjs.map +1 -1
- package/dist/storage/archetype-graph.d.ts +2 -0
- package/dist/storage/archetype-graph.d.ts.map +1 -1
- package/dist/storage/change-detection.d.ts +4 -0
- package/dist/storage/change-detection.d.ts.map +1 -1
- package/dist/storage/table.d.ts +6 -3
- package/dist/storage/table.d.ts.map +1 -1
- package/dist/world-internal.d.ts +0 -2
- package/dist/world-internal.d.ts.map +1 -1
- package/dist/world.d.ts +0 -2
- package/dist/world.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/component-version-surface.test.ts +1 -7
- package/src/__tests__/derived-range-writer.contract.test.ts +6 -0
- package/src/__tests__/ecs-core-reduction.characterization.test.ts +2 -3
- package/src/__tests__/execution-conflict-boundary.unit.test.ts +4 -0
- package/src/__tests__/externalization-render-read-lease.unit.test.ts +4 -10
- package/src/__tests__/query-idle.unit.test.ts +19 -0
- package/src/__tests__/set-allocation.unit.test.ts +30 -0
- package/src/__tests__/shared-ref-lifetime.unit.test.ts +1 -2
- package/src/__tests__/shared-ref-store.unit.test.ts +2 -34
- package/src/__tests__/state-projection.unit.test.ts +159 -0
- package/src/__tests__/world-health.contract.test.ts +0 -23
- package/src/index.ts +1 -1
- package/src/projection/index.ts +9 -39
- package/src/projection/state-projection.ts +250 -0
- package/src/query/query.ts +23 -16
- package/src/shared-ref-store.ts +0 -70
- package/src/storage/archetype-graph.ts +15 -1
- package/src/storage/change-detection.ts +36 -4
- package/src/storage/table.ts +20 -1
- package/src/world-internal.ts +0 -2
- package/src/world.ts +35 -38
- package/dist/__tests__/structural-evidence.contract.test-d.d.ts +0 -2
- package/dist/__tests__/structural-evidence.contract.test-d.d.ts.map +0 -1
- package/dist/__tests__/structural-evidence.contract.test.d.ts +0 -2
- package/dist/__tests__/structural-evidence.contract.test.d.ts.map +0 -1
- package/dist/storage/structural-evidence.d.ts +0 -30
- package/dist/storage/structural-evidence.d.ts.map +0 -1
- package/src/__tests__/structural-evidence.contract.test-d.ts +0 -6
- package/src/__tests__/structural-evidence.contract.test.ts +0 -49
- package/src/storage/structural-evidence.ts +0 -64
|
@@ -28,7 +28,6 @@ describe('World execution health', () => {
|
|
|
28
28
|
const injected = new ComponentNotPresentError(target as number, 'InjectedRelationshipFailure');
|
|
29
29
|
const mutationEpoch = world[worldInternal].getMutationEpoch();
|
|
30
30
|
const structureEpoch = world.getStructureEpoch();
|
|
31
|
-
const evidenceCursor = world[worldInternal].getStructuralEvidence().cursor;
|
|
32
31
|
const onInsert = vi.spyOn(
|
|
33
32
|
world as unknown as {
|
|
34
33
|
relationshipOnInsert: (...args: never[]) => unknown;
|
|
@@ -45,7 +44,6 @@ describe('World execution health', () => {
|
|
|
45
44
|
expect(world.execution.fault?.partialWrite).toBe(true);
|
|
46
45
|
expect(world[worldInternal].getMutationEpoch()).toBe(mutationEpoch);
|
|
47
46
|
expect(world.getStructureEpoch()).toBe(structureEpoch);
|
|
48
|
-
expect(world[worldInternal].getStructuralEvidence().cursor).toBe(evidenceCursor);
|
|
49
47
|
const rejected = world.spawn();
|
|
50
48
|
expect(rejected.ok).toBe(false);
|
|
51
49
|
if (!rejected.ok) expect(rejected.error.code).toBe('world-poisoned');
|
|
@@ -89,7 +87,6 @@ describe('World execution health', () => {
|
|
|
89
87
|
});
|
|
90
88
|
const pending = buffer.spawn({ component: WorldHealthChildOf, data: { parent: target } });
|
|
91
89
|
const mutationEpoch = world[worldInternal].getMutationEpoch();
|
|
92
|
-
const evidenceCursor = world[worldInternal].getStructuralEvidence().cursor;
|
|
93
90
|
|
|
94
91
|
expect(() => flushCommands(buffer, world)).toThrow();
|
|
95
92
|
expect(buffer.status).toBe('aborted');
|
|
@@ -97,7 +94,6 @@ describe('World execution health', () => {
|
|
|
97
94
|
expect(world.execution.fault?.partialWrite).toBe(true);
|
|
98
95
|
expect(world.hasComponent(pending, WorldHealthChildOf)).toBe(true);
|
|
99
96
|
expect(world[worldInternal].getMutationEpoch()).toBe(mutationEpoch);
|
|
100
|
-
expect(world[worldInternal].getStructuralEvidence().cursor).toBe(evidenceCursor);
|
|
101
97
|
const rejected = world.spawn();
|
|
102
98
|
expect(rejected.ok).toBe(false);
|
|
103
99
|
if (!rejected.ok) expect(rejected.error.code).toBe('world-poisoned');
|
|
@@ -115,7 +111,6 @@ describe('World execution health', () => {
|
|
|
115
111
|
.unwrap();
|
|
116
112
|
const mutationEpoch = world[worldInternal].getMutationEpoch();
|
|
117
113
|
const structureEpoch = world.getStructureEpoch();
|
|
118
|
-
const evidenceCursor = world[worldInternal].getStructuralEvidence().cursor;
|
|
119
114
|
|
|
120
115
|
world.addComponent(child, { component: WorldHealthChildOf, data: { parent } }).unwrap();
|
|
121
116
|
|
|
@@ -131,24 +126,6 @@ describe('World execution health', () => {
|
|
|
131
126
|
expect(targetChange?.added).toBeGreaterThan(0);
|
|
132
127
|
expect(world[worldInternal].getMutationEpoch()).toBeGreaterThan(mutationEpoch);
|
|
133
128
|
expect(world.getStructureEpoch()).toBeGreaterThan(structureEpoch);
|
|
134
|
-
const evidence = world[worldInternal].getStructuralEvidence().readAfter(evidenceCursor);
|
|
135
|
-
expect(evidence.status).toBe('ok');
|
|
136
|
-
if (evidence.status === 'ok') {
|
|
137
|
-
expect(evidence.events).toEqual(
|
|
138
|
-
expect.arrayContaining([
|
|
139
|
-
expect.objectContaining({
|
|
140
|
-
kind: 'component-added',
|
|
141
|
-
entity: parent,
|
|
142
|
-
componentId: componentId(WorldHealthChildren),
|
|
143
|
-
}),
|
|
144
|
-
expect.objectContaining({
|
|
145
|
-
kind: 'component-added',
|
|
146
|
-
entity: child,
|
|
147
|
-
componentId: componentId(WorldHealthChildOf),
|
|
148
|
-
}),
|
|
149
|
-
]),
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
129
|
});
|
|
153
130
|
|
|
154
131
|
it('publishes target mirror evidence through addChild', () => {
|
package/src/index.ts
CHANGED
|
@@ -244,7 +244,7 @@ export type {
|
|
|
244
244
|
* // the SharedRefStore publishes release evidence at rc=0.
|
|
245
245
|
* ```
|
|
246
246
|
*/
|
|
247
|
-
export type { SharedRefReleaseEvidence
|
|
247
|
+
export type { SharedRefReleaseEvidence } from './shared-ref-store.js';
|
|
248
248
|
|
|
249
249
|
/**
|
|
250
250
|
* ECS-managed handle store (M1). Owns the lifecycle of every
|
package/src/projection/index.ts
CHANGED
|
@@ -2,8 +2,6 @@ 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 { StructuralEvidenceRead } from '../storage/structural-evidence';
|
|
7
5
|
import type { EcsError, World } from '../world';
|
|
8
6
|
import { worldInternal } from '../world-internal';
|
|
9
7
|
|
|
@@ -24,11 +22,6 @@ export {
|
|
|
24
22
|
StaleEntityError,
|
|
25
23
|
} from '../errors';
|
|
26
24
|
|
|
27
|
-
/** Read producer-owned typed structural facts without inferring from a World scan. */
|
|
28
|
-
export function readStructuralEvidence(world: World, cursor: number): StructuralEvidenceRead {
|
|
29
|
-
return world[worldInternal].getStructuralEvidence().readAfter(cursor) as StructuralEvidenceRead;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
25
|
export interface RenderProjectionComponentRequest {
|
|
33
26
|
readonly component: Component;
|
|
34
27
|
readonly fields: readonly string[];
|
|
@@ -45,24 +38,12 @@ export interface RenderProjectionSpan {
|
|
|
45
38
|
|
|
46
39
|
export interface RenderProjectionSpans {
|
|
47
40
|
readonly generation: number;
|
|
48
|
-
readonly sharedRefEpoch: number;
|
|
49
41
|
readonly spans: readonly RenderProjectionSpan[];
|
|
50
42
|
}
|
|
51
43
|
|
|
52
|
-
export interface
|
|
53
|
-
readonly status: 'ok';
|
|
54
|
-
readonly version: RenderReadVersion;
|
|
55
|
-
readonly world: RenderWorldChanges;
|
|
56
|
-
readonly sharedRefs: SharedRefMutationRead;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export interface RenderChangeBatchRebuild {
|
|
60
|
-
readonly status: 'rebuild';
|
|
44
|
+
export interface RenderChangeBatch {
|
|
61
45
|
readonly version: RenderReadVersion;
|
|
62
|
-
readonly resync: true;
|
|
63
|
-
readonly reason: 'structure-changed';
|
|
64
46
|
readonly world: RenderWorldChanges;
|
|
65
|
-
readonly sharedRefs: SharedRefMutationRead;
|
|
66
47
|
}
|
|
67
48
|
|
|
68
49
|
export interface RenderWorldChanges {
|
|
@@ -71,8 +52,6 @@ export interface RenderWorldChanges {
|
|
|
71
52
|
readonly changedComponentIds: readonly number[];
|
|
72
53
|
}
|
|
73
54
|
|
|
74
|
-
export type RenderChangeBatch = RenderChangeBatchOk | RenderChangeBatchRebuild;
|
|
75
|
-
|
|
76
55
|
export interface RenderReadLease {
|
|
77
56
|
readonly worldIdentity: string;
|
|
78
57
|
readonly generation: number;
|
|
@@ -101,7 +80,6 @@ export function readRenderArrayView(
|
|
|
101
80
|
export interface RenderReadVersion {
|
|
102
81
|
readonly mutationEpoch: number;
|
|
103
82
|
readonly structureEpoch: number;
|
|
104
|
-
readonly sharedRefEpoch: number;
|
|
105
83
|
}
|
|
106
84
|
|
|
107
85
|
function readProjectionSpans(
|
|
@@ -133,7 +111,6 @@ function readProjectionSpans(
|
|
|
133
111
|
}
|
|
134
112
|
return {
|
|
135
113
|
generation,
|
|
136
|
-
sharedRefEpoch: world[worldInternal].getSharedRefs().getMutationEpoch(),
|
|
137
114
|
spans: Object.freeze(spans),
|
|
138
115
|
};
|
|
139
116
|
}
|
|
@@ -141,7 +118,6 @@ function readProjectionSpans(
|
|
|
141
118
|
/** Create the render-owned lease from the ECS projection boundary. */
|
|
142
119
|
export function createRenderReadLease(world: World, token: object = {}): RenderReadLease {
|
|
143
120
|
void token;
|
|
144
|
-
const sharedRefs = world[worldInternal].getSharedRefs();
|
|
145
121
|
let disposed = false;
|
|
146
122
|
|
|
147
123
|
const assertLive = (): void => {
|
|
@@ -152,7 +128,6 @@ export function createRenderReadLease(world: World, token: object = {}): RenderR
|
|
|
152
128
|
return {
|
|
153
129
|
mutationEpoch: world[worldInternal].getMutationEpoch(),
|
|
154
130
|
structureEpoch: world[worldInternal].getStructureEpoch(),
|
|
155
|
-
sharedRefEpoch: sharedRefs.getMutationEpoch(),
|
|
156
131
|
};
|
|
157
132
|
};
|
|
158
133
|
|
|
@@ -181,20 +156,8 @@ export function createRenderReadLease(world: World, token: object = {}): RenderR
|
|
|
181
156
|
toEpoch,
|
|
182
157
|
changedComponentIds,
|
|
183
158
|
};
|
|
184
|
-
const sharedRead = sharedRefs.readChangesSince(start.sharedRefEpoch);
|
|
185
159
|
const version = captureVersion();
|
|
186
|
-
|
|
187
|
-
if (structureChanged) {
|
|
188
|
-
return {
|
|
189
|
-
status: 'rebuild',
|
|
190
|
-
version,
|
|
191
|
-
resync: true,
|
|
192
|
-
reason: 'structure-changed',
|
|
193
|
-
world: worldRead,
|
|
194
|
-
sharedRefs: sharedRead,
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
return { status: 'ok', version, world: worldRead, sharedRefs: sharedRead };
|
|
160
|
+
return { version, world: worldRead };
|
|
198
161
|
},
|
|
199
162
|
querySpans(request: RenderProjectionRequest): RenderProjectionSpans {
|
|
200
163
|
assertLive();
|
|
@@ -235,3 +198,10 @@ export function routeWorldError(
|
|
|
235
198
|
): void {
|
|
236
199
|
world[worldInternal].routeError(error, context);
|
|
237
200
|
}
|
|
201
|
+
|
|
202
|
+
export {
|
|
203
|
+
createStateProjection,
|
|
204
|
+
type StateProjection,
|
|
205
|
+
type StateProjectionBatch,
|
|
206
|
+
StateProjectionExpiredError,
|
|
207
|
+
} from './state-projection';
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import type { Component } from '../component';
|
|
2
|
+
import { componentId } from '../component';
|
|
3
|
+
import { Entity } from '../entity';
|
|
4
|
+
import { type EntityHandle, encodeEntity, entityIndex } from '../entity-handle';
|
|
5
|
+
import { WorldPoisonedError } from '../errors';
|
|
6
|
+
import { PROJECTION_BLOCK_SIZE } from '../storage/change-detection';
|
|
7
|
+
import type { Table } from '../storage/table';
|
|
8
|
+
import type { World } from '../world';
|
|
9
|
+
import { worldInternal } from '../world-internal';
|
|
10
|
+
|
|
11
|
+
interface BlockBaseline {
|
|
12
|
+
readonly ids: Uint32Array;
|
|
13
|
+
readonly membership: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class StateProjectionExpiredError extends Error {
|
|
17
|
+
readonly code = 'state-projection-expired' as const;
|
|
18
|
+
readonly expected = 'an unmodified source and the latest valid projection candidate';
|
|
19
|
+
readonly hint = 'Read and apply the current state again before accepting the candidate.';
|
|
20
|
+
|
|
21
|
+
constructor() {
|
|
22
|
+
super('State projection candidate expired; read and apply the current state again.');
|
|
23
|
+
this.name = 'StateProjectionExpiredError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface StateProjectionBatch {
|
|
28
|
+
/** Source indices, deduplicated across migration and generation replacement. */
|
|
29
|
+
readonly indices: readonly number[];
|
|
30
|
+
readonly epoch: number;
|
|
31
|
+
readonly scannedRows: number;
|
|
32
|
+
readonly checkedBlocks: number;
|
|
33
|
+
readonly membershipChanged: boolean;
|
|
34
|
+
readonly changedComponents: readonly Component[];
|
|
35
|
+
/** Commit only after the owning consumer has successfully applied its candidate. */
|
|
36
|
+
validate(): void;
|
|
37
|
+
accept(): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface StateProjection {
|
|
41
|
+
/** Whether the accepted source still matches the live World, without creating a candidate. */
|
|
42
|
+
isCurrent(): boolean;
|
|
43
|
+
read(): StateProjectionBatch;
|
|
44
|
+
/** Resolve the final live generation directly through the World record. */
|
|
45
|
+
entity(index: number): EntityHandle | undefined;
|
|
46
|
+
changed(entity: EntityHandle, component: Component): boolean;
|
|
47
|
+
invalidate(): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Current-state candidate discovery. Blocks remember accepted identities, never
|
|
52
|
+
* structural operations. Reads are synchronous and accepting a candidate does
|
|
53
|
+
* not consume evidence belonging to another projection.
|
|
54
|
+
*/
|
|
55
|
+
export function createStateProjection(
|
|
56
|
+
world: World,
|
|
57
|
+
components: readonly Component[],
|
|
58
|
+
candidates: readonly Component[] = components,
|
|
59
|
+
): StateProjection {
|
|
60
|
+
const owner = world[worldInternal];
|
|
61
|
+
const graph = owner.getGraph();
|
|
62
|
+
const ids = components.map(componentId);
|
|
63
|
+
const candidateIds = candidates.map(componentId);
|
|
64
|
+
const sparseCandidates = candidates.some((component) => component.storage === 'sparse');
|
|
65
|
+
const baseline = new Map<Table, Map<number, BlockBaseline>>();
|
|
66
|
+
let acceptedEpoch = -1;
|
|
67
|
+
let acceptedStructure = -1;
|
|
68
|
+
let invalid = true;
|
|
69
|
+
let readToken = 0;
|
|
70
|
+
let stamp = new Uint32Array(64);
|
|
71
|
+
let serial = 0;
|
|
72
|
+
const work: number[] = [];
|
|
73
|
+
|
|
74
|
+
function enqueue(index: number): void {
|
|
75
|
+
if (index >= stamp.length) {
|
|
76
|
+
let size = stamp.length;
|
|
77
|
+
while (size <= index) size *= 2;
|
|
78
|
+
const next = new Uint32Array(size);
|
|
79
|
+
next.set(stamp);
|
|
80
|
+
stamp = next;
|
|
81
|
+
}
|
|
82
|
+
if (stamp[index] === serial) return;
|
|
83
|
+
stamp[index] = serial;
|
|
84
|
+
work.push(index);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
isCurrent() {
|
|
89
|
+
return (
|
|
90
|
+
world.execution.health !== 'poisoned' &&
|
|
91
|
+
!invalid &&
|
|
92
|
+
acceptedEpoch === owner.getMutationEpoch() &&
|
|
93
|
+
acceptedStructure === owner.getStructureEpoch()
|
|
94
|
+
);
|
|
95
|
+
},
|
|
96
|
+
entity(index) {
|
|
97
|
+
const record = owner.getRecords()[index];
|
|
98
|
+
if (record === undefined || record.archetypeId < 0) return undefined;
|
|
99
|
+
return encodeEntity(index, record.generation);
|
|
100
|
+
},
|
|
101
|
+
changed(entity, component) {
|
|
102
|
+
const id = componentId(component);
|
|
103
|
+
if ((owner.getComponentMutationEpochs()[id] ?? 0) <= acceptedEpoch) return false;
|
|
104
|
+
return (owner.getComponentChange(entity, id)?.changed ?? -1) > acceptedEpoch;
|
|
105
|
+
},
|
|
106
|
+
invalidate() {
|
|
107
|
+
readToken++;
|
|
108
|
+
invalid = true;
|
|
109
|
+
},
|
|
110
|
+
read() {
|
|
111
|
+
if (world.execution.health === 'poisoned')
|
|
112
|
+
throw new WorldPoisonedError(world.identity, world.execution.fault);
|
|
113
|
+
const token = ++readToken;
|
|
114
|
+
const epoch = owner.getMutationEpoch();
|
|
115
|
+
const structure = owner.getStructureEpoch();
|
|
116
|
+
const changedComponents = components.filter(
|
|
117
|
+
(component) =>
|
|
118
|
+
(owner.getComponentMutationEpochs()[componentId(component)] ?? 0) > acceptedEpoch,
|
|
119
|
+
);
|
|
120
|
+
const membershipChanged = invalid || structure !== acceptedStructure;
|
|
121
|
+
const changedRoots =
|
|
122
|
+
invalid ||
|
|
123
|
+
structure !== acceptedStructure ||
|
|
124
|
+
ids.some((id) => (owner.getComponentMutationEpochs()[id] ?? 0) > acceptedEpoch);
|
|
125
|
+
work.length = 0;
|
|
126
|
+
serial = (serial + 1) >>> 0;
|
|
127
|
+
if (serial === 0) {
|
|
128
|
+
stamp.fill(0);
|
|
129
|
+
serial = 1;
|
|
130
|
+
}
|
|
131
|
+
let scannedRows = 0;
|
|
132
|
+
let checkedBlocks = 0;
|
|
133
|
+
const updates: { table: Table; block: number; value: BlockBaseline | undefined }[] = [];
|
|
134
|
+
const visited = new Set<Table>();
|
|
135
|
+
if (changedRoots) {
|
|
136
|
+
const tables = new Set<Table>();
|
|
137
|
+
if (sparseCandidates) {
|
|
138
|
+
for (const table of graph.activeTables) tables.add(table);
|
|
139
|
+
} else {
|
|
140
|
+
for (const id of candidateIds)
|
|
141
|
+
for (const table of graph.activeTablesByComponent.get(id) ?? []) tables.add(table);
|
|
142
|
+
}
|
|
143
|
+
for (const table of tables) {
|
|
144
|
+
visited.add(table);
|
|
145
|
+
const prior = baseline.get(table);
|
|
146
|
+
const entities = table.storage.get(componentId(Entity))?.fields.get('self')?.view;
|
|
147
|
+
if (entities === undefined) continue;
|
|
148
|
+
const columns = ids.flatMap((id) => {
|
|
149
|
+
const epochs = table.storage.get(id)?.epochs;
|
|
150
|
+
return epochs === undefined ? [] : [epochs];
|
|
151
|
+
});
|
|
152
|
+
const blocks = Math.ceil(table.size / PROJECTION_BLOCK_SIZE);
|
|
153
|
+
for (let block = 0; block < blocks; block++) {
|
|
154
|
+
checkedBlocks++;
|
|
155
|
+
const previous = prior?.get(block);
|
|
156
|
+
const membership = table.membership[block] ?? 0;
|
|
157
|
+
const start = block * PROJECTION_BLOCK_SIZE;
|
|
158
|
+
const end = Math.min(table.size, start + PROJECTION_BLOCK_SIZE);
|
|
159
|
+
if (invalid || previous === undefined || previous.membership !== membership) {
|
|
160
|
+
if (previous !== undefined) {
|
|
161
|
+
for (const index of previous.ids) enqueue(index);
|
|
162
|
+
scannedRows += previous.ids.length;
|
|
163
|
+
}
|
|
164
|
+
const current = new Uint32Array(end - start);
|
|
165
|
+
for (let row = start; row < end; row++) {
|
|
166
|
+
const index = entityIndex(entities[row] as EntityHandle);
|
|
167
|
+
current[row - start] = index;
|
|
168
|
+
enqueue(index);
|
|
169
|
+
}
|
|
170
|
+
scannedRows += end - start;
|
|
171
|
+
updates.push({ table, block, value: { ids: current, membership } });
|
|
172
|
+
} else {
|
|
173
|
+
let changedBlock = false;
|
|
174
|
+
for (const column of columns) {
|
|
175
|
+
if ((column.blocks[block] ?? 0) <= acceptedEpoch) continue;
|
|
176
|
+
changedBlock = true;
|
|
177
|
+
for (let row = start; row < end; row++) {
|
|
178
|
+
if ((column.changed[row] ?? 0) > acceptedEpoch)
|
|
179
|
+
enqueue(entityIndex(entities[row] as EntityHandle));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (!changedBlock) continue;
|
|
183
|
+
scannedRows += end - start;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (prior !== undefined) {
|
|
187
|
+
for (const [block, previous] of prior) {
|
|
188
|
+
if (block < blocks) continue;
|
|
189
|
+
checkedBlocks++;
|
|
190
|
+
for (const index of previous.ids) enqueue(index);
|
|
191
|
+
scannedRows += previous.ids.length;
|
|
192
|
+
updates.push({ table, block, value: undefined });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
for (const [table, prior] of baseline) {
|
|
197
|
+
if (visited.has(table)) continue;
|
|
198
|
+
for (const [block, previous] of prior) {
|
|
199
|
+
checkedBlocks++;
|
|
200
|
+
for (const index of previous.ids) enqueue(index);
|
|
201
|
+
scannedRows += previous.ids.length;
|
|
202
|
+
updates.push({ table, block, value: undefined });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
let accepted = false;
|
|
207
|
+
const validate = (): void => {
|
|
208
|
+
if (world.execution.health === 'poisoned')
|
|
209
|
+
throw new WorldPoisonedError(world.identity, world.execution.fault);
|
|
210
|
+
if (
|
|
211
|
+
token !== readToken ||
|
|
212
|
+
epoch !== owner.getMutationEpoch() ||
|
|
213
|
+
structure !== owner.getStructureEpoch()
|
|
214
|
+
) {
|
|
215
|
+
throw new StateProjectionExpiredError();
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
return {
|
|
219
|
+
indices: work,
|
|
220
|
+
epoch,
|
|
221
|
+
scannedRows,
|
|
222
|
+
checkedBlocks,
|
|
223
|
+
membershipChanged,
|
|
224
|
+
changedComponents,
|
|
225
|
+
validate,
|
|
226
|
+
accept() {
|
|
227
|
+
if (accepted) return;
|
|
228
|
+
validate();
|
|
229
|
+
for (const update of updates) {
|
|
230
|
+
let blocks = baseline.get(update.table);
|
|
231
|
+
if (update.value === undefined) {
|
|
232
|
+
blocks?.delete(update.block);
|
|
233
|
+
if (blocks?.size === 0) baseline.delete(update.table);
|
|
234
|
+
} else {
|
|
235
|
+
if (blocks === undefined) {
|
|
236
|
+
blocks = new Map();
|
|
237
|
+
baseline.set(update.table, blocks);
|
|
238
|
+
}
|
|
239
|
+
blocks.set(update.block, update.value);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
acceptedEpoch = epoch;
|
|
243
|
+
acceptedStructure = structure;
|
|
244
|
+
invalid = false;
|
|
245
|
+
accepted = true;
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
package/src/query/query.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { DERIVED_WRITER } from '../internal';
|
|
|
24
24
|
import { isRelationshipTarget, relationshipRole } from '../relationship-index';
|
|
25
25
|
import type { Archetype, ArchetypeId } from '../storage/archetype';
|
|
26
26
|
import type { ArchetypeGraph } from '../storage/archetype-graph';
|
|
27
|
-
import { sparseTagIndex } from '../storage/change-detection';
|
|
27
|
+
import { PROJECTION_BLOCK_SIZE, sparseTagIndex } from '../storage/change-detection';
|
|
28
28
|
import type { FieldView, ManagedColumnReader } from '../storage/column';
|
|
29
29
|
import type { Table } from '../storage/table';
|
|
30
30
|
import { type WorldInternal, worldInternal } from '../world-internal';
|
|
@@ -206,21 +206,14 @@ class QueryRowFacade<
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
has(component: R[number] | W[number] | O[number]): boolean {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
(candidate) => componentId(candidate) === componentId(component),
|
|
212
|
-
) === true
|
|
213
|
-
);
|
|
209
|
+
const id = componentId(component);
|
|
210
|
+
return this.archetype?.components.some((candidate) => componentId(candidate) === id) === true;
|
|
214
211
|
}
|
|
215
212
|
|
|
216
213
|
get<C extends R[number]>(component: C): ReadonlyRowShape<C>;
|
|
217
214
|
get<C extends O[number]>(component: C): ReadonlyRowShape<C> | undefined;
|
|
218
215
|
get(component: Component): Record<string, unknown> | undefined {
|
|
219
|
-
if (
|
|
220
|
-
!this.archetype?.components.some(
|
|
221
|
-
(candidate) => componentId(candidate) === componentId(component),
|
|
222
|
-
)
|
|
223
|
-
) {
|
|
216
|
+
if (!this.has(component)) {
|
|
224
217
|
return undefined;
|
|
225
218
|
}
|
|
226
219
|
const result = this.world[worldInternal].getQueryRow(this.entity, component);
|
|
@@ -370,8 +363,9 @@ class ExecutableQuery<
|
|
|
370
363
|
const structureEpoch = this.world[worldInternal].getStructureEpoch();
|
|
371
364
|
const upperBound = this.world[worldInternal].getMutationEpoch();
|
|
372
365
|
const row = new QueryRowFacade<R, W, O>(this.world);
|
|
373
|
-
|
|
374
|
-
let
|
|
366
|
+
const unchanged = this.hasUnchangedInput();
|
|
367
|
+
let archetypeIndex = unchanged ? this.matchedArchetypes.length : 0;
|
|
368
|
+
let tableIndex = unchanged ? this.matchedTables.length : 0;
|
|
375
369
|
let rowIndex = 0;
|
|
376
370
|
let finished = false;
|
|
377
371
|
|
|
@@ -446,7 +440,7 @@ class ExecutableQuery<
|
|
|
446
440
|
at(entity: EntityHandle): QueryRow<R, W, O> | undefined {
|
|
447
441
|
const archetype = this.world[worldInternal].getEntityArchetype(entity);
|
|
448
442
|
if (archetype === undefined || !this.archetypeMatches(archetype)) return undefined;
|
|
449
|
-
return new QueryRowFacade<R, W, O>(this.world).bind(entity, archetype)
|
|
443
|
+
return new QueryRowFacade<R, W, O>(this.world).bind(entity, archetype);
|
|
450
444
|
}
|
|
451
445
|
|
|
452
446
|
spans(): Result<Iterable<QuerySpan<R, W>>, QuerySpanUnavailableError> {
|
|
@@ -459,7 +453,8 @@ class ExecutableQuery<
|
|
|
459
453
|
query.refreshMatches();
|
|
460
454
|
const structureEpoch = query.world[worldInternal].getStructureEpoch();
|
|
461
455
|
const upperBound = query.world[worldInternal].getMutationEpoch();
|
|
462
|
-
|
|
456
|
+
const filterIds = [...query.compiled.changedIds, ...query.compiled.addedIds];
|
|
457
|
+
let tableIndex = query.hasUnchangedInput() ? query.matchedTables.length : 0;
|
|
463
458
|
let rowIndex = 0;
|
|
464
459
|
let finished = false;
|
|
465
460
|
const close = (commit: boolean): void => {
|
|
@@ -498,7 +493,12 @@ class ExecutableQuery<
|
|
|
498
493
|
rowIndex < table.size &&
|
|
499
494
|
!query.denseChangeMatches(table, rowIndex, upperBound)
|
|
500
495
|
) {
|
|
501
|
-
rowIndex
|
|
496
|
+
const block = Math.floor(rowIndex / PROJECTION_BLOCK_SIZE);
|
|
497
|
+
const unchanged = filterIds.some(
|
|
498
|
+
(id) =>
|
|
499
|
+
(table.storage.get(id)?.epochs.blocks[block] ?? 0) <= query.lastObservedEpoch,
|
|
500
|
+
);
|
|
501
|
+
rowIndex = unchanged ? (block + 1) * PROJECTION_BLOCK_SIZE : rowIndex + 1;
|
|
502
502
|
}
|
|
503
503
|
if (rowIndex >= table.size) {
|
|
504
504
|
tableIndex += 1;
|
|
@@ -594,6 +594,13 @@ class ExecutableQuery<
|
|
|
594
594
|
};
|
|
595
595
|
}
|
|
596
596
|
|
|
597
|
+
private hasUnchangedInput(): boolean {
|
|
598
|
+
const epochs = this.world[worldInternal].getComponentMutationEpochs();
|
|
599
|
+
// Changed predicates are conjunctive. One unchanged input proves the
|
|
600
|
+
// entire result empty without consulting any entity or table row.
|
|
601
|
+
return this.compiled.changedIds.some((id) => (epochs[id] ?? 0) <= this.lastObservedEpoch);
|
|
602
|
+
}
|
|
603
|
+
|
|
597
604
|
private beginIteration(): void {
|
|
598
605
|
if (this.active) throw new QueryIterationActiveError();
|
|
599
606
|
this.active = true;
|
package/src/shared-ref-store.ts
CHANGED
|
@@ -52,7 +52,6 @@
|
|
|
52
52
|
//
|
|
53
53
|
// Release path (D-1 codes):
|
|
54
54
|
// - resolve(h): err(SharedRefReleasedError) if payload absent.
|
|
55
|
-
// - markChanged(h): publish an in-place payload mutation to subscribers.
|
|
56
55
|
// - retain(h): err(SharedRefReleasedError) if payload absent.
|
|
57
56
|
// - release(h): err(SharedRefDoubleReleaseError, rc=0) on rc=0 input.
|
|
58
57
|
// - any(builtin slot): err(BuiltinSlotNotOwnedError) (D-15).
|
|
@@ -84,13 +83,6 @@ import {
|
|
|
84
83
|
SharedRefStaleError,
|
|
85
84
|
} from './errors.js';
|
|
86
85
|
|
|
87
|
-
const SHARED_REF_RELEASE_EVIDENCE_CAPACITY = 4096;
|
|
88
|
-
|
|
89
|
-
export interface SharedRefMutation {
|
|
90
|
-
readonly epoch: number;
|
|
91
|
-
readonly handle: number;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
86
|
export interface SharedRefReleaseEvidence {
|
|
95
87
|
readonly payload: unknown;
|
|
96
88
|
readonly refcount: 0;
|
|
@@ -98,16 +90,6 @@ export interface SharedRefReleaseEvidence {
|
|
|
98
90
|
readonly evidence: 'released';
|
|
99
91
|
}
|
|
100
92
|
|
|
101
|
-
/** Diagnostic history identifies releases without retaining external payloads. */
|
|
102
|
-
export type SharedRefReleaseRecord = Omit<SharedRefReleaseEvidence, 'payload'> & {
|
|
103
|
-
readonly handle: number;
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
export interface SharedRefMutationRead {
|
|
107
|
-
readonly cursor: number;
|
|
108
|
-
readonly records: readonly SharedRefMutation[];
|
|
109
|
-
}
|
|
110
|
-
|
|
111
93
|
// MAX_SLOT is now imported from @forgeax/engine-types (codec SSOT, D-1).
|
|
112
94
|
// The local constant is removed to avoid drift (AC-15).
|
|
113
95
|
|
|
@@ -127,8 +109,6 @@ export interface SharedRefMutationRead {
|
|
|
127
109
|
* - alloc(target, payload) -> Handle<T, 'shared'> (rc=1)
|
|
128
110
|
* - intern(target, payload) -> stable producer handle per target + object identity
|
|
129
111
|
* - resolve(handle) -> Result<T, SharedRefReleasedError | SharedRefStaleError | BuiltinSlotNotOwnedError>
|
|
130
|
-
* - markChanged(handle) -> Result<void, SharedRefReleasedError | SharedRefStaleError | BuiltinSlotNotOwnedError>
|
|
131
|
-
* - getMutationEpoch() -> monotonic payload-mutation cursor
|
|
132
112
|
* - retain(handle) -> Result<void, SharedRefReleasedError | SharedRefStaleError | BuiltinSlotNotOwnedError>
|
|
133
113
|
* - release(handle) -> Result<release evidence | undefined, ...>
|
|
134
114
|
* - refcount(handle) -> number (0 == released; debug + tests)
|
|
@@ -144,10 +124,7 @@ export class SharedRefStore {
|
|
|
144
124
|
{ readonly target: string; readonly payload: object }
|
|
145
125
|
>();
|
|
146
126
|
private nextSlot = BUILTIN_BASE;
|
|
147
|
-
private mutationEpoch = 0;
|
|
148
127
|
/** Latest published mutation epoch per live handle; not an event journal. */
|
|
149
|
-
private readonly mutationEpochs = new Map<number, number>();
|
|
150
|
-
private readonly releaseJournal: SharedRefReleaseRecord[] = [];
|
|
151
128
|
|
|
152
129
|
/**
|
|
153
130
|
* Generation table indexed by slot (D-6). Each entry tracks the current
|
|
@@ -261,36 +238,6 @@ export class SharedRefStore {
|
|
|
261
238
|
* projections of shared payload data compare the monotonic epoch and
|
|
262
239
|
* explicitly refresh instead of rescanning every payload each frame.
|
|
263
240
|
*/
|
|
264
|
-
markChanged<Target extends string>(
|
|
265
|
-
handle: Handle<Target, 'shared'>,
|
|
266
|
-
): Result<void, SharedRefReleasedError | SharedRefStaleError | BuiltinSlotNotOwnedError> {
|
|
267
|
-
const resolved = this.resolve(handle);
|
|
268
|
-
if (!resolved.ok) return resolved;
|
|
269
|
-
if (this.mutationEpoch >= Number.MAX_SAFE_INTEGER) {
|
|
270
|
-
throw new RangeError('SharedRefStore mutation epoch exhausted');
|
|
271
|
-
}
|
|
272
|
-
this.mutationEpoch += 1;
|
|
273
|
-
this.mutationEpochs.set(unwrapHandle(handle), this.mutationEpoch);
|
|
274
|
-
return ok(undefined);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/** Current upper bound for explicitly published payload mutations. */
|
|
278
|
-
getMutationEpoch(): number {
|
|
279
|
-
return this.mutationEpoch;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/** Read each live handle whose latest published mutation is after `cursor`. */
|
|
283
|
-
readChangesSince(cursor: number): SharedRefMutationRead {
|
|
284
|
-
const records: SharedRefMutation[] = [];
|
|
285
|
-
for (const [handle, epoch] of this.mutationEpochs) {
|
|
286
|
-
if (epoch > cursor) records.push({ epoch, handle });
|
|
287
|
-
}
|
|
288
|
-
records.sort((left, right) => left.epoch - right.epoch || left.handle - right.handle);
|
|
289
|
-
return {
|
|
290
|
-
cursor: this.mutationEpoch,
|
|
291
|
-
records,
|
|
292
|
-
};
|
|
293
|
-
}
|
|
294
241
|
|
|
295
242
|
/**
|
|
296
243
|
* Increment the refcount of a live shared handle. Returns
|
|
@@ -368,7 +315,6 @@ export class SharedRefStore {
|
|
|
368
315
|
}
|
|
369
316
|
this.refcounts.delete(raw);
|
|
370
317
|
this.payloads.delete(raw);
|
|
371
|
-
this.mutationEpochs.delete(raw);
|
|
372
318
|
// Gen increment + retire (AC-07): bump gen; once it would exceed MAX_GEN
|
|
373
319
|
// (gen 255 is still usable; the bump to 256 triggers retire) the slot is
|
|
374
320
|
// permanently retired — NOT pushed to freeSlots. This prevents handle
|
|
@@ -385,25 +331,9 @@ export class SharedRefStore {
|
|
|
385
331
|
generation,
|
|
386
332
|
evidence: 'released' as const,
|
|
387
333
|
});
|
|
388
|
-
this.releaseJournal.push(
|
|
389
|
-
Object.freeze({
|
|
390
|
-
handle: raw,
|
|
391
|
-
refcount: 0 as const,
|
|
392
|
-
generation,
|
|
393
|
-
evidence: 'released' as const,
|
|
394
|
-
}),
|
|
395
|
-
);
|
|
396
|
-
if (this.releaseJournal.length > SHARED_REF_RELEASE_EVIDENCE_CAPACITY) {
|
|
397
|
-
this.releaseJournal.shift();
|
|
398
|
-
}
|
|
399
334
|
return ok(evidence);
|
|
400
335
|
}
|
|
401
336
|
|
|
402
|
-
/** Read bounded release metadata; payload ownership remains with the caller. */
|
|
403
|
-
readReleaseEvidence(): readonly SharedRefReleaseRecord[] {
|
|
404
|
-
return this.releaseJournal;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
337
|
/**
|
|
408
338
|
* Return the current refcount for `handle`. Returns 0 for a released
|
|
409
339
|
* (or never-allocated) slot. Primarily a debug + tests entry point;
|