@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.
Files changed (100) hide show
  1. package/README.md +50 -16
  2. package/dist/__tests__/changed-block-summary.candidate.test.d.ts +2 -0
  3. package/dist/__tests__/changed-block-summary.candidate.test.d.ts.map +1 -0
  4. package/dist/__tests__/component-requirements.unit.test.d.ts +2 -0
  5. package/dist/__tests__/component-requirements.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/component-version-surface.test.d.ts +2 -0
  7. package/dist/__tests__/component-version-surface.test.d.ts.map +1 -0
  8. package/dist/__tests__/derived-range-writer.contract.test-d.d.ts +2 -0
  9. package/dist/__tests__/derived-range-writer.contract.test-d.d.ts.map +1 -0
  10. package/dist/__tests__/derived-range-writer.contract.test.d.ts +2 -0
  11. package/dist/__tests__/derived-range-writer.contract.test.d.ts.map +1 -0
  12. package/dist/__tests__/structural-evidence.contract.test-d.d.ts +2 -0
  13. package/dist/__tests__/structural-evidence.contract.test-d.d.ts.map +1 -0
  14. package/dist/__tests__/structural-evidence.contract.test.d.ts +2 -0
  15. package/dist/__tests__/structural-evidence.contract.test.d.ts.map +1 -0
  16. package/dist/component-schema.d.ts +17 -0
  17. package/dist/component-schema.d.ts.map +1 -1
  18. package/dist/component.d.ts +7 -1
  19. package/dist/component.d.ts.map +1 -1
  20. package/dist/errors/query-and-component-errors.d.ts +1 -1
  21. package/dist/errors/query-and-component-errors.d.ts.map +1 -1
  22. package/dist/errors/validation-errors.d.ts +24 -0
  23. package/dist/errors/validation-errors.d.ts.map +1 -1
  24. package/dist/errors.d.ts +14 -2
  25. package/dist/errors.d.ts.map +1 -1
  26. package/dist/externalization/index.mjs.map +1 -1
  27. package/dist/index.mjs +415 -128
  28. package/dist/index.mjs.map +1 -1
  29. package/dist/internal.d.ts +16 -0
  30. package/dist/internal.d.ts.map +1 -0
  31. package/dist/internal.mjs +15 -2
  32. package/dist/internal.mjs.map +1 -1
  33. package/dist/projection/index.d.ts +27 -52
  34. package/dist/projection/index.d.ts.map +1 -1
  35. package/dist/projection/index.mjs +66 -76
  36. package/dist/projection/index.mjs.map +1 -1
  37. package/dist/query/derived-range-writer.d.ts +48 -0
  38. package/dist/query/derived-range-writer.d.ts.map +1 -0
  39. package/dist/query/query.d.ts +1 -1
  40. package/dist/query/query.d.ts.map +1 -1
  41. package/dist/relationship-index.d.ts +8 -0
  42. package/dist/relationship-index.d.ts.map +1 -1
  43. package/dist/shared-ref-store.d.ts +5 -9
  44. package/dist/shared-ref-store.d.ts.map +1 -1
  45. package/dist/shared.mjs.map +1 -1
  46. package/dist/storage/change-detection.d.ts +1 -3
  47. package/dist/storage/change-detection.d.ts.map +1 -1
  48. package/dist/storage/structural-evidence.d.ts +30 -0
  49. package/dist/storage/structural-evidence.d.ts.map +1 -0
  50. package/dist/world-component-access.d.ts +4 -3
  51. package/dist/world-component-access.d.ts.map +1 -1
  52. package/dist/world-core.d.ts +2 -2
  53. package/dist/world-core.d.ts.map +1 -1
  54. package/dist/world-entity-lifecycle.d.ts.map +1 -1
  55. package/dist/world-internal.d.ts +1 -1
  56. package/dist/world-internal.d.ts.map +1 -1
  57. package/dist/world.d.ts +10 -14
  58. package/dist/world.d.ts.map +1 -1
  59. package/package.json +5 -5
  60. package/src/__tests__/changed-block-summary.candidate.test.ts +57 -0
  61. package/src/__tests__/component-requirements.unit.test.ts +76 -0
  62. package/src/__tests__/component-version-surface.test.ts +45 -0
  63. package/src/__tests__/derived-range-writer.contract.test-d.ts +35 -0
  64. package/src/__tests__/derived-range-writer.contract.test.ts +339 -0
  65. package/src/__tests__/errors.unit.test.ts +21 -5
  66. package/src/__tests__/externalization-render-read-lease.test-d.ts +1 -1
  67. package/src/__tests__/externalization-render-read-lease.unit.test.ts +15 -15
  68. package/src/__tests__/query-storage-trends.perf.test.ts +19 -0
  69. package/src/__tests__/query.unit.test.ts +10 -6
  70. package/src/__tests__/shared-ref-store.unit.test.ts +3 -8
  71. package/src/__tests__/structural-evidence.contract.test-d.ts +6 -0
  72. package/src/__tests__/structural-evidence.contract.test.ts +49 -0
  73. package/src/component-schema.ts +51 -0
  74. package/src/component.ts +12 -2
  75. package/src/errors/query-and-component-errors.ts +1 -1
  76. package/src/errors/validation-errors.ts +30 -0
  77. package/src/errors.ts +29 -5
  78. package/src/internal.ts +56 -0
  79. package/src/projection/index.ts +68 -149
  80. package/src/query/derived-range-writer.ts +332 -0
  81. package/src/query/query.ts +89 -15
  82. package/src/relationship-index.ts +13 -1
  83. package/src/shared-ref-store.ts +16 -24
  84. package/src/storage/change-detection.ts +0 -15
  85. package/src/storage/structural-evidence.ts +64 -0
  86. package/src/world-component-access.ts +53 -5
  87. package/src/world-core.ts +2 -2
  88. package/src/world-entity-lifecycle.ts +10 -1
  89. package/src/world-internal.ts +5 -5
  90. package/src/world.ts +45 -50
  91. package/dist/.tsbuildinfo +0 -1
  92. package/dist/__tests__/world-change-journal.unit.test.d.ts +0 -2
  93. package/dist/__tests__/world-change-journal.unit.test.d.ts.map +0 -1
  94. package/dist/__tests__/world-internal-identity.unit.test.d.ts +0 -2
  95. package/dist/__tests__/world-internal-identity.unit.test.d.ts.map +0 -1
  96. package/dist/world-change-journal.d.ts +0 -34
  97. package/dist/world-change-journal.d.ts.map +0 -1
  98. package/src/__tests__/world-change-journal.unit.test.ts +0 -97
  99. package/src/__tests__/world-internal-identity.unit.test.ts +0 -17
  100. package/src/world-change-journal.ts +0 -84
