@forgeax/engine-ecs 0.0.0-dev.8d955ade1c79
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/LICENSE +202 -0
- package/README.md +278 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__fixtures__/field-shape-fixture.d.ts +16 -0
- package/dist/__fixtures__/field-shape-fixture.d.ts.map +1 -0
- package/dist/__tests__/__fixtures__/inspect-scripts.snapshot.d.ts +3 -0
- package/dist/__tests__/__fixtures__/inspect-scripts.snapshot.d.ts.map +1 -0
- package/dist/__tests__/archetype.unit.test.d.ts +2 -0
- package/dist/__tests__/archetype.unit.test.d.ts.map +1 -0
- package/dist/__tests__/array-handle-element.test-d.d.ts +2 -0
- package/dist/__tests__/array-handle-element.test-d.d.ts.map +1 -0
- package/dist/__tests__/buffer-array-vocab.test-d.d.ts +2 -0
- package/dist/__tests__/buffer-array-vocab.test-d.d.ts.map +1 -0
- package/dist/__tests__/buffer-field-input-widen.unit.test.d.ts +2 -0
- package/dist/__tests__/buffer-field-input-widen.unit.test.d.ts.map +1 -0
- package/dist/__tests__/buffer-pool.test-d.d.ts +2 -0
- package/dist/__tests__/buffer-pool.test-d.d.ts.map +1 -0
- package/dist/__tests__/change-detection.unit.test.d.ts +2 -0
- package/dist/__tests__/change-detection.unit.test.d.ts.map +1 -0
- package/dist/__tests__/command-buffer.test.d.ts +2 -0
- package/dist/__tests__/command-buffer.test.d.ts.map +1 -0
- package/dist/__tests__/commands-deferred.unit.test.d.ts +2 -0
- package/dist/__tests__/commands-deferred.unit.test.d.ts.map +1 -0
- package/dist/__tests__/component-catalog.test.d.ts +2 -0
- package/dist/__tests__/component-catalog.test.d.ts.map +1 -0
- package/dist/__tests__/component-definition-identity.unit.test.d.ts +2 -0
- package/dist/__tests__/component-definition-identity.unit.test.d.ts.map +1 -0
- package/dist/__tests__/component-nan-preflight.unit.test.d.ts +2 -0
- package/dist/__tests__/component-nan-preflight.unit.test.d.ts.map +1 -0
- package/dist/__tests__/component-schema.test-d.d.ts +2 -0
- package/dist/__tests__/component-schema.test-d.d.ts.map +1 -0
- package/dist/__tests__/component-schema.test.d.ts +2 -0
- package/dist/__tests__/component-schema.test.d.ts.map +1 -0
- package/dist/__tests__/define-component-options.test-d.d.ts +2 -0
- package/dist/__tests__/define-component-options.test-d.d.ts.map +1 -0
- package/dist/__tests__/deprecated-vocab-rejection.unit.test.d.ts +2 -0
- package/dist/__tests__/deprecated-vocab-rejection.unit.test.d.ts.map +1 -0
- package/dist/__tests__/despawn-all.unit.test.d.ts +2 -0
- package/dist/__tests__/despawn-all.unit.test.d.ts.map +1 -0
- package/dist/__tests__/ecs-core-reduction.characterization.test.d.ts +2 -0
- package/dist/__tests__/ecs-core-reduction.characterization.test.d.ts.map +1 -0
- package/dist/__tests__/ecs-core-reduction.perf.test.d.ts +2 -0
- package/dist/__tests__/ecs-core-reduction.perf.test.d.ts.map +1 -0
- package/dist/__tests__/ecs-error-code-31.test-d.d.ts +2 -0
- package/dist/__tests__/ecs-error-code-31.test-d.d.ts.map +1 -0
- package/dist/__tests__/entity-disabling.unit.test.d.ts +2 -0
- package/dist/__tests__/entity-disabling.unit.test.d.ts.map +1 -0
- package/dist/__tests__/entity-handle-codec-parity.unit.test.d.ts +2 -0
- package/dist/__tests__/entity-handle-codec-parity.unit.test.d.ts.map +1 -0
- package/dist/__tests__/errors.unit.test.d.ts +2 -0
- package/dist/__tests__/errors.unit.test.d.ts.map +1 -0
- package/dist/__tests__/execution-conflict-boundary.unit.test.d.ts +2 -0
- package/dist/__tests__/execution-conflict-boundary.unit.test.d.ts.map +1 -0
- package/dist/__tests__/execution-shared-storage.unit.test.d.ts +2 -0
- package/dist/__tests__/execution-shared-storage.unit.test.d.ts.map +1 -0
- package/dist/__tests__/externalization-portable-value.unit.test.d.ts +2 -0
- package/dist/__tests__/externalization-portable-value.unit.test.d.ts.map +1 -0
- package/dist/__tests__/externalization-projection.unit.test.d.ts +2 -0
- package/dist/__tests__/externalization-projection.unit.test.d.ts.map +1 -0
- package/dist/__tests__/externalization-render-read-lease.test-d.d.ts +2 -0
- package/dist/__tests__/externalization-render-read-lease.test-d.d.ts.map +1 -0
- package/dist/__tests__/externalization-render-read-lease.unit.test.d.ts +2 -0
- package/dist/__tests__/externalization-render-read-lease.unit.test.d.ts.map +1 -0
- package/dist/__tests__/externalization-root-surface.unit.test.d.ts +2 -0
- package/dist/__tests__/externalization-root-surface.unit.test.d.ts.map +1 -0
- package/dist/__tests__/field-enum-labels-reflection.test.d.ts +2 -0
- package/dist/__tests__/field-enum-labels-reflection.test.d.ts.map +1 -0
- package/dist/__tests__/field-shape-fixture.test.d.ts +2 -0
- package/dist/__tests__/field-shape-fixture.test.d.ts.map +1 -0
- package/dist/__tests__/field-transient-reflection.test.d.ts +2 -0
- package/dist/__tests__/field-transient-reflection.test.d.ts.map +1 -0
- package/dist/__tests__/field-value-type.test-d.d.ts +2 -0
- package/dist/__tests__/field-value-type.test-d.d.ts.map +1 -0
- package/dist/__tests__/fixed-update.unit.test.d.ts +2 -0
- package/dist/__tests__/fixed-update.unit.test.d.ts.map +1 -0
- package/dist/__tests__/handle.test-d.d.ts +2 -0
- package/dist/__tests__/handle.test-d.d.ts.map +1 -0
- package/dist/__tests__/hierarchy.unit.test.d.ts +2 -0
- package/dist/__tests__/hierarchy.unit.test.d.ts.map +1 -0
- package/dist/__tests__/managed-array-vocab.test-d.d.ts +2 -0
- package/dist/__tests__/managed-array-vocab.test-d.d.ts.map +1 -0
- package/dist/__tests__/minimal-example.test-d.d.ts +2 -0
- package/dist/__tests__/minimal-example.test-d.d.ts.map +1 -0
- package/dist/__tests__/name-read-types.test-d.d.ts +2 -0
- package/dist/__tests__/name-read-types.test-d.d.ts.map +1 -0
- package/dist/__tests__/plugin-service.test.d.ts +2 -0
- package/dist/__tests__/plugin-service.test.d.ts.map +1 -0
- package/dist/__tests__/query-combinations.unit.test.d.ts +2 -0
- package/dist/__tests__/query-combinations.unit.test.d.ts.map +1 -0
- package/dist/__tests__/query-light-extract-flat-column-ratio.perf.test.d.ts +2 -0
- package/dist/__tests__/query-light-extract-flat-column-ratio.perf.test.d.ts.map +1 -0
- package/dist/__tests__/query-storage-trends.perf.test.d.ts +2 -0
- package/dist/__tests__/query-storage-trends.perf.test.d.ts.map +1 -0
- package/dist/__tests__/query-trs-flat-column-ratio.perf.test.d.ts +2 -0
- package/dist/__tests__/query-trs-flat-column-ratio.perf.test.d.ts.map +1 -0
- package/dist/__tests__/query.unit.test.d.ts +2 -0
- package/dist/__tests__/query.unit.test.d.ts.map +1 -0
- package/dist/__tests__/relationship-index.perf.test.d.ts +2 -0
- package/dist/__tests__/relationship-index.perf.test.d.ts.map +1 -0
- package/dist/__tests__/relationship-index.test.d.ts +2 -0
- package/dist/__tests__/relationship-index.test.d.ts.map +1 -0
- package/dist/__tests__/scalar-field-type-owner.test-d.d.ts +2 -0
- package/dist/__tests__/scalar-field-type-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/schedule-determinism.unit.test.d.ts +2 -0
- package/dist/__tests__/schedule-determinism.unit.test.d.ts.map +1 -0
- package/dist/__tests__/schedule-inspection.unit.test.d.ts +2 -0
- package/dist/__tests__/schedule-inspection.unit.test.d.ts.map +1 -0
- package/dist/__tests__/schedule-scope-error.unit.test.d.ts +2 -0
- package/dist/__tests__/schedule-scope-error.unit.test.d.ts.map +1 -0
- package/dist/__tests__/schedule-token.unit.test.d.ts +2 -0
- package/dist/__tests__/schedule-token.unit.test.d.ts.map +1 -0
- package/dist/__tests__/schema-vocab.test-d.d.ts +2 -0
- package/dist/__tests__/schema-vocab.test-d.d.ts.map +1 -0
- package/dist/__tests__/serialization.unit.test.d.ts +2 -0
- package/dist/__tests__/serialization.unit.test.d.ts.map +1 -0
- package/dist/__tests__/shared-kernel-eligibility.test-d.d.ts +2 -0
- package/dist/__tests__/shared-kernel-eligibility.test-d.d.ts.map +1 -0
- package/dist/__tests__/shared-kernel-eligibility.unit.test.d.ts +2 -0
- package/dist/__tests__/shared-kernel-eligibility.unit.test.d.ts.map +1 -0
- package/dist/__tests__/shared-ref-store.unit.test.d.ts +2 -0
- package/dist/__tests__/shared-ref-store.unit.test.d.ts.map +1 -0
- package/dist/__tests__/sparse-tag.unit.test.d.ts +2 -0
- package/dist/__tests__/sparse-tag.unit.test.d.ts.map +1 -0
- package/dist/__tests__/spawn-field-error.test-d.d.ts +2 -0
- package/dist/__tests__/spawn-field-error.test-d.d.ts.map +1 -0
- package/dist/__tests__/stale-error.test-d.d.ts +2 -0
- package/dist/__tests__/stale-error.test-d.d.ts.map +1 -0
- package/dist/__tests__/string-view-symmetry.test-d.d.ts +2 -0
- package/dist/__tests__/string-view-symmetry.test-d.d.ts.map +1 -0
- package/dist/__tests__/string-vocab.test-d.d.ts +2 -0
- package/dist/__tests__/string-vocab.test-d.d.ts.map +1 -0
- package/dist/__tests__/structure-epoch-boundaries.unit.test.d.ts +2 -0
- package/dist/__tests__/structure-epoch-boundaries.unit.test.d.ts.map +1 -0
- package/dist/__tests__/structure-epoch.unit.test.d.ts +2 -0
- package/dist/__tests__/structure-epoch.unit.test.d.ts.map +1 -0
- package/dist/__tests__/table-archetype-mapping.unit.test.d.ts +2 -0
- package/dist/__tests__/table-archetype-mapping.unit.test.d.ts.map +1 -0
- package/dist/__tests__/time.unit.test.d.ts +2 -0
- package/dist/__tests__/time.unit.test.d.ts.map +1 -0
- package/dist/__tests__/types.test-d.d.ts +2 -0
- package/dist/__tests__/types.test-d.d.ts.map +1 -0
- package/dist/__tests__/unique-ref-store.unit.test.d.ts +2 -0
- package/dist/__tests__/unique-ref-store.unit.test.d.ts.map +1 -0
- package/dist/__tests__/utils/handle-numeric.d.ts +14 -0
- package/dist/__tests__/utils/handle-numeric.d.ts.map +1 -0
- package/dist/__tests__/validation-root-surface.unit.test.d.ts +2 -0
- package/dist/__tests__/validation-root-surface.unit.test.d.ts.map +1 -0
- package/dist/__tests__/visibility-atomic-write.unit.test.d.ts +2 -0
- package/dist/__tests__/visibility-atomic-write.unit.test.d.ts.map +1 -0
- package/dist/__tests__/world-change-journal.unit.test.d.ts +2 -0
- package/dist/__tests__/world-change-journal.unit.test.d.ts.map +1 -0
- package/dist/__tests__/world-characterization.unit.test.d.ts +2 -0
- package/dist/__tests__/world-characterization.unit.test.d.ts.map +1 -0
- package/dist/__tests__/world-consumer-inference.test-d.d.ts +2 -0
- package/dist/__tests__/world-consumer-inference.test-d.d.ts.map +1 -0
- package/dist/__tests__/world-health.contract.test.d.ts +2 -0
- package/dist/__tests__/world-health.contract.test.d.ts.map +1 -0
- package/dist/__tests__/world-inspection.test.d.ts +2 -0
- package/dist/__tests__/world-inspection.test.d.ts.map +1 -0
- package/dist/__tests__/world-internal-identity.unit.test.d.ts +2 -0
- package/dist/__tests__/world-internal-identity.unit.test.d.ts.map +1 -0
- package/dist/__tests__/world-managed-roundtrip.unit.test.d.ts +2 -0
- package/dist/__tests__/world-managed-roundtrip.unit.test.d.ts.map +1 -0
- package/dist/__tests__/world-scheduling.m4.test.d.ts +2 -0
- package/dist/__tests__/world-scheduling.m4.test.d.ts.map +1 -0
- package/dist/__tests__/world-unique-roundtrip.unit.test.d.ts +2 -0
- package/dist/__tests__/world-unique-roundtrip.unit.test.d.ts.map +1 -0
- package/dist/__tests__/world.test-d.d.ts +2 -0
- package/dist/__tests__/world.test-d.d.ts.map +1 -0
- package/dist/buffer-pool.d.ts +109 -0
- package/dist/buffer-pool.d.ts.map +1 -0
- package/dist/commands.d.ts +93 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/component-default-fallback.d.ts +88 -0
- package/dist/component-default-fallback.d.ts.map +1 -0
- package/dist/component-schema.d.ts +34 -0
- package/dist/component-schema.d.ts.map +1 -0
- package/dist/component-value-validate.d.ts +29 -0
- package/dist/component-value-validate.d.ts.map +1 -0
- package/dist/component.d.ts +581 -0
- package/dist/component.d.ts.map +1 -0
- package/dist/entity-handle.d.ts +68 -0
- package/dist/entity-handle.d.ts.map +1 -0
- package/dist/entity.d.ts +86 -0
- package/dist/entity.d.ts.map +1 -0
- package/dist/errors/query-and-component-errors.d.ts +132 -0
- package/dist/errors/query-and-component-errors.d.ts.map +1 -0
- package/dist/errors/relationship-errors.d.ts +112 -0
- package/dist/errors/relationship-errors.d.ts.map +1 -0
- package/dist/errors/sprite-and-shared-errors.d.ts +107 -0
- package/dist/errors/sprite-and-shared-errors.d.ts.map +1 -0
- package/dist/errors/validation-errors.d.ts +229 -0
- package/dist/errors/validation-errors.d.ts.map +1 -0
- package/dist/errors.d.ts +763 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/execution/shared-kernel.d.ts +106 -0
- package/dist/execution/shared-kernel.d.ts.map +1 -0
- package/dist/externalization/index.d.ts +31 -0
- package/dist/externalization/index.d.ts.map +1 -0
- package/dist/externalization/index.mjs +298 -0
- package/dist/externalization/index.mjs.map +1 -0
- package/dist/index.d.ts +261 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +7422 -0
- package/dist/index.mjs.map +1 -0
- package/dist/internal.mjs +479 -0
- package/dist/internal.mjs.map +1 -0
- package/dist/plugin-service.d.ts +11 -0
- package/dist/plugin-service.d.ts.map +1 -0
- package/dist/projection/index.d.ts +103 -0
- package/dist/projection/index.d.ts.map +1 -0
- package/dist/projection/index.mjs +615 -0
- package/dist/projection/index.mjs.map +1 -0
- package/dist/query/query.d.ts +58 -0
- package/dist/query/query.d.ts.map +1 -0
- package/dist/relationship-index.d.ts +81 -0
- package/dist/relationship-index.d.ts.map +1 -0
- package/dist/resource.d.ts +70 -0
- package/dist/resource.d.ts.map +1 -0
- package/dist/schedule-token.d.ts +16 -0
- package/dist/schedule-token.d.ts.map +1 -0
- package/dist/schedule.d.ts +271 -0
- package/dist/schedule.d.ts.map +1 -0
- package/dist/shared-ref-store.d.ts +144 -0
- package/dist/shared-ref-store.d.ts.map +1 -0
- package/dist/shared.d.ts +3 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.mjs +364 -0
- package/dist/shared.mjs.map +1 -0
- package/dist/storage/archetype-graph.d.ts +54 -0
- package/dist/storage/archetype-graph.d.ts.map +1 -0
- package/dist/storage/archetype.d.ts +23 -0
- package/dist/storage/archetype.d.ts.map +1 -0
- package/dist/storage/change-detection.d.ts +40 -0
- package/dist/storage/change-detection.d.ts.map +1 -0
- package/dist/storage/column.d.ts +111 -0
- package/dist/storage/column.d.ts.map +1 -0
- package/dist/storage/table.d.ts +29 -0
- package/dist/storage/table.d.ts.map +1 -0
- package/dist/time.d.ts +57 -0
- package/dist/time.d.ts.map +1 -0
- package/dist/unique-ref-store.d.ts +105 -0
- package/dist/unique-ref-store.d.ts.map +1 -0
- package/dist/world-change-journal.d.ts +34 -0
- package/dist/world-change-journal.d.ts.map +1 -0
- package/dist/world-component-access.d.ts +265 -0
- package/dist/world-component-access.d.ts.map +1 -0
- package/dist/world-component-storage.d.ts +282 -0
- package/dist/world-component-storage.d.ts.map +1 -0
- package/dist/world-core.d.ts +39 -0
- package/dist/world-core.d.ts.map +1 -0
- package/dist/world-entity-lifecycle.d.ts +30 -0
- package/dist/world-entity-lifecycle.d.ts.map +1 -0
- package/dist/world-internal.d.ts +14 -0
- package/dist/world-internal.d.ts.map +1 -0
- package/dist/world-scheduling.d.ts +30 -0
- package/dist/world-scheduling.d.ts.map +1 -0
- package/dist/world.d.ts +620 -0
- package/dist/world.d.ts.map +1 -0
- package/package.json +77 -0
- package/src/__fixtures__/field-shape-fixture.ts +30 -0
- package/src/__tests__/__fixtures__/inspect-scripts.snapshot.ts +2 -0
- package/src/__tests__/archetype.unit.test.ts +1837 -0
- package/src/__tests__/array-handle-element.test-d.ts +34 -0
- package/src/__tests__/buffer-array-vocab.test-d.ts +53 -0
- package/src/__tests__/buffer-field-input-widen.unit.test.ts +99 -0
- package/src/__tests__/buffer-pool.test-d.ts +32 -0
- package/src/__tests__/change-detection.unit.test.ts +86 -0
- package/src/__tests__/command-buffer.test.ts +179 -0
- package/src/__tests__/commands-deferred.unit.test.ts +68 -0
- package/src/__tests__/component-catalog.test.ts +59 -0
- package/src/__tests__/component-definition-identity.unit.test.ts +24 -0
- package/src/__tests__/component-nan-preflight.unit.test.ts +186 -0
- package/src/__tests__/component-schema.test-d.ts +119 -0
- package/src/__tests__/component-schema.test.ts +55 -0
- package/src/__tests__/define-component-options.test-d.ts +35 -0
- package/src/__tests__/deprecated-vocab-rejection.unit.test.ts +59 -0
- package/src/__tests__/despawn-all.unit.test.ts +17 -0
- package/src/__tests__/ecs-core-reduction.characterization.test.ts +130 -0
- package/src/__tests__/ecs-core-reduction.perf.test.ts +260 -0
- package/src/__tests__/ecs-error-code-31.test-d.ts +42 -0
- package/src/__tests__/entity-disabling.unit.test.ts +37 -0
- package/src/__tests__/entity-handle-codec-parity.unit.test.ts +126 -0
- package/src/__tests__/errors.unit.test.ts +1456 -0
- package/src/__tests__/execution-conflict-boundary.unit.test.ts +123 -0
- package/src/__tests__/execution-shared-storage.unit.test.ts +40 -0
- package/src/__tests__/externalization-portable-value.unit.test.ts +275 -0
- package/src/__tests__/externalization-projection.unit.test.ts +325 -0
- package/src/__tests__/externalization-render-read-lease.test-d.ts +30 -0
- package/src/__tests__/externalization-render-read-lease.unit.test.ts +79 -0
- package/src/__tests__/externalization-root-surface.unit.test.ts +33 -0
- package/src/__tests__/field-enum-labels-reflection.test.ts +66 -0
- package/src/__tests__/field-shape-fixture.test.ts +61 -0
- package/src/__tests__/field-transient-reflection.test.ts +59 -0
- package/src/__tests__/field-value-type.test-d.ts +57 -0
- package/src/__tests__/fixed-update.unit.test.ts +78 -0
- package/src/__tests__/handle.test-d.ts +86 -0
- package/src/__tests__/hierarchy.unit.test.ts +2534 -0
- package/src/__tests__/managed-array-vocab.test-d.ts +137 -0
- package/src/__tests__/minimal-example.test-d.ts +42 -0
- package/src/__tests__/name-read-types.test-d.ts +72 -0
- package/src/__tests__/plugin-service.test.ts +13 -0
- package/src/__tests__/query-combinations.unit.test.ts +42 -0
- package/src/__tests__/query-light-extract-flat-column-ratio.perf.test.ts +252 -0
- package/src/__tests__/query-storage-trends.perf.test.ts +104 -0
- package/src/__tests__/query-trs-flat-column-ratio.perf.test.ts +194 -0
- package/src/__tests__/query.unit.test.ts +196 -0
- package/src/__tests__/relationship-index.perf.test.ts +126 -0
- package/src/__tests__/relationship-index.test.ts +70 -0
- package/src/__tests__/scalar-field-type-owner.test-d.ts +71 -0
- package/src/__tests__/schedule-determinism.unit.test.ts +36 -0
- package/src/__tests__/schedule-inspection.unit.test.ts +81 -0
- package/src/__tests__/schedule-scope-error.unit.test.ts +63 -0
- package/src/__tests__/schedule-token.unit.test.ts +54 -0
- package/src/__tests__/schema-vocab.test-d.ts +103 -0
- package/src/__tests__/serialization.unit.test.ts +2740 -0
- package/src/__tests__/shared-kernel-eligibility.test-d.ts +18 -0
- package/src/__tests__/shared-kernel-eligibility.unit.test.ts +78 -0
- package/src/__tests__/shared-ref-store.unit.test.ts +646 -0
- package/src/__tests__/sparse-tag.unit.test.ts +201 -0
- package/src/__tests__/spawn-field-error.test-d.ts +41 -0
- package/src/__tests__/stale-error.test-d.ts +208 -0
- package/src/__tests__/string-view-symmetry.test-d.ts +26 -0
- package/src/__tests__/string-vocab.test-d.ts +49 -0
- package/src/__tests__/structure-epoch-boundaries.unit.test.ts +39 -0
- package/src/__tests__/structure-epoch.unit.test.ts +48 -0
- package/src/__tests__/table-archetype-mapping.unit.test.ts +78 -0
- package/src/__tests__/time.unit.test.ts +79 -0
- package/src/__tests__/types.test-d.ts +38 -0
- package/src/__tests__/unique-ref-store.unit.test.ts +348 -0
- package/src/__tests__/utils/handle-numeric.ts +30 -0
- package/src/__tests__/validation-root-surface.unit.test.ts +15 -0
- package/src/__tests__/visibility-atomic-write.unit.test.ts +82 -0
- package/src/__tests__/world-change-journal.unit.test.ts +97 -0
- package/src/__tests__/world-characterization.unit.test.ts +408 -0
- package/src/__tests__/world-consumer-inference.test-d.ts +16 -0
- package/src/__tests__/world-health.contract.test.ts +87 -0
- package/src/__tests__/world-inspection.test.ts +37 -0
- package/src/__tests__/world-internal-identity.unit.test.ts +17 -0
- package/src/__tests__/world-managed-roundtrip.unit.test.ts +98 -0
- package/src/__tests__/world-scheduling.m4.test.ts +52 -0
- package/src/__tests__/world-unique-roundtrip.unit.test.ts +357 -0
- package/src/__tests__/world.test-d.ts +156 -0
- package/src/buffer-pool.ts +357 -0
- package/src/commands.ts +644 -0
- package/src/component-default-fallback.ts +236 -0
- package/src/component-schema.ts +92 -0
- package/src/component-value-validate.ts +135 -0
- package/src/component.ts +1268 -0
- package/src/entity-handle.ts +104 -0
- package/src/entity.ts +140 -0
- package/src/errors/query-and-component-errors.ts +200 -0
- package/src/errors/relationship-errors.ts +198 -0
- package/src/errors/sprite-and-shared-errors.ts +231 -0
- package/src/errors/validation-errors.ts +506 -0
- package/src/errors.ts +1464 -0
- package/src/execution/shared-kernel.ts +392 -0
- package/src/externalization/index.ts +170 -0
- package/src/index.ts +414 -0
- package/src/plugin-service.ts +35 -0
- package/src/projection/index.ts +318 -0
- package/src/query/query.ts +606 -0
- package/src/relationship-index.ts +175 -0
- package/src/resource.ts +106 -0
- package/src/schedule-token.ts +40 -0
- package/src/schedule.ts +846 -0
- package/src/shared-ref-store.ts +418 -0
- package/src/shared.ts +23 -0
- package/src/storage/archetype-graph.ts +166 -0
- package/src/storage/archetype.ts +78 -0
- package/src/storage/change-detection.ts +231 -0
- package/src/storage/column.ts +247 -0
- package/src/storage/table.ts +153 -0
- package/src/time.ts +122 -0
- package/src/unique-ref-store.ts +278 -0
- package/src/world-change-journal.ts +84 -0
- package/src/world-component-access.ts +1421 -0
- package/src/world-component-storage.ts +1178 -0
- package/src/world-core.ts +74 -0
- package/src/world-entity-lifecycle.ts +539 -0
- package/src/world-internal.ts +75 -0
- package/src/world-scheduling.ts +499 -0
- package/src/world.ts +1399 -0
|
@@ -0,0 +1,1421 @@
|
|
|
1
|
+
// @forgeax/engine-ecs — world-component-access: component storage and access.
|
|
2
|
+
//
|
|
3
|
+
// This module owns component rows, managed storage, array operations, archetype
|
|
4
|
+
// migration, and the relationship callbacks that mutate component storage. World
|
|
5
|
+
// remains the typed facade and supplies one narrow per-World state capability.
|
|
6
|
+
|
|
7
|
+
import { err, isRetiredSlot, ok, type Result, unwrapHandle } from '@forgeax/engine-types';
|
|
8
|
+
import type { BufferPool } from './buffer-pool';
|
|
9
|
+
import {
|
|
10
|
+
bufferFieldByteLength,
|
|
11
|
+
type Component,
|
|
12
|
+
type ComponentSchema,
|
|
13
|
+
componentId,
|
|
14
|
+
componentSchema,
|
|
15
|
+
type InputShapeOf,
|
|
16
|
+
isEntityField,
|
|
17
|
+
isManagedBufferField,
|
|
18
|
+
isManagedField,
|
|
19
|
+
type ManagedArrayElementType,
|
|
20
|
+
type ManagedArrayElementValue,
|
|
21
|
+
type ShapeOf,
|
|
22
|
+
TYPE_METADATA,
|
|
23
|
+
} from './component';
|
|
24
|
+
import { fillComponentDefaults, validateComponentDataKeys } from './component-default-fallback';
|
|
25
|
+
import { componentDefinition } from './component-schema';
|
|
26
|
+
import { validateManagedArrayValues, validateSharedFieldValues } from './component-value-validate';
|
|
27
|
+
import { Entity as EntityComponent } from './entity';
|
|
28
|
+
import {
|
|
29
|
+
ENTITY_MAX_INDEX,
|
|
30
|
+
ENTITY_NULL_RAW,
|
|
31
|
+
type EntityHandle,
|
|
32
|
+
encodeEntity,
|
|
33
|
+
entityGeneration,
|
|
34
|
+
entityIndex,
|
|
35
|
+
} from './entity-handle';
|
|
36
|
+
import {
|
|
37
|
+
ComponentAlreadyPresentError,
|
|
38
|
+
ComponentNotPresentError,
|
|
39
|
+
EntityIndexOverflowError,
|
|
40
|
+
FixedSizeMismatchError,
|
|
41
|
+
ManagedBufferOutOfBoundsError,
|
|
42
|
+
RelationshipSelfCycleError,
|
|
43
|
+
RelationshipTargetReadonlyError,
|
|
44
|
+
RemoveEssentialComponentError,
|
|
45
|
+
StaleEntityError,
|
|
46
|
+
validateEnumFieldValues,
|
|
47
|
+
validateNumericFieldValues,
|
|
48
|
+
} from './errors';
|
|
49
|
+
import {
|
|
50
|
+
isRelationshipTarget,
|
|
51
|
+
RelationshipIndex,
|
|
52
|
+
relationshipMirror,
|
|
53
|
+
relationshipRole,
|
|
54
|
+
} from './relationship-index';
|
|
55
|
+
import type { SharedRefStore } from './shared-ref-store';
|
|
56
|
+
import { type Archetype, appendArchetypeRow } from './storage/archetype';
|
|
57
|
+
import {
|
|
58
|
+
type ArchetypeGraph,
|
|
59
|
+
getAddEdge,
|
|
60
|
+
getOrCreateArchetype,
|
|
61
|
+
getRemoveEdge,
|
|
62
|
+
getTable,
|
|
63
|
+
} from './storage/archetype-graph';
|
|
64
|
+
import { removeSparseTag } from './storage/change-detection';
|
|
65
|
+
import { arrayCountColumnName, type FieldView, normalizeBufferWrite } from './storage/column';
|
|
66
|
+
import { appendTableRow, type Table } from './storage/table';
|
|
67
|
+
import type { UniqueRefStore } from './unique-ref-store';
|
|
68
|
+
import type { ComponentData, EcsError, EntityRecord } from './world';
|
|
69
|
+
import { ComponentStorage } from './world-component-storage';
|
|
70
|
+
|
|
71
|
+
type ErrorContext = { readonly systemName: string };
|
|
72
|
+
|
|
73
|
+
type ArrayFieldsOf<S extends ComponentSchema> = {
|
|
74
|
+
[K in keyof S]: S[K] extends
|
|
75
|
+
| `array<${ManagedArrayElementType}>`
|
|
76
|
+
| `array<${ManagedArrayElementType}, ${number}>`
|
|
77
|
+
? K
|
|
78
|
+
: never;
|
|
79
|
+
}[keyof S];
|
|
80
|
+
|
|
81
|
+
type ArrayFieldElementValue<
|
|
82
|
+
S extends ComponentSchema,
|
|
83
|
+
K extends keyof S,
|
|
84
|
+
> = S[K] extends `array<${infer Elem extends ManagedArrayElementType}>`
|
|
85
|
+
? ManagedArrayElementValue<Elem>
|
|
86
|
+
: S[K] extends `array<${infer Elem extends ManagedArrayElementType}, ${number}>`
|
|
87
|
+
? ManagedArrayElementValue<Elem>
|
|
88
|
+
: never;
|
|
89
|
+
|
|
90
|
+
function relationshipPayloadWrites(data: Readonly<Record<string, unknown>>): boolean {
|
|
91
|
+
return Object.values(data).some((value) => {
|
|
92
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
93
|
+
if (ArrayBuffer.isView(value)) return value.byteLength > 0;
|
|
94
|
+
return true;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ComponentAccessState {
|
|
99
|
+
readonly graph: ArchetypeGraph;
|
|
100
|
+
readonly records: EntityRecord[];
|
|
101
|
+
readonly freeIndices: number[];
|
|
102
|
+
readonly bufferPool: BufferPool;
|
|
103
|
+
readonly uniqueRefs: UniqueRefStore;
|
|
104
|
+
readonly sharedRefs: SharedRefStore;
|
|
105
|
+
readonly relationshipIndexes: Map<number, RelationshipIndex>;
|
|
106
|
+
readonly markComponentAdded: (entity: EntityHandle, componentId: number) => void;
|
|
107
|
+
readonly markComponentsAdded: (entity: EntityHandle, componentIds: readonly number[]) => void;
|
|
108
|
+
readonly markComponentChanged: (entity: EntityHandle, componentId: number) => void;
|
|
109
|
+
readonly removeComponentChange: (entity: EntityHandle, componentId: number) => void;
|
|
110
|
+
readonly markStructureChanged: () => void;
|
|
111
|
+
routeError(err: unknown, ctx: ErrorContext): void;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class WorldComponentAccess {
|
|
115
|
+
private readonly storage: ComponentStorage;
|
|
116
|
+
|
|
117
|
+
constructor(private readonly state: ComponentAccessState) {
|
|
118
|
+
this.storage = new ComponentStorage(state);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private get graph(): ArchetypeGraph {
|
|
122
|
+
return this.state.graph;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private get records(): EntityRecord[] {
|
|
126
|
+
return this.state.records;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private table(archetype: Archetype): Table {
|
|
130
|
+
return getTable(this.graph, archetype.tableId);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private tableRow(record: EntityRecord): number {
|
|
134
|
+
return this.graph.archetypes[record.archetypeId]?.rows[record.archetypeRow] ?? -1;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private get freeIndices(): number[] {
|
|
138
|
+
return this.state.freeIndices;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private get bufferPool(): BufferPool {
|
|
142
|
+
return this.state.bufferPool;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private get uniqueRefs(): UniqueRefStore {
|
|
146
|
+
return this.state.uniqueRefs;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private routeError(err: unknown, ctx: ErrorContext): void {
|
|
150
|
+
this.state.routeError(err, ctx);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private relationshipIndex(component: Component): RelationshipIndex | undefined {
|
|
154
|
+
if (relationshipRole(component)?.kind !== 'source') return undefined;
|
|
155
|
+
let index = this.state.relationshipIndexes.get(componentId(component));
|
|
156
|
+
if (index === undefined) {
|
|
157
|
+
index = new RelationshipIndex();
|
|
158
|
+
this.state.relationshipIndexes.set(componentId(component), index);
|
|
159
|
+
}
|
|
160
|
+
return index;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Read the World-owned materialized target array; never consults a shadow list. */
|
|
164
|
+
relationshipTargetEntries(source: Component, target: EntityHandle): readonly EntityHandle[] {
|
|
165
|
+
const role = relationshipRole(source);
|
|
166
|
+
if (role?.kind !== 'source') return [];
|
|
167
|
+
const mirror = relationshipMirror(source);
|
|
168
|
+
if (mirror === undefined) return [];
|
|
169
|
+
const result = this.get(target, mirror);
|
|
170
|
+
if (!result.ok) return [];
|
|
171
|
+
const entries = (result.value as Record<string, unknown>)[role.targetField];
|
|
172
|
+
return entries !== undefined && typeof entries === 'object' ? (entries as EntityHandle[]) : [];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private markComponentAdded(entity: EntityHandle, component: Component): void {
|
|
176
|
+
this.state.markComponentAdded(entity, componentId(component));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private markComponentChanged(entity: EntityHandle, component: Component): void {
|
|
180
|
+
this.state.markComponentChanged(entity, componentId(component));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private markStructureChanged(): void {
|
|
184
|
+
this.state.markStructureChanged();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
relationshipTargetEntity(
|
|
188
|
+
component: Component,
|
|
189
|
+
value: Record<string, unknown>,
|
|
190
|
+
): EntityHandle | null {
|
|
191
|
+
for (const [fieldName, fieldType] of Object.entries(componentSchema(component))) {
|
|
192
|
+
if (isEntityField(fieldType)) {
|
|
193
|
+
const raw = value[fieldName];
|
|
194
|
+
if (raw === null || raw === undefined) return null;
|
|
195
|
+
const asNum = raw as number;
|
|
196
|
+
if (asNum === ENTITY_NULL_RAW) return null;
|
|
197
|
+
return asNum as EntityHandle;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private preflightComponentFieldValues(
|
|
204
|
+
holder: EntityHandle | null,
|
|
205
|
+
componentData: ComponentData,
|
|
206
|
+
): Result<void, EcsError> {
|
|
207
|
+
const data = componentData.data as Record<string, unknown>;
|
|
208
|
+
const arrayError = validateManagedArrayValues(componentData.component, data);
|
|
209
|
+
if (arrayError !== null) return err(arrayError as unknown as EcsError);
|
|
210
|
+
const sharedError = validateSharedFieldValues(componentData.component, data);
|
|
211
|
+
if (sharedError !== null) return err(sharedError as unknown as EcsError);
|
|
212
|
+
const numericError = validateNumericFieldValues(
|
|
213
|
+
componentData.component,
|
|
214
|
+
data,
|
|
215
|
+
holder === null ? undefined : (holder as number),
|
|
216
|
+
);
|
|
217
|
+
if (numericError !== null) return err(numericError as unknown as EcsError);
|
|
218
|
+
return ok(undefined);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Validate one structural component payload without touching archetypes,
|
|
223
|
+
* columns, relationship mirrors, epochs, or managed-reference stores.
|
|
224
|
+
* CommandBuffer uses this same owner-level gate as the direct World facade;
|
|
225
|
+
* the optional pending set lets a batch refer to an entity reserved earlier
|
|
226
|
+
* in that batch without mistaking it for a stale live handle.
|
|
227
|
+
*/
|
|
228
|
+
preflightComponentData(
|
|
229
|
+
holder: EntityHandle | null,
|
|
230
|
+
componentData: ComponentData,
|
|
231
|
+
pendingEntities?: ReadonlySet<number>,
|
|
232
|
+
unavailableEntities?: ReadonlySet<number>,
|
|
233
|
+
): Result<void, EcsError> {
|
|
234
|
+
const data = componentData.data as Record<string, unknown>;
|
|
235
|
+
const keyError = validateComponentDataKeys(componentData.component, data);
|
|
236
|
+
if (keyError !== null) return err(keyError as unknown as EcsError);
|
|
237
|
+
const valuePreflight = this.preflightComponentFieldValues(holder, componentData);
|
|
238
|
+
if (!valuePreflight.ok) return valuePreflight;
|
|
239
|
+
if (isRelationshipTarget(componentData.component) && relationshipPayloadWrites(data)) {
|
|
240
|
+
return err(new RelationshipTargetReadonlyError(componentData.component.name, 'command'));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const filled = fillComponentDefaults(componentData.component, data);
|
|
244
|
+
const enumError = validateEnumFieldValues(
|
|
245
|
+
componentData.component,
|
|
246
|
+
filled,
|
|
247
|
+
holder === null ? undefined : (holder as number),
|
|
248
|
+
);
|
|
249
|
+
if (enumError !== null) return err(enumError as unknown as EcsError);
|
|
250
|
+
|
|
251
|
+
const role = relationshipRole(componentData.component as Component);
|
|
252
|
+
if (role?.kind !== 'source') return ok(undefined);
|
|
253
|
+
const target = this.relationshipTargetEntity(componentData.component as Component, filled);
|
|
254
|
+
if (target === null) return ok(undefined);
|
|
255
|
+
|
|
256
|
+
const targetRaw = target as unknown as number;
|
|
257
|
+
if (unavailableEntities?.has(targetRaw) === true) {
|
|
258
|
+
const targetRecord = this.records[entityIndex(target)];
|
|
259
|
+
return err(
|
|
260
|
+
new StaleEntityError(target as number, entityIndex(target), entityGeneration(target), {
|
|
261
|
+
operation: 'relationship-insert',
|
|
262
|
+
component: componentData.component.name,
|
|
263
|
+
expectedGeneration: entityGeneration(target),
|
|
264
|
+
actualGeneration: targetRecord?.generation ?? -1,
|
|
265
|
+
}),
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
const targetIsPending = pendingEntities?.has(targetRaw) === true;
|
|
269
|
+
const targetRecord = this.records[entityIndex(target)];
|
|
270
|
+
const actualGeneration = targetRecord?.generation ?? -1;
|
|
271
|
+
const targetLive = this.recordIsLive(targetRecord, entityGeneration(target));
|
|
272
|
+
const holderIsPending =
|
|
273
|
+
holder === null || pendingEntities?.has(holder as unknown as number) === true;
|
|
274
|
+
if (!targetIsPending && !targetLive && !holderIsPending) {
|
|
275
|
+
return err(
|
|
276
|
+
new StaleEntityError(target as number, entityIndex(target), entityGeneration(target), {
|
|
277
|
+
operation: 'relationship-insert',
|
|
278
|
+
component: componentData.component.name,
|
|
279
|
+
expectedGeneration: entityGeneration(target),
|
|
280
|
+
actualGeneration,
|
|
281
|
+
}),
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// A pending holder has no row to walk yet. Once materialized, its target
|
|
286
|
+
// is still checked by the same source-side relationship callback.
|
|
287
|
+
if (holder === null || pendingEntities?.has(holder as unknown as number) === true) {
|
|
288
|
+
return ok(undefined);
|
|
289
|
+
}
|
|
290
|
+
const roleAllowsSelf = role?.kind === 'source' && role.allowSelf;
|
|
291
|
+
if (holder === target && !roleAllowsSelf) {
|
|
292
|
+
return err(
|
|
293
|
+
new RelationshipSelfCycleError(
|
|
294
|
+
componentData.component.name,
|
|
295
|
+
holder as number,
|
|
296
|
+
target as number,
|
|
297
|
+
),
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const cycleHit =
|
|
302
|
+
holder === target && roleAllowsSelf
|
|
303
|
+
? null
|
|
304
|
+
: this.relationshipCycleHit(componentData.component as Component, target, holder);
|
|
305
|
+
if (cycleHit !== null) {
|
|
306
|
+
return err(
|
|
307
|
+
new RelationshipSelfCycleError(
|
|
308
|
+
componentData.component.name,
|
|
309
|
+
holder as number,
|
|
310
|
+
cycleHit as number,
|
|
311
|
+
),
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
return ok(undefined);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private relationshipCycleHit(
|
|
318
|
+
holderComponent: Component,
|
|
319
|
+
start: EntityHandle,
|
|
320
|
+
holder: EntityHandle,
|
|
321
|
+
): EntityHandle | null {
|
|
322
|
+
const visited = new Set<number>();
|
|
323
|
+
let current = start;
|
|
324
|
+
while (true) {
|
|
325
|
+
if (current === holder) return current;
|
|
326
|
+
const raw = current as unknown as number;
|
|
327
|
+
if (visited.has(raw)) return null;
|
|
328
|
+
visited.add(raw);
|
|
329
|
+
const record = this.records[entityIndex(current)];
|
|
330
|
+
if (!this.recordIsLive(record, entityGeneration(current))) return null;
|
|
331
|
+
const archetype = this.graph.archetypes[record.archetypeId];
|
|
332
|
+
if (
|
|
333
|
+
!archetype?.components.some(
|
|
334
|
+
(candidate) => componentId(candidate) === componentId(holderComponent),
|
|
335
|
+
)
|
|
336
|
+
) {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
const value = this.readRow(archetype, holderComponent, this.tableRow(record)) as Record<
|
|
340
|
+
string,
|
|
341
|
+
unknown
|
|
342
|
+
>;
|
|
343
|
+
const next = this.relationshipTargetEntity(holderComponent, value);
|
|
344
|
+
if (next === null) return null;
|
|
345
|
+
current = next;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Prepare the target side before a source archetype mutation commits. */
|
|
350
|
+
private prepareRelationshipInsert(
|
|
351
|
+
component: Component,
|
|
352
|
+
value: Record<string, unknown>,
|
|
353
|
+
): Result<void, EcsError> {
|
|
354
|
+
const role = relationshipRole(component);
|
|
355
|
+
if (role?.kind !== 'source') return ok(undefined);
|
|
356
|
+
const target = this.relationshipTargetEntity(component, value);
|
|
357
|
+
if (target === null) return ok(undefined);
|
|
358
|
+
const mirror = relationshipMirror(component);
|
|
359
|
+
if (mirror === undefined) return ok(undefined);
|
|
360
|
+
const targetRec = this.records[entityIndex(target)];
|
|
361
|
+
const actualGeneration = targetRec?.generation ?? -1;
|
|
362
|
+
if (!this.recordIsLive(targetRec, entityGeneration(target))) {
|
|
363
|
+
return err(
|
|
364
|
+
new StaleEntityError(target as number, entityIndex(target), entityGeneration(target), {
|
|
365
|
+
operation: 'relationship-insert',
|
|
366
|
+
component: component.name,
|
|
367
|
+
expectedGeneration: entityGeneration(target),
|
|
368
|
+
actualGeneration,
|
|
369
|
+
}),
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
const targetArch = this.graph.archetypes[targetRec.archetypeId];
|
|
373
|
+
const hasMirror =
|
|
374
|
+
targetArch?.components.some((candidate) => componentId(candidate) === componentId(mirror)) ??
|
|
375
|
+
false;
|
|
376
|
+
if (!hasMirror) {
|
|
377
|
+
const added = this._addComponentCore(
|
|
378
|
+
target,
|
|
379
|
+
{ component: mirror, data: {} as Partial<ShapeOf<ComponentSchema>> },
|
|
380
|
+
true,
|
|
381
|
+
);
|
|
382
|
+
if (!added.ok) return added;
|
|
383
|
+
}
|
|
384
|
+
const length = this.relationshipTargetEntries(component, target).length;
|
|
385
|
+
return this.ensureArrayCapacity(target, mirror, role.targetField as never, length + 1);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Append `holder` to the materialized target list. */
|
|
389
|
+
relationshipOnInsert(
|
|
390
|
+
holder: EntityHandle,
|
|
391
|
+
component: Component,
|
|
392
|
+
value: Record<string, unknown>,
|
|
393
|
+
): Result<void, EcsError> {
|
|
394
|
+
const role = relationshipRole(component);
|
|
395
|
+
if (role?.kind !== 'source') return ok(undefined);
|
|
396
|
+
const target = this.relationshipTargetEntity(component, value);
|
|
397
|
+
if (target === null) return ok(undefined);
|
|
398
|
+
const mirror = relationshipMirror(component);
|
|
399
|
+
/* istanbul ignore next -- defineComponent relationship validation guarantees mirror exists */
|
|
400
|
+
if (mirror === undefined) return ok(undefined);
|
|
401
|
+
|
|
402
|
+
const prepared = this.prepareRelationshipInsert(component, value);
|
|
403
|
+
if (!prepared.ok) {
|
|
404
|
+
// A dangling source edge is still useful state: hierarchy/animation
|
|
405
|
+
// projections report the missing target. The target mirror cannot be
|
|
406
|
+
// updated, but insertion itself remains atomic and successful.
|
|
407
|
+
if (prepared.error.code === 'stale-entity') return ok(undefined);
|
|
408
|
+
return prepared;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Lazy-create the mirror component on the target when absent (D-3c).
|
|
412
|
+
const targetSlot = entityIndex(target);
|
|
413
|
+
const targetRec = this.records[targetSlot];
|
|
414
|
+
if (!this.recordIsLive(targetRec, entityGeneration(target))) return ok(undefined);
|
|
415
|
+
const targetArch = this.graph.archetypes[targetRec.archetypeId];
|
|
416
|
+
const mirrorLocalId = componentId(mirror);
|
|
417
|
+
const hasMirror =
|
|
418
|
+
targetArch?.components.some((component) => componentId(component) === mirrorLocalId) ?? false;
|
|
419
|
+
if (!hasMirror) {
|
|
420
|
+
const added = this._addComponentCore(
|
|
421
|
+
target,
|
|
422
|
+
{
|
|
423
|
+
component: mirror,
|
|
424
|
+
data: {} as Partial<ShapeOf<ComponentSchema>>,
|
|
425
|
+
},
|
|
426
|
+
true,
|
|
427
|
+
);
|
|
428
|
+
if (!added.ok) return added;
|
|
429
|
+
}
|
|
430
|
+
const targetEntries = this.relationshipTargetEntries(component, target);
|
|
431
|
+
const slot = targetEntries.length;
|
|
432
|
+
const mirrored = this.appendArrayElement(
|
|
433
|
+
target,
|
|
434
|
+
mirror as Component<string, ComponentSchema>,
|
|
435
|
+
role.targetField as never,
|
|
436
|
+
holder as never,
|
|
437
|
+
);
|
|
438
|
+
if (!mirrored.ok) return mirrored;
|
|
439
|
+
this.relationshipIndex(component)?.attach(holder, target, slot);
|
|
440
|
+
return ok(undefined);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Remove `holder` from the materialized target list. */
|
|
444
|
+
relationshipOnRemove(
|
|
445
|
+
holder: EntityHandle,
|
|
446
|
+
component: Component,
|
|
447
|
+
oldValue: Record<string, unknown>,
|
|
448
|
+
): Result<void, EcsError> {
|
|
449
|
+
const role = relationshipRole(component);
|
|
450
|
+
if (role?.kind !== 'source') return ok(undefined);
|
|
451
|
+
const target = this.relationshipTargetEntity(component, oldValue);
|
|
452
|
+
if (target === null) return ok(undefined);
|
|
453
|
+
const mirror = relationshipMirror(component);
|
|
454
|
+
/* istanbul ignore next -- defineComponent relationship validation guarantees mirror exists */
|
|
455
|
+
if (mirror === undefined) return ok(undefined);
|
|
456
|
+
const targetSlot = entityIndex(target);
|
|
457
|
+
const targetRec = this.records[targetSlot];
|
|
458
|
+
if (!this.recordIsLive(targetRec, entityGeneration(target))) return ok(undefined);
|
|
459
|
+
|
|
460
|
+
const index = this.relationshipIndex(component);
|
|
461
|
+
if (index === undefined) return ok(undefined);
|
|
462
|
+
const slot = index.slotOf(holder);
|
|
463
|
+
if (slot === undefined || index.targetOf(holder) !== target) return ok(undefined);
|
|
464
|
+
const mirrored = this.removeArrayElementAt(
|
|
465
|
+
target,
|
|
466
|
+
mirror as Component<string, ComponentSchema>,
|
|
467
|
+
role.targetField as never,
|
|
468
|
+
slot,
|
|
469
|
+
);
|
|
470
|
+
if (!mirrored.ok) return mirrored;
|
|
471
|
+
index.detach(holder);
|
|
472
|
+
if (mirrored.value !== undefined) index.updateSlot(mirrored.value, target, slot);
|
|
473
|
+
return ok(undefined);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Read component data from an entity.
|
|
478
|
+
*
|
|
479
|
+
* **Transient view contract (feat-20260602):** for fixed-capacity
|
|
480
|
+
* `array<T,N>` and `buffer<N>` fields, the returned `TypedArray` (and any
|
|
481
|
+
* subarray of it) aliases the archetype column buffer directly. The view is
|
|
482
|
+
* valid only until the next structural change (`spawn` / `despawn` /
|
|
483
|
+
* `addComponent` / `removeComponent`). Holding a view across a structural
|
|
484
|
+
* change is undefined behaviour -- the backing `ArrayBuffer` is detached on
|
|
485
|
+
* column growth, and swap-remove at the same row index points to the wrong
|
|
486
|
+
* entity. **Re-fetch `world.get(e, C)` on every access.** See
|
|
487
|
+
* `packages/ecs/README.md` Transient view contract section.
|
|
488
|
+
*
|
|
489
|
+
* @returns `Result<ShapeOf<S>, EcsError>` —
|
|
490
|
+
* `ok(ShapeOf<S>)` on success;
|
|
491
|
+
* `err(StaleEntityError)` (`.code = 'stale-entity'`) if entity is dead;
|
|
492
|
+
* `err(ComponentNotPresentError)` (`.code = 'component-not-present'`) if
|
|
493
|
+
* the entity does not have the component (a never-present component on
|
|
494
|
+
* this entity degrades to the same `component-not-present` path — there is
|
|
495
|
+
* no separate "not registered" failure; components are global at
|
|
496
|
+
* `defineComponent` time).
|
|
497
|
+
*
|
|
498
|
+
* @example
|
|
499
|
+
* ```ts
|
|
500
|
+
* const Position = defineComponent('Position', { x: 'f32', y: 'f32' });
|
|
501
|
+
* const world = new World();
|
|
502
|
+
* const e = world.spawn({ component: Position, data: { x: 1, y: 2 } }).unwrap();
|
|
503
|
+
* const r = world.get(e, Position);
|
|
504
|
+
* if (!r.ok) { return; } // r.error.code === 'stale-entity' on dead handle
|
|
505
|
+
* const pos = r.value;
|
|
506
|
+
* ```
|
|
507
|
+
*/
|
|
508
|
+
get<S extends ComponentSchema>(
|
|
509
|
+
entity: EntityHandle,
|
|
510
|
+
component: Component<string, S>,
|
|
511
|
+
): Result<ShapeOf<S>, EcsError> {
|
|
512
|
+
const record = this.lookupAlive(entity, 'get', component.name);
|
|
513
|
+
if (!record.ok) return record;
|
|
514
|
+
|
|
515
|
+
const rec = record.value;
|
|
516
|
+
const arch = this.graph.archetypes[rec.archetypeId];
|
|
517
|
+
/* istanbul ignore next -- defensive: alive record always has valid archetypeId */
|
|
518
|
+
if (!arch) {
|
|
519
|
+
return err(
|
|
520
|
+
new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
|
|
521
|
+
operation: 'get',
|
|
522
|
+
component: component.name,
|
|
523
|
+
expectedGeneration: entityGeneration(entity),
|
|
524
|
+
actualGeneration: rec.generation,
|
|
525
|
+
}),
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Check if this archetype has the component (using World-local ID).
|
|
530
|
+
const localId = componentId(component);
|
|
531
|
+
if (!arch.components.some((candidate) => componentId(candidate) === localId)) {
|
|
532
|
+
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return ok(this.storage.readRow(arch, component, this.tableRow(rec)));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Column-level zero-copy view of an `array<T, N>` / `array<T>` field.
|
|
540
|
+
*
|
|
541
|
+
* Resolves the live byte region for `(entity, component, fieldName)`
|
|
542
|
+
* directly at the column level and returns the element-typed TypedArray
|
|
543
|
+
* aliasing it (`view.buffer` is the SSOT byte region; mutations route
|
|
544
|
+
* through `world.set`). Unlike `get`, this does NOT build the
|
|
545
|
+
* `{}` whole-component object nor walk every schema field. Per-frame
|
|
546
|
+
* consumers that need one column (the resolved world mat4) take this path to
|
|
547
|
+
* avoid the `get` overhead (1 `{}` alloc + N-field readRow walk).
|
|
548
|
+
*
|
|
549
|
+
* Fixed `array<T,N>` columns (feat-20260602) store their elements inline, so
|
|
550
|
+
* the view aliases the archetype column buffer directly (no BufferPool
|
|
551
|
+
* indirection); variable `array<T>` columns still alias the BufferPool slot.
|
|
552
|
+
* The returned view's element type follows the schema element type
|
|
553
|
+
* (`array<entity,N>` -> `Uint32Array`, `array<f32,N>` -> `Float32Array`,
|
|
554
|
+
* etc.) -- the prior f32-only early-return gate is removed.
|
|
555
|
+
*
|
|
556
|
+
* **Transient view contract:** the returned `TypedArray` aliases the column
|
|
557
|
+
* buffer and is valid only until the next structural change (`spawn` /
|
|
558
|
+
* `despawn` / `addComponent` / `removeComponent`). Column growth
|
|
559
|
+
* (`growColumn`) detaches the old `ArrayBuffer` via `transfer()`; a
|
|
560
|
+
* swap-remove at the same row index leaves the view pointing to the wrong
|
|
561
|
+
* entity. **Callers must re-fetch `_getArrayView` on every access** and must
|
|
562
|
+
* not hold the view across any operation that may cause archetype migration.
|
|
563
|
+
* All existing per-frame consumers (`propagateTransforms` / `render-extract`
|
|
564
|
+
* / `pick`) already conform -- they fetch the view inside a single pass with
|
|
565
|
+
* no intervening structural changes.
|
|
566
|
+
*
|
|
567
|
+
* Returns `undefined` when the entity is dead, the component is absent, the
|
|
568
|
+
* field does not exist, or the field is not an `array<...>` column.
|
|
569
|
+
*
|
|
570
|
+
* @internal Engine-internal fast path; AI users read the typed view through
|
|
571
|
+
* `world.get(e, Transform).world`. The accessor is the zero-materialization
|
|
572
|
+
* route the propagate kernel and render walk use.
|
|
573
|
+
*/
|
|
574
|
+
_getArrayView(
|
|
575
|
+
entity: EntityHandle,
|
|
576
|
+
component: Component,
|
|
577
|
+
fieldName: string,
|
|
578
|
+
): FieldView | undefined {
|
|
579
|
+
const record = this.lookupAlive(entity, '_getArrayView', component.name);
|
|
580
|
+
if (!record.ok) return undefined;
|
|
581
|
+
|
|
582
|
+
const rec = record.value;
|
|
583
|
+
const arch = this.graph.archetypes[rec.archetypeId];
|
|
584
|
+
if (!arch) return undefined;
|
|
585
|
+
return this.storage.readArrayView(arch, component, this.tableRow(rec), fieldName);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Write (partial) component data to an entity.
|
|
590
|
+
*
|
|
591
|
+
* @returns `Result<void, EcsError>` —
|
|
592
|
+
* `ok(void)` on success;
|
|
593
|
+
* `err(StaleEntityError)` (`.code = 'stale-entity'`) if entity is dead;
|
|
594
|
+
* `err(ComponentNotPresentError)` (`.code = 'component-not-present'`) if
|
|
595
|
+
* entity does not have the component (F-02: no longer silently ignores).
|
|
596
|
+
*
|
|
597
|
+
* @example
|
|
598
|
+
* ```ts
|
|
599
|
+
* const Position = defineComponent('Position', { x: 'f32', y: 'f32' });
|
|
600
|
+
* const world = new World();
|
|
601
|
+
* const e = world.spawn({ component: Position, data: { x: 0, y: 0 } }).unwrap();
|
|
602
|
+
* const r = world.set(e, Position, { x: 10 });
|
|
603
|
+
* if (!r.ok) { return; } // r.error.code === 'stale-entity' on dead handle
|
|
604
|
+
* r.unwrap();
|
|
605
|
+
* ```
|
|
606
|
+
*/
|
|
607
|
+
set<S extends ComponentSchema>(
|
|
608
|
+
entity: EntityHandle,
|
|
609
|
+
component: Component<string, S>,
|
|
610
|
+
value: Partial<InputShapeOf<S>>,
|
|
611
|
+
markChanged = true,
|
|
612
|
+
): Result<void, EcsError> {
|
|
613
|
+
const record = this.lookupAlive(entity, 'set', component.name);
|
|
614
|
+
if (!record.ok) return record;
|
|
615
|
+
|
|
616
|
+
const rec = record.value;
|
|
617
|
+
const arch = this.graph.archetypes[rec.archetypeId];
|
|
618
|
+
/* istanbul ignore next -- defensive: alive record always has valid archetypeId */
|
|
619
|
+
if (!arch) {
|
|
620
|
+
return err(
|
|
621
|
+
new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
|
|
622
|
+
operation: 'set',
|
|
623
|
+
component: component.name,
|
|
624
|
+
expectedGeneration: entityGeneration(entity),
|
|
625
|
+
actualGeneration: rec.generation,
|
|
626
|
+
}),
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
const localId = componentId(component);
|
|
630
|
+
if (!arch.components.some((candidate) => componentId(candidate) === localId)) {
|
|
631
|
+
// F-02: set on missing component returns err instead of silent ignore
|
|
632
|
+
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
633
|
+
}
|
|
634
|
+
const valuePreflight = this.preflightComponentFieldValues(entity, {
|
|
635
|
+
component,
|
|
636
|
+
data: value,
|
|
637
|
+
});
|
|
638
|
+
if (!valuePreflight.ok) return valuePreflight;
|
|
639
|
+
const currentValue = this.storage.readRow(arch, component, this.tableRow(rec)) as Record<
|
|
640
|
+
string,
|
|
641
|
+
unknown
|
|
642
|
+
>;
|
|
643
|
+
const enumError = validateEnumFieldValues(
|
|
644
|
+
component,
|
|
645
|
+
{ ...currentValue, ...(value as Record<string, unknown>) },
|
|
646
|
+
entity as number,
|
|
647
|
+
);
|
|
648
|
+
if (enumError !== null) return err(enumError as unknown as EcsError);
|
|
649
|
+
if (component.storage === 'sparse') {
|
|
650
|
+
if (markChanged) this.markComponentChanged(entity, component);
|
|
651
|
+
return ok(undefined);
|
|
652
|
+
}
|
|
653
|
+
const fieldCols = this.table(arch).storage.get(localId)?.fields;
|
|
654
|
+
if (fieldCols === undefined) {
|
|
655
|
+
throw new Error(`Table storage for ${component.name} does not exist.`);
|
|
656
|
+
}
|
|
657
|
+
for (const fieldName of Object.keys(value)) {
|
|
658
|
+
const col = fieldCols.get(fieldName);
|
|
659
|
+
if (!col) {
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
const fieldType = (componentSchema(component) as Record<string, string>)[fieldName] ?? '';
|
|
663
|
+
// M1/M2 release loop (set path): release the prior managed value
|
|
664
|
+
// BEFORE writing the new one. Single SSOT helper `releaseManagedFieldOnRow`
|
|
665
|
+
// (feat-20260614 D-2) covers every managed-field family (`ref<T>` /
|
|
666
|
+
// `string` / `buffer` / variable `array<T>`); it self-skips fields that
|
|
667
|
+
// do not match `isManagedField` here, but for set-ref/string we already
|
|
668
|
+
// gated on it so the call is hot. Zeroes the column when applicable.
|
|
669
|
+
if (isManagedField(fieldType)) {
|
|
670
|
+
this.storage.releaseManagedFieldOnRow(arch, component, this.tableRow(rec), fieldName);
|
|
671
|
+
}
|
|
672
|
+
const raw = (value as Record<string, unknown>)[fieldName];
|
|
673
|
+
if (fieldType === 'bool') {
|
|
674
|
+
col.view[this.tableRow(rec)] = raw ? 1 : 0;
|
|
675
|
+
} else if (isEntityField(fieldType)) {
|
|
676
|
+
// M3 entity field overwrite: encode null as ENTITY_NULL_RAW;
|
|
677
|
+
// otherwise store the Entity bit pattern (slot+gen).
|
|
678
|
+
col.view[this.tableRow(rec)] =
|
|
679
|
+
raw === null || raw === undefined ? ENTITY_NULL_RAW : (raw as number);
|
|
680
|
+
} else if (isManagedBufferField(fieldType)) {
|
|
681
|
+
// M2 set path: collapsed-vocab keyword family `'buffer'` (variable) +
|
|
682
|
+
// `'buffer<N>'` (fixed). The two shapes diverge here:
|
|
683
|
+
// - `buffer<N>` — schema-declared byteLength is fixed; raw must be a
|
|
684
|
+
// `Uint8Array` whose `byteLength === N`. Mismatched payloads route
|
|
685
|
+
// `FixedSizeMismatchError` via Result.err so AI users observe an
|
|
686
|
+
// explicit failure instead of silent truncation (verify round 1
|
|
687
|
+
// B1 fix; charter P3 — explicit failure > silent acceptance).
|
|
688
|
+
// - `'buffer'` — variable capacity; release the prior slot then
|
|
689
|
+
// alloc a fresh one sized to the new payload's byteLength (mirrors
|
|
690
|
+
// the `array<T>` set path's release-then-alloc D-5 ordering).
|
|
691
|
+
// raw is normalized from any AllowSharedBufferSource view to a
|
|
692
|
+
// Uint8Array over its bytes (feat-20260621 V2 / AC-A4). Non-buffer
|
|
693
|
+
// raw (a forced cast feeding e.g. a number) normalizes to null and
|
|
694
|
+
// is treated as a no-op (column slot stays unchanged).
|
|
695
|
+
const isFixedBuffer = fieldType !== 'buffer';
|
|
696
|
+
const bytes = normalizeBufferWrite(raw);
|
|
697
|
+
if (bytes !== null) {
|
|
698
|
+
if (isFixedBuffer) {
|
|
699
|
+
// feat-20260602: fixed `buffer<N>` lives inline as a stride-N u8
|
|
700
|
+
// column (arity = N bytes). Write the payload straight into the
|
|
701
|
+
// row window -- no BufferPool slot.
|
|
702
|
+
const expected = bufferFieldByteLength(fieldType);
|
|
703
|
+
if (bytes.byteLength !== expected) {
|
|
704
|
+
return err(new FixedSizeMismatchError(fieldName, expected, bytes.byteLength));
|
|
705
|
+
}
|
|
706
|
+
const arity = col.arity;
|
|
707
|
+
(col.view as Uint8Array).set(bytes.subarray(0, arity), this.tableRow(rec) * arity);
|
|
708
|
+
} else {
|
|
709
|
+
// Variable `'buffer'` set: release prior slot via SSOT helper
|
|
710
|
+
// (feat-20260614 D-2) then alloc fresh sized to the new payload
|
|
711
|
+
// (verify round 1 B2 fix path). The helper zeroes the column on
|
|
712
|
+
// release; sentinel slot id 0 is a no-op.
|
|
713
|
+
this.storage.releaseManagedFieldOnRow(arch, component, this.tableRow(rec), fieldName);
|
|
714
|
+
const allocR = this.bufferPool.alloc(bytes.byteLength);
|
|
715
|
+
if (!allocR.ok) {
|
|
716
|
+
const ctx: ErrorContext = {
|
|
717
|
+
systemName: `World.set (${component.name}.${fieldName})`,
|
|
718
|
+
};
|
|
719
|
+
this.routeError(allocR.error, ctx);
|
|
720
|
+
col.view[this.tableRow(rec)] = 0;
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
const slot = allocR.value;
|
|
724
|
+
slot.view.set(bytes);
|
|
725
|
+
col.view[this.tableRow(rec)] = slot.id;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
} else if (fieldType === 'string') {
|
|
729
|
+
// M1 string-field set path (AC-05 path 3): the prior handle was
|
|
730
|
+
// already released by the unified `isManagedField` pre-write block
|
|
731
|
+
// above (D-R3) -- here we just alloc the new handle and store the
|
|
732
|
+
// u32. Mirrors the array<T> release-then-alloc pattern (D-5) so AI
|
|
733
|
+
// users observe the UniqueRefStore _liveCount net-zero invariant
|
|
734
|
+
// on field overwrite. Missing / non-string raw -> '' fallback
|
|
735
|
+
// (AC-06).
|
|
736
|
+
const text = typeof raw === 'string' ? raw : '';
|
|
737
|
+
const handle = this.uniqueRefs.alloc<'String'>('String', text);
|
|
738
|
+
col.view[this.tableRow(rec)] = unwrapHandle(handle);
|
|
739
|
+
} else {
|
|
740
|
+
const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
|
|
741
|
+
if (arrayMeta !== undefined) {
|
|
742
|
+
// M1 set path for array<T> / array<T,N> fields (feat-20260614 D-3
|
|
743
|
+
// calling convention). The set semantics mirror spawn: release the
|
|
744
|
+
// prior slot via the SSOT helper, then alloc a fresh one sized to
|
|
745
|
+
// the new value, copy bytes verbatim, store slot id (+ count for
|
|
746
|
+
// variable). Fixed `array<T,N>` is inline — the helper short-
|
|
747
|
+
// circuits and writeArrayField writes directly into the row's
|
|
748
|
+
// stride window with no pool traffic.
|
|
749
|
+
this.storage.releaseManagedFieldOnRow(arch, component, this.tableRow(rec), fieldName);
|
|
750
|
+
this.storage.writeArrayField(
|
|
751
|
+
arch,
|
|
752
|
+
component,
|
|
753
|
+
this.tableRow(rec),
|
|
754
|
+
fieldName,
|
|
755
|
+
fieldType,
|
|
756
|
+
arrayMeta,
|
|
757
|
+
raw,
|
|
758
|
+
);
|
|
759
|
+
} else {
|
|
760
|
+
// The pre-write `releaseManagedFieldOnRow` block above already
|
|
761
|
+
// released the prior `'shared<T>'` rc via SharedRefStore.release;
|
|
762
|
+
// here we retain the new value so net rc delta is +1 / 0 / -1 per
|
|
763
|
+
// M4 invariant (set: -1+1=0; spawn: 0+1=+1; despawn: -1).
|
|
764
|
+
col.view[this.tableRow(rec)] = raw as number;
|
|
765
|
+
if (fieldType.startsWith('shared<') && (raw as number) !== 0) {
|
|
766
|
+
this.storage.retainSharedScalarHandle(raw as number, component.name, fieldName);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
if (markChanged) this.markComponentChanged(entity, component);
|
|
772
|
+
return ok(undefined);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
776
|
+
// Internal relationship array maintenance. Public array mutation is always
|
|
777
|
+
// expressed as one `world.set` payload; these helpers only implement the
|
|
778
|
+
// engine-owned target projection and backpointer swap-remove path.
|
|
779
|
+
//
|
|
780
|
+
// Append/remove are engine-owned relationship maintenance only.
|
|
781
|
+
//
|
|
782
|
+
// The `fieldName` parameter is typed `ArrayFieldsOf<S>` so cross-shape
|
|
783
|
+
// access (entity / buffer / string / scalar field names) is rejected at
|
|
784
|
+
// compile time -- AI users see a TS error well before any runtime path.
|
|
785
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Append `value` to the variable `array<T>` field `fieldName` on `entity`.
|
|
789
|
+
*
|
|
790
|
+
* BufferPool grow is amortized O(1) via the size-class freelist (research
|
|
791
|
+
* Finding 5). Relationship target arrays grow byte-wise.
|
|
792
|
+
*
|
|
793
|
+
* @returns `Result<void, EcsError>` with the normal stale/component errors.
|
|
794
|
+
*
|
|
795
|
+
* The helper is called only by relationship synchronization.
|
|
796
|
+
*/
|
|
797
|
+
private appendArrayElement<S extends ComponentSchema, K extends ArrayFieldsOf<S>>(
|
|
798
|
+
entity: EntityHandle,
|
|
799
|
+
component: Component<string, S>,
|
|
800
|
+
fieldName: K,
|
|
801
|
+
value: ArrayFieldElementValue<S, K>,
|
|
802
|
+
): Result<void, EcsError> {
|
|
803
|
+
const record = this.lookupAlive(entity, 'relationship-append', component.name);
|
|
804
|
+
if (!record.ok) return record;
|
|
805
|
+
const rec = record.value;
|
|
806
|
+
const arch = this.graph.archetypes[rec.archetypeId];
|
|
807
|
+
/* istanbul ignore next -- alive record always has a valid archetype */
|
|
808
|
+
if (!arch) {
|
|
809
|
+
return err(
|
|
810
|
+
new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
|
|
811
|
+
operation: 'relationship-append',
|
|
812
|
+
component: component.name,
|
|
813
|
+
expectedGeneration: entityGeneration(entity),
|
|
814
|
+
actualGeneration: rec.generation,
|
|
815
|
+
}),
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
const localId = componentId(component);
|
|
819
|
+
const fieldCols = this.table(arch).storage.get(localId)?.fields;
|
|
820
|
+
if (!fieldCols) {
|
|
821
|
+
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
822
|
+
}
|
|
823
|
+
const fieldNameStr = fieldName as string;
|
|
824
|
+
const col = fieldCols.get(fieldNameStr);
|
|
825
|
+
/* istanbul ignore next -- ArrayFieldsOf filter ensures the column exists */
|
|
826
|
+
if (!col) return err(new ComponentNotPresentError(entity as number, component.name));
|
|
827
|
+
const arrayMeta = componentDefinition(component).fields[fieldNameStr]?.arrayMeta;
|
|
828
|
+
/* istanbul ignore next -- ArrayFieldsOf filter guarantees array<*> */
|
|
829
|
+
if (arrayMeta === undefined) {
|
|
830
|
+
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
831
|
+
}
|
|
832
|
+
const meta = TYPE_METADATA[arrayMeta.elementType];
|
|
833
|
+
/* istanbul ignore next -- arrayMeta.elementType is guaranteed in TYPE_METADATA */
|
|
834
|
+
if (!meta) return err(new ComponentNotPresentError(entity as number, component.name));
|
|
835
|
+
// biome-ignore lint/style/noNonNullAssertion: ManagedArrayElementType always scalar -> byteSize present
|
|
836
|
+
const elementBytes = meta.byteSize!;
|
|
837
|
+
const slotId = col.view[this.tableRow(rec)] as number;
|
|
838
|
+
|
|
839
|
+
const countCol = fieldCols.get(arrayCountColumnName(fieldNameStr));
|
|
840
|
+
/* istanbul ignore next -- variable arrays always allocate the count column */
|
|
841
|
+
if (countCol === undefined) {
|
|
842
|
+
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
843
|
+
}
|
|
844
|
+
const count = countCol.view[this.tableRow(rec)] as number;
|
|
845
|
+
const newCount = count + 1;
|
|
846
|
+
const newByteLength = newCount * elementBytes;
|
|
847
|
+
|
|
848
|
+
let liveSlotId = slotId;
|
|
849
|
+
if (liveSlotId === 0) {
|
|
850
|
+
// Empty/unallocated slot — alloc fresh.
|
|
851
|
+
const allocR = this.bufferPool.alloc(newByteLength);
|
|
852
|
+
if (!allocR.ok) return err(allocR.error);
|
|
853
|
+
liveSlotId = allocR.value.id;
|
|
854
|
+
col.view[this.tableRow(rec)] = liveSlotId;
|
|
855
|
+
} else {
|
|
856
|
+
// A previously-allocated slot may have drained below its high-water
|
|
857
|
+
// mark: swap-remove (`_removeArrayElementByValue`) and `pop` only lower
|
|
858
|
+
// the count column, never shrink the managed buffer. When the refilled
|
|
859
|
+
// length still fits inside the slot's current logical length, reuse the
|
|
860
|
+
// buffer in place -- routing through `grow` would hit the (correct, but
|
|
861
|
+
// here irrelevant) shrink-not-supported guard and strand the field
|
|
862
|
+
// (e.g. `Children.entities` never repopulating after a full drain).
|
|
863
|
+
if (newByteLength > this.bufferPool.view(liveSlotId).byteLength) {
|
|
864
|
+
const growR = this.bufferPool.grow(liveSlotId, newByteLength);
|
|
865
|
+
if (!growR.ok) return err(growR.error);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
const liveBytes = this.bufferPool.view(liveSlotId);
|
|
869
|
+
// Reinterpret the slot bytes as the element-typed view and write at the
|
|
870
|
+
// tail index. Entity values are stored as their u32 bit pattern.
|
|
871
|
+
this.storage.writeArrayElementAt(liveBytes, count, arrayMeta.elementType, value as number);
|
|
872
|
+
countCol.view[this.tableRow(rec)] = newCount;
|
|
873
|
+
this.markComponentChanged(entity, component);
|
|
874
|
+
return ok(undefined);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
private ensureArrayCapacity<S extends ComponentSchema, K extends ArrayFieldsOf<S>>(
|
|
878
|
+
entity: EntityHandle,
|
|
879
|
+
component: Component<string, S>,
|
|
880
|
+
fieldName: K,
|
|
881
|
+
minimum: number,
|
|
882
|
+
): Result<void, EcsError> {
|
|
883
|
+
const record = this.lookupAlive(entity, 'relationship-capacity', component.name);
|
|
884
|
+
if (!record.ok) return record;
|
|
885
|
+
const rec = record.value;
|
|
886
|
+
const arch = this.graph.archetypes[rec.archetypeId];
|
|
887
|
+
if (!arch) {
|
|
888
|
+
return err(
|
|
889
|
+
new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
|
|
890
|
+
operation: 'relationship-capacity',
|
|
891
|
+
component: component.name,
|
|
892
|
+
expectedGeneration: entityGeneration(entity),
|
|
893
|
+
actualGeneration: rec.generation,
|
|
894
|
+
}),
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
|
|
898
|
+
if (!fieldCols) return err(new ComponentNotPresentError(entity as number, component.name));
|
|
899
|
+
const fieldNameStr = fieldName as string;
|
|
900
|
+
const col = fieldCols.get(fieldNameStr);
|
|
901
|
+
if (!col) return err(new ComponentNotPresentError(entity as number, component.name));
|
|
902
|
+
const arrayMeta = componentDefinition(component).fields[fieldNameStr]?.arrayMeta;
|
|
903
|
+
if (arrayMeta === undefined) {
|
|
904
|
+
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
905
|
+
}
|
|
906
|
+
const meta = TYPE_METADATA[arrayMeta.elementType];
|
|
907
|
+
if (!meta?.byteSize) {
|
|
908
|
+
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
909
|
+
}
|
|
910
|
+
const maximum = Math.floor(262_144 / meta.byteSize);
|
|
911
|
+
if (!Number.isSafeInteger(minimum) || minimum < 0 || minimum > maximum) {
|
|
912
|
+
return err(new ManagedBufferOutOfBoundsError(minimum, maximum));
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const byteLength = minimum * meta.byteSize;
|
|
916
|
+
const slotId = col.view[this.tableRow(rec)] as number;
|
|
917
|
+
if (slotId === 0) {
|
|
918
|
+
if (minimum === 0) return ok(undefined);
|
|
919
|
+
const allocated = this.bufferPool.alloc(byteLength);
|
|
920
|
+
if (!allocated.ok) return allocated;
|
|
921
|
+
col.view[this.tableRow(rec)] = allocated.value.id;
|
|
922
|
+
return ok(undefined);
|
|
923
|
+
}
|
|
924
|
+
if (this.bufferPool.view(slotId).byteLength >= byteLength) return ok(undefined);
|
|
925
|
+
const grown = this.bufferPool.grow(slotId, byteLength);
|
|
926
|
+
return grown.ok ? ok(undefined) : grown;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Remove one variable-array element at a known slot. Relationship holders
|
|
931
|
+
* supply the slot from their backpointer, so this is O(1) and never scans
|
|
932
|
+
* the materialized target array.
|
|
933
|
+
*/
|
|
934
|
+
private removeArrayElementAt(
|
|
935
|
+
entity: EntityHandle,
|
|
936
|
+
component: Component<string, ComponentSchema>,
|
|
937
|
+
fieldName: string,
|
|
938
|
+
slot: number,
|
|
939
|
+
): Result<EntityHandle | undefined, EcsError> {
|
|
940
|
+
const record = this.lookupAlive(entity, 'removeArrayElementAt', component.name);
|
|
941
|
+
if (!record.ok) return record;
|
|
942
|
+
const rec = record.value;
|
|
943
|
+
const arch = this.graph.archetypes[rec.archetypeId];
|
|
944
|
+
if (!arch) return err(new ComponentNotPresentError(entity as number, component.name));
|
|
945
|
+
const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
|
|
946
|
+
if (!fieldCols) return err(new ComponentNotPresentError(entity as number, component.name));
|
|
947
|
+
const col = fieldCols.get(fieldName);
|
|
948
|
+
const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
|
|
949
|
+
const countCol = fieldCols.get(arrayCountColumnName(fieldName));
|
|
950
|
+
if (!col || !arrayMeta || arrayMeta.length !== undefined || !countCol) {
|
|
951
|
+
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
952
|
+
}
|
|
953
|
+
const row = this.tableRow(rec);
|
|
954
|
+
const count = countCol.view[row] as number;
|
|
955
|
+
if (slot < 0 || slot >= count) return ok(undefined);
|
|
956
|
+
const slotId = col.view[row] as number;
|
|
957
|
+
if (slotId === 0) return ok(undefined);
|
|
958
|
+
const liveBytes = this.bufferPool.view(slotId);
|
|
959
|
+
const last = count - 1;
|
|
960
|
+
const moved =
|
|
961
|
+
slot === last
|
|
962
|
+
? undefined
|
|
963
|
+
: (this.storage.readArrayElementAt(liveBytes, last, arrayMeta.elementType) as EntityHandle);
|
|
964
|
+
if (slot !== last) {
|
|
965
|
+
this.storage.writeArrayElementAt(liveBytes, slot, arrayMeta.elementType, moved as number);
|
|
966
|
+
}
|
|
967
|
+
countCol.view[row] = last;
|
|
968
|
+
this.markComponentChanged(entity, component);
|
|
969
|
+
return ok(moved);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
973
|
+
// addComponent / removeComponent (archetype migration via edges, AC-07)
|
|
974
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Add a component to an existing entity, triggering archetype migration.
|
|
978
|
+
*
|
|
979
|
+
* @returns `Result<void, EcsError>` —
|
|
980
|
+
* `ok(void)` on success;
|
|
981
|
+
* `err(StaleEntityError)` (`.code = 'stale-entity'`) if entity is dead;
|
|
982
|
+
* `err(ComponentAlreadyPresentError)` (`.code = 'component-already-present'`)
|
|
983
|
+
* if entity already has the component (E-03).
|
|
984
|
+
*
|
|
985
|
+
* @example
|
|
986
|
+
* ```ts
|
|
987
|
+
* const Position = defineComponent('Position', { x: 'f32', y: 'f32' });
|
|
988
|
+
* const Velocity = defineComponent('Velocity', { dx: 'f32', dy: 'f32' });
|
|
989
|
+
* const world = new World();
|
|
990
|
+
* const e = world.spawn({ component: Position, data: { x: 0, y: 0 } }).unwrap();
|
|
991
|
+
* const r = world.addComponent(e, { component: Velocity, data: { dx: 1, dy: 0 } });
|
|
992
|
+
* if (!r.ok) { return; } // r.error.code === 'stale-entity' on dead handle
|
|
993
|
+
* r.unwrap();
|
|
994
|
+
* ```
|
|
995
|
+
*/
|
|
996
|
+
addComponent<S extends ComponentSchema>(
|
|
997
|
+
entity: EntityHandle,
|
|
998
|
+
componentData: ComponentData<S>,
|
|
999
|
+
): Result<void, EcsError> {
|
|
1000
|
+
return this._addComponentCore(entity, componentData, false);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Core implementation of `addComponent` with reentry guard.
|
|
1005
|
+
*
|
|
1006
|
+
* @param internal — `true` when called from relationship maintenance
|
|
1007
|
+
* (lazy mirror create or exclusive reparent).
|
|
1008
|
+
* @internal
|
|
1009
|
+
*/
|
|
1010
|
+
_addComponentCore<S extends ComponentSchema>(
|
|
1011
|
+
entity: EntityHandle,
|
|
1012
|
+
componentData: ComponentData<S>,
|
|
1013
|
+
internal: boolean,
|
|
1014
|
+
): Result<void, EcsError> {
|
|
1015
|
+
const record = this.lookupAlive(entity, 'addComponent', componentData.component.name);
|
|
1016
|
+
if (!record.ok) return record;
|
|
1017
|
+
|
|
1018
|
+
const rec = record.value;
|
|
1019
|
+
const srcArch = this.graph.archetypes[rec.archetypeId];
|
|
1020
|
+
/* istanbul ignore next -- defensive: alive record always has valid archetypeId */
|
|
1021
|
+
if (!srcArch) {
|
|
1022
|
+
return err(
|
|
1023
|
+
new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
|
|
1024
|
+
operation: 'addComponent',
|
|
1025
|
+
component: componentData.component.name,
|
|
1026
|
+
expectedGeneration: entityGeneration(entity),
|
|
1027
|
+
actualGeneration: rec.generation,
|
|
1028
|
+
}),
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const preflight = this.preflightComponentData(entity, componentData);
|
|
1033
|
+
if (!preflight.ok) return preflight;
|
|
1034
|
+
|
|
1035
|
+
// bug-20260615: unknown-key fail-fast BEFORE archetype mutation so a
|
|
1036
|
+
// typo aborts cleanly without partial state (mirrors _spawnCore).
|
|
1037
|
+
const keyErr = validateComponentDataKeys(
|
|
1038
|
+
componentData.component,
|
|
1039
|
+
componentData.data as Record<string, unknown>,
|
|
1040
|
+
);
|
|
1041
|
+
if (keyErr !== null) {
|
|
1042
|
+
return err(keyErr as unknown as EcsError);
|
|
1043
|
+
}
|
|
1044
|
+
const arrayErr = validateManagedArrayValues(
|
|
1045
|
+
componentData.component,
|
|
1046
|
+
componentData.data as Record<string, unknown>,
|
|
1047
|
+
);
|
|
1048
|
+
if (arrayErr !== null) {
|
|
1049
|
+
return err(arrayErr as unknown as EcsError);
|
|
1050
|
+
}
|
|
1051
|
+
// feat-20260713 M2 / w9: P3 shared-field value gate (see _spawnCore). Runs
|
|
1052
|
+
// before archetype mutation so a mis-bound GUID aborts cleanly.
|
|
1053
|
+
const sharedErr = validateSharedFieldValues(
|
|
1054
|
+
componentData.component,
|
|
1055
|
+
componentData.data as Record<string, unknown>,
|
|
1056
|
+
);
|
|
1057
|
+
if (sharedErr !== null) {
|
|
1058
|
+
return err(sharedErr as unknown as EcsError);
|
|
1059
|
+
}
|
|
1060
|
+
const filled = fillComponentDefaults(
|
|
1061
|
+
componentData.component,
|
|
1062
|
+
componentData.data as Record<string, unknown>,
|
|
1063
|
+
);
|
|
1064
|
+
const enumErr = validateEnumFieldValues(componentData.component, filled, entity as number);
|
|
1065
|
+
if (enumErr !== null) {
|
|
1066
|
+
return err(enumErr as unknown as EcsError);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// Check if entity already has this component (using World-local ID).
|
|
1070
|
+
const localId = componentId(componentData.component);
|
|
1071
|
+
if (srcArch.components.some((candidate) => componentId(candidate) === localId)) {
|
|
1072
|
+
// M2 exclusive relationship: re-adding the holder with a (possibly new)
|
|
1073
|
+
// target auto-reparents instead of failing (AC-12). Prune the old side
|
|
1074
|
+
// first (removeComponent prunes the old target), then fall through to
|
|
1075
|
+
// the normal add (which appends the new target). The two steps keep the
|
|
1076
|
+
// materialized target list consistent (AC-13);
|
|
1077
|
+
// removeComponent + addComponent each touch the mirror exactly once and
|
|
1078
|
+
// the mirror component carries no relationship of its own, so there is
|
|
1079
|
+
// no recursion. Reparent only fires for top-level user calls
|
|
1080
|
+
// (!internal); engine-internal lazy create / append
|
|
1081
|
+
// never re-add an existing relationship component.
|
|
1082
|
+
const role = relationshipRole(componentData.component as Component);
|
|
1083
|
+
if (role?.kind === 'source' && role.exclusive && !internal) {
|
|
1084
|
+
const prepared = this.prepareRelationshipInsert(
|
|
1085
|
+
componentData.component as Component,
|
|
1086
|
+
filled as Record<string, unknown>,
|
|
1087
|
+
);
|
|
1088
|
+
if (!prepared.ok) return prepared;
|
|
1089
|
+
const removeR = this._removeComponentCore(
|
|
1090
|
+
entity,
|
|
1091
|
+
componentData.component as Component,
|
|
1092
|
+
false,
|
|
1093
|
+
);
|
|
1094
|
+
if (!removeR.ok) return removeR;
|
|
1095
|
+
return this._addComponentCore(entity, componentData, false);
|
|
1096
|
+
}
|
|
1097
|
+
return err(new ComponentAlreadyPresentError(entity as number, componentData.component.name));
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
if (!internal && relationshipRole(componentData.component as Component)?.kind === 'source') {
|
|
1101
|
+
const prepared = this.prepareRelationshipInsert(
|
|
1102
|
+
componentData.component as Component,
|
|
1103
|
+
filled as Record<string, unknown>,
|
|
1104
|
+
);
|
|
1105
|
+
if (!prepared.ok) return prepared;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// Get target archetype via edge cache.
|
|
1109
|
+
const targetArch = getAddEdge(
|
|
1110
|
+
this.graph,
|
|
1111
|
+
srcArch,
|
|
1112
|
+
localId,
|
|
1113
|
+
componentData.component as Component,
|
|
1114
|
+
);
|
|
1115
|
+
|
|
1116
|
+
if (componentData.component.storage === 'sparse') {
|
|
1117
|
+
this.storage.moveEntityArchetype(rec, srcArch, targetArch);
|
|
1118
|
+
} else {
|
|
1119
|
+
this.storage.migrateEntity(rec, srcArch, targetArch);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// Write the new component's data. Apply layer-2 + layer-3 silent
|
|
1123
|
+
// fallback so addComponent shares the SAME default-resolution path
|
|
1124
|
+
// as spawn / SceneAsset.instantiate (feat-20260517 / M2 / AC-04
|
|
1125
|
+
// research §F4 auto-symmetry; ComponentData<S>['data'] is the
|
|
1126
|
+
// physical bridge).
|
|
1127
|
+
if (componentData.component.storage === 'table') {
|
|
1128
|
+
this.storage.writeRow(
|
|
1129
|
+
targetArch,
|
|
1130
|
+
componentData.component,
|
|
1131
|
+
this.tableRow(rec),
|
|
1132
|
+
filled as ShapeOf<S>,
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
this.markComponentAdded(entity, componentData.component as Component);
|
|
1136
|
+
// Relationship sync: append to the materialized target list.
|
|
1137
|
+
if (!internal && relationshipRole(componentData.component as Component)?.kind === 'source') {
|
|
1138
|
+
const relationshipResult = this.relationshipOnInsert(
|
|
1139
|
+
entity,
|
|
1140
|
+
componentData.component as Component,
|
|
1141
|
+
filled as Record<string, unknown>,
|
|
1142
|
+
);
|
|
1143
|
+
if (!relationshipResult.ok) return relationshipResult;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
this.markStructureChanged();
|
|
1147
|
+
return ok(undefined);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Remove a component from an existing entity, triggering archetype migration.
|
|
1152
|
+
*
|
|
1153
|
+
* @returns `Result<void, EcsError>` —
|
|
1154
|
+
* `ok(void)` on success;
|
|
1155
|
+
* `err(StaleEntityError)` (`.code = 'stale-entity'`) if entity is dead;
|
|
1156
|
+
* `err(ComponentNotPresentError)` (`.code = 'component-not-present'`)
|
|
1157
|
+
* if entity doesn't have the component (E-04).
|
|
1158
|
+
*
|
|
1159
|
+
* @example
|
|
1160
|
+
* ```ts
|
|
1161
|
+
* const Position = defineComponent('Position', { x: 'f32', y: 'f32' });
|
|
1162
|
+
* const world = new World();
|
|
1163
|
+
* const e = world.spawn({ component: Position, data: { x: 0, y: 0 } }).unwrap();
|
|
1164
|
+
* const r = world.removeComponent(e, Position);
|
|
1165
|
+
* if (!r.ok) { return; } // r.error.code === 'stale-entity' on dead handle
|
|
1166
|
+
* r.unwrap();
|
|
1167
|
+
* ```
|
|
1168
|
+
*/
|
|
1169
|
+
removeComponent<S extends ComponentSchema>(
|
|
1170
|
+
entity: EntityHandle,
|
|
1171
|
+
component: Component<string, S>,
|
|
1172
|
+
): Result<void, EcsError> {
|
|
1173
|
+
return this._removeComponentCore(entity, component, false);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* Core implementation of `removeComponent` with reentry guard.
|
|
1178
|
+
*
|
|
1179
|
+
* @param internal — `true` when called from relationship maintenance
|
|
1180
|
+
* (exclusive reparent).
|
|
1181
|
+
* @internal
|
|
1182
|
+
*/
|
|
1183
|
+
_removeComponentCore<S extends ComponentSchema>(
|
|
1184
|
+
entity: EntityHandle,
|
|
1185
|
+
component: Component<string, S>,
|
|
1186
|
+
internal: boolean,
|
|
1187
|
+
): Result<void, EcsError> {
|
|
1188
|
+
// Essential-component hard reject (feat-20260602 / plan-strategy D-3): the
|
|
1189
|
+
// id=0 `Entity` component is carried by every archetype unconditionally (it
|
|
1190
|
+
// is the row's own packed handle) and cannot be removed. Reject before any
|
|
1191
|
+
// liveness lookup so the rejection is structural, not entity-state-dependent.
|
|
1192
|
+
if (componentId(component) === componentId(EntityComponent)) {
|
|
1193
|
+
return err(new RemoveEssentialComponentError(component.name));
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
const record = this.lookupAlive(entity, 'removeComponent', component.name);
|
|
1197
|
+
if (!record.ok) return record;
|
|
1198
|
+
|
|
1199
|
+
const rec = record.value;
|
|
1200
|
+
const srcArch = this.graph.archetypes[rec.archetypeId];
|
|
1201
|
+
/* istanbul ignore next -- defensive: alive record always has valid archetypeId */
|
|
1202
|
+
if (!srcArch) {
|
|
1203
|
+
return err(
|
|
1204
|
+
new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
|
|
1205
|
+
operation: 'removeComponent',
|
|
1206
|
+
component: component.name,
|
|
1207
|
+
expectedGeneration: entityGeneration(entity),
|
|
1208
|
+
actualGeneration: rec.generation,
|
|
1209
|
+
}),
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// Check if entity has this component (using World-local ID).
|
|
1214
|
+
const localId = componentId(component);
|
|
1215
|
+
if (!srcArch.components.some((candidate) => componentId(candidate) === localId)) {
|
|
1216
|
+
return err(new ComponentNotPresentError(entity as number, component.name));
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// Capture the old relationship value before column removal so the
|
|
1220
|
+
// materialized target list can be pruned.
|
|
1221
|
+
const role = relationshipRole(component as Component);
|
|
1222
|
+
const needsOldValue = role?.kind === 'source' && !internal;
|
|
1223
|
+
if (needsOldValue) {
|
|
1224
|
+
const oldValue = this.storage.readRow(
|
|
1225
|
+
srcArch,
|
|
1226
|
+
component as Component,
|
|
1227
|
+
this.tableRow(rec),
|
|
1228
|
+
) as Record<string, unknown>;
|
|
1229
|
+
// Relationship sync: prune the holder from the target's materialized list.
|
|
1230
|
+
if (role?.kind === 'source' && !internal) {
|
|
1231
|
+
const relation = this.relationshipOnRemove(entity, component as Component, oldValue);
|
|
1232
|
+
if (!relation.ok) return relation;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// M1 release loop (removeComponent path): release every `ref<T>` field
|
|
1237
|
+
// on the component being removed before migration drops the row.
|
|
1238
|
+
if (component.storage === 'table') {
|
|
1239
|
+
this.storage.releaseManagedRefsOnRow(srcArch, component as Component, this.tableRow(rec));
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// Get target archetype via edge cache.
|
|
1243
|
+
const targetArch = getRemoveEdge(this.graph, srcArch, localId);
|
|
1244
|
+
|
|
1245
|
+
if (component.storage === 'sparse') {
|
|
1246
|
+
this.storage.moveEntityArchetype(rec, srcArch, targetArch);
|
|
1247
|
+
const set = this.graph.sparseTags.get(componentId(component));
|
|
1248
|
+
if (set !== undefined) removeSparseTag(set, entity);
|
|
1249
|
+
} else {
|
|
1250
|
+
this.storage.migrateEntity(rec, srcArch, targetArch);
|
|
1251
|
+
}
|
|
1252
|
+
this.state.removeComponentChange(entity, componentId(component));
|
|
1253
|
+
this.markStructureChanged();
|
|
1254
|
+
return ok(undefined);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
1258
|
+
// Internal — deferred command support (CommandBuffer interface)
|
|
1259
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* @internal Allocate a pending entity for deferred spawn.
|
|
1263
|
+
* Returns an Entity handle. The entity is "pending" because
|
|
1264
|
+
* archetypeId === -1 (set by allocateIndex); no separate flag needed.
|
|
1265
|
+
*/
|
|
1266
|
+
_allocatePendingEntity(): EntityHandle {
|
|
1267
|
+
const indexSlot = this.allocateIndex();
|
|
1268
|
+
// biome-ignore lint/style/noNonNullAssertion: allocateIndex guarantees a valid slot with an initialized record
|
|
1269
|
+
return encodeEntity(indexSlot, this.records[indexSlot]!.generation);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* Return a deferred-spawn reservation to the free-list without publishing a
|
|
1274
|
+
* row or advancing an epoch. CommandBuffer.abort is the sole caller; a
|
|
1275
|
+
* materialized entity is intentionally left untouched so an unexpected
|
|
1276
|
+
* post-write failure poisons the World instead of attempting an unsafe undo.
|
|
1277
|
+
*/
|
|
1278
|
+
_cancelPendingEntity(entity: EntityHandle): void {
|
|
1279
|
+
const slot = entityIndex(entity);
|
|
1280
|
+
const record = this.records[slot];
|
|
1281
|
+
if (record === undefined || record.generation !== entityGeneration(entity)) return;
|
|
1282
|
+
if (record.archetypeId !== -1 || record.archetypeRow !== -1) return;
|
|
1283
|
+
record.generation += 1;
|
|
1284
|
+
if (!isRetiredSlot(record.generation)) this.freeIndices.push(slot);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/**
|
|
1288
|
+
* @internal Materialize a pending entity: actually place it into an archetype.
|
|
1289
|
+
* Idempotent: a record with archetypeId !== -1 is already materialized.
|
|
1290
|
+
*/
|
|
1291
|
+
_materializePendingEntity(
|
|
1292
|
+
entity: EntityHandle,
|
|
1293
|
+
componentDatas: ComponentData[],
|
|
1294
|
+
): Result<void, EcsError> {
|
|
1295
|
+
const slot = entityIndex(entity);
|
|
1296
|
+
const record = this.records[slot];
|
|
1297
|
+
if (!record || record.archetypeId !== -1) return ok(undefined);
|
|
1298
|
+
|
|
1299
|
+
// Find or create target archetype (using World-local IDs).
|
|
1300
|
+
const componentIds = componentDatas.map((cd) => componentId(cd.component));
|
|
1301
|
+
const components = componentDatas.map((cd) => cd.component);
|
|
1302
|
+
const arch = getOrCreateArchetype(this.graph, componentIds, components);
|
|
1303
|
+
|
|
1304
|
+
// Append entity row.
|
|
1305
|
+
const table = this.table(arch);
|
|
1306
|
+
const tableRow = appendTableRow(table, entity);
|
|
1307
|
+
const archetypeRow = appendArchetypeRow(arch, tableRow);
|
|
1308
|
+
record.archetypeId = arch.id;
|
|
1309
|
+
record.archetypeRow = archetypeRow;
|
|
1310
|
+
|
|
1311
|
+
// Write initial data. Apply layer-2 + layer-3 silent fallback so
|
|
1312
|
+
// deferred-spawn (Commands.spawn) shares the SAME default-resolution
|
|
1313
|
+
// path as the synchronous `world.spawn` / `addComponent` /
|
|
1314
|
+
// SceneAsset.instantiate (feat-20260517 / M2 / AC-04 + AC-09).
|
|
1315
|
+
this.state.markComponentsAdded(entity, [
|
|
1316
|
+
componentId(EntityComponent),
|
|
1317
|
+
...componentDatas.map((cd) => componentId(cd.component)),
|
|
1318
|
+
]);
|
|
1319
|
+
for (const cd of componentDatas) {
|
|
1320
|
+
const filled = fillComponentDefaults(cd.component, cd.data as Record<string, unknown>);
|
|
1321
|
+
this.storage.writeRow(arch, cd.component, tableRow, filled as ShapeOf<ComponentSchema>);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// Essential id=0 `Entity` column write (feat-20260602 / plan-strategy D-3),
|
|
1325
|
+
// mirroring the synchronous `spawn` path: the deferred handle was minted at
|
|
1326
|
+
// `_allocatePendingEntity` time and is passed in here.
|
|
1327
|
+
this.storage.writeEntitySelf(arch, tableRow, entity);
|
|
1328
|
+
|
|
1329
|
+
// Publish relationship targets after all rows are written.
|
|
1330
|
+
for (const cd of componentDatas) {
|
|
1331
|
+
if (relationshipRole(cd.component as Component)?.kind === 'source') {
|
|
1332
|
+
const filled = fillComponentDefaults(cd.component, cd.data as Record<string, unknown>);
|
|
1333
|
+
const relationshipResult = this.relationshipOnInsert(
|
|
1334
|
+
entity,
|
|
1335
|
+
cd.component as Component,
|
|
1336
|
+
filled as Record<string, unknown>,
|
|
1337
|
+
);
|
|
1338
|
+
if (!relationshipResult.ok) {
|
|
1339
|
+
return relationshipResult;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
this.markStructureChanged();
|
|
1344
|
+
return ok(undefined);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
1348
|
+
// Internal — entity index allocation
|
|
1349
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
1350
|
+
|
|
1351
|
+
allocateIndex(): number {
|
|
1352
|
+
const recycled = this.freeIndices.pop();
|
|
1353
|
+
if (recycled !== undefined) {
|
|
1354
|
+
return recycled;
|
|
1355
|
+
}
|
|
1356
|
+
const slot = this.records.length;
|
|
1357
|
+
if (slot > ENTITY_MAX_INDEX) {
|
|
1358
|
+
throw new EntityIndexOverflowError(slot);
|
|
1359
|
+
}
|
|
1360
|
+
this.records.push({ generation: 0, archetypeId: -1, archetypeRow: -1 });
|
|
1361
|
+
return slot;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* Single liveness predicate (feat-20260602 / plan-strategy D-4): a slot is
|
|
1366
|
+
* live for a given handle generation iff the record exists, its generation
|
|
1367
|
+
* still matches the handle (despawn bumps generation, so a stale or recycled
|
|
1368
|
+
* handle fails here), and the slot is materialized into an archetype
|
|
1369
|
+
* (archetypeId !== -1). Replaces the former `record.alive && record.generation
|
|
1370
|
+
* === gen` conjunction and the intermediate `!record.pending` clause.
|
|
1371
|
+
*/
|
|
1372
|
+
recordIsLive(record: EntityRecord | undefined, gen: number): record is EntityRecord {
|
|
1373
|
+
return record !== undefined && record.generation === gen && record.archetypeId !== -1;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
lookupAlive(
|
|
1377
|
+
entity: EntityHandle,
|
|
1378
|
+
operation: string,
|
|
1379
|
+
component?: string,
|
|
1380
|
+
): Result<EntityRecord, EcsError> {
|
|
1381
|
+
const slot = entityIndex(entity);
|
|
1382
|
+
const gen = entityGeneration(entity);
|
|
1383
|
+
const record = this.records[slot];
|
|
1384
|
+
if (!this.recordIsLive(record, gen)) {
|
|
1385
|
+
return err(
|
|
1386
|
+
new StaleEntityError(entity as number, slot, gen, {
|
|
1387
|
+
operation,
|
|
1388
|
+
...(component !== undefined ? { component } : {}),
|
|
1389
|
+
expectedGeneration: gen,
|
|
1390
|
+
actualGeneration: this.records[slot]?.generation ?? -1,
|
|
1391
|
+
}),
|
|
1392
|
+
);
|
|
1393
|
+
}
|
|
1394
|
+
return ok(record);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
readRow<S extends ComponentSchema>(
|
|
1398
|
+
arch: Archetype,
|
|
1399
|
+
component: Component<string, S>,
|
|
1400
|
+
row: number,
|
|
1401
|
+
): ShapeOf<S> {
|
|
1402
|
+
return this.storage.readRow(arch, component, row);
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
writeEntitySelf(arch: Archetype, row: number, handle: EntityHandle): void {
|
|
1406
|
+
this.storage.writeEntitySelf(arch, row, handle);
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
writeRow<S extends ComponentSchema>(
|
|
1410
|
+
arch: Archetype,
|
|
1411
|
+
component: Component<string, S>,
|
|
1412
|
+
row: number,
|
|
1413
|
+
value: ShapeOf<S>,
|
|
1414
|
+
): void {
|
|
1415
|
+
this.storage.writeRow(arch, component, row, value);
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
releaseManagedRefsOnRow(arch: Archetype, component: Component, row: number): void {
|
|
1419
|
+
this.storage.releaseManagedRefsOnRow(arch, component, row);
|
|
1420
|
+
}
|
|
1421
|
+
}
|