@forgeax/engine-ecs 0.1.20 → 0.1.23
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 +50 -16
- package/dist/__tests__/changed-block-summary.candidate.test.d.ts +2 -0
- package/dist/__tests__/changed-block-summary.candidate.test.d.ts.map +1 -0
- package/dist/__tests__/component-requirements.unit.test.d.ts +2 -0
- package/dist/__tests__/component-requirements.unit.test.d.ts.map +1 -0
- package/dist/__tests__/component-version-surface.test.d.ts +2 -0
- package/dist/__tests__/component-version-surface.test.d.ts.map +1 -0
- package/dist/__tests__/derived-range-writer.contract.test-d.d.ts +2 -0
- package/dist/__tests__/derived-range-writer.contract.test-d.d.ts.map +1 -0
- package/dist/__tests__/derived-range-writer.contract.test.d.ts +2 -0
- package/dist/__tests__/derived-range-writer.contract.test.d.ts.map +1 -0
- package/dist/__tests__/structural-evidence.contract.test-d.d.ts +2 -0
- package/dist/__tests__/structural-evidence.contract.test-d.d.ts.map +1 -0
- package/dist/__tests__/structural-evidence.contract.test.d.ts +2 -0
- package/dist/__tests__/structural-evidence.contract.test.d.ts.map +1 -0
- package/dist/component-schema.d.ts +17 -0
- package/dist/component-schema.d.ts.map +1 -1
- package/dist/component.d.ts +7 -1
- package/dist/component.d.ts.map +1 -1
- package/dist/errors/query-and-component-errors.d.ts +1 -1
- package/dist/errors/query-and-component-errors.d.ts.map +1 -1
- package/dist/errors/validation-errors.d.ts +24 -0
- package/dist/errors/validation-errors.d.ts.map +1 -1
- package/dist/errors.d.ts +14 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/externalization/index.mjs.map +1 -1
- package/dist/index.mjs +415 -128
- package/dist/index.mjs.map +1 -1
- package/dist/internal.d.ts +16 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.mjs +15 -2
- package/dist/internal.mjs.map +1 -1
- package/dist/projection/index.d.ts +27 -52
- package/dist/projection/index.d.ts.map +1 -1
- package/dist/projection/index.mjs +66 -76
- package/dist/projection/index.mjs.map +1 -1
- package/dist/query/derived-range-writer.d.ts +48 -0
- package/dist/query/derived-range-writer.d.ts.map +1 -0
- package/dist/query/query.d.ts +1 -1
- package/dist/query/query.d.ts.map +1 -1
- package/dist/relationship-index.d.ts +8 -0
- package/dist/relationship-index.d.ts.map +1 -1
- package/dist/shared-ref-store.d.ts +5 -9
- package/dist/shared-ref-store.d.ts.map +1 -1
- package/dist/shared.mjs.map +1 -1
- package/dist/storage/change-detection.d.ts +1 -3
- package/dist/storage/change-detection.d.ts.map +1 -1
- package/dist/storage/structural-evidence.d.ts +30 -0
- package/dist/storage/structural-evidence.d.ts.map +1 -0
- package/dist/world-component-access.d.ts +4 -3
- package/dist/world-component-access.d.ts.map +1 -1
- package/dist/world-core.d.ts +2 -2
- package/dist/world-core.d.ts.map +1 -1
- package/dist/world-entity-lifecycle.d.ts.map +1 -1
- package/dist/world-internal.d.ts +1 -1
- package/dist/world-internal.d.ts.map +1 -1
- package/dist/world.d.ts +10 -14
- package/dist/world.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/changed-block-summary.candidate.test.ts +57 -0
- package/src/__tests__/component-requirements.unit.test.ts +76 -0
- package/src/__tests__/component-version-surface.test.ts +45 -0
- package/src/__tests__/derived-range-writer.contract.test-d.ts +35 -0
- package/src/__tests__/derived-range-writer.contract.test.ts +339 -0
- package/src/__tests__/errors.unit.test.ts +21 -5
- package/src/__tests__/externalization-render-read-lease.test-d.ts +1 -1
- package/src/__tests__/externalization-render-read-lease.unit.test.ts +15 -15
- package/src/__tests__/query-storage-trends.perf.test.ts +19 -0
- package/src/__tests__/query.unit.test.ts +10 -6
- package/src/__tests__/shared-ref-store.unit.test.ts +3 -8
- package/src/__tests__/structural-evidence.contract.test-d.ts +6 -0
- package/src/__tests__/structural-evidence.contract.test.ts +49 -0
- package/src/component-schema.ts +51 -0
- package/src/component.ts +12 -2
- package/src/errors/query-and-component-errors.ts +1 -1
- package/src/errors/validation-errors.ts +30 -0
- package/src/errors.ts +29 -5
- package/src/internal.ts +56 -0
- package/src/projection/index.ts +68 -149
- package/src/query/derived-range-writer.ts +332 -0
- package/src/query/query.ts +89 -15
- package/src/relationship-index.ts +13 -1
- package/src/shared-ref-store.ts +16 -24
- package/src/storage/change-detection.ts +0 -15
- package/src/storage/structural-evidence.ts +64 -0
- package/src/world-component-access.ts +53 -5
- package/src/world-core.ts +2 -2
- package/src/world-entity-lifecycle.ts +10 -1
- package/src/world-internal.ts +5 -5
- package/src/world.ts +45 -50
- package/dist/.tsbuildinfo +0 -1
- package/dist/__tests__/world-change-journal.unit.test.d.ts +0 -2
- package/dist/__tests__/world-change-journal.unit.test.d.ts.map +0 -1
- package/dist/__tests__/world-internal-identity.unit.test.d.ts +0 -2
- package/dist/__tests__/world-internal-identity.unit.test.d.ts.map +0 -1
- package/dist/world-change-journal.d.ts +0 -34
- package/dist/world-change-journal.d.ts.map +0 -1
- package/src/__tests__/world-change-journal.unit.test.ts +0 -97
- package/src/__tests__/world-internal-identity.unit.test.ts +0 -17
- package/src/world-change-journal.ts +0 -84
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { componentId, defineComponent } from '../component';
|
|
3
|
+
import { ChangeEpochExhaustedError } from '../errors';
|
|
4
|
+
import { getDerivedWriter } from '../internal';
|
|
5
|
+
import type { Query } from '../query/query';
|
|
6
|
+
import { World } from '../world';
|
|
7
|
+
import { worldInternal } from '../world-internal';
|
|
8
|
+
|
|
9
|
+
const Value = defineComponent('DerivedRangeWriterValue', { value: 'f32' });
|
|
10
|
+
const Input = defineComponent('DerivedRangeWriterInput', { value: 'f32' });
|
|
11
|
+
|
|
12
|
+
function fixture(): {
|
|
13
|
+
world: World;
|
|
14
|
+
query: Query<readonly [typeof Input], readonly [typeof Value]>;
|
|
15
|
+
} {
|
|
16
|
+
const world = new World();
|
|
17
|
+
for (let index = 0; index < 4; index += 1) {
|
|
18
|
+
world
|
|
19
|
+
.spawn(
|
|
20
|
+
{ component: Input, data: { value: index } },
|
|
21
|
+
{ component: Value, data: { value: index } },
|
|
22
|
+
)
|
|
23
|
+
.unwrap();
|
|
24
|
+
}
|
|
25
|
+
const query = world.query({ read: [Input], write: [Value] }).unwrap();
|
|
26
|
+
return { world, query };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('package-internal derived range writer contract', () => {
|
|
30
|
+
it('publishes only the exact span-relative range with one component epoch', () => {
|
|
31
|
+
const { world, query } = fixture();
|
|
32
|
+
const writer = getDerivedWriter(query, Value).unwrap();
|
|
33
|
+
const beforeEpoch = world[worldInternal].getMutationEpoch();
|
|
34
|
+
|
|
35
|
+
expect(
|
|
36
|
+
writer.writeRange(
|
|
37
|
+
0,
|
|
38
|
+
0,
|
|
39
|
+
1,
|
|
40
|
+
2,
|
|
41
|
+
(binding, _base, start, count) => {
|
|
42
|
+
expect(count).toBe(2);
|
|
43
|
+
binding.write.value[start] = 11;
|
|
44
|
+
binding.write.value[start + 1] = 12;
|
|
45
|
+
},
|
|
46
|
+
undefined,
|
|
47
|
+
),
|
|
48
|
+
).toEqual({ ok: true, value: undefined });
|
|
49
|
+
expect(world[worldInternal].getMutationEpoch()).toBe(beforeEpoch + 1);
|
|
50
|
+
const values: number[] = [];
|
|
51
|
+
for (const row of world.query({ read: [Value] }).unwrap()) {
|
|
52
|
+
values.push(row.get(Value).value);
|
|
53
|
+
}
|
|
54
|
+
expect(values).toEqual([0, 11, 12, 3]);
|
|
55
|
+
|
|
56
|
+
const graph = world[worldInternal].getGraph();
|
|
57
|
+
const record = world[worldInternal].getRecords()[0];
|
|
58
|
+
const table = graph.tables[graph.archetypes[record?.archetypeId ?? -1]?.tableId ?? -1];
|
|
59
|
+
const epochs = table?.storage.get(componentId(Value))?.epochs.changed;
|
|
60
|
+
expect(epochs?.slice(0, 4)).toEqual(new Float64Array([1, beforeEpoch + 1, beforeEpoch + 1, 4]));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('does not call the kernel or consume an epoch for a zero-length range', () => {
|
|
64
|
+
const { world, query } = fixture();
|
|
65
|
+
const writer = getDerivedWriter(query, Value).unwrap();
|
|
66
|
+
const beforeEpoch = world[worldInternal].getMutationEpoch();
|
|
67
|
+
let called = false;
|
|
68
|
+
expect(
|
|
69
|
+
writer.writeRange(
|
|
70
|
+
0,
|
|
71
|
+
0,
|
|
72
|
+
2,
|
|
73
|
+
0,
|
|
74
|
+
() => {
|
|
75
|
+
called = true;
|
|
76
|
+
},
|
|
77
|
+
undefined,
|
|
78
|
+
),
|
|
79
|
+
).toEqual({ ok: true, value: undefined });
|
|
80
|
+
expect(called).toBe(false);
|
|
81
|
+
expect(world[worldInternal].getMutationEpoch()).toBe(beforeEpoch);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('probes each row once and commits only the exact changed runs', () => {
|
|
85
|
+
const { world, query } = fixture();
|
|
86
|
+
const writer = getDerivedWriter(query, Value).unwrap();
|
|
87
|
+
const beforeEpoch = world[worldInternal].getMutationEpoch();
|
|
88
|
+
let probes = 0;
|
|
89
|
+
let commits = 0;
|
|
90
|
+
const result = writer.probeAndCommitRange(
|
|
91
|
+
0,
|
|
92
|
+
0,
|
|
93
|
+
0,
|
|
94
|
+
4,
|
|
95
|
+
(_binding, row) => {
|
|
96
|
+
probes += 1;
|
|
97
|
+
return row === 1 || row === 3;
|
|
98
|
+
},
|
|
99
|
+
(binding, row) => {
|
|
100
|
+
commits += 1;
|
|
101
|
+
binding.write.value[row] = row + 10;
|
|
102
|
+
},
|
|
103
|
+
undefined,
|
|
104
|
+
);
|
|
105
|
+
expect(result).toEqual({ ok: true, value: undefined });
|
|
106
|
+
expect(probes).toBe(4);
|
|
107
|
+
expect(commits).toBe(2);
|
|
108
|
+
expect(world[worldInternal].getMutationEpoch()).toBe(beforeEpoch + 1);
|
|
109
|
+
const values: number[] = [];
|
|
110
|
+
for (const row of world.query({ read: [Value] }).unwrap()) values.push(row.get(Value).value);
|
|
111
|
+
expect(values).toEqual([0, 11, 2, 13]);
|
|
112
|
+
const graph = world[worldInternal].getGraph();
|
|
113
|
+
const record = world[worldInternal].getRecords()[0];
|
|
114
|
+
const table = graph.tables[graph.archetypes[record?.archetypeId ?? -1]?.tableId ?? -1];
|
|
115
|
+
const epochs = table?.storage.get(componentId(Value))?.epochs.changed;
|
|
116
|
+
expect(epochs?.[1]).toBe(beforeEpoch + 1);
|
|
117
|
+
expect(epochs?.[3]).toBe(beforeEpoch + 1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('poisons the World when a changed-row commit fails after partial output', () => {
|
|
121
|
+
const { world, query } = fixture();
|
|
122
|
+
const writer = getDerivedWriter(query, Value).unwrap();
|
|
123
|
+
const failed = writer.probeAndCommitRange(
|
|
124
|
+
0,
|
|
125
|
+
0,
|
|
126
|
+
0,
|
|
127
|
+
2,
|
|
128
|
+
() => true,
|
|
129
|
+
(binding, row) => {
|
|
130
|
+
binding.write.value[row] = 31;
|
|
131
|
+
if (row === 1) throw new Error('partial row commit failure');
|
|
132
|
+
},
|
|
133
|
+
undefined,
|
|
134
|
+
);
|
|
135
|
+
expect(failed.ok).toBe(false);
|
|
136
|
+
expect(world.execution.health).toBe('poisoned');
|
|
137
|
+
expect(
|
|
138
|
+
writer.probeAndCommitRange(
|
|
139
|
+
0,
|
|
140
|
+
0,
|
|
141
|
+
0,
|
|
142
|
+
1,
|
|
143
|
+
() => false,
|
|
144
|
+
() => undefined,
|
|
145
|
+
undefined,
|
|
146
|
+
).ok,
|
|
147
|
+
).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('keeps the World healthy when the initial epoch reservation is exhausted', () => {
|
|
151
|
+
const { world, query } = fixture();
|
|
152
|
+
const writer = getDerivedWriter(query, Value).unwrap();
|
|
153
|
+
const rows = query[Symbol.iterator]();
|
|
154
|
+
const first = rows.next();
|
|
155
|
+
const firstEntity = first.done ? undefined : first.value.entity;
|
|
156
|
+
rows.return?.();
|
|
157
|
+
if (firstEntity === undefined) throw new Error('expected a fixture entity');
|
|
158
|
+
const beforeEpoch = world[worldInternal].getMutationEpoch();
|
|
159
|
+
const originalNextEpoch = world[worldInternal].nextMutationEpoch;
|
|
160
|
+
Object.defineProperty(world[worldInternal], 'nextMutationEpoch', {
|
|
161
|
+
configurable: true,
|
|
162
|
+
value: () => {
|
|
163
|
+
throw new ChangeEpochExhaustedError(Number.MAX_SAFE_INTEGER);
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
try {
|
|
167
|
+
let commits = 0;
|
|
168
|
+
const failed = writer.probeAndCommitRange(
|
|
169
|
+
0,
|
|
170
|
+
0,
|
|
171
|
+
0,
|
|
172
|
+
2,
|
|
173
|
+
(_binding, row) => row === 0,
|
|
174
|
+
(binding, row) => {
|
|
175
|
+
commits += 1;
|
|
176
|
+
binding.write.value[row] = 10;
|
|
177
|
+
},
|
|
178
|
+
undefined,
|
|
179
|
+
);
|
|
180
|
+
expect(failed.ok).toBe(false);
|
|
181
|
+
if (!failed.ok) expect(failed.error.code).toBe('change-epoch-exhausted');
|
|
182
|
+
expect(commits).toBe(0);
|
|
183
|
+
expect(world.execution.health).toBe('healthy');
|
|
184
|
+
expect(world[worldInternal].getMutationEpoch()).toBe(beforeEpoch);
|
|
185
|
+
expect(world.get(firstEntity, Value).unwrap().value).toBe(0);
|
|
186
|
+
const graph = world[worldInternal].getGraph();
|
|
187
|
+
const record = world[worldInternal].getRecords()[0];
|
|
188
|
+
const table = graph.tables[graph.archetypes[record?.archetypeId ?? -1]?.tableId ?? -1];
|
|
189
|
+
const epochs = table?.storage.get(componentId(Value))?.epochs.changed;
|
|
190
|
+
expect(epochs?.slice(0, 4)).toEqual(new Float64Array([1, 2, 3, 4]));
|
|
191
|
+
} finally {
|
|
192
|
+
Object.defineProperty(world[worldInternal], 'nextMutationEpoch', {
|
|
193
|
+
configurable: true,
|
|
194
|
+
value: originalNextEpoch,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const retried = writer.probeAndCommitRange(
|
|
199
|
+
0,
|
|
200
|
+
0,
|
|
201
|
+
0,
|
|
202
|
+
2,
|
|
203
|
+
() => true,
|
|
204
|
+
(binding, row) => {
|
|
205
|
+
binding.write.value[row] = 10;
|
|
206
|
+
},
|
|
207
|
+
undefined,
|
|
208
|
+
);
|
|
209
|
+
expect(retried.ok).toBe(true);
|
|
210
|
+
expect(world.execution.health).toBe('healthy');
|
|
211
|
+
expect(world[worldInternal].getMutationEpoch()).toBe(beforeEpoch + 1);
|
|
212
|
+
expect(world.get(firstEntity, Value).unwrap().value).toBe(10);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('poisons and restores the epoch when a later sparse probe fails', () => {
|
|
216
|
+
const { world, query } = fixture();
|
|
217
|
+
const writer = getDerivedWriter(query, Value).unwrap();
|
|
218
|
+
const rows = query[Symbol.iterator]();
|
|
219
|
+
const first = rows.next();
|
|
220
|
+
const firstEntity = first.done ? undefined : first.value.entity;
|
|
221
|
+
rows.return?.();
|
|
222
|
+
if (firstEntity === undefined) throw new Error('expected a fixture entity');
|
|
223
|
+
const beforeEpoch = world[worldInternal].getMutationEpoch();
|
|
224
|
+
const failed = writer.probeAndCommitRange(
|
|
225
|
+
0,
|
|
226
|
+
0,
|
|
227
|
+
0,
|
|
228
|
+
3,
|
|
229
|
+
(_binding, row) => {
|
|
230
|
+
if (row === 2) throw new Error('later sparse probe failure');
|
|
231
|
+
return row === 0;
|
|
232
|
+
},
|
|
233
|
+
(binding, row) => {
|
|
234
|
+
binding.write.value[row] = row + 10;
|
|
235
|
+
},
|
|
236
|
+
undefined,
|
|
237
|
+
);
|
|
238
|
+
expect(failed.ok).toBe(false);
|
|
239
|
+
expect(world.execution.health).toBe('poisoned');
|
|
240
|
+
expect(world[worldInternal].getMutationEpoch()).toBe(beforeEpoch);
|
|
241
|
+
expect(world.get(firstEntity, Value).unwrap().value).toBe(10);
|
|
242
|
+
const graph = world[worldInternal].getGraph();
|
|
243
|
+
const record = world[worldInternal].getRecords()[0];
|
|
244
|
+
const table = graph.tables[graph.archetypes[record?.archetypeId ?? -1]?.tableId ?? -1];
|
|
245
|
+
const epochs = table?.storage.get(componentId(Value))?.epochs.changed;
|
|
246
|
+
expect(epochs?.slice(0, 4)).toEqual(new Float64Array([1, 2, 3, 4]));
|
|
247
|
+
expect(
|
|
248
|
+
writer.probeAndCommitRange(
|
|
249
|
+
0,
|
|
250
|
+
0,
|
|
251
|
+
0,
|
|
252
|
+
1,
|
|
253
|
+
() => false,
|
|
254
|
+
() => undefined,
|
|
255
|
+
undefined,
|
|
256
|
+
).ok,
|
|
257
|
+
).toBe(false);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('rejects an out-of-bounds range before the kernel and permits same-cursor retry', () => {
|
|
261
|
+
const { world, query } = fixture();
|
|
262
|
+
const writer = getDerivedWriter(query, Value).unwrap();
|
|
263
|
+
const beforeEpoch = world[worldInternal].getMutationEpoch();
|
|
264
|
+
const failed = writer.writeRange(
|
|
265
|
+
0,
|
|
266
|
+
0,
|
|
267
|
+
3,
|
|
268
|
+
2,
|
|
269
|
+
() => {
|
|
270
|
+
throw new Error('kernel must not run');
|
|
271
|
+
},
|
|
272
|
+
undefined,
|
|
273
|
+
);
|
|
274
|
+
expect(failed.ok).toBe(false);
|
|
275
|
+
if (!failed.ok && failed.error.code === 'derived-range-out-of-bounds') {
|
|
276
|
+
expect(failed.error.code).toBe('derived-range-out-of-bounds');
|
|
277
|
+
expect(failed.error.expected).toContain('span-relative range');
|
|
278
|
+
expect(failed.error.hint).toContain('start');
|
|
279
|
+
expect(failed.error.detail).toMatchObject({ start: 3, count: 2 });
|
|
280
|
+
}
|
|
281
|
+
expect(world[worldInternal].getMutationEpoch()).toBe(beforeEpoch);
|
|
282
|
+
expect(
|
|
283
|
+
writer.writeRange(
|
|
284
|
+
0,
|
|
285
|
+
0,
|
|
286
|
+
2,
|
|
287
|
+
1,
|
|
288
|
+
(binding, _base, start) => {
|
|
289
|
+
binding.write.value[start] = 22;
|
|
290
|
+
},
|
|
291
|
+
undefined,
|
|
292
|
+
).ok,
|
|
293
|
+
).toBe(true);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('keeps state on epoch exhaustion and poisons after a partial kernel write', () => {
|
|
297
|
+
const { world, query } = fixture();
|
|
298
|
+
const writer = getDerivedWriter(query, Value).unwrap();
|
|
299
|
+
const beforeEpoch = world[worldInternal].getMutationEpoch();
|
|
300
|
+
const originalNextEpoch = world[worldInternal].nextMutationEpoch;
|
|
301
|
+
Object.defineProperty(world[worldInternal], 'nextMutationEpoch', {
|
|
302
|
+
configurable: true,
|
|
303
|
+
value: () => {
|
|
304
|
+
throw new ChangeEpochExhaustedError(Number.MAX_SAFE_INTEGER);
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
const exhausted = writer.writeRange(
|
|
308
|
+
0,
|
|
309
|
+
0,
|
|
310
|
+
0,
|
|
311
|
+
1,
|
|
312
|
+
() => {
|
|
313
|
+
throw new Error('kernel must not run');
|
|
314
|
+
},
|
|
315
|
+
undefined,
|
|
316
|
+
);
|
|
317
|
+
expect(exhausted.ok).toBe(false);
|
|
318
|
+
expect(world[worldInternal].getMutationEpoch()).toBe(beforeEpoch);
|
|
319
|
+
Object.defineProperty(world[worldInternal], 'nextMutationEpoch', {
|
|
320
|
+
configurable: true,
|
|
321
|
+
value: originalNextEpoch,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const partial = writer.writeRange(
|
|
325
|
+
0,
|
|
326
|
+
0,
|
|
327
|
+
0,
|
|
328
|
+
2,
|
|
329
|
+
(binding) => {
|
|
330
|
+
binding.write.value[0] = 31;
|
|
331
|
+
throw new Error('partial kernel failure');
|
|
332
|
+
},
|
|
333
|
+
undefined,
|
|
334
|
+
);
|
|
335
|
+
expect(partial.ok).toBe(false);
|
|
336
|
+
expect(world.execution.health).toBe('poisoned');
|
|
337
|
+
expect(writer.writeRange(0, 0, 0, 1, () => undefined, undefined).ok).toBe(false);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
@@ -23,7 +23,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
23
23
|
import { err, ok, type Result } from '@forgeax/engine-types';
|
|
24
24
|
import { describe, expect, expectTypeOf, it, test } from 'vitest';
|
|
25
25
|
import { componentSchema, defineComponent } from '../component';
|
|
26
|
-
import type { EcsErrorCode } from '../errors';
|
|
26
|
+
import type { EcsErrorCode, EcsErrorDetail } from '../errors';
|
|
27
27
|
import {
|
|
28
28
|
ComponentAlreadyPresentError,
|
|
29
29
|
ComponentNotPresentError,
|
|
@@ -977,25 +977,41 @@ import { World } from '../world';
|
|
|
977
977
|
expect(code).toBe('spawn-light-invalid-bounds');
|
|
978
978
|
});
|
|
979
979
|
|
|
980
|
-
it("EcsErrorDetail discriminated variant for 'spawn-light-invalid-bounds' carries
|
|
981
|
-
|
|
980
|
+
it("EcsErrorDetail discriminated variant for 'spawn-light-invalid-bounds' carries scalar and RGB fields", () => {
|
|
981
|
+
type InvalidLightBoundsDetail = Extract<
|
|
982
|
+
EcsErrorDetail,
|
|
983
|
+
{ readonly code: 'spawn-light-invalid-bounds' }
|
|
984
|
+
>;
|
|
985
|
+
const range: InvalidLightBoundsDetail = {
|
|
982
986
|
code: 'spawn-light-invalid-bounds',
|
|
983
987
|
field: 'range',
|
|
984
988
|
got: -1,
|
|
985
989
|
};
|
|
986
|
-
const innerOuter = {
|
|
990
|
+
const innerOuter: InvalidLightBoundsDetail = {
|
|
987
991
|
code: 'spawn-light-invalid-bounds',
|
|
988
992
|
field: 'innerOuter',
|
|
989
993
|
got: 25,
|
|
990
994
|
};
|
|
991
|
-
const outerNinety = {
|
|
995
|
+
const outerNinety: InvalidLightBoundsDetail = {
|
|
992
996
|
code: 'spawn-light-invalid-bounds',
|
|
993
997
|
field: 'outerNinety',
|
|
994
998
|
got: 91,
|
|
995
999
|
};
|
|
1000
|
+
const direction: InvalidLightBoundsDetail = {
|
|
1001
|
+
code: 'spawn-light-invalid-bounds',
|
|
1002
|
+
field: 'direction',
|
|
1003
|
+
got: [Number.NaN, 0, 0],
|
|
1004
|
+
};
|
|
1005
|
+
const irradiance: InvalidLightBoundsDetail = {
|
|
1006
|
+
code: 'spawn-light-invalid-bounds',
|
|
1007
|
+
field: 'irradiance',
|
|
1008
|
+
got: Array.from(new Float32Array(27)),
|
|
1009
|
+
};
|
|
996
1010
|
expect(range.code).toBe('spawn-light-invalid-bounds');
|
|
997
1011
|
expect(innerOuter.code).toBe('spawn-light-invalid-bounds');
|
|
998
1012
|
expect(outerNinety.code).toBe('spawn-light-invalid-bounds');
|
|
1013
|
+
expect(direction.field).toBe('direction');
|
|
1014
|
+
expect(irradiance.field).toBe('irradiance');
|
|
999
1015
|
});
|
|
1000
1016
|
|
|
1001
1017
|
it("type-level: switch over EcsErrorCode includes 'spawn-light-invalid-bounds' branch", () => {
|
|
@@ -13,7 +13,7 @@ const lease = createRenderReadLease(world);
|
|
|
13
13
|
const request: RenderProjectionRequest = {
|
|
14
14
|
components: [{ component: Position, fields: ['x'] }],
|
|
15
15
|
};
|
|
16
|
-
const batch = lease.readChanges(lease.
|
|
16
|
+
const batch = lease.readChanges(lease.captureVersion());
|
|
17
17
|
const projection = lease.querySpans(request);
|
|
18
18
|
|
|
19
19
|
expectTypeOf(lease.worldIdentity).toEqualTypeOf<string>();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { defineComponent } from '../component';
|
|
2
|
+
import { componentId, defineComponent } from '../component';
|
|
3
3
|
import { createRenderReadLease, type RenderProjectionRequest } from '../projection/index';
|
|
4
4
|
import { World } from '../world';
|
|
5
5
|
|
|
@@ -27,36 +27,36 @@ describe('RenderReadLease contract', () => {
|
|
|
27
27
|
expect(projection.spans[0]?.fields.y).toEqual(new Float32Array([2]));
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
-
it('publishes world and shared-ref changes
|
|
30
|
+
it('publishes world and shared-ref changes from an explicit consumer version', () => {
|
|
31
31
|
const world = new World();
|
|
32
|
-
const lease = createRenderReadLease(world);
|
|
33
|
-
const cursor = lease.inspectCursor();
|
|
34
32
|
const entity = world.spawn({ component: Position, data: { x: 0, y: 0 } }).unwrap();
|
|
33
|
+
const lease = createRenderReadLease(world);
|
|
34
|
+
const version = lease.captureVersion();
|
|
35
35
|
world.set(entity, Position, { x: 3 }).unwrap();
|
|
36
|
-
const batch = lease.readChanges(
|
|
36
|
+
const batch = lease.readChanges(version);
|
|
37
37
|
|
|
38
38
|
expect(batch.status).toBe('ok');
|
|
39
39
|
if (batch.status !== 'ok') return;
|
|
40
|
-
expect(batch.
|
|
41
|
-
expect(batch.world.
|
|
42
|
-
expect(batch.sharedRefs.
|
|
40
|
+
expect(batch.version.mutationEpoch).toBeGreaterThan(version.mutationEpoch);
|
|
41
|
+
expect(batch.world.changedComponentIds).toContain(componentId(Position));
|
|
42
|
+
expect(batch.sharedRefs.records).toEqual([]);
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
-
it('
|
|
45
|
+
it('requires an explicit rebuild after structure changes', () => {
|
|
46
46
|
const world = new World();
|
|
47
47
|
const lease = createRenderReadLease(world);
|
|
48
|
-
const
|
|
48
|
+
const version = lease.captureVersion();
|
|
49
49
|
|
|
50
50
|
for (let index = 0; index < 65537; index += 1) {
|
|
51
51
|
const entity = world.spawn({ component: Position, data: { x: index, y: 0 } }).unwrap();
|
|
52
52
|
world.despawn(entity).unwrap();
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
const batch = lease.readChanges(
|
|
56
|
-
expect(batch.status).toBe('
|
|
57
|
-
if (batch.status !== '
|
|
55
|
+
const batch = lease.readChanges(version);
|
|
56
|
+
expect(batch.status).toBe('rebuild');
|
|
57
|
+
if (batch.status !== 'rebuild') return;
|
|
58
58
|
expect(batch.resync).toBe(true);
|
|
59
|
-
expect(batch.
|
|
59
|
+
expect(batch.reason).toBe('structure-changed');
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
it('detaches idempotently and rejects reads after dispose', () => {
|
|
@@ -64,7 +64,7 @@ describe('RenderReadLease contract', () => {
|
|
|
64
64
|
lease.dispose();
|
|
65
65
|
lease.dispose();
|
|
66
66
|
|
|
67
|
-
expect(() => lease.
|
|
67
|
+
expect(() => lease.captureVersion()).toThrow(/disposed/i);
|
|
68
68
|
expect(() => lease.querySpans(request)).toThrow(/disposed/i);
|
|
69
69
|
});
|
|
70
70
|
|
|
@@ -4,6 +4,7 @@ import { World } from '../world';
|
|
|
4
4
|
|
|
5
5
|
const ENTITY_COUNT = 2_000;
|
|
6
6
|
const ROUNDS = 3;
|
|
7
|
+
const SCALE_CHARACTERIZATION_COUNTS = [10_000, 100_000] as const;
|
|
7
8
|
|
|
8
9
|
const TrendValue = defineComponent('StorageTrendValue', { value: 'f32' });
|
|
9
10
|
const TrendSelected = defineComponent('StorageTrendSelected', {}, { storage: 'sparse' });
|
|
@@ -20,6 +21,24 @@ function minimumDuration(run: () => number): { duration: number; sum: number } {
|
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
describe('table, sparse, span, and change-distribution trends', () => {
|
|
24
|
+
it('characterizes fixed-size dense traversal without treating it as a performance verdict', () => {
|
|
25
|
+
const observations = SCALE_CHARACTERIZATION_COUNTS.map((entityCount) => {
|
|
26
|
+
const values = new Float32Array(entityCount);
|
|
27
|
+
for (let index = 0; index < entityCount; index++) values[index] = index + 1;
|
|
28
|
+
const start = performance.now();
|
|
29
|
+
let sum = 0;
|
|
30
|
+
for (const value of values) sum += value;
|
|
31
|
+
return { entityCount, elapsedMs: performance.now() - start, sum };
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(observations.map(({ sum }) => sum)).toEqual(
|
|
35
|
+
SCALE_CHARACTERIZATION_COUNTS.map((entityCount) => (entityCount * (entityCount + 1)) / 2),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// biome-ignore lint/suspicious/noConsole: characterization output is evidence, not a gate
|
|
39
|
+
console.info(`[scale characterization] ${JSON.stringify(observations)}`);
|
|
40
|
+
});
|
|
41
|
+
|
|
23
42
|
it('reports equivalent table-row, sparse-row, and dense-span traversal', () => {
|
|
24
43
|
const world = new World();
|
|
25
44
|
for (let index = 0; index < ENTITY_COUNT; index++) {
|
|
@@ -138,7 +138,7 @@ describe('executable Query', () => {
|
|
|
138
138
|
expect(spans[0]?.get(Position).x[0]).toBe(1);
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
-
it('returns structured span capability failures', () => {
|
|
141
|
+
it('returns structured span capability failures and coalesces changed rows', () => {
|
|
142
142
|
const world = new World();
|
|
143
143
|
const optional = world
|
|
144
144
|
.query({ read: [Position], optional: [Velocity] })
|
|
@@ -146,11 +146,15 @@ describe('executable Query', () => {
|
|
|
146
146
|
.spans();
|
|
147
147
|
expect(!optional.ok && optional.error.detail.reason).toBe('optional-data');
|
|
148
148
|
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
149
|
+
const { world: changedWorld, first, second } = createWorld();
|
|
150
|
+
const changedQuery = changedWorld.query({ read: [Position], changed: [Position] }).unwrap();
|
|
151
|
+
expect([...changedQuery.spans().unwrap()].flatMap((span) => Array.from(span.entities))).toEqual(
|
|
152
|
+
[first, second],
|
|
153
|
+
);
|
|
154
|
+
changedWorld.set(second, Position, { x: 9 }).unwrap();
|
|
155
|
+
const changedSpans = [...changedQuery.spans().unwrap()];
|
|
156
|
+
expect(changedSpans).toHaveLength(1);
|
|
157
|
+
expect(Array.from(changedSpans[0]?.entities ?? [])).toEqual([second]);
|
|
154
158
|
});
|
|
155
159
|
|
|
156
160
|
it('does not commit observation after partial iteration', () => {
|
|
@@ -70,7 +70,6 @@ describe('w6 SharedRefStore: alloc + resolve', () => {
|
|
|
70
70
|
expect(store.markChanged(handle).ok).toBe(true);
|
|
71
71
|
expect(store.getMutationEpoch()).toBe(1);
|
|
72
72
|
expect(store.readChangesSince(0)).toEqual({
|
|
73
|
-
status: 'ok',
|
|
74
73
|
cursor: 1,
|
|
75
74
|
records: [{ epoch: 1, handle: unwrapHandle(handle) }],
|
|
76
75
|
});
|
|
@@ -80,21 +79,17 @@ describe('w6 SharedRefStore: alloc + resolve', () => {
|
|
|
80
79
|
expect(store.getMutationEpoch()).toBe(1);
|
|
81
80
|
});
|
|
82
81
|
|
|
83
|
-
it('
|
|
82
|
+
it('keeps one latest mutation epoch per handle without a bounded journal', () => {
|
|
84
83
|
const store = new SharedRefStore();
|
|
85
84
|
const handle = store.alloc('MaterialAsset', { value: 1 });
|
|
86
85
|
for (let index = 0; index < 4097; index += 1) store.markChanged(handle).unwrap();
|
|
87
86
|
|
|
88
87
|
expect(store.readChangesSince(0)).toEqual({
|
|
89
|
-
status: 'overflow',
|
|
90
88
|
cursor: 4097,
|
|
91
|
-
|
|
89
|
+
records: [{ epoch: 4097, handle: unwrapHandle(handle) }],
|
|
92
90
|
});
|
|
93
91
|
const latest = store.readChangesSince(4096);
|
|
94
|
-
expect(latest.
|
|
95
|
-
if (latest.status === 'ok') {
|
|
96
|
-
expect(latest.records).toEqual([{ epoch: 4097, handle: unwrapHandle(handle) }]);
|
|
97
|
-
}
|
|
92
|
+
expect(latest.records).toEqual([{ epoch: 4097, handle: unwrapHandle(handle) }]);
|
|
98
93
|
});
|
|
99
94
|
|
|
100
95
|
it('resolve returns SharedRefStaleError after rc drops to 0 (gen incremented on release)', () => {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { StructuralEvidence, StructuralEvidenceKind } from '../storage/structural-evidence';
|
|
2
|
+
|
|
3
|
+
const kind: StructuralEvidenceKind = 'component-added';
|
|
4
|
+
const evidence: StructuralEvidence = { sequence: 1, kind, entity: 0 as never, componentId: 4 };
|
|
5
|
+
// @ts-expect-error evidence sequence is producer-owned.
|
|
6
|
+
evidence.sequence = 2;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { encodeEntity } from '../entity-handle';
|
|
3
|
+
import { StructuralEvidenceRing } from '../storage/structural-evidence';
|
|
4
|
+
|
|
5
|
+
describe('typed structural evidence ring', () => {
|
|
6
|
+
it('records lifecycle, component membership, packed identity, and sequence facts', () => {
|
|
7
|
+
const ring = new StructuralEvidenceRing(8);
|
|
8
|
+
const entity = encodeEntity(4, 2);
|
|
9
|
+
const cursor = ring.cursor;
|
|
10
|
+
ring.append({ kind: 'spawn', entity });
|
|
11
|
+
ring.append({ kind: 'component-added', entity, componentId: 7 });
|
|
12
|
+
ring.append({ kind: 'component-removed', entity, componentId: 7 });
|
|
13
|
+
ring.append({ kind: 'despawn', entity });
|
|
14
|
+
const read = ring.readAfter(cursor);
|
|
15
|
+
expect(read.status).toBe('ok');
|
|
16
|
+
if (read.status !== 'ok') return;
|
|
17
|
+
expect(
|
|
18
|
+
read.events.map(({ kind, entity: packed, sequence }) => ({ kind, packed, sequence })),
|
|
19
|
+
).toEqual([
|
|
20
|
+
{ kind: 'spawn', packed: entity, sequence: 1 },
|
|
21
|
+
{ kind: 'component-added', packed: entity, sequence: 2 },
|
|
22
|
+
{ kind: 'component-removed', packed: entity, sequence: 3 },
|
|
23
|
+
{ kind: 'despawn', packed: entity, sequence: 4 },
|
|
24
|
+
]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('fails closed on overflow and exposes the cursor for consumer reconciliation', () => {
|
|
28
|
+
const ring = new StructuralEvidenceRing(2);
|
|
29
|
+
const cursor = ring.cursor;
|
|
30
|
+
const entity = encodeEntity(1, 4);
|
|
31
|
+
const reused = encodeEntity(1, 5);
|
|
32
|
+
ring.append({ kind: 'spawn', entity });
|
|
33
|
+
ring.append({ kind: 'despawn', entity });
|
|
34
|
+
ring.append({ kind: 'spawn', entity: reused });
|
|
35
|
+
expect(ring.readAfter(cursor)).toMatchObject({
|
|
36
|
+
status: 'overflow',
|
|
37
|
+
cursor: 3,
|
|
38
|
+
oldestAvailable: 2,
|
|
39
|
+
});
|
|
40
|
+
expect(ring.cursor).toBe(3);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('rejects cursors outside the producer-owned sequence range', () => {
|
|
44
|
+
const ring = new StructuralEvidenceRing(4);
|
|
45
|
+
const entity = encodeEntity(3, 1);
|
|
46
|
+
ring.append({ kind: 'spawn', entity });
|
|
47
|
+
expect(() => ring.readAfter(2)).toThrow(RangeError);
|
|
48
|
+
});
|
|
49
|
+
});
|