@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
|
@@ -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', () => {
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import { err } from '@forgeax/engine-types';
|
|
1
2
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { componentSchema } from '../component';
|
|
3
|
+
import { componentSchema, defineComponent } from '../component';
|
|
3
4
|
import { componentDefinition } from '../component-schema';
|
|
5
|
+
import { ManagedBufferOutOfBoundsError } from '../errors';
|
|
6
|
+
import { getDerivedWriter } from '../internal';
|
|
4
7
|
import { defineRelationship, RelationshipIndex, relationshipRole } from '../relationship-index';
|
|
5
8
|
import { World } from '../world';
|
|
9
|
+
import { worldInternal } from '../world-internal';
|
|
6
10
|
|
|
7
11
|
describe('relationship index', () => {
|
|
8
12
|
it('keeps source writable and target materialized/read-only by role', () => {
|
|
@@ -67,4 +71,146 @@ describe('relationship index', () => {
|
|
|
67
71
|
);
|
|
68
72
|
world.spawn({ component: pair.target, data: { sources: [] } });
|
|
69
73
|
});
|
|
74
|
+
|
|
75
|
+
it('detaches public target arrays from the materialized mirror and index', () => {
|
|
76
|
+
const pair = defineRelationship({
|
|
77
|
+
sourceName: 'DetachedSource',
|
|
78
|
+
sourceField: 'target',
|
|
79
|
+
targetName: 'DetachedTargets',
|
|
80
|
+
targetField: 'sources',
|
|
81
|
+
});
|
|
82
|
+
const world = new World();
|
|
83
|
+
const target = world.spawn().unwrap();
|
|
84
|
+
const source = world.spawn({ component: pair.source, data: { target } }).unwrap();
|
|
85
|
+
const beforeEpoch = world[worldInternal].getRelationshipEpoch(pair.source);
|
|
86
|
+
const exposed = world.get(target, pair.target).unwrap().sources;
|
|
87
|
+
|
|
88
|
+
exposed.fill(0);
|
|
89
|
+
exposed[0] = 0;
|
|
90
|
+
|
|
91
|
+
expect(Array.from(world.get(target, pair.target).unwrap().sources)).toEqual([source]);
|
|
92
|
+
expect(
|
|
93
|
+
Array.from(world[worldInternal].getRelationshipTargetEntities(pair.source, target)),
|
|
94
|
+
).toEqual([source]);
|
|
95
|
+
expect(world.get(source, pair.source).unwrap().target).toBe(target);
|
|
96
|
+
expect(world[worldInternal].getRelationshipEpoch(pair.source)).toBe(beforeEpoch);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('routes source set, row mutation, reparent, and null detach through one mirror owner', () => {
|
|
100
|
+
const pair = defineRelationship({
|
|
101
|
+
sourceName: 'OwnedSource',
|
|
102
|
+
sourceField: 'target',
|
|
103
|
+
targetName: 'OwnedTargets',
|
|
104
|
+
targetField: 'sources',
|
|
105
|
+
});
|
|
106
|
+
const world = new World();
|
|
107
|
+
const first = world.spawn().unwrap();
|
|
108
|
+
const second = world.spawn().unwrap();
|
|
109
|
+
const source = world.spawn({ component: pair.source, data: { target: first } }).unwrap();
|
|
110
|
+
|
|
111
|
+
expect(Array.from(world.get(first, pair.target).unwrap().sources)).toEqual([source]);
|
|
112
|
+
world.set(source, pair.source, { target: second }).unwrap();
|
|
113
|
+
expect(Array.from(world.get(first, pair.target).unwrap().sources)).toEqual([]);
|
|
114
|
+
expect(Array.from(world.get(second, pair.target).unwrap().sources)).toEqual([source]);
|
|
115
|
+
|
|
116
|
+
const row = world
|
|
117
|
+
.query({ write: [pair.source] })
|
|
118
|
+
.unwrap()
|
|
119
|
+
.at(source);
|
|
120
|
+
if (row === undefined) throw new Error('expected source row');
|
|
121
|
+
row.mut(pair.source).target = first;
|
|
122
|
+
expect(Array.from(world.get(second, pair.target).unwrap().sources)).toEqual([]);
|
|
123
|
+
expect(Array.from(world.get(first, pair.target).unwrap().sources)).toEqual([source]);
|
|
124
|
+
|
|
125
|
+
world.set(source, pair.source, { target: null }).unwrap();
|
|
126
|
+
expect(Array.from(world.get(first, pair.target).unwrap().sources)).toEqual([]);
|
|
127
|
+
|
|
128
|
+
const sourceQuery = world.query({ write: [pair.source] }).unwrap();
|
|
129
|
+
const sourceSpans = sourceQuery.spans();
|
|
130
|
+
expect(sourceSpans.ok).toBe(false);
|
|
131
|
+
if (!sourceSpans.ok) expect(sourceSpans.error.detail.reason).toBe('relationship-component');
|
|
132
|
+
const sourceWriter = getDerivedWriter(sourceQuery, pair.source);
|
|
133
|
+
expect(sourceWriter.ok).toBe(false);
|
|
134
|
+
if (!sourceWriter.ok) expect(sourceWriter.error.detail.reason).toBe('relationship-component');
|
|
135
|
+
|
|
136
|
+
const targetQuery = world.query({ write: [pair.target] }).unwrap();
|
|
137
|
+
const targetWriter = getDerivedWriter(targetQuery, pair.target);
|
|
138
|
+
expect(targetWriter.ok).toBe(false);
|
|
139
|
+
if (!targetWriter.ok) expect(targetWriter.error.detail.reason).toBe('relationship-component');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('rejects stale source targets before changing owner state', () => {
|
|
143
|
+
const pair = defineRelationship({
|
|
144
|
+
sourceName: 'AtomicSource',
|
|
145
|
+
sourceField: 'target',
|
|
146
|
+
targetName: 'AtomicTargets',
|
|
147
|
+
targetField: 'sources',
|
|
148
|
+
});
|
|
149
|
+
const world = new World();
|
|
150
|
+
const target = world.spawn().unwrap();
|
|
151
|
+
const source = world.spawn({ component: pair.source, data: { target } }).unwrap();
|
|
152
|
+
const stale = world.spawn().unwrap();
|
|
153
|
+
world.despawn(stale).unwrap();
|
|
154
|
+
const beforeValue = world.get(source, pair.source).unwrap().target;
|
|
155
|
+
const beforeMirror = Array.from(world.get(target, pair.target).unwrap().sources);
|
|
156
|
+
const beforeMutation = world[worldInternal].getMutationEpoch();
|
|
157
|
+
const beforeRelationship = world[worldInternal].getRelationshipEpoch(pair.source);
|
|
158
|
+
const result = world.set(source, pair.source, { target: stale });
|
|
159
|
+
|
|
160
|
+
expect(result.ok).toBe(false);
|
|
161
|
+
if (!result.ok) expect(result.error.code).toBe('stale-entity');
|
|
162
|
+
expect(world.get(source, pair.source).unwrap().target).toBe(beforeValue);
|
|
163
|
+
expect(Array.from(world.get(target, pair.target).unwrap().sources)).toEqual(beforeMirror);
|
|
164
|
+
expect(world[worldInternal].getMutationEpoch()).toBe(beforeMutation);
|
|
165
|
+
expect(world[worldInternal].getRelationshipEpoch(pair.source)).toBe(beforeRelationship);
|
|
166
|
+
|
|
167
|
+
const row = world
|
|
168
|
+
.query({ write: [pair.source] })
|
|
169
|
+
.unwrap()
|
|
170
|
+
.at(source);
|
|
171
|
+
if (row === undefined) throw new Error('expected source row after stale rejection');
|
|
172
|
+
expect(() => {
|
|
173
|
+
row.mut(pair.source).target = stale;
|
|
174
|
+
}).toThrow('stale');
|
|
175
|
+
expect(world.get(source, pair.source).unwrap().target).toBe(beforeValue);
|
|
176
|
+
expect(world[worldInternal].getMutationEpoch()).toBe(beforeMutation);
|
|
177
|
+
expect(world[worldInternal].getRelationshipEpoch(pair.source)).toBe(beforeRelationship);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('prepares mirror capacity before source structural mutation on allocation failure', () => {
|
|
181
|
+
const Required = defineComponent('CapacityAtomicRequired', {});
|
|
182
|
+
const pair = defineRelationship({
|
|
183
|
+
sourceName: 'CapacityAtomicSource',
|
|
184
|
+
sourceField: 'target',
|
|
185
|
+
targetName: 'CapacityAtomicTargets',
|
|
186
|
+
targetField: 'sources',
|
|
187
|
+
sourceRequires: [Required],
|
|
188
|
+
});
|
|
189
|
+
const world = new World();
|
|
190
|
+
const target = world.spawn().unwrap();
|
|
191
|
+
const source = world.spawn().unwrap();
|
|
192
|
+
const pool = world[worldInternal].getBufferPool();
|
|
193
|
+
const originalAlloc = pool.alloc;
|
|
194
|
+
const beforeMutation = world[worldInternal].getMutationEpoch();
|
|
195
|
+
const beforeStructure = world[worldInternal].getStructureEpoch();
|
|
196
|
+
const beforeRelationship = world[worldInternal].getRelationshipEpoch(pair.source);
|
|
197
|
+
Object.defineProperty(pool, 'alloc', {
|
|
198
|
+
configurable: true,
|
|
199
|
+
value: () => err(new ManagedBufferOutOfBoundsError(4, 0)),
|
|
200
|
+
});
|
|
201
|
+
try {
|
|
202
|
+
const result = world.addComponent(source, { component: pair.source, data: { target } });
|
|
203
|
+
expect(result.ok).toBe(false);
|
|
204
|
+
if (!result.ok) expect(result.error.code).toBe('managed-buffer-out-of-bounds');
|
|
205
|
+
expect(world.hasComponent(source, pair.source)).toBe(false);
|
|
206
|
+
expect(world.hasComponent(source, Required)).toBe(false);
|
|
207
|
+
expect(world.hasComponent(target, pair.target)).toBe(false);
|
|
208
|
+
expect(world[worldInternal].getMutationEpoch()).toBe(beforeMutation);
|
|
209
|
+
expect(world[worldInternal].getStructureEpoch()).toBe(beforeStructure);
|
|
210
|
+
expect(world[worldInternal].getRelationshipEpoch(pair.source)).toBe(beforeRelationship);
|
|
211
|
+
expect(pool._liveCount()).toBe(0);
|
|
212
|
+
} finally {
|
|
213
|
+
Object.defineProperty(pool, 'alloc', { configurable: true, value: originalAlloc });
|
|
214
|
+
}
|
|
215
|
+
});
|
|
70
216
|
});
|
|
@@ -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
|
+
});
|
package/src/component-schema.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface ComponentSchemaDefinition {
|
|
|
18
18
|
export interface ComponentPolicy {
|
|
19
19
|
readonly transient: boolean;
|
|
20
20
|
readonly meta: Record<string, unknown>;
|
|
21
|
+
/** Components that are materialized whenever this component is added. */
|
|
22
|
+
readonly requires: readonly Component[];
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
/** Immutable schema reflection plus non-token policy projection. */
|
|
@@ -60,6 +62,55 @@ export function componentDefinition(component: Component): ComponentDefinition {
|
|
|
60
62
|
return definition;
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
/** Read the generic structural requirements declared by one component. */
|
|
66
|
+
export function componentRequirements(component: Component): readonly Component[] {
|
|
67
|
+
// Keep invalid/foreign tokens on the ordinary preflight error path. The
|
|
68
|
+
// expansion helper runs before validation, so it must not turn a structured
|
|
69
|
+
// `component-not-defined` Result into an uncaught registry exception.
|
|
70
|
+
return definitions.definitions.get(component)?.policy.requires ?? [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type ComponentDataLike = {
|
|
74
|
+
readonly component: Component;
|
|
75
|
+
readonly data: Partial<Record<string, unknown>>;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Expand component requirements once at the structural boundary.
|
|
80
|
+
*
|
|
81
|
+
* Explicit component data wins and is never duplicated. Requirements are
|
|
82
|
+
* appended in declaration order, and the same identity set also terminates a
|
|
83
|
+
* malformed dependency cycle without a per-frame scan.
|
|
84
|
+
*/
|
|
85
|
+
export function expandComponentRequirements<T extends ComponentDataLike>(
|
|
86
|
+
componentDatas: readonly T[],
|
|
87
|
+
): T[] {
|
|
88
|
+
let hasRequirements = false;
|
|
89
|
+
for (const entry of componentDatas) {
|
|
90
|
+
if (componentRequirements(entry.component).length !== 0) {
|
|
91
|
+
hasRequirements = true;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Most structural operations use components without dependencies. Preserve
|
|
96
|
+
// that path without copying or allocating a Set; callers only consume the
|
|
97
|
+
// returned list and never mutate it.
|
|
98
|
+
if (!hasRequirements) return componentDatas as T[];
|
|
99
|
+
|
|
100
|
+
const expanded = [...componentDatas];
|
|
101
|
+
const seen = new Set<Component>(expanded.map((entry) => entry.component));
|
|
102
|
+
for (let index = 0; index < expanded.length; index++) {
|
|
103
|
+
const component = expanded[index]?.component;
|
|
104
|
+
if (component === undefined) continue;
|
|
105
|
+
for (const required of componentRequirements(component)) {
|
|
106
|
+
if (seen.has(required)) continue;
|
|
107
|
+
seen.add(required);
|
|
108
|
+
expanded.push({ component: required, data: {} } as T);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return expanded;
|
|
112
|
+
}
|
|
113
|
+
|
|
63
114
|
/**
|
|
64
115
|
* Freeze a schema value recursively. Component descriptors are authored data,
|
|
65
116
|
* so nested defaults and enum label maps must not become mutation channels.
|
package/src/component.ts
CHANGED
|
@@ -392,12 +392,15 @@ export type ManagedArrayElementValue<T extends ManagedArrayElementType> = T exte
|
|
|
392
392
|
*
|
|
393
393
|
* The 4 buffer/array keywords (`'buffer'` / `'buffer<N>'` / `'array<T>'` /
|
|
394
394
|
* `'array<T, N>'`) all resolve directly to a concrete TypedArray (or
|
|
395
|
-
* Uint8Array for the byte-only buffer family).
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
395
|
+
* Uint8Array for the byte-only buffer family). At the public `world.get`
|
|
396
|
+
* boundary, a relationship-target `array<entity>` is a detached `Uint32Array`
|
|
397
|
+
* snapshot. Other public array fields retain their existing transient live
|
|
398
|
+
* TypedArray alias: fixed `buffer<N>` / `array<T,N>` values alias the inline
|
|
399
|
+
* column buffer (feat-20260602), while variable `buffer` / `array<T>` values
|
|
400
|
+
* alias the BufferPool slot bytes. Internal `readRow`, `_getArrayView`, and
|
|
401
|
+
* `materializeArrayView` paths always use the live zero-copy alias. Mutation
|
|
402
|
+
* flows through `world.set` / `world.push` / `world.pop`, not direct
|
|
403
|
+
* assignment to a returned TypedArray.
|
|
401
404
|
*
|
|
402
405
|
* The `'string'` arm resolves to a native JS `string` (D-R1 / AC-13): the
|
|
403
406
|
* dispatch routes the column u32 through `UniqueRefStore.resolve(handle)`
|
|
@@ -930,7 +933,7 @@ export interface FieldDescriptor<T extends SchemaFieldType = SchemaFieldType> {
|
|
|
930
933
|
* same meaning): scene collect skips a `transient` field just as it skips a
|
|
931
934
|
* `transient` component. Granularity is sunk to the field level so a component
|
|
932
935
|
* can persist most of its fields while excluding a derived/reconstructable one
|
|
933
|
-
* (e.g. `
|
|
936
|
+
* (e.g. `GlobalTransform.world`). Absent means the field participates in
|
|
934
937
|
* serialization.
|
|
935
938
|
*/
|
|
936
939
|
export interface FieldReflection {
|
|
@@ -998,6 +1001,12 @@ export interface DefineComponentOptions {
|
|
|
998
1001
|
* rebuilt by the mirror hook after instantiateScene).
|
|
999
1002
|
*/
|
|
1000
1003
|
readonly transient?: boolean;
|
|
1004
|
+
/**
|
|
1005
|
+
* Components materialized automatically when this component is added.
|
|
1006
|
+
* Explicit data for a required component wins; the ECS appends only missing
|
|
1007
|
+
* identities at the spawn/add boundary, never from a frame system.
|
|
1008
|
+
*/
|
|
1009
|
+
readonly requires?: readonly Component[];
|
|
1001
1010
|
/**
|
|
1002
1011
|
* Component-level open metadata namespace. Entries are copied into
|
|
1003
1012
|
* `Component.meta` at registration; the ECS core assigns no meaning to any
|
|
@@ -1171,7 +1180,11 @@ export function defineComponent<const N extends string, const S extends FieldsIn
|
|
|
1171
1180
|
registerComponentDefinition(token, {
|
|
1172
1181
|
fields: frozenFields,
|
|
1173
1182
|
defaults: frozenDefaults,
|
|
1174
|
-
policy: {
|
|
1183
|
+
policy: {
|
|
1184
|
+
transient: options?.transient ?? false,
|
|
1185
|
+
meta,
|
|
1186
|
+
requires: Object.freeze([...(options?.requires ?? [])]),
|
|
1187
|
+
},
|
|
1175
1188
|
});
|
|
1176
1189
|
return Object.freeze(token);
|
|
1177
1190
|
}
|
|
@@ -122,7 +122,10 @@ export class SpawnDataUnknownFieldError extends Error {
|
|
|
122
122
|
this.detail = { component: componentName, field: fieldName, knownFields: sortedKnown };
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
|
-
export type QuerySpanUnavailableReason =
|
|
125
|
+
export type QuerySpanUnavailableReason =
|
|
126
|
+
| 'optional-data'
|
|
127
|
+
| 'sparse-component'
|
|
128
|
+
| 'relationship-component';
|
|
126
129
|
|
|
127
130
|
export class QueryDescriptorConflictError extends Error {
|
|
128
131
|
override readonly name = 'QueryDescriptorConflictError';
|
|
@@ -291,6 +291,36 @@ export function validateEnumFieldValues<S extends ComponentSchema>(
|
|
|
291
291
|
* `.hint` — names the offending field plus the valid replacement form.
|
|
292
292
|
*/
|
|
293
293
|
const SPAWN_LIGHT_INVALID_BOUNDS_POLICY = {
|
|
294
|
+
intensity: {
|
|
295
|
+
expected: 'intensity is finite and >= 0',
|
|
296
|
+
hint: (componentName: string, got: number | readonly number[]) =>
|
|
297
|
+
`${componentName}.intensity must be a finite non-negative number (got ${got})`,
|
|
298
|
+
},
|
|
299
|
+
color: {
|
|
300
|
+
expected: 'color is a finite non-negative [r, g, b] vector',
|
|
301
|
+
hint: (componentName: string, got: number | readonly number[]) =>
|
|
302
|
+
`${componentName}.color must contain three finite non-negative channels (got ${JSON.stringify(got)})`,
|
|
303
|
+
},
|
|
304
|
+
width: {
|
|
305
|
+
expected: 'width is finite and > 0',
|
|
306
|
+
hint: (componentName: string, got: number | readonly number[]) =>
|
|
307
|
+
`${componentName}.width must be a finite positive meter value (got ${got})`,
|
|
308
|
+
},
|
|
309
|
+
height: {
|
|
310
|
+
expected: 'height is finite and > 0',
|
|
311
|
+
hint: (componentName: string, got: number | readonly number[]) =>
|
|
312
|
+
`${componentName}.height must be a finite positive meter value (got ${got})`,
|
|
313
|
+
},
|
|
314
|
+
irradiance: {
|
|
315
|
+
expected: 'irradiance is a finite 27-value SH vector',
|
|
316
|
+
hint: (componentName: string, got: number | readonly number[]) =>
|
|
317
|
+
`${componentName}.irradiance must contain 27 finite SH values (got ${JSON.stringify(got)})`,
|
|
318
|
+
},
|
|
319
|
+
radius: {
|
|
320
|
+
expected: 'radius is finite and >= R_MIN',
|
|
321
|
+
hint: (componentName: string, got: number | readonly number[]) =>
|
|
322
|
+
`${componentName}.radius must be a finite value >= R_MIN (got ${got})`,
|
|
323
|
+
},
|
|
294
324
|
range: {
|
|
295
325
|
expected: 'range >= 0 or Number.POSITIVE_INFINITY',
|
|
296
326
|
hint: (componentName: string, got: number | readonly number[]) =>
|
package/src/errors.ts
CHANGED
|
@@ -399,6 +399,20 @@ export class ChangeEpochExhaustedError extends Error {
|
|
|
399
399
|
}
|
|
400
400
|
}
|
|
401
401
|
|
|
402
|
+
export class DerivedRangeOutOfBoundsError extends Error {
|
|
403
|
+
override readonly name = 'DerivedRangeOutOfBoundsError';
|
|
404
|
+
readonly code = 'derived-range-out-of-bounds' as const;
|
|
405
|
+
readonly expected = 'a non-negative span-relative range with start + count <= span.length';
|
|
406
|
+
readonly hint =
|
|
407
|
+
'check start and count against the QuerySpan length, then retry without changing World state';
|
|
408
|
+
readonly detail: { readonly start: number; readonly count: number; readonly spanLength: number };
|
|
409
|
+
|
|
410
|
+
constructor(start: number, count: number, spanLength: number) {
|
|
411
|
+
super(`Derived range [${start}, ${start + count}) exceeds QuerySpan length ${spanLength}.`);
|
|
412
|
+
this.detail = { start, count, spanLength };
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
402
416
|
/**
|
|
403
417
|
* Thrown when insertResource/removeResource is called on a World-owned
|
|
404
418
|
* protected resource (Time or FixedTime).
|
|
@@ -1189,15 +1203,25 @@ export type EcsErrorDetail =
|
|
|
1189
1203
|
readonly actualLength: number;
|
|
1190
1204
|
readonly expectedStride: 16;
|
|
1191
1205
|
}
|
|
1192
|
-
// feat-20260519-light-casters-point-spot-pbr w2 —
|
|
1206
|
+
// feat-20260519-light-casters-point-spot-pbr w2 — light and local probe
|
|
1193
1207
|
// spawn-time payload bound violation (plan-strategy D-S3 a). detail.field
|
|
1194
|
-
//
|
|
1195
|
-
//
|
|
1208
|
+
// names the validated scalar or RGB payload while one code keeps the
|
|
1209
|
+
// recovery surface closed; AI users narrow on `.detail.field` after the
|
|
1196
1210
|
// outer `switch (err.code)` to pick the specific recovery hint.
|
|
1197
1211
|
| {
|
|
1198
1212
|
readonly code: 'spawn-light-invalid-bounds';
|
|
1199
|
-
readonly field:
|
|
1200
|
-
|
|
1213
|
+
readonly field:
|
|
1214
|
+
| 'direction'
|
|
1215
|
+
| 'intensity'
|
|
1216
|
+
| 'color'
|
|
1217
|
+
| 'width'
|
|
1218
|
+
| 'height'
|
|
1219
|
+
| 'irradiance'
|
|
1220
|
+
| 'radius'
|
|
1221
|
+
| 'range'
|
|
1222
|
+
| 'innerOuter'
|
|
1223
|
+
| 'outerNinety';
|
|
1224
|
+
readonly got: number | readonly number[];
|
|
1201
1225
|
}
|
|
1202
1226
|
// feat-20260520-2d-sprite-layer-mvp M-2 w13 — resource-setter bound
|
|
1203
1227
|
// violation (plan-strategy D-4). receivedMode carries the rejected
|
package/src/internal.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// @forgeax/engine-ecs/internal — package-owner seams that are intentionally
|
|
2
|
+
// absent from the public ECS barrel.
|
|
3
|
+
//
|
|
4
|
+
// This entry keeps the existing component-owner imports stable and adds the
|
|
5
|
+
// typed symbol used by scene propagation. A symbol avoids putting a derived
|
|
6
|
+
// writer method on the discoverable Query contract while still letting the
|
|
7
|
+
// owning package share the exact query implementation without a private path
|
|
8
|
+
// import or an untyped cast at each consumer.
|
|
9
|
+
|
|
10
|
+
export * from './component';
|
|
11
|
+
export type { WorldInternal } from './world-internal';
|
|
12
|
+
export { worldInternal } from './world-internal';
|
|
13
|
+
|
|
14
|
+
import type { Result } from '@forgeax/engine-types';
|
|
15
|
+
import type { Component } from './component';
|
|
16
|
+
import type { QuerySpanUnavailableError } from './errors';
|
|
17
|
+
import type { DerivedRangeWriter } from './query/derived-range-writer';
|
|
18
|
+
import type { Query } from './query/query';
|
|
19
|
+
|
|
20
|
+
/** Internal identity for the ECS-owned derived range writer accessor. */
|
|
21
|
+
export const DERIVED_WRITER: unique symbol = Symbol.for(
|
|
22
|
+
'forgeax.ecs.query.derivedWriter',
|
|
23
|
+
) as unknown as typeof DERIVED_WRITER;
|
|
24
|
+
|
|
25
|
+
type DerivedWriterQuery<
|
|
26
|
+
R extends readonly Component[],
|
|
27
|
+
W extends readonly Component[],
|
|
28
|
+
O extends readonly Component[],
|
|
29
|
+
> = Query<R, W, O> & {
|
|
30
|
+
readonly [DERIVED_WRITER]: <C extends W[number]>(
|
|
31
|
+
component: C,
|
|
32
|
+
) => Result<DerivedRangeWriter<R[number], C>, QuerySpanUnavailableError>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the ECS-owned derived writer through its internal symbol seam.
|
|
37
|
+
* Consumers of this module retain the component/query type relationship while
|
|
38
|
+
* the public Query and QuerySpan surfaces remain read/write-only contracts.
|
|
39
|
+
*/
|
|
40
|
+
export function getDerivedWriter<
|
|
41
|
+
R extends readonly Component[],
|
|
42
|
+
W extends readonly Component[],
|
|
43
|
+
O extends readonly Component[],
|
|
44
|
+
C extends W[number],
|
|
45
|
+
>(
|
|
46
|
+
query: Query<R, W, O>,
|
|
47
|
+
component: C,
|
|
48
|
+
): Result<DerivedRangeWriter<R[number], C>, QuerySpanUnavailableError> {
|
|
49
|
+
const internalQuery = query as DerivedWriterQuery<R, W, O>;
|
|
50
|
+
return internalQuery[DERIVED_WRITER](component);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type {
|
|
54
|
+
DerivedColumnBinding,
|
|
55
|
+
DerivedRangeCursor,
|
|
56
|
+
DerivedRangeRowCommit,
|
|
57
|
+
DerivedRangeRowProbe,
|
|
58
|
+
DerivedRangeWriter,
|
|
59
|
+
} from './query/derived-range-writer';
|