@@ -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
@@ -930,7 +930,7 @@ export interface FieldDescriptor<T extends SchemaFieldType = SchemaFieldType> {
930
930
  * same meaning): scene collect skips a `transient` field just as it skips a
931
931
  * `transient` component. Granularity is sunk to the field level so a component
932
932
  * can persist most of its fields while excluding a derived/reconstructable one
933
- * (e.g. `Transform.world`). Absent means the field participates in
933
+ * (e.g. `GlobalTransform.world`). Absent means the field participates in
934
934
  * serialization.
935
935
  */
936
936
  export interface FieldReflection {
@@ -998,6 +998,12 @@ export interface DefineComponentOptions {
998
998
  * rebuilt by the mirror hook after instantiateScene).
999
999
  */
1000
1000
  readonly transient?: boolean;
1001
+ /**
1002
+ * Components materialized automatically when this component is added.
1003
+ * Explicit data for a required component wins; the ECS appends only missing
1004
+ * identities at the spawn/add boundary, never from a frame system.
1005
+ */
1006
+ readonly requires?: readonly Component[];
1001
1007
  /**
1002
1008
  * Component-level open metadata namespace. Entries are copied into
1003
1009
  * `Component.meta` at registration; the ECS core assigns no meaning to any
@@ -1171,7 +1177,11 @@ export function defineComponent<const N extends string, const S extends FieldsIn
1171
1177
  registerComponentDefinition(token, {
1172
1178
  fields: frozenFields,
1173
1179
  defaults: frozenDefaults,
1174
- policy: { transient: options?.transient ?? false, meta },
1180
+ policy: {
1181
+ transient: options?.transient ?? false,
1182
+ meta,
1183
+ requires: Object.freeze([...(options?.requires ?? [])]),
1184
+ },
1175
1185
  });
1176
1186
  return Object.freeze(token);
1177
1187
  }
@@ -122,7 +122,7 @@ export class SpawnDataUnknownFieldError extends Error {
122
122
  this.detail = { component: componentName, field: fieldName, knownFields: sortedKnown };
123
123
  }
124
124
  }
125
- export type QuerySpanUnavailableReason = 'optional-data' | 'row-change-filter' | 'sparse-component';
125
+ export type QuerySpanUnavailableReason = 'optional-data' | 'sparse-component';
126
126
 
127
127
  export class QueryDescriptorConflictError extends Error {
128
128
  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 — PointLight / SpotLight
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
- // three-branch ('range' | 'innerOuter' | 'outerNinety') keeps four bound
1195
- // violations under one code; AI users narrow on `.detail.field` after the
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: 'range' | 'innerOuter' | 'outerNinety';
1200
- readonly got: number;
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
@@ -0,0 +1,56 @@
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
+
12
+ import type { Result } from '@forgeax/engine-types';
13
+ import type { Component } from './component';
14
+ import type { QuerySpanUnavailableError } from './errors';
15
+ import type { DerivedRangeWriter } from './query/derived-range-writer';
16
+ import type { Query } from './query/query';
17
+
18
+ /** Internal identity for the ECS-owned derived range writer accessor. */
19
+ export const DERIVED_WRITER: unique symbol = Symbol.for(
20
+ 'forgeax.ecs.query.derivedWriter',
21
+ ) as unknown as typeof DERIVED_WRITER;
22
+
23
+ type DerivedWriterQuery<
24
+ R extends readonly Component[],
25
+ W extends readonly Component[],
26
+ O extends readonly Component[],
27
+ > = Query<R, W, O> & {
28
+ readonly [DERIVED_WRITER]: <C extends W[number]>(
29
+ component: C,
30
+ ) => Result<DerivedRangeWriter<R[number], C>, QuerySpanUnavailableError>;
31
+ };
32
+
33
+ /**
34
+ * Resolve the ECS-owned derived writer through its internal symbol seam.
35
+ * Consumers of this module retain the component/query type relationship while
36
+ * the public Query and QuerySpan surfaces remain read/write-only contracts.
37
+ */
38
+ export function getDerivedWriter<
39
+ R extends readonly Component[],
40
+ W extends readonly Component[],
41
+ O extends readonly Component[],
42
+ C extends W[number],
43
+ >(
44
+ query: Query<R, W, O>,
45
+ component: C,
46
+ ): Result<DerivedRangeWriter<R[number], C>, QuerySpanUnavailableError> {
47
+ const internalQuery = query as DerivedWriterQuery<R, W, O>;
48
+ return internalQuery[DERIVED_WRITER](component);
49
+ }
50
+
51
+ export type {
52
+ DerivedColumnBinding,
53
+ DerivedRangeRowCommit,
54
+ DerivedRangeRowProbe,
55
+ DerivedRangeWriter,
56
+ } from './query/derived-range-writer';
@@ -3,7 +3,7 @@ import type { Component } from '../component';
3
3
  import { componentId } from '../component';
4
4
  import type { EntityHandle } from '../entity-handle';
5
5
  import type { SharedRefMutationRead } from '../shared-ref-store';
6
- import type { WorldChangeRead } from '../storage/change-detection';
6
+ import type { StructuralEvidenceRead } from '../storage/structural-evidence';
7
7
  import type { EcsError, World } from '../world';
8
8
  import { worldInternal } from '../world-internal';
9
9
 
@@ -24,41 +24,9 @@ export {
24
24
  StaleEntityError,
25
25
  } from '../errors';
26
26
 
27
- export type ProjectionChangeKind =
28
- | 'component-added'
29
- | 'component-changed'
30
- | 'component-removed'
31
- | 'derived-component-changed'
32
- | 'entity-removed';
33
-
34
- export interface ProjectionChange {
35
- readonly entity: EntityHandle;
36
- readonly kind: ProjectionChangeKind;
37
- /** Token identity is the owner-facing component discriminator. */
38
- readonly component?: Component;
39
- /** Numeric id remains journal evidence for ECS-internal consumers. */
40
- readonly componentId?: number;
41
- }
42
-
43
- export type ProjectionRead =
44
- | {
45
- readonly status: 'delta';
46
- readonly cursor: number;
47
- readonly changes: readonly ProjectionChange[];
48
- }
49
- | {
50
- readonly status: 'rebuild';
51
- readonly cursor: number;
52
- readonly reason: 'journal-overflow';
53
- };
54
-
55
- export interface WorldProjection {
56
- readonly cursor: number;
57
- poll(): ProjectionRead;
58
- }
59
-
60
- export interface WorldProjectionOptions {
61
- readonly components?: readonly Component[];
27
+ /** Read producer-owned typed structural facts without inferring from a World scan. */
28
+ export function readStructuralEvidence(world: World, cursor: number): StructuralEvidenceRead {
29
+ return world[worldInternal].getStructuralEvidence().readAfter(cursor) as StructuralEvidenceRead;
62
30
  }
63
31
 
64
32
  export interface RenderProjectionComponentRequest {
@@ -83,28 +51,34 @@ export interface RenderProjectionSpans {
83
51
 
84
52
  export interface RenderChangeBatchOk {
85
53
  readonly status: 'ok';
86
- readonly cursor: number;
87
- readonly world: Extract<WorldChangeRead, { readonly status: 'ok' }>;
88
- readonly sharedRefs: Extract<SharedRefMutationRead, { readonly status: 'ok' }>;
54
+ readonly version: RenderReadVersion;
55
+ readonly world: RenderWorldChanges;
56
+ readonly sharedRefs: SharedRefMutationRead;
89
57
  }
90
58
 
91
- export interface RenderChangeBatchOverflow {
92
- readonly status: 'overflow';
93
- readonly cursor: number;
59
+ export interface RenderChangeBatchRebuild {
60
+ readonly status: 'rebuild';
61
+ readonly version: RenderReadVersion;
94
62
  readonly resync: true;
95
- readonly oldestAvailable: number;
96
- readonly world: WorldChangeRead;
63
+ readonly reason: 'structure-changed';
64
+ readonly world: RenderWorldChanges;
97
65
  readonly sharedRefs: SharedRefMutationRead;
98
66
  }
99
67
 
100
- export type RenderChangeBatch = RenderChangeBatchOk | RenderChangeBatchOverflow;
68
+ export interface RenderWorldChanges {
69
+ readonly fromEpoch: number;
70
+ readonly toEpoch: number;
71
+ readonly changedComponentIds: readonly number[];
72
+ }
73
+
74
+ export type RenderChangeBatch = RenderChangeBatchOk | RenderChangeBatchRebuild;
101
75
 
102
76
  export interface RenderReadLease {
103
77
  readonly worldIdentity: string;
104
78
  readonly generation: number;
105
- readChanges(cursor: number): RenderChangeBatch;
79
+ readChanges(version: RenderReadVersion): RenderChangeBatch;
106
80
  querySpans(request: RenderProjectionRequest): RenderProjectionSpans;
107
- inspectCursor(): number;
81
+ captureVersion(): RenderReadVersion;
108
82
  dispose(): void;
109
83
  }
110
84
 
@@ -124,9 +98,10 @@ export function readRenderArrayView(
124
98
  | undefined;
125
99
  }
126
100
 
127
- interface ProjectionCursor {
128
- readonly world: number;
129
- readonly sharedRefs: number;
101
+ export interface RenderReadVersion {
102
+ readonly mutationEpoch: number;
103
+ readonly structureEpoch: number;
104
+ readonly sharedRefEpoch: number;
130
105
  }
131
106
 
132
107
  function readProjectionSpans(
@@ -167,80 +142,78 @@ function readProjectionSpans(
167
142
  export function createRenderReadLease(world: World, token: object = {}): RenderReadLease {
168
143
  void token;
169
144
  const sharedRefs = world[worldInternal].getSharedRefs();
170
- const generation = Math.max(1, world[worldInternal].getStructureEpoch());
171
145
  let disposed = false;
172
- let nextCursor = 1;
173
- const cursors = new Map<number, ProjectionCursor>([
174
- [
175
- 0,
176
- { world: world[worldInternal].getChangeCursor(), sharedRefs: sharedRefs.getMutationEpoch() },
177
- ],
178
- ]);
179
146
 
180
147
  const assertLive = (): void => {
181
148
  if (disposed) throw new Error('RenderReadLease is disposed.');
182
149
  };
183
150
 
184
- const captureCursor = (): number => {
185
- const cursor = nextCursor++;
186
- cursors.set(cursor, {
187
- world: world[worldInternal].getChangeCursor(),
188
- sharedRefs: sharedRefs.getMutationEpoch(),
189
- });
190
- if (cursors.size > 8) {
191
- const oldest = cursors.keys().next().value;
192
- if (typeof oldest === 'number' && oldest !== cursor) cursors.delete(oldest);
193
- }
194
- return cursor;
151
+ const captureVersion = (): RenderReadVersion => {
152
+ return {
153
+ mutationEpoch: world[worldInternal].getMutationEpoch(),
154
+ structureEpoch: world[worldInternal].getStructureEpoch(),
155
+ sharedRefEpoch: sharedRefs.getMutationEpoch(),
156
+ };
195
157
  };
196
158
 
197
159
  return {
198
160
  worldIdentity: world.identity,
199
- generation,
200
- inspectCursor(): number {
161
+ get generation(): number {
162
+ return Math.max(1, world[worldInternal].getStructureEpoch());
163
+ },
164
+ captureVersion(): RenderReadVersion {
201
165
  assertLive();
202
- return captureCursor();
166
+ return captureVersion();
203
167
  },
204
- readChanges(cursor: number): RenderChangeBatch {
168
+ readChanges(start: RenderReadVersion): RenderChangeBatch {
205
169
  assertLive();
206
- const start = cursors.get(cursor);
207
- if (start === undefined) throw new RangeError(`Unknown RenderReadLease cursor ${cursor}.`);
208
- const worldRead = world[worldInternal].readChangesSince(start.world) as WorldChangeRead;
209
- const sharedRead = sharedRefs.readChangesSince(start.sharedRefs);
210
- const next = captureCursor();
211
- if (worldRead.status === 'overflow' || sharedRead.status === 'overflow') {
212
- const oldestAvailable = Math.min(
213
- worldRead.status === 'overflow' ? worldRead.oldestAvailable : Number.MAX_SAFE_INTEGER,
214
- sharedRead.status === 'overflow' ? sharedRead.oldestAvailable : Number.MAX_SAFE_INTEGER,
215
- );
170
+ const toEpoch = world[worldInternal].getMutationEpoch() as number;
171
+ const componentEpochs = world[
172
+ worldInternal
173
+ ].getComponentMutationEpochs() as readonly number[];
174
+ const changedComponentIds: number[] = [];
175
+ for (let componentId = 0; componentId < componentEpochs.length; componentId += 1) {
176
+ const epoch = componentEpochs[componentId] ?? 0;
177
+ if (epoch > start.mutationEpoch && epoch <= toEpoch) changedComponentIds.push(componentId);
178
+ }
179
+ const worldRead: RenderWorldChanges = {
180
+ fromEpoch: start.mutationEpoch,
181
+ toEpoch,
182
+ changedComponentIds,
183
+ };
184
+ const sharedRead = sharedRefs.readChangesSince(start.sharedRefEpoch);
185
+ const version = captureVersion();
186
+ const structureChanged = start.structureEpoch !== world[worldInternal].getStructureEpoch();
187
+ if (structureChanged) {
216
188
  return {
217
- status: 'overflow',
218
- cursor: next,
189
+ status: 'rebuild',
190
+ version,
219
191
  resync: true,
220
- oldestAvailable,
192
+ reason: 'structure-changed',
221
193
  world: worldRead,
222
194
  sharedRefs: sharedRead,
223
195
  };
224
196
  }
225
- return { status: 'ok', cursor: next, world: worldRead, sharedRefs: sharedRead };
197
+ return { status: 'ok', version, world: worldRead, sharedRefs: sharedRead };
226
198
  },
227
199
  querySpans(request: RenderProjectionRequest): RenderProjectionSpans {
228
200
  assertLive();
229
- return readProjectionSpans(world, generation, request);
201
+ return readProjectionSpans(
202
+ world,
203
+ Math.max(1, world[worldInternal].getStructureEpoch()),
204
+ request,
205
+ );
230
206
  },
231
207
  dispose(): void {
232
208
  disposed = true;
233
- cursors.clear();
234
209
  },
235
210
  };
236
211
  }
237
212
 
238
213
  /**
239
- * Publish one owner-derived component value without confusing it with an
240
- * authored mutation. Owner packages use this narrow seam for fields such as
241
- * `Transform.world`: the write still passes through ECS validation and managed
242
- * storage, while the journal records a `derived-component-changed` fact for
243
- * incremental projections.
214
+ * Publish one owner-derived component value through the component's ordinary
215
+ * version. Numeric consumers observe the same row epoch regardless of which
216
+ * owner computed the value; there is no parallel derived-change vocabulary.
244
217
  */
245
218
  export function setDerivedComponent(
246
219
  world: World,
@@ -250,7 +223,7 @@ export function setDerivedComponent(
250
223
  ): Result<void, EcsError> {
251
224
  const result = world[worldInternal].setQueryRow(entity, component, value);
252
225
  if (!result.ok) return result;
253
- world[worldInternal].markDerivedComponentChanges(componentId(component), [entity]);
226
+ world[worldInternal].markComponentChanged(entity, componentId(component));
254
227
  return result;
255
228
  }
256
229
 
@@ -262,57 +235,3 @@ export function routeWorldError(
262
235
  ): void {
263
236
  world[worldInternal].routeError(error, context);
264
237
  }
265
-
266
- /**
267
- * Create the narrow incremental evidence surface for an owner projection.
268
- * Rebuilds intentionally return a reason; complete data always comes from
269
- * the owner's regular Query/QuerySpan path.
270
- */
271
- export function createWorldProjection(
272
- world: World,
273
- options: WorldProjectionOptions = {},
274
- ): WorldProjection {
275
- const componentById =
276
- options.components === undefined
277
- ? undefined
278
- : new Map(options.components.map((component) => [componentId(component), component]));
279
- const componentIds = componentById === undefined ? undefined : new Set(componentById.keys());
280
- let cursor = world[worldInternal].getChangeCursor();
281
-
282
- const pollSince = (requestedCursor: number): ProjectionRead => {
283
- const result = world[worldInternal].readChangesSince(requestedCursor);
284
- if (result.status === 'overflow') {
285
- cursor = result.cursor;
286
- return { status: 'rebuild', cursor, reason: 'journal-overflow' };
287
- }
288
- cursor = result.cursor;
289
- const changes: ProjectionChange[] = [];
290
- for (const record of result.records) {
291
- if (
292
- componentIds !== undefined &&
293
- record.kind !== 'entity-removed' &&
294
- (record.componentId === undefined || !componentIds.has(record.componentId))
295
- ) {
296
- continue;
297
- }
298
- const component =
299
- record.componentId === undefined ? undefined : componentById?.get(record.componentId);
300
- changes.push({
301
- entity: record.entity,
302
- kind: record.kind,
303
- ...(component === undefined ? {} : { component }),
304
- ...(record.componentId === undefined ? {} : { componentId: record.componentId }),
305
- });
306
- }
307
- return { status: 'delta', cursor, changes };
308
- };
309
-
310
- return {
311
- get cursor(): number {
312
- return cursor;
313
- },
314
- poll(): ProjectionRead {
315
- return pollSince(cursor);
316
- },
317
- };
318
- }