@forgeax/engine-ecs 0.1.21 → 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -17
- 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 +16 -7
- 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 +977 -255
- package/dist/index.mjs.map +1 -1
- package/dist/internal.d.ts +18 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.mjs +20 -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 +62 -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 +65 -19
- package/dist/world-component-access.d.ts.map +1 -1
- package/dist/world-component-storage.d.ts +26 -10
- package/dist/world-component-storage.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 +15 -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__/relationship-index.test.ts +147 -1
- 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 +21 -8
- package/src/errors/query-and-component-errors.ts +4 -1
- package/src/errors/validation-errors.ts +30 -0
- package/src/errors.ts +29 -5
- package/src/internal.ts +59 -0
- package/src/projection/index.ts +68 -149
- package/src/query/derived-range-writer.ts +443 -0
- package/src/query/query.ts +115 -16
- 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 +479 -130
- package/src/world-component-storage.ts +104 -18
- package/src/world-core.ts +2 -2
- package/src/world-entity-lifecycle.ts +59 -10
- package/src/world-internal.ts +10 -5
- package/src/world.ts +97 -53
- 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,76 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { defineComponent } from '../component';
|
|
3
|
+
import type { EntityHandle } from '../entity-handle';
|
|
4
|
+
import { Update } from '../schedule-token';
|
|
5
|
+
import { World } from '../world';
|
|
6
|
+
|
|
7
|
+
describe('component requirements', () => {
|
|
8
|
+
it('materializes transitive requirements for synchronous spawn', () => {
|
|
9
|
+
const Base = defineComponent('RequirementsBase', { value: { type: 'f32', default: 3 } });
|
|
10
|
+
const Middle = defineComponent('RequirementsMiddle', {}, { requires: [Base] });
|
|
11
|
+
const Carrier = defineComponent('RequirementsCarrier', {}, { requires: [Middle] });
|
|
12
|
+
const world = new World();
|
|
13
|
+
|
|
14
|
+
const entity = world.spawn({ component: Carrier, data: {} }).unwrap();
|
|
15
|
+
|
|
16
|
+
expect(world.get(entity, Carrier).ok).toBe(true);
|
|
17
|
+
expect(world.get(entity, Middle).ok).toBe(true);
|
|
18
|
+
expect(world.get(entity, Base).unwrap().value).toBe(3);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('keeps explicit required data and adds missing requirements on addComponent', () => {
|
|
22
|
+
const Base = defineComponent('RequirementsExplicitBase', { value: 'f32' });
|
|
23
|
+
const Carrier = defineComponent('RequirementsExplicitCarrier', {}, { requires: [Base] });
|
|
24
|
+
const world = new World();
|
|
25
|
+
const entity = world.spawn({ component: Base, data: { value: 9 } }).unwrap();
|
|
26
|
+
|
|
27
|
+
expect(world.addComponent(entity, { component: Carrier, data: {} }).ok).toBe(true);
|
|
28
|
+
expect(world.get(entity, Base).unwrap().value).toBe(9);
|
|
29
|
+
|
|
30
|
+
const explicit = world
|
|
31
|
+
.spawn({ component: Carrier, data: {} }, { component: Base, data: { value: 17 } })
|
|
32
|
+
.unwrap();
|
|
33
|
+
expect(world.get(explicit, Base).unwrap().value).toBe(17);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('applies requirements to deferred Commands.spawn', () => {
|
|
37
|
+
const Base = defineComponent('RequirementsDeferredBase', { value: 'f32' });
|
|
38
|
+
const Carrier = defineComponent('RequirementsDeferredCarrier', {}, { requires: [Base] });
|
|
39
|
+
const world = new World();
|
|
40
|
+
let entity: EntityHandle | undefined;
|
|
41
|
+
|
|
42
|
+
world
|
|
43
|
+
.addSystem(Update, {
|
|
44
|
+
name: 'requirements-deferred-spawn',
|
|
45
|
+
queries: [],
|
|
46
|
+
fn: (_world, _queries, commands) => {
|
|
47
|
+
entity = commands.spawn({ component: Carrier, data: {} });
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
.unwrap();
|
|
51
|
+
|
|
52
|
+
expect(world.update(0).ok).toBe(true);
|
|
53
|
+
expect(entity).toBeDefined();
|
|
54
|
+
expect(world.get(entity as EntityHandle, Base).ok).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('applies requirements to deferred Commands.addComponent', () => {
|
|
58
|
+
const Base = defineComponent('RequirementsDeferredAddBase', { value: 'f32' });
|
|
59
|
+
const Carrier = defineComponent('RequirementsDeferredAddCarrier', {}, { requires: [Base] });
|
|
60
|
+
const world = new World();
|
|
61
|
+
const entity = world.spawn().unwrap();
|
|
62
|
+
|
|
63
|
+
world
|
|
64
|
+
.addSystem(Update, {
|
|
65
|
+
name: 'requirements-deferred-add',
|
|
66
|
+
queries: [],
|
|
67
|
+
fn: (_world, _queries, commands) => {
|
|
68
|
+
commands.addComponent(entity, { component: Carrier, data: {} });
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
.unwrap();
|
|
72
|
+
|
|
73
|
+
expect(world.update(0).ok).toBe(true);
|
|
74
|
+
expect(world.get(entity, Base).ok).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { defineComponent } from '../component';
|
|
3
|
+
import { readStructuralEvidence } from '../projection';
|
|
4
|
+
import type { Query } from '../query/query';
|
|
5
|
+
import { World } from '../world';
|
|
6
|
+
|
|
7
|
+
const Value = defineComponent('ComponentVersionSurfaceValue', { value: 'f32' });
|
|
8
|
+
|
|
9
|
+
function changedEntities(query: Query): number[] {
|
|
10
|
+
const entities: number[] = [];
|
|
11
|
+
for (const span of query.spans().unwrap()) entities.push(...span.entities);
|
|
12
|
+
return entities;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('component version surface', () => {
|
|
16
|
+
it('keeps value versions and typed structural evidence separate', () => {
|
|
17
|
+
const world = new World();
|
|
18
|
+
const query = world.query({ changed: [Value] }).unwrap();
|
|
19
|
+
const entity = world.spawn({ component: Value, data: { value: 1 } }).unwrap();
|
|
20
|
+
|
|
21
|
+
expect(changedEntities(query)).toEqual([entity]);
|
|
22
|
+
world.set(entity, Value, { value: 2 }).unwrap();
|
|
23
|
+
expect(changedEntities(query)).toEqual([entity]);
|
|
24
|
+
expect(changedEntities(query)).toEqual([]);
|
|
25
|
+
|
|
26
|
+
const structuralRead = readStructuralEvidence(world, 0);
|
|
27
|
+
expect(structuralRead.status).toBe('ok');
|
|
28
|
+
if (structuralRead.status !== 'ok') return;
|
|
29
|
+
expect(structuralRead.events).toEqual([expect.objectContaining({ kind: 'spawn', entity })]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('lets independent consumers observe the same component version', () => {
|
|
33
|
+
const world = new World();
|
|
34
|
+
const entity = world.spawn({ component: Value, data: { value: 2 } }).unwrap();
|
|
35
|
+
const first = world.query({ changed: [Value] }).unwrap();
|
|
36
|
+
const second = world.query({ changed: [Value] }).unwrap();
|
|
37
|
+
changedEntities(first);
|
|
38
|
+
changedEntities(second);
|
|
39
|
+
|
|
40
|
+
world.set(entity, Value, { value: 3 }).unwrap();
|
|
41
|
+
|
|
42
|
+
expect(changedEntities(first)).toEqual([entity]);
|
|
43
|
+
expect(changedEntities(second)).toEqual([entity]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Component } from '../component';
|
|
2
|
+
import { getDerivedWriter } from '../internal';
|
|
3
|
+
import type { DerivedColumnBinding, DerivedRangeKernel } from '../query/derived-range-writer';
|
|
4
|
+
import type { Query, QuerySpan } from '../query/query';
|
|
5
|
+
|
|
6
|
+
declare const Value: Component<'DerivedRangeWriterValue', { value: 'f32' }>;
|
|
7
|
+
declare const span: QuerySpan<readonly [], readonly [typeof Value]>;
|
|
8
|
+
declare const query: Query<readonly [], readonly [typeof Value]>;
|
|
9
|
+
declare const readOnlyQuery: Query<readonly [typeof Value], readonly []>;
|
|
10
|
+
declare const binding: DerivedColumnBinding<typeof Value, typeof Value>;
|
|
11
|
+
const kernel: DerivedRangeKernel<typeof Value, typeof Value> = (
|
|
12
|
+
nextBinding,
|
|
13
|
+
base,
|
|
14
|
+
start,
|
|
15
|
+
count,
|
|
16
|
+
_context,
|
|
17
|
+
) => {
|
|
18
|
+
nextBinding.write.value[base + start] = count;
|
|
19
|
+
binding.write.value[0] = nextBinding.write.value[base + start] ?? 0;
|
|
20
|
+
};
|
|
21
|
+
void kernel;
|
|
22
|
+
|
|
23
|
+
// The derived writer is intentionally absent from the public Query contract.
|
|
24
|
+
// It is resolved through the package-internal typed accessor instead.
|
|
25
|
+
void getDerivedWriter(query, Value);
|
|
26
|
+
// @ts-expect-error Query must not expose the package-internal writer as a string method.
|
|
27
|
+
query.derivedWriter(Value);
|
|
28
|
+
// @ts-expect-error A read-only Query cannot create a derived writer.
|
|
29
|
+
readOnlyQuery.derivedWriter(Value);
|
|
30
|
+
|
|
31
|
+
// A writer is query-owned and package-internal; QuerySpan has no writer method.
|
|
32
|
+
// @ts-expect-error QuerySpan must not expose a public derived writer protocol.
|
|
33
|
+
span.derivedWriter(Value);
|
|
34
|
+
// @ts-expect-error QuerySpan must not expose raw table or storage handles.
|
|
35
|
+
span.table;
|
|
@@ -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++) {
|