From 55695f102f8fa19502ad1ae6e0be8b2c7b2e8fc2 Mon Sep 17 00:00:00 2001 From: waynemwashuma <94756970+waynemwashuma@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:37:20 +0300 Subject: [PATCH 1/5] Add extensible query filter transforms --- packages/ecs/src/query/filters/filter.js | 34 +++++++++++++++--- packages/ecs/src/query/filters/index.js | 1 + packages/ecs/src/query/filters/types.ts | 45 ++++++++++++++++++++++++ packages/ecs/src/query/query.js | 38 ++++++++++++++------ 4 files changed, 102 insertions(+), 16 deletions(-) create mode 100644 packages/ecs/src/query/filters/types.ts diff --git a/packages/ecs/src/query/filters/filter.js b/packages/ecs/src/query/filters/filter.js index 8ff0994b..7559cc2d 100644 --- a/packages/ecs/src/query/filters/filter.js +++ b/packages/ecs/src/query/filters/filter.js @@ -1,11 +1,33 @@ /** @import { TypeId, Constructor } from '@wimaengine/type'*/ +/** @import { FilterTransform, IdentityTransform, OptionalTransform } from './types' */ import { typeid } from '@wimaengine/type' /** - * @interface + * @abstract + * @template {FilterTransform} [Transform = FilterTransform] */ export class QueryFilter { + /** @type {Transform} */ + typeTransform + + constructor() { + if (new.target === QueryFilter) { + throw new TypeError('QueryFilter is abstract') + } + } + + /** + * Returns whether a descriptor must be present for this filter to be + * applicable to an archetype. + * + * @param {TypeId} _type + * @returns {boolean} + */ + isRequired(_type) { + return true + } + /** * @param {readonly TypeId[]} _types * @throws {string} When the method is not implemented. @@ -18,9 +40,9 @@ export class QueryFilter { /** * @template T - * @implements {QueryFilter} + * @extends {QueryFilter} */ -export class Has { +export class Has extends QueryFilter { /** * @type {TypeId} @@ -31,6 +53,7 @@ export class Has { * @param {Constructor} component */ constructor(component) { + super() this.typeid = typeid(component) } @@ -54,9 +77,9 @@ export function has(component) { /** * @template T - * @implements {QueryFilter} + * @extends {QueryFilter} */ -export class Without { +export class Without extends QueryFilter { /** * @type {TypeId} @@ -67,6 +90,7 @@ export class Without { * @param {Constructor} component */ constructor(component) { + super() this.typeid = typeid(component) } diff --git a/packages/ecs/src/query/filters/index.js b/packages/ecs/src/query/filters/index.js index dcda72dd..e68d1572 100644 --- a/packages/ecs/src/query/filters/index.js +++ b/packages/ecs/src/query/filters/index.js @@ -1 +1,2 @@ export * from './filter' +export * from './types' diff --git a/packages/ecs/src/query/filters/types.ts b/packages/ecs/src/query/filters/types.ts new file mode 100644 index 00000000..372f1a07 --- /dev/null +++ b/packages/ecs/src/query/filters/types.ts @@ -0,0 +1,45 @@ +/** + * A type-level function with a replaceable input slot. + */ +export interface FilterTransform { + _input: unknown + 0: unknown +} + +/** A transform that preserves the component tuple. */ +export interface IdentityTransform extends FilterTransform { + 0: this['_input'] +} + +/** Apply a transform to a concrete component tuple. */ +export type ApplyTransform = + (Transform & { _input: Input })[0] + +/** Transform the tuple entries matching a component into optional entries. */ +export type Optionalize = { + [K in keyof Components]: Extract extends never + ? Components[K] + : Components[K] | undefined +} + +export interface OptionalTransform extends FilterTransform { + 0: this['_input'] extends unknown[] + ? Optionalize + : never +} + +/** Apply one filter's type-level transformation. */ +export type ApplyFilter = + Filter extends { readonly typeTransform: infer Transform } + ? Transform extends FilterTransform + ? ApplyTransform + : Components + : Components + +/** Apply filters from left to right. */ +export type ApplyFilters = + Filters extends [infer Filter, ...infer Rest] + ? Rest extends unknown[] + ? ApplyFilters, Rest> + : ApplyFilter + : Components diff --git a/packages/ecs/src/query/query.js b/packages/ecs/src/query/query.js index fcd42975..f0cff8d2 100644 --- a/packages/ecs/src/query/query.js +++ b/packages/ecs/src/query/query.js @@ -1,5 +1,6 @@ /** @import { TableId, TableRow } from '../typedef'*/ /** @import { TypeId, TupleConstructor } from '@wimaengine/type'*/ +/** @import { ApplyFilters } from './filters' */ import { typeid } from '@wimaengine/type' import { Archetype } from '../archetype' @@ -8,6 +9,12 @@ import { Table } from '../tables' import { World } from '../world' import { QueryFilter } from './filters' +/** + * @template {unknown[]} T + * @template {QueryFilter[]} U + * @typedef {ApplyFilters} QueryComponents + */ + /** * Enables operations to be performed on entities matching the * given component constructors on a {@link World}. @@ -43,6 +50,12 @@ export class Query { */ descriptors = [] + /** + * @readonly + * @type {TypeId[]} + */ + requiredDescriptors = [] + /** * @readonly * @type {U} @@ -70,6 +83,9 @@ export class Query { this.world = world this.descriptors = componentTypes.map((c) => typeid(c)) this.filters = filters + this.requiredDescriptors = this.descriptors.filter((descriptor) => { + return this.filters.every((entry) => entry.isRequired(descriptor)) + }) this.update() } @@ -77,11 +93,11 @@ export class Query { * @returns {void} */ update() { - const { world } = this + const { world, filters } = this const archetypes = world.getArchetypes() const tableIds = filterMap(archetypes.values(), (archetype) => { - if (!archetype.has(this.descriptors)) { + if (!archetype.has(this.requiredDescriptors)) { return undefined } if (!filter(archetype, this.filters)) { @@ -98,7 +114,7 @@ export class Query { * Gets the components of a given entity. * * @param {EntityHandle} entity - * @returns {T | null} + * @returns {QueryComponents | null} */ get(entity) { const { world, descriptors, tableIds } = this @@ -120,18 +136,18 @@ export class Query { mapComponents(table, descriptors, index, components) // SAFETY: Components are fetched in same order and types as the generic. - return /** @type {T}*/ (components) + return /** @type {QueryComponents}*/ (components) } /** - * @param {EachFunc} callback + * @param {EachFunc>} callback */ each(callback) { const { tableIds, descriptors } = this const tables = this.world.getTables() // SAFETY: Components are fetched below. - const components = /** @type {T}*/(new Array(this.descriptors.length)) + const components = /** @type {[...QueryComponents]}*/(new Array(this.descriptors.length)) for (let i = 0; i < tableIds.length; i++) { const table = tables.get(tableIds[i]) @@ -148,15 +164,15 @@ export class Query { } /** - * @param {EachCombinationFunc} callback + * @param {EachCombinationFunc>} callback */ eachCombination(callback) { const { tableIds, descriptors } = this const tables = this.world.getTables() // SAFETY: Components are filled below - const components1 = /** @type {T}*/(new Array(this.descriptors.length)) - const components2 = /** @type {T}*/(new Array(this.descriptors.length)) + const components1 = /** @type {[...QueryComponents]}*/(new Array(this.descriptors.length)) + const components2 = /** @type {[...QueryComponents]}*/(new Array(this.descriptors.length)) for (let i = 0; i < tableIds.length; i++) { const table1 = tables.get(tableIds[i]) @@ -188,7 +204,7 @@ export class Query { } /** - * @returns {T | null} + * @returns {QueryComponents | null} */ single() { const { descriptors, world, tableIds } = this @@ -201,7 +217,7 @@ export class Query { if (table.size() < 1) continue // SAFETY: Components are fetched below. - const components = /** @type {T}*/(new Array(this.descriptors.length)) + const components = /** @type {[...QueryComponents]}*/(new Array(this.descriptors.length)) // SAFETY: Table row is in bounds as checked above. mapComponents(table, descriptors, 0, components) From 2e9bf58c05e0621e2281ac5aac7c07f0debdf26f Mon Sep 17 00:00:00 2001 From: waynemwashuma <94756970+waynemwashuma@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:37:53 +0300 Subject: [PATCH 2/5] Add optional component query filter --- packages/ecs/src/query/filters/filter.js | 41 ++++++++++++++++++++++++ packages/ecs/tests/query.test.js | 26 +++++++++++++-- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/packages/ecs/src/query/filters/filter.js b/packages/ecs/src/query/filters/filter.js index 7559cc2d..a59710dc 100644 --- a/packages/ecs/src/query/filters/filter.js +++ b/packages/ecs/src/query/filters/filter.js @@ -75,6 +75,47 @@ export function has(component) { return new Has(component) } +/** + * Marks a component in a query as optional. Optional components do not affect + * archetype matching and are returned as `undefined` when absent. + * + * @template T + * @extends {QueryFilter>} + */ +export class Optional extends QueryFilter { + + /** @type {TypeId} */ + typeid + + /** @param {Constructor} component */ + constructor(component) { + super() + this.typeid = typeid(component) + } + + /** + * @param {TypeId} type + * @returns {boolean} + */ + isRequired(type) { + return this.typeid !== type + } + + /** @param {readonly TypeId[]} _types */ + archetype(_types) { + return true + } +} + +/** + * @template T + * @param {Constructor} component + * @returns {Optional} + */ +export function optional(component) { + return new Optional(component) +} + /** * @template T * @extends {QueryFilter} diff --git a/packages/ecs/tests/query.test.js b/packages/ecs/tests/query.test.js index 603ee8c3..6669c35b 100644 --- a/packages/ecs/tests/query.test.js +++ b/packages/ecs/tests/query.test.js @@ -1,5 +1,5 @@ import { test, describe } from "vitest"; -import { EntityHandle, Query, has, without, World } from ".."; +import { EntityHandle, Query, has, optional, without, World } from ".."; import assert, { strictEqual } from "node:assert"; import { typeid } from "@wimaengine/type"; @@ -123,6 +123,28 @@ describe("Testing `Query`", () => { strictEqual(query.count(), 10) }) + test('query with an optional component.', () => { + const world = createWorld() + const query = new Query(world, [A, B], [optional(B)]) + let missing = 0 + let present = 0 + + query.each(([componentA, componentB]) => { + strictEqual(componentA instanceof A, true) + + if (componentB) { + strictEqual(componentB instanceof B, true) + present += 1 + } else { + missing += 1 + } + }) + + strictEqual(missing, 10) + strictEqual(present, 20) + strictEqual(query.count(), 30) + }) + test('query for combination of entities', () => { const world = createWorld() const query = new Query(world, [A, B, C]) @@ -207,4 +229,4 @@ function createWorld() { } return world -} \ No newline at end of file +} From bb47afcee06f2f7cae06cbbf6fee533ff7605180 Mon Sep 17 00:00:00 2001 From: waynemwashuma <94756970+waynemwashuma@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:15:19 +0300 Subject: [PATCH 3/5] Lint files --- packages/ecs/src/query/filters/filter.js | 7 ++++--- packages/ecs/src/query/filters/index.js | 2 +- packages/ecs/src/query/query.js | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/ecs/src/query/filters/filter.js b/packages/ecs/src/query/filters/filter.js index a59710dc..1525774f 100644 --- a/packages/ecs/src/query/filters/filter.js +++ b/packages/ecs/src/query/filters/filter.js @@ -9,6 +9,7 @@ import { typeid } from '@wimaengine/type' export class QueryFilter { /** @type {Transform} */ + // @ts-ignore typeTransform constructor() { @@ -40,7 +41,7 @@ export class QueryFilter { /** * @template T - * @extends {QueryFilter} + * @augments {QueryFilter} */ export class Has extends QueryFilter { @@ -80,7 +81,7 @@ export function has(component) { * archetype matching and are returned as `undefined` when absent. * * @template T - * @extends {QueryFilter>} + * @augments {QueryFilter>} */ export class Optional extends QueryFilter { @@ -118,7 +119,7 @@ export function optional(component) { /** * @template T - * @extends {QueryFilter} + * @augments {QueryFilter} */ export class Without extends QueryFilter { diff --git a/packages/ecs/src/query/filters/index.js b/packages/ecs/src/query/filters/index.js index e68d1572..00b699be 100644 --- a/packages/ecs/src/query/filters/index.js +++ b/packages/ecs/src/query/filters/index.js @@ -1,2 +1,2 @@ export * from './filter' -export * from './types' +export * from './types.ts' diff --git a/packages/ecs/src/query/query.js b/packages/ecs/src/query/query.js index f0cff8d2..d1ad9556 100644 --- a/packages/ecs/src/query/query.js +++ b/packages/ecs/src/query/query.js @@ -93,7 +93,7 @@ export class Query { * @returns {void} */ update() { - const { world, filters } = this + const { world } = this const archetypes = world.getArchetypes() const tableIds = filterMap(archetypes.values(), (archetype) => { @@ -298,7 +298,7 @@ function mapComponents(table, descriptor, row, list) { */ function filter(archetype, filters) { for (let i = 0; i < filters.length; i++) { - if (!filters[i].archetype(archetype.types)) { + if (!filters[i]?.archetype(archetype.types)) { return false } } From 5c7ac53ef4f71b470f1dc5de757a8268ad690174 Mon Sep 17 00:00:00 2001 From: waynemwashuma <94756970+waynemwashuma@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:48:33 +0300 Subject: [PATCH 4/5] Apply filter transforms on relationship queries --- .config/tsc.lint.json | 1 + .config/tsc.type.base.json | 1 + packages/relationship/src/core/query.js | 66 +++++++++++++++++-------- 3 files changed, 47 insertions(+), 21 deletions(-) diff --git a/.config/tsc.lint.json b/.config/tsc.lint.json index a9aba20b..0267165e 100644 --- a/.config/tsc.lint.json +++ b/.config/tsc.lint.json @@ -8,6 +8,7 @@ "allowUnreachableCode": false, "allowUnusedLabels": false, "alwaysStrict": true, + "allowImportingTsExtensions": true, "checkJs": true, "module": "esnext", "moduleResolution": "bundler", diff --git a/.config/tsc.type.base.json b/.config/tsc.type.base.json index 66a9e307..47fca56c 100644 --- a/.config/tsc.type.base.json +++ b/.config/tsc.type.base.json @@ -4,6 +4,7 @@ "allowUnusedLabels": false, "alwaysStrict": true, "allowJs": true, + "allowImportingTsExtensions": true, "checkJs": true, "declaration": true, "emitDeclarationOnly": true, diff --git a/packages/relationship/src/core/query.js b/packages/relationship/src/core/query.js index 3af81b5e..b366f20c 100644 --- a/packages/relationship/src/core/query.js +++ b/packages/relationship/src/core/query.js @@ -1,4 +1,4 @@ -/** @import { QueryFilter,EntityId } from '@wimaengine/ecs' */ +/** @import { ApplyFilters, QueryFilter,EntityId } from '@wimaengine/ecs' */ /** @import { Constructor, TupleConstructor } from '@wimaengine/type' */ import { Query, World, EntityHandle } from '@wimaengine/ecs' import { TraverseEntities } from './traverseentities' @@ -66,7 +66,10 @@ export class RelationshipQuery { /** * @param {EntityHandle} entity - * @param { (descendant: Data, ancestor: Data) => void } visit + * @param {( + * descendant: ApplyFilters, + * ancestor: ApplyFilters + * ) => void} visit */ treebfs(entity, visit) { const queue = [entity] @@ -76,7 +79,7 @@ export class RelationshipQuery { // SAFETY: `stack` is dense and an element is guaranteed to exist // as we check the length above. const ancestorEntity = /** @type {EntityHandle}*/(queue.shift()) - const ancestor = this.ancestors.get(ancestorEntity) + const ancestor = /** @type {[Relationship, ...Data] | null} */ (this.ancestors.get(ancestorEntity)) if (!ancestor) continue @@ -87,26 +90,32 @@ export class RelationshipQuery { for (let i = 0; i < descendants.length; i++) { - const descendant = this.descendants.get(descendants[i]) + const descendant = /** @type {[Target, ...Data] | null} */ (this.descendants.get(descendants[i])) if (!descendant) continue const [, ...descendantData] = descendant - visit(descendantData, ancestorData) + visit( + /** @type {ApplyFilters} */ (descendantData), + /** @type {ApplyFilters} */ (ancestorData) + ) } } } /** * @param {EntityHandle} entity - * @param { (descendant: Data, ancestor: Data) => void } visit + * @param {( + * descendant: ApplyFilters, + * ancestor: ApplyFilters + * ) => void} visit */ treedfs(entity, visit) { /** @type {[EntityHandle,EntityHandle][]} */ const stack = [] - const root = this.ancestors.get(entity) + const root = /** @type {[Relationship, ...Data] | null} */ (this.ancestors.get(entity)) if (!root) return @@ -120,17 +129,20 @@ export class RelationshipQuery { while (stack.length) { const [ancestorEntity, descendantEntity] = stack.pop() - const ancestor = this.ancestors.get(ancestorEntity) - const descendant = this.descendants.get(descendantEntity) + const ancestor = /** @type {[Relationship, ...Data] | null} */ (this.ancestors.get(ancestorEntity)) + const descendant = /** @type {[Target, ...Data] | null} */ (this.descendants.get(descendantEntity)) if (!ancestor || !descendant) continue const [, ...descendantData] = descendant const [, ...ancestorData] = ancestor - visit(descendantData, ancestorData) + visit( + /** @type {ApplyFilters} */ (descendantData), + /** @type {ApplyFilters} */ (ancestorData) + ) - const grand = this.ancestors.get(descendantEntity) + const grand = /** @type {[Relationship, ...Data] | null} */ (this.ancestors.get(descendantEntity)) if (grand) stack.push( ...grand[0].visit() @@ -142,7 +154,10 @@ export class RelationshipQuery { /** * @param {EntityHandle} entity - * @param { (descendant: Data, ancestor: Data) => void } visit + * @param {( + * descendant: ApplyFilters, + * ancestor: ApplyFilters + * ) => void} visit */ graphbfs(entity, visit) { const queue = [entity] @@ -156,7 +171,7 @@ export class RelationshipQuery { // as we check the length above. const ancestorEntity = /** @type {EntityHandle}*/(queue.shift()) const ancestorEntityId = ancestorEntity.id() - const ancestor = this.ancestors.get(ancestorEntity) + const ancestor = /** @type {[Relationship, ...Data] | null} */ (this.ancestors.get(ancestorEntity)) if (visited.has(ancestorEntityId)) continue if (!ancestor) continue @@ -169,20 +184,26 @@ export class RelationshipQuery { for (let i = 0; i < descendants.length; i++) { const descendantEntity = descendants[i] - const descendant = this.descendants.get(descendantEntity) + const descendant = /** @type {[Target, ...Data] | null} */ (this.descendants.get(descendantEntity)) if (!descendant) continue const [, ...descendantData] = descendant - visit(descendantData, ancestorData) + visit( + /** @type {ApplyFilters} */ (descendantData), + /** @type {ApplyFilters} */ (ancestorData) + ) } } } /** * @param {EntityHandle} entity - * @param { (descendant: Data, ancestor: Data) => void } visit + * @param {( + * descendant: ApplyFilters, + * ancestor: ApplyFilters + * ) => void} visit */ graphdfs(entity, visit) { @@ -191,7 +212,7 @@ export class RelationshipQuery { /** @type {Set} */ const visited = new Set() - const root = this.ancestors.get(entity) + const root = /** @type {[Relationship, ...Data] | null} */ (this.ancestors.get(entity)) if (!root) return @@ -207,17 +228,20 @@ export class RelationshipQuery { if (visited.has(descendantEntity.id())) continue - const ancestor = this.ancestors.get(ancestorEntity) - const descendant = this.descendants.get(descendantEntity) + const ancestor = /** @type {[Relationship, ...Data] | null} */ (this.ancestors.get(ancestorEntity)) + const descendant = /** @type {[Target, ...Data] | null} */ (this.descendants.get(descendantEntity)) if (!ancestor || !descendant) continue const [, ...descendantData] = descendant const [, ...ancestorData] = ancestor - visit(descendantData, ancestorData) + visit( + /** @type {ApplyFilters} */ (descendantData), + /** @type {ApplyFilters} */ (ancestorData) + ) visited.add(ancestorEntity.id()) - const grand = this.ancestors.get(descendantEntity) + const grand = /** @type {[Relationship, ...Data] | null} */ (this.ancestors.get(descendantEntity)) if (grand) stack.push( ...grand[0].visit() From 4e198f56710e2c2c9aff853900613b4ea86bf537 Mon Sep 17 00:00:00 2001 From: waynemwashuma <94756970+waynemwashuma@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:00:24 +0300 Subject: [PATCH 5/5] Lint files --- packages/ecs/src/query/filters/filter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ecs/src/query/filters/filter.js b/packages/ecs/src/query/filters/filter.js index 1525774f..417d1e7a 100644 --- a/packages/ecs/src/query/filters/filter.js +++ b/packages/ecs/src/query/filters/filter.js @@ -35,7 +35,7 @@ export class QueryFilter { * @returns {boolean} */ archetype(_types) { - throw `Implement ${this.constructor.name}.archetype` + return true } }