From 4c240c2c41d2923a09fca6b19ed2aef57b63811e Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Mon, 26 May 2025 15:06:48 +0200 Subject: [PATCH 01/39] #105 init --- packages/feature-ecs/README.md | 1 + packages/feature-ecs/eslint.config.js | 5 + packages/feature-ecs/package.json | 48 ++ packages/feature-ecs/rollup.config.js | 6 + packages/feature-ecs/src/index.ts | 1 + packages/feature-ecs/tsconfig.json | 10 + packages/feature-ecs/tsconfig.prod.json | 6 + packages/feature-ecs/vitest.config.mjs | 4 + pnpm-lock.yaml | 610 +++++++++++++++++++++++- 9 files changed, 675 insertions(+), 16 deletions(-) create mode 100644 packages/feature-ecs/README.md create mode 100644 packages/feature-ecs/eslint.config.js create mode 100644 packages/feature-ecs/package.json create mode 100644 packages/feature-ecs/rollup.config.js create mode 100644 packages/feature-ecs/src/index.ts create mode 100644 packages/feature-ecs/tsconfig.json create mode 100644 packages/feature-ecs/tsconfig.prod.json create mode 100644 packages/feature-ecs/vitest.config.mjs diff --git a/packages/feature-ecs/README.md b/packages/feature-ecs/README.md new file mode 100644 index 00000000..3b4dee73 --- /dev/null +++ b/packages/feature-ecs/README.md @@ -0,0 +1 @@ +# feature-ecs \ No newline at end of file diff --git a/packages/feature-ecs/eslint.config.js b/packages/feature-ecs/eslint.config.js new file mode 100644 index 00000000..275e54fa --- /dev/null +++ b/packages/feature-ecs/eslint.config.js @@ -0,0 +1,5 @@ +/** + * @see https://eslint.org/docs/latest/use/configure/configuration-files + * @type {import("eslint").Linter.Config} + */ +module.exports = [...require('@blgc/config/eslint/library')]; diff --git a/packages/feature-ecs/package.json b/packages/feature-ecs/package.json new file mode 100644 index 00000000..0316a70d --- /dev/null +++ b/packages/feature-ecs/package.json @@ -0,0 +1,48 @@ +{ + "name": "feature-ecs", + "version": "0.0.1", + "private": false, + "description": "Straightforward, typesafe, and feature-based Entity Component System (ECS)", + "keywords": [], + "homepage": "https://builder.group/?source=github", + "bugs": { + "url": "https://github.com/builder-group/community/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/builder-group/community.git" + }, + "license": "MIT", + "author": "@bennobuilder", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "source": "./src/index.ts", + "types": "./dist/types/index.d.ts", + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "shx rm -rf dist && rollup -c rollup.config.js", + "build:prod": "export NODE_ENV=production && pnpm build", + "clean": "shx rm -rf dist && shx rm -rf .turbo && shx rm -rf node_modules", + "install:clean": "pnpm run clean && pnpm install", + "lint": "eslint . --fix", + "publish:patch": "pnpm build:prod && pnpm version patch && pnpm publish --no-git-checks --access=public", + "size": "size-limit --why", + "start:dev": "tsc -w", + "test": "vitest run", + "update:latest": "pnpm update --latest" + }, + "dependencies": {}, + "devDependencies": { + "@blgc/config": "workspace:*", + "@types/node": "^22.15.21", + "rollup-presets": "workspace:*" + }, + "size-limit": [ + { + "path": "dist/esm/index.js" + } + ] +} diff --git a/packages/feature-ecs/rollup.config.js b/packages/feature-ecs/rollup.config.js new file mode 100644 index 00000000..d09fd346 --- /dev/null +++ b/packages/feature-ecs/rollup.config.js @@ -0,0 +1,6 @@ +const { libraryPreset } = require('rollup-presets'); + +/** + * @type {import('rollup').RollupOptions[]} + */ +module.exports = libraryPreset(); diff --git a/packages/feature-ecs/src/index.ts b/packages/feature-ecs/src/index.ts new file mode 100644 index 00000000..e9fe0090 --- /dev/null +++ b/packages/feature-ecs/src/index.ts @@ -0,0 +1 @@ +console.log('Hello, world!'); diff --git a/packages/feature-ecs/tsconfig.json b/packages/feature-ecs/tsconfig.json new file mode 100644 index 00000000..bf70a3c1 --- /dev/null +++ b/packages/feature-ecs/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@blgc/config/typescript/library", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declarationDir": "./dist/types" + }, + "include": ["src"], + "exclude": ["**/__tests__/*", "**/*.test.ts"] +} diff --git a/packages/feature-ecs/tsconfig.prod.json b/packages/feature-ecs/tsconfig.prod.json new file mode 100644 index 00000000..01151c39 --- /dev/null +++ b/packages/feature-ecs/tsconfig.prod.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declarationMap": false + } +} diff --git a/packages/feature-ecs/vitest.config.mjs b/packages/feature-ecs/vitest.config.mjs new file mode 100644 index 00000000..8482b939 --- /dev/null +++ b/packages/feature-ecs/vitest.config.mjs @@ -0,0 +1,4 @@ +import { nodeConfig } from '@blgc/config/vite/node'; +import { defineConfig, mergeConfig } from 'vitest/config'; + +export default mergeConfig(nodeConfig, defineConfig({})); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e567850..ae58f7bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,7 +92,7 @@ importers: version: 19.1.0(react@19.1.0) valibot: specifier: 1.0.0-beta.9 - version: 1.1.0(typescript@5.8.3) + version: 1.0.0-beta.9(typescript@5.8.3) validation-adapters: specifier: workspace:* version: link:../../../../packages/validation-adapters @@ -184,13 +184,13 @@ importers: dependencies: express: specifier: ^4.21.2 - version: 5.1.0 + version: 4.21.2 openapi-ts-router: specifier: workspace:* version: link:../../../../packages/openapi-ts-router valibot: specifier: 1.0.0-beta.12 - version: 1.1.0(typescript@5.8.3) + version: 1.0.0-beta.12(typescript@5.8.3) validation-adapters: specifier: workspace:* version: link:../../../../packages/validation-adapters @@ -224,7 +224,7 @@ importers: version: 1.14.2(hono@4.7.10) '@hono/zod-validator': specifier: ^0.4.2 - version: 0.5.0(hono@4.7.10)(zod@3.25.28) + version: 0.4.3(hono@4.7.10)(zod@3.25.28) hono: specifier: ^4.6.15 version: 4.7.10 @@ -252,10 +252,10 @@ importers: version: 6.2.3 fast-xml-parser: specifier: ^4.4.1 - version: 5.2.3 + version: 4.5.3 tinybench: specifier: ^2.9.0 - version: 4.0.1 + version: 2.9.0 txml: specifier: ^5.1.1 version: 5.1.1 @@ -274,7 +274,7 @@ importers: version: 5.8.3 vite: specifier: ^5.3.4 - version: 6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4) + version: 5.4.19(@types/node@22.15.21) packages/config: dependencies: @@ -390,6 +390,18 @@ importers: specifier: workspace:* version: link:../rollup-presets + packages/feature-ecs: + devDependencies: + '@blgc/config': + specifier: workspace:* + version: link:../config + '@types/node': + specifier: ^22.15.21 + version: 22.15.21 + rollup-presets: + specifier: workspace:* + version: link:../rollup-presets + packages/feature-fetch: dependencies: '@0no-co/graphql.web': @@ -929,6 +941,12 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.25.0': resolution: {integrity: sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==} engines: {node: '>=18'} @@ -941,6 +959,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.25.0': resolution: {integrity: sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==} engines: {node: '>=18'} @@ -953,6 +977,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.0': resolution: {integrity: sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==} engines: {node: '>=18'} @@ -965,6 +995,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.25.0': resolution: {integrity: sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==} engines: {node: '>=18'} @@ -977,6 +1013,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.25.0': resolution: {integrity: sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==} engines: {node: '>=18'} @@ -989,6 +1031,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.25.0': resolution: {integrity: sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==} engines: {node: '>=18'} @@ -1001,6 +1049,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.25.0': resolution: {integrity: sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==} engines: {node: '>=18'} @@ -1013,6 +1067,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.0': resolution: {integrity: sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==} engines: {node: '>=18'} @@ -1025,6 +1085,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.25.0': resolution: {integrity: sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==} engines: {node: '>=18'} @@ -1037,6 +1103,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.25.0': resolution: {integrity: sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==} engines: {node: '>=18'} @@ -1049,6 +1121,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.25.0': resolution: {integrity: sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==} engines: {node: '>=18'} @@ -1061,6 +1139,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.0': resolution: {integrity: sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==} engines: {node: '>=18'} @@ -1073,6 +1157,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.25.0': resolution: {integrity: sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==} engines: {node: '>=18'} @@ -1085,6 +1175,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.25.0': resolution: {integrity: sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==} engines: {node: '>=18'} @@ -1097,6 +1193,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.25.0': resolution: {integrity: sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==} engines: {node: '>=18'} @@ -1109,6 +1211,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.25.0': resolution: {integrity: sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==} engines: {node: '>=18'} @@ -1121,6 +1229,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.25.0': resolution: {integrity: sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==} engines: {node: '>=18'} @@ -1145,6 +1259,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.0': resolution: {integrity: sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==} engines: {node: '>=18'} @@ -1169,6 +1289,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.0': resolution: {integrity: sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==} engines: {node: '>=18'} @@ -1181,6 +1307,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.0': resolution: {integrity: sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==} engines: {node: '>=18'} @@ -1193,6 +1325,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.25.0': resolution: {integrity: sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==} engines: {node: '>=18'} @@ -1205,6 +1343,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.25.0': resolution: {integrity: sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==} engines: {node: '>=18'} @@ -1217,6 +1361,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.25.0': resolution: {integrity: sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==} engines: {node: '>=18'} @@ -1276,8 +1426,8 @@ packages: peerDependencies: hono: ^4 - '@hono/zod-validator@0.5.0': - resolution: {integrity: sha512-ds5bW6DCgAnNHP33E3ieSbaZFd5dkV52ZjyaXtGoR06APFrCtzAsKZxTHwOrJNBdXsi0e5wNwo5L4nVEVnJUdg==} + '@hono/zod-validator@0.4.3': + resolution: {integrity: sha512-xIgMYXDyJ4Hj6ekm9T9Y27s080Nl9NXHcJkOvkXPhubOLj8hZkOL8pDnnXfvCf5xEE8Q4oMFenQUZZREUY2gqQ==} peerDependencies: hono: '>=3.9.0' zod: ^3.19.1 @@ -1736,6 +1886,10 @@ packages: '@vitest/utils@3.1.4': resolution: {integrity: sha512-yriMuO1cfFhmiGc8ataN51+9ooHRuURdfAZfwFd3usWynjzpLslZdYnRegTv32qdgtJTsj15FoeZe2g15fY1gg==} + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -1794,6 +1948,9 @@ packages: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + array-includes@3.1.8: resolution: {integrity: sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==} engines: {node: '>= 0.4'} @@ -1848,6 +2005,10 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + body-parser@1.20.3: + resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + body-parser@2.2.0: resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} engines: {node: '>=18'} @@ -1960,6 +2121,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + content-disposition@1.0.0: resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} engines: {node: '>= 0.6'} @@ -1971,6 +2136,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -2018,6 +2186,14 @@ packages: dataloader@1.4.0: resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.0: resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} engines: {node: '>=6.0'} @@ -2071,6 +2247,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -2120,6 +2300,10 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -2174,6 +2358,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.0: resolution: {integrity: sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==} engines: {node: '>=18'} @@ -2300,6 +2489,10 @@ packages: resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==} engines: {node: '>=12.0.0'} + express@4.21.2: + resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} + engines: {node: '>= 0.10.0'} + express@5.1.0: resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} engines: {node: '>= 18'} @@ -2328,6 +2521,10 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-xml-parser@4.5.3: + resolution: {integrity: sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==} + hasBin: true + fast-xml-parser@5.2.3: resolution: {integrity: sha512-OdCYfRqfpuLUFonTNjvd30rCBZUneHpSQkCqfaeWQ9qrKcl6XlWeDBNVwGb+INAIxRshuN2jF+BE0L6gbBO2mw==} hasBin: true @@ -2363,6 +2560,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@1.3.1: + resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} + engines: {node: '>= 0.8'} + finalhandler@2.1.0: resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} engines: {node: '>= 0.8'} @@ -2390,6 +2591,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -2861,10 +3066,17 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} @@ -2873,18 +3085,35 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime-types@3.0.1: resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} engines: {node: '>= 0.6'} + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -2903,6 +3132,9 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2936,6 +3168,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -3123,6 +3359,9 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-to-regexp@0.1.12: + resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -3293,6 +3532,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + engines: {node: '>=0.6'} + qs@6.14.0: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} @@ -3310,6 +3553,10 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} + raw-body@2.5.2: + resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + engines: {node: '>= 0.8'} + raw-body@3.0.0: resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==} engines: {node: '>= 0.8'} @@ -3475,10 +3722,18 @@ packages: engines: {node: '>=10'} hasBin: true + send@0.19.0: + resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} + engines: {node: '>= 0.8.0'} + send@1.2.0: resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} engines: {node: '>= 18'} + serve-static@1.16.2: + resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} + engines: {node: '>= 0.8.0'} + serve-static@2.2.0: resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} engines: {node: '>= 18'} @@ -3639,6 +3894,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strnum@1.1.2: + resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} + strnum@2.1.0: resolution: {integrity: sha512-w0S//9BqZZGw0L0Y8uLSelFGnDJgTyyNQLmSlPnVz43zPAiqu3w4t8J8sDqqANOGeZIZ/9jWuPguYcEnsoHv4A==} @@ -3675,10 +3933,6 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinybench@4.0.1: - resolution: {integrity: sha512-Nb1srn7dvzkVx0J5h1vq8f48e3TIcbrS7e/UfAI/cDSef/n8yLh4zsAEsFkfpw6auTY+ZaspEvam/xs8nMnotQ==} - engines: {node: '>=18.0.0'} - tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} @@ -3816,6 +4070,10 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -3896,9 +4154,29 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + valibot@1.0.0-beta.12: + resolution: {integrity: sha512-j3WIxJ0pmUFMfdfUECn3YnZPYOiG0yHYcFEa/+RVgo0I+MXE3ToLt7gNRLtY5pwGfgNmsmhenGZfU5suu9ijUA==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + + valibot@1.0.0-beta.9: + resolution: {integrity: sha512-yEX8gMAZ2R1yI2uwOO4NCtVnJQx36zn3vD0omzzj9FhcoblvPukENIiRZXKZwCnqSeV80bMm8wNiGhQ0S8fiww==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + valibot@1.1.0: resolution: {integrity: sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==} peerDependencies: @@ -3924,6 +4202,37 @@ packages: vite: optional: true + vite@5.4.19: + resolution: {integrity: sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + vite@6.3.5: resolution: {integrity: sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -4400,102 +4709,153 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.25.0': optional: true '@esbuild/aix-ppc64@0.25.4': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.25.0': optional: true '@esbuild/android-arm64@0.25.4': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.25.0': optional: true '@esbuild/android-arm@0.25.4': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.25.0': optional: true '@esbuild/android-x64@0.25.4': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.25.0': optional: true '@esbuild/darwin-arm64@0.25.4': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.25.0': optional: true '@esbuild/darwin-x64@0.25.4': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.25.0': optional: true '@esbuild/freebsd-arm64@0.25.4': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.25.0': optional: true '@esbuild/freebsd-x64@0.25.4': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.25.0': optional: true '@esbuild/linux-arm64@0.25.4': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.25.0': optional: true '@esbuild/linux-arm@0.25.4': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.25.0': optional: true '@esbuild/linux-ia32@0.25.4': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.25.0': optional: true '@esbuild/linux-loong64@0.25.4': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.25.0': optional: true '@esbuild/linux-mips64el@0.25.4': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.25.0': optional: true '@esbuild/linux-ppc64@0.25.4': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.25.0': optional: true '@esbuild/linux-riscv64@0.25.4': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.25.0': optional: true '@esbuild/linux-s390x@0.25.4': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.25.0': optional: true @@ -4508,6 +4868,9 @@ snapshots: '@esbuild/netbsd-arm64@0.25.4': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.25.0': optional: true @@ -4520,30 +4883,45 @@ snapshots: '@esbuild/openbsd-arm64@0.25.4': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.25.0': optional: true '@esbuild/openbsd-x64@0.25.4': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.25.0': optional: true '@esbuild/sunos-x64@0.25.4': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.25.0': optional: true '@esbuild/win32-arm64@0.25.4': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.25.0': optional: true '@esbuild/win32-ia32@0.25.4': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.25.0': optional: true @@ -4600,7 +4978,7 @@ snapshots: dependencies: hono: 4.7.10 - '@hono/zod-validator@0.5.0(hono@4.7.10)(zod@3.25.28)': + '@hono/zod-validator@0.4.3(hono@4.7.10)(zod@3.25.28)': dependencies: hono: 4.7.10 zod: 3.25.28 @@ -5094,6 +5472,11 @@ snapshots: loupe: 3.1.3 tinyrainbow: 2.0.0 + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + accepts@2.0.0: dependencies: mime-types: 3.0.1 @@ -5148,6 +5531,8 @@ snapshots: call-bound: 1.0.4 is-array-buffer: 3.0.5 + array-flatten@1.1.1: {} + array-includes@3.1.8: dependencies: call-bind: 1.0.8 @@ -5218,6 +5603,23 @@ snapshots: binary-extensions@2.3.0: {} + body-parser@1.20.3: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.13.0 + raw-body: 2.5.2 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + body-parser@2.2.0: dependencies: bytes: 3.1.2 @@ -5344,6 +5746,10 @@ snapshots: concat-map@0.0.1: {} + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + content-disposition@1.0.0: dependencies: safe-buffer: 5.2.1 @@ -5352,6 +5758,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie-signature@1.0.6: {} + cookie-signature@1.2.2: {} cookie@0.7.1: {} @@ -5400,6 +5808,10 @@ snapshots: dataloader@1.4.0: {} + debug@2.6.9: + dependencies: + ms: 2.0.0 + debug@4.4.0(supports-color@5.5.0): dependencies: ms: 2.1.3 @@ -5441,6 +5853,8 @@ snapshots: depd@2.0.0: {} + destroy@1.2.0: {} + detect-indent@6.1.0: {} detect-indent@7.0.1: {} @@ -5475,6 +5889,8 @@ snapshots: emoji-regex@8.0.0: {} + encodeurl@1.0.2: {} + encodeurl@2.0.0: {} end-of-stream@1.4.4: @@ -5594,6 +6010,32 @@ snapshots: picomatch: 4.0.2 yargs: 17.7.2 + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.25.0: optionalDependencies: '@esbuild/aix-ppc64': 0.25.0 @@ -5806,6 +6248,42 @@ snapshots: expect-type@1.2.1: {} + express@4.21.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.3 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.1 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.1 + fresh: 0.5.2 + http-errors: 2.0.0 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.12 + proxy-addr: 2.0.7 + qs: 6.13.0 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.0 + serve-static: 1.16.2 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + express@5.1.0: dependencies: accepts: 2.0.0 @@ -5868,6 +6346,10 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-xml-parser@4.5.3: + dependencies: + strnum: 1.1.2 + fast-xml-parser@5.2.3: dependencies: strnum: 2.1.0 @@ -5896,6 +6378,18 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@1.3.1: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + finalhandler@2.1.0: dependencies: debug: 4.4.1(supports-color@10.0.0) @@ -5930,6 +6424,8 @@ snapshots: forwarded@0.2.0: {} + fresh@0.5.2: {} + fresh@2.0.0: {} fs-extra@7.0.1: @@ -6370,23 +6866,37 @@ snapshots: math-intrinsics@1.1.0: {} + media-typer@0.3.0: {} + media-typer@1.1.0: {} + merge-descriptors@1.0.3: {} + merge-descriptors@2.0.0: {} merge2@1.4.1: {} + methods@1.1.2: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime-types@3.0.1: dependencies: mime-db: 1.54.0 + mime@1.6.0: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.11 @@ -6403,6 +6913,8 @@ snapshots: mri@1.2.0: {} + ms@2.0.0: {} + ms@2.1.3: {} msw@2.8.4(@types/node@22.15.21)(typescript@5.8.3): @@ -6442,6 +6954,8 @@ snapshots: natural-compare@1.4.0: {} + negotiator@0.6.3: {} + negotiator@1.0.0: {} nice-napi@1.0.2: @@ -6632,6 +7146,8 @@ snapshots: path-parse@1.0.7: {} + path-to-regexp@0.1.12: {} + path-to-regexp@6.3.0: {} path-to-regexp@8.2.0: {} @@ -6735,6 +7251,10 @@ snapshots: punycode@2.3.1: {} + qs@6.13.0: + dependencies: + side-channel: 1.1.0 + qs@6.14.0: dependencies: side-channel: 1.1.0 @@ -6747,6 +7267,13 @@ snapshots: range-parser@1.2.1: {} + raw-body@2.5.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + raw-body@3.0.0: dependencies: bytes: 3.1.2 @@ -6936,6 +7463,24 @@ snapshots: semver@7.7.2: {} + send@0.19.0: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + send@1.2.0: dependencies: debug: 4.4.1(supports-color@10.0.0) @@ -6952,6 +7497,15 @@ snapshots: transitivePeerDependencies: - supports-color + serve-static@1.16.2: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.0 + transitivePeerDependencies: + - supports-color + serve-static@2.2.0: dependencies: encodeurl: 2.0.0 @@ -7154,6 +7708,8 @@ snapshots: strip-json-comments@3.1.1: {} + strnum@1.1.2: {} + strnum@2.1.0: {} supports-color@10.0.0: {} @@ -7183,8 +7739,6 @@ snapshots: tinybench@2.9.0: {} - tinybench@4.0.1: {} - tinyexec@0.3.2: {} tinyglobby@0.2.13: @@ -7301,6 +7855,11 @@ snapshots: type-fest@4.41.0: {} + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -7395,8 +7954,18 @@ snapshots: util-deprecate@1.0.2: {} + utils-merge@1.0.1: {} + v8-compile-cache-lib@3.0.1: {} + valibot@1.0.0-beta.12(typescript@5.8.3): + optionalDependencies: + typescript: 5.8.3 + + valibot@1.0.0-beta.9(typescript@5.8.3): + optionalDependencies: + typescript: 5.8.3 + valibot@1.1.0(typescript@5.8.3): optionalDependencies: typescript: 5.8.3 @@ -7435,6 +8004,15 @@ snapshots: - supports-color - typescript + vite@5.4.19(@types/node@22.15.21): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.3 + rollup: 4.41.1 + optionalDependencies: + '@types/node': 22.15.21 + fsevents: 2.3.3 + vite@6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4): dependencies: esbuild: 0.25.4 From 0fd8c9b28e26c9cc120fced66c705e5f2638bb67 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Mon, 26 May 2025 17:06:26 +0200 Subject: [PATCH 02/39] #105 wip entity index --- .../src/create-entity-index.test.ts | 380 ++++++++++++++++++ .../feature-ecs/src/create-entity-index.ts | 275 +++++++++++++ 2 files changed, 655 insertions(+) create mode 100644 packages/feature-ecs/src/create-entity-index.test.ts create mode 100644 packages/feature-ecs/src/create-entity-index.ts diff --git a/packages/feature-ecs/src/create-entity-index.test.ts b/packages/feature-ecs/src/create-entity-index.test.ts new file mode 100644 index 00000000..f4943381 --- /dev/null +++ b/packages/feature-ecs/src/create-entity-index.test.ts @@ -0,0 +1,380 @@ +import { describe, expect, it } from 'vitest'; +import { createEntityIndex } from './create-entity-index'; + +describe('createEntityIndex', () => { + describe('initialization', () => { + it('should create index with default options', () => { + const index = createEntityIndex(); + + expect(index.aliveCount).toBe(0); + expect(index.dense).toEqual([]); + expect(index._sparse).toEqual([]); + expect(index._nextId).toBe(1); + expect(index._config.versioning).toBe(false); + expect(index._versionBits).toBe(8); + expect(index._entityBits).toBe(24); + }); + + it('should create index with versioning enabled', () => { + const index = createEntityIndex({ versioning: true, versionBits: 4 }); + + expect(index._config.versioning).toBe(true); + expect(index._versionBits).toBe(4); + expect(index._entityBits).toBe(28); + }); + + it('should validate versionBits range', () => { + expect(() => createEntityIndex({ versionBits: 0 })).toThrow( + 'versionBits must be between 1 and 16' + ); + expect(() => createEntityIndex({ versionBits: 17 })).toThrow( + 'versionBits must be between 1 and 16' + ); + expect(() => createEntityIndex({ versionBits: 1 })).not.toThrow(); + expect(() => createEntityIndex({ versionBits: 16 })).not.toThrow(); + }); + }); + + describe('addEntity', () => { + it('should add first entity with ID 1', () => { + const index = createEntityIndex(); + const id = index.addEntity(); + + expect(id).toBe(1); + expect(index.aliveCount).toBe(1); + expect(index.dense).toEqual([1]); + expect(index._sparse[1]).toBe(0); + expect(index._nextId).toBe(2); + }); + + it('should add multiple entities with sequential IDs', () => { + const index = createEntityIndex(); + const id1 = index.addEntity(); + const id2 = index.addEntity(); + const id3 = index.addEntity(); + + expect(id1).toBe(1); + expect(id2).toBe(2); + expect(id3).toBe(3); + expect(index.aliveCount).toBe(3); + expect(index.dense).toEqual([1, 2, 3]); + }); + + it('should recycle removed entity IDs', () => { + const index = createEntityIndex(); + const id1 = index.addEntity(); + const id2 = index.addEntity(); + + index.removeEntity(id1); + const recycledId = index.addEntity(); + + expect(recycledId).toBe(id1); + expect(index.aliveCount).toBe(2); + }); + + it('should throw error when exceeding max entities', () => { + // Use maximum versionBits to minimize entity space for testing + const index = createEntityIndex({ versionBits: 16 }); // 16 entity bits, max = 65535 + + // Manually set _nextId to the limit to test the boundary condition + index._nextId = index._maxEid; // Set to max allowed (65535) + + // This should work (creates entity with ID = maxEid) + const lastValidId = index.addEntity(); + expect(lastValidId).toBe(index._maxEid); + + // This should fail (nextId is now maxEid + 1 = 65536) + expect(() => index.addEntity()).toThrow('Maximum number of entities exceeded'); + }); + }); + + describe('removeEntity', () => { + it('should remove existing entity', () => { + const index = createEntityIndex(); + const id = index.addEntity(); + + const result = index.removeEntity(id); + + expect(result).toBe(true); + expect(index.aliveCount).toBe(0); + expect(index.isEntityAlive(id)).toBe(false); + }); + + it('should return false for non-existent entity', () => { + const index = createEntityIndex(); + + const result = index.removeEntity(999); + + expect(result).toBe(false); + expect(index.aliveCount).toBe(0); + }); + + it('should return false for already removed entity', () => { + const index = createEntityIndex(); + const id = index.addEntity(); + + index.removeEntity(id); + const result = index.removeEntity(id); + + expect(result).toBe(false); + }); + + it('should handle swap-and-pop correctly', () => { + const index = createEntityIndex(); + const id1 = index.addEntity(); + const id2 = index.addEntity(); + const id3 = index.addEntity(); + + index.removeEntity(id2); // Remove middle entity + + expect(index.aliveCount).toBe(2); + expect(index.dense[0]).toBe(id1); + expect(index.dense[1]).toBe(id3); // id3 moved to position 1 + expect(index._sparse[1]).toBe(0); // id1 at position 0 + expect(index._sparse[3]).toBe(1); // id3 at position 1 + }); + + it('should increment version when versioning enabled', () => { + const index = createEntityIndex({ versioning: true }); + const id = index.addEntity(); + + index.removeEntity(id); + const recycledId = index.addEntity(); + + expect(index.getEid(recycledId)).toBe(index.getEid(id)); + expect(index.getEidVersion(recycledId)).toBe(1); + expect(recycledId).not.toBe(id); + }); + }); + + describe('isEntityAlive', () => { + it('should return true for alive entity', () => { + const index = createEntityIndex(); + const id = index.addEntity(); + + expect(index.isEntityAlive(id)).toBe(true); + }); + + it('should return false for removed entity', () => { + const index = createEntityIndex(); + const id = index.addEntity(); + + index.removeEntity(id); + + expect(index.isEntityAlive(id)).toBe(false); + }); + + it('should return false for non-existent entity', () => { + const index = createEntityIndex(); + + expect(index.isEntityAlive(999)).toBe(false); + }); + + it('should return false for stale versioned entity', () => { + const index = createEntityIndex({ versioning: true }); + const id = index.addEntity(); + + index.removeEntity(id); + index.addEntity(); // Recycle with new version + + expect(index.isEntityAlive(id)).toBe(false); // Old version should be dead + }); + }); + + describe('getEid', () => { + it('should return entity ID without version', () => { + const index = createEntityIndex(); + const id = index.addEntity(); + + expect(index.getEid(id)).toBe(id); + }); + + it('should extract base ID from versioned entity', () => { + const index = createEntityIndex({ versioning: true }); + const id = index.addEntity(); + + index.removeEntity(id); + const recycledId = index.addEntity(); + + expect(index.getEid(recycledId)).toBe(index.getEid(id)); + }); + }); + + describe('getEidVersion', () => { + it('should return 0 when versioning disabled', () => { + const index = createEntityIndex({ versioning: false }); + const id = index.addEntity(); + + expect(index.getEidVersion(id)).toBe(0); + }); + + it('should return 0 for new entity when versioning enabled', () => { + const index = createEntityIndex({ versioning: true }); + const id = index.addEntity(); + + expect(index.getEidVersion(id)).toBe(0); + }); + + it('should return incremented version for recycled entity', () => { + const index = createEntityIndex({ versioning: true }); + const id = index.addEntity(); + + index.removeEntity(id); + const recycledId = index.addEntity(); + + expect(index.getEidVersion(recycledId)).toBe(1); + }); + + it('should handle version overflow', () => { + const index = createEntityIndex({ versioning: true, versionBits: 2 }); // Max version 3 + let currentId = index.addEntity(); + + // Cycle through versions 0, 1, 2, 3, then back to 0 + for (let i = 0; i < 4; i++) { + index.removeEntity(currentId); + currentId = index.addEntity(); + } + + expect(index.getEidVersion(currentId)).toBe(0); // Wrapped around + }); + }); + + describe('getAliveEntities', () => { + it('should return empty array for new index', () => { + const index = createEntityIndex(); + + expect(index.getAliveEntities()).toEqual([]); + }); + + it('should return all alive entities', () => { + const index = createEntityIndex(); + const id1 = index.addEntity(); + const id2 = index.addEntity(); + const id3 = index.addEntity(); + + expect(index.getAliveEntities()).toEqual([id1, id2, id3]); + }); + + it('should not include removed entities', () => { + const index = createEntityIndex(); + const id1 = index.addEntity(); + const id2 = index.addEntity(); + const id3 = index.addEntity(); + + index.removeEntity(id2); + + expect(index.getAliveEntities()).toEqual([id1, id3]); + }); + }); + + describe('_createVersionedId', () => { + it('should return base ID when versioning disabled', () => { + const index = createEntityIndex({ versioning: false }); + + expect(index._createVersionedId(5, 3)).toBe(5); + }); + + it('should combine base ID and version when versioning enabled', () => { + const index = createEntityIndex({ versioning: true, versionBits: 8 }); + const baseId = 5; + const version = 3; + + const versionedId = index._createVersionedId(baseId, version); + + expect(index.getEid(versionedId)).toBe(baseId); + expect(index.getEidVersion(versionedId)).toBe(version); + }); + }); + + describe('_validate', () => { + it('should return true for valid empty index', () => { + const index = createEntityIndex(); + + expect(index._validate()).toBe(true); + }); + + it('should return true for valid index with entities', () => { + const index = createEntityIndex(); + index.addEntity(); + index.addEntity(); + index.addEntity(); + + expect(index._validate()).toBe(true); + }); + + it('should return true after remove operations', () => { + const index = createEntityIndex(); + const id1 = index.addEntity(); + const id2 = index.addEntity(); + const id3 = index.addEntity(); + + index.removeEntity(id2); + + expect(index._validate()).toBe(true); + }); + + it('should return true after recycling', () => { + const index = createEntityIndex({ versioning: true }); + const id1 = index.addEntity(); + + index.removeEntity(id1); + index.addEntity(); // Recycle + + expect(index._validate()).toBe(true); + }); + }); + + describe('complex scenarios', () => { + it('should handle multiple add/remove cycles', () => { + const index = createEntityIndex({ versioning: true }); + const entities: number[] = []; + + // Add 5 entities + for (let i = 0; i < 5; i++) { + entities.push(index.addEntity()); + } + + // Remove every other entity + for (let i = 0; i < entities.length; i += 2) { + index.removeEntity(entities[i] as number); + } + + // Add 3 more entities (should recycle) + for (let i = 0; i < 3; i++) { + index.addEntity(); + } + + expect(index.aliveCount).toBe(5); // 2 remaining + 3 new + expect(index._validate()).toBe(true); + }); + + it('should maintain consistency with random operations', () => { + const index = createEntityIndex({ versioning: true }); + const aliveEntities = new Set(); + + // Perform 100 random operations + for (let i = 0; i < 100; i++) { + if (Math.random() < 0.7 || aliveEntities.size === 0) { + // Add entity + const id = index.addEntity(); + aliveEntities.add(id); + } else { + // Remove random entity + const entities = Array.from(aliveEntities); + const randomEntity = entities[Math.floor(Math.random() * entities.length)] as number; + index.removeEntity(randomEntity); + aliveEntities.delete(randomEntity); + } + + // Validate consistency + expect(index.aliveCount).toBe(aliveEntities.size); + expect(index._validate()).toBe(true); + + // Check all tracked entities are alive + for (const entity of aliveEntities) { + expect(index.isEntityAlive(entity)).toBe(true); + } + } + }); + }); +}); diff --git a/packages/feature-ecs/src/create-entity-index.ts b/packages/feature-ecs/src/create-entity-index.ts new file mode 100644 index 00000000..3fb45723 --- /dev/null +++ b/packages/feature-ecs/src/create-entity-index.ts @@ -0,0 +1,275 @@ +/** + * Entity Index for ECS (Entity Component System) + * + * Provides efficient entity ID management with optional versioning support. + * Uses a sparse-dense array pattern for O(1) operations while maintaining + * cache-friendly dense iteration. + * + * Key features: + * - O(1) entity creation, removal, and alive checks + * - Memory-efficient ID recycling + * - Optional versioning to prevent stale entity references + * - Dense array for cache-friendly iteration + */ + +/** + * Creates a new entity index with the specified configuration. + * + * @param options - Configuration options + * @returns A new entity index instance + * + * @example + * ```typescript + * // Basic usage without versioning + * const index = createEntityIndex(); + * const eid = index.addEntity(); + * + * // With versioning enabled + * const versionedIndex = createEntityIndex({ versioning: true }); + * ``` + */ +export function createEntityIndex(options: TCreateEntityIndexOptions = {}): TEntityIndex { + const { versioning = false, versionBits = 8 } = options; + + // Validate configuration + if (versionBits < 1 || versionBits > 16) { + throw new Error('versionBits must be between 1 and 16'); + } + + // Split 32-bit integer between entity ID and version + const entityBits = 32 - versionBits; + const maxEid = (1 << entityBits) - 1; + const entityMask = maxEid; + const versionMask = ((1 << versionBits) - 1) << entityBits; + + return { + _config: { + versioning + }, + + _sparse: [] as number[], // Maps entity ID -> dense array index + _nextId: 1, // Next entity ID to assign (start from 1) + dense: [] as number[], // Dense array of entity IDs for iteration + aliveCount: 0, // Number of currently alive entities + + _versionBits: versionBits, + _entityBits: entityBits, + _maxEid: maxEid, + _entityMask: entityMask, + _versionMask: versionMask, + + getEid(id: number): number { + return id & this._entityMask; + }, + + getEidVersion(id: number): number { + return this._config.versioning + ? (id >>> this._entityBits) & ((1 << this._versionBits) - 1) + : 0; + }, + + addEntity(): number { + // Try to recycle a removed entity first + if (this.aliveCount < this.dense.length) { + const recycledId = this.dense[this.aliveCount] as number; + const baseId = this.getEid(recycledId); + + // Restore the sparse mapping and increment alive count + this._sparse[baseId] = this.aliveCount; + this.aliveCount++; + return recycledId; + } + + // Check if we've reached the maximum number of entities + if (this._nextId > this._maxEid) { + throw new Error(`Maximum number of entities exceeded (${this._maxEid})`); + } + + // Create new entity with version 0 + const baseId = this._nextId++; + const id = this._createVersionedId(baseId, 0); + + // Add to both dense and sparse arrays + this.dense.push(id); + this._sparse[baseId] = this.aliveCount; + this.aliveCount++; + + return id; + }, + + removeEntity(id: number): boolean { + const baseId = this.getEid(id); + const denseIndex = this._sparse[baseId]; + + // Check if entity exists and is alive + if (denseIndex == null || denseIndex >= this.aliveCount || this.dense[denseIndex] !== id) { + return false; + } + + const lastIndex = this.aliveCount - 1; + + // Swap-and-pop: move the last alive entity to fill the gap + if (denseIndex !== lastIndex) { + const lastId = this.dense[lastIndex] as number; + const lastBaseId = this.getEid(lastId); + + this.dense[denseIndex] = lastId; + this._sparse[lastBaseId] = denseIndex; + } + + // Increment version to invalidate old references and prepare for recycling + const currentVersion = this.getEidVersion(id); + const newVersion = this._config.versioning + ? (currentVersion + 1) & ((1 << this._versionBits) - 1) + : 0; + const recycledId = this._createVersionedId(baseId, newVersion); + + // Place recycled entity in the "dead" section and clean up + this.dense[lastIndex] = recycledId; + delete this._sparse[baseId]; + this.aliveCount--; + + return true; + }, + + isEntityAlive(id: number): boolean { + const baseId = this.getEid(id); + const denseIndex = this._sparse[baseId]; + return denseIndex != null && denseIndex < this.aliveCount && this.dense[denseIndex] === id; + }, + + getAliveEntities(): number[] { + return this.dense.slice(0, this.aliveCount); + }, + + _validate(): boolean { + // Check that all alive entities have correct sparse mappings + for (let i = 0; i < this.aliveCount; i++) { + const eid = this.dense[i] as number; + const baseId = this.getEid(eid); + if (this._sparse[baseId] !== i) { + return false; + } + } + + // Check that all entities in sparse array point to valid positions + for (let baseId = 1; baseId < this._nextId; baseId++) { + const denseIndex = this._sparse[baseId]; + if (denseIndex != null) { + // Check bounds + if (denseIndex >= this.dense.length || denseIndex < 0) { + return false; + } + + // Check that the entity at this position has the correct base ID + const storedId = this.dense[denseIndex] as number; + if (this.getEid(storedId) !== baseId) { + return false; + } + } + } + + return true; + }, + + _createVersionedId(baseId: number, version: number): number { + return this._config.versioning ? baseId | (version << this._entityBits) : baseId; + } + }; +} + +/** + * Configuration options for creating an entity index. + */ +interface TCreateEntityIndexOptions { + /** Enable versioning to prevent stale entity references. Default: false */ + versioning?: boolean; + /** Number of bits reserved for version information. Default: 8 (allows 256 versions) */ + versionBits?: number; +} + +/** + * Entity index interface providing efficient entity ID management. + */ +export interface TEntityIndex { + _config: { + /** Whether versioning is enabled */ + versioning: boolean; + }; + + /** Sparse array mapping entity IDs to their index in the dense array */ + _sparse: number[]; + /** The next entity ID to be assigned */ + _nextId: number; + /** Dense array of alive entity IDs for efficient iteration */ + dense: number[]; + /** Number of currently alive entities */ + aliveCount: number; + + /** Number of bits reserved for version information */ + _versionBits: number; + /** Number of bits used for entity ID */ + _entityBits: number; + /** Maximum entity ID that can be assigned */ + _maxEid: number; + /** Bit mask for extracting entity ID */ + _entityMask: number; + /** Bit mask for extracting version */ + _versionMask: number; + + /** + * Creates a new entity ID or recycles a previously removed one. + * @returns A unique entity ID + */ + addEntity(): number; + + /** + * Removes an entity from the index, making its ID available for recycling. + * If versioning is enabled, increments the version to invalidate stale references. + * @param id - The entity ID to remove + * @returns True if the entity was removed, false if it wasn't alive + */ + removeEntity(id: number): boolean; + + /** + * Checks if an entity ID is currently alive. + * @param id - The entity ID to check + * @returns True if the entity is alive, false otherwise + */ + isEntityAlive(id: number): boolean; + + /** + * Extracts the base entity ID without version information. + * @param id - The potentially versioned entity ID + * @returns The base entity ID + */ + getEid(id: number): number; + + /** + * Extracts the version from an entity ID. + * @param id - The entity ID + * @returns The version number (0 if versioning is disabled) + */ + getEidVersion(id: number): number; + + /** + * Gets all alive entity IDs for iteration. + * @returns Array of alive entity IDs + */ + getAliveEntities(): number[]; + + /** + * Validates the internal data structure integrity. + * Useful for debugging and testing. + * @returns True if the data structure is valid, false otherwise + */ + _validate(): boolean; + + /** + * Creates a versioned entity ID by combining base ID and version. + * @param baseId - The base entity ID + * @param version - The version number + * @returns The versioned entity ID + */ + _createVersionedId(baseId: number, version: number): number; +} From b3ef124554ced68c918b7bd55f67c90c43d4f4c9 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Mon, 26 May 2025 17:27:45 +0200 Subject: [PATCH 03/39] #105 fixed typos --- README.md | 2 +- packages/feature-ecs/README.md | 136 +++++++++++++++++- .../src/create-entity-index.test.ts | 74 ++-------- .../feature-ecs/src/create-entity-index.ts | 124 ++++++++-------- .../rollup-plugin-ts-declarations/README.md | 2 +- packages/rollup-presets/tsconfig.json | 2 +- 6 files changed, 210 insertions(+), 130 deletions(-) diff --git a/README.md b/README.md index a281f613..dab2ebea 100644 --- a/README.md +++ b/README.md @@ -119,4 +119,4 @@ In short, we use objects because they are more flexible and allow for the kind o ### What Features? -Think of features like components in an Entity Component System (ECS). Every feature based object (e.g., feature-state) has base functionality, and additional components (features) can be added to extend it. Unlike traditional ECS, we only adopt the concept of Components, without Systems or Entities. Each component contains the necessary functions to interact with the feature based object. \ No newline at end of file +Think of features like components in an Entity Component System (ECS). Every feature based object (e.g., feature-state) has base functionality, and additional components (features) can be added to extend it. Unlike traditional ECS, we only adopt the concept of Components, without Systems or Entities. Each component contains the necessary functions to interact with the feature based object. diff --git a/packages/feature-ecs/README.md b/packages/feature-ecs/README.md index 3b4dee73..b20b0588 100644 --- a/packages/feature-ecs/README.md +++ b/packages/feature-ecs/README.md @@ -1 +1,135 @@ -# feature-ecs \ No newline at end of file +# feature-ecs + +TODO + +## Entity Index + +The entity index provides efficient entity ID management with optional versioning support using a sparse-dense array pattern. This component handles O(1) entity operations while maintaining cache-friendly iteration. + +### Architecture Overview + +The entity index uses a **sparse-dense array pattern** that provides O(1) operations while maintaining cache-friendly iteration: + +``` +Sparse Array: [_, 0, _, 2, 1, _, _] ← Maps entity ID → dense index + 1 2 3 4 5 6 7 ← Entity IDs + +Dense Array: [2, 5, 4, 7, 3] ← Alive entities (cache-friendly) + [0, 1, 2, 3, 4] ← Indices + └─ alive ─┘ └ dead ┘ + +aliveCount: 3 ← First 3 elements are alive +``` + +#### Core Data Structures + +1. **Sparse Array** (`_sparse`): Maps base entity IDs to their position in the dense array +2. **Dense Array** (`dense`): Contiguous array of entity IDs, split into alive and dead sections +3. **Alive Count** (`aliveCount`): Boundary between alive and dead entities in dense array + +#### Entity ID Format (with versioning) + +``` +32-bit Entity ID = [Version Bits | Entity ID Bits] + +Example with 8 version bits: +┌─ Version (8 bits) ─┐┌─── Entity ID (24 bits) ───┐ +00000001 000000000000000000000001 +│ │ +└─ Version 1 └─ Base Entity ID 1 +``` + +### Why This Architecture? + +#### 1. **Performance Requirements** + +ECS systems need to handle thousands of entities efficiently in game loops that run 60+ times per second. + +**Our solution:** + +- **O(1) entity creation/removal**: No searching or shifting arrays +- **Cache-friendly iteration**: Dense array keeps alive entities contiguous +- **Minimal memory allocation**: Recycles entity IDs instead of growing indefinitely + +#### 2. **Memory Safety with Versioning** + +Without versioning, stale entity references can cause bugs: + +```typescript +const enemy = entityIndex.addEntity(); +const enemyRef = enemy; // Store reference + +// Later... +entityIndex.removeEntity(enemy); +const newEntity = entityIndex.addEntity(); // Might reuse same ID! + +// BUG: enemyRef might accidentally refer to newEntity +if (entityIndex.isEntityAlive(enemyRef)) { + // This could be true for the wrong entity! +} +``` + +**Our solution with versioning:** + +```typescript +const enemy = entityIndex.addEntity(); // Returns ID with version 0 +entityIndex.removeEntity(enemy); // Increments version to 1 +const newEntity = entityIndex.addEntity(); // Reuses base ID but with version 1 + +// Safe: old reference (version 0) won't match new entity (version 1) +entityIndex.isEntityAlive(enemy); // false - version mismatch +``` + +#### 3. **Swap-and-Pop for Efficient Removal** + +Traditional array removal requires shifting elements (O(n)): + +```typescript +// Traditional approach - O(n) +array = [1, 2, 3, 4, 5]; +array.splice(1, 1); // Remove element at index 1 +// Result: [1, 3, 4, 5] - had to shift 3 elements +``` + +Our swap-and-pop approach achieves O(1) removal: + +```typescript +// Our approach - O(1) +dense = [1, 2, 3, 4, 5]; +// Remove element at index 1: +// 1. Swap with last element: [1, 5, 3, 4, 2] +// 2. Decrease alive count: aliveCount = 4 +// Result: [1, 5, 3, 4 | 2] - only alive section matters +``` + +#### 4. **Configurable Bit Allocation** + +Different applications have different needs: + +```typescript +// Game with many short-lived entities (bullets, particles) +versionBits: 12; // 4096 versions, ~1M entities max + +// Simulation with fewer, long-lived entities +versionBits: 4; // 16 versions, ~256M entities max +``` + +### Performance Characteristics + +| Operation | Time Complexity | Space Complexity | +| ------------- | -------------------------- | ---------------- | +| Add Entity | O(1) | O(1) | +| Remove Entity | O(1) | O(1) | +| Check Alive | O(1) | O(1) | +| Iterate Alive | O(n) where n = alive count | O(1) | + +**Memory Usage:** + +- Sparse array: 4 bytes × max entities ever created +- Dense array: 4 bytes × max entities ever created +- Total: ~8 bytes per entity slot + +**Cache Performance:** + +- Iteration over alive entities is cache-friendly (contiguous memory) +- Sparse lookups may cause cache misses but are O(1) diff --git a/packages/feature-ecs/src/create-entity-index.test.ts b/packages/feature-ecs/src/create-entity-index.test.ts index f4943381..ca52350d 100644 --- a/packages/feature-ecs/src/create-entity-index.test.ts +++ b/packages/feature-ecs/src/create-entity-index.test.ts @@ -9,7 +9,7 @@ describe('createEntityIndex', () => { expect(index.aliveCount).toBe(0); expect(index.dense).toEqual([]); expect(index._sparse).toEqual([]); - expect(index._nextId).toBe(1); + expect(index._nextBaseEid).toBe(1); expect(index._config.versioning).toBe(false); expect(index._versionBits).toBe(8); expect(index._entityBits).toBe(24); @@ -44,7 +44,7 @@ describe('createEntityIndex', () => { expect(index.aliveCount).toBe(1); expect(index.dense).toEqual([1]); expect(index._sparse[1]).toBe(0); - expect(index._nextId).toBe(2); + expect(index._nextBaseEid).toBe(2); }); it('should add multiple entities with sequential IDs', () => { @@ -77,11 +77,11 @@ describe('createEntityIndex', () => { const index = createEntityIndex({ versionBits: 16 }); // 16 entity bits, max = 65535 // Manually set _nextId to the limit to test the boundary condition - index._nextId = index._maxEid; // Set to max allowed (65535) + index._nextBaseEid = index._maxBaseEid; // Set to max allowed (65535) // This should work (creates entity with ID = maxEid) const lastValidId = index.addEntity(); - expect(lastValidId).toBe(index._maxEid); + expect(lastValidId).toBe(index._maxBaseEid); // This should fail (nextId is now maxEid + 1 = 65536) expect(() => index.addEntity()).toThrow('Maximum number of entities exceeded'); @@ -141,7 +141,7 @@ describe('createEntityIndex', () => { index.removeEntity(id); const recycledId = index.addEntity(); - expect(index.getEid(recycledId)).toBe(index.getEid(id)); + expect(index.getBaseEid(recycledId)).toBe(index.getBaseEid(id)); expect(index.getEidVersion(recycledId)).toBe(1); expect(recycledId).not.toBe(id); }); @@ -186,7 +186,7 @@ describe('createEntityIndex', () => { const index = createEntityIndex(); const id = index.addEntity(); - expect(index.getEid(id)).toBe(id); + expect(index.getBaseEid(id)).toBe(id); }); it('should extract base ID from versioned entity', () => { @@ -196,7 +196,7 @@ describe('createEntityIndex', () => { index.removeEntity(id); const recycledId = index.addEntity(); - expect(index.getEid(recycledId)).toBe(index.getEid(id)); + expect(index.getBaseEid(recycledId)).toBe(index.getBaseEid(id)); }); }); @@ -271,7 +271,7 @@ describe('createEntityIndex', () => { it('should return base ID when versioning disabled', () => { const index = createEntityIndex({ versioning: false }); - expect(index._createVersionedId(5, 3)).toBe(5); + expect(index._createVersionedEid(5, 3)).toBe(5); }); it('should combine base ID and version when versioning enabled', () => { @@ -279,9 +279,9 @@ describe('createEntityIndex', () => { const baseId = 5; const version = 3; - const versionedId = index._createVersionedId(baseId, version); + const versionedId = index._createVersionedEid(baseId, version); - expect(index.getEid(versionedId)).toBe(baseId); + expect(index.getBaseEid(versionedId)).toBe(baseId); expect(index.getEidVersion(versionedId)).toBe(version); }); }); @@ -323,58 +323,4 @@ describe('createEntityIndex', () => { expect(index._validate()).toBe(true); }); }); - - describe('complex scenarios', () => { - it('should handle multiple add/remove cycles', () => { - const index = createEntityIndex({ versioning: true }); - const entities: number[] = []; - - // Add 5 entities - for (let i = 0; i < 5; i++) { - entities.push(index.addEntity()); - } - - // Remove every other entity - for (let i = 0; i < entities.length; i += 2) { - index.removeEntity(entities[i] as number); - } - - // Add 3 more entities (should recycle) - for (let i = 0; i < 3; i++) { - index.addEntity(); - } - - expect(index.aliveCount).toBe(5); // 2 remaining + 3 new - expect(index._validate()).toBe(true); - }); - - it('should maintain consistency with random operations', () => { - const index = createEntityIndex({ versioning: true }); - const aliveEntities = new Set(); - - // Perform 100 random operations - for (let i = 0; i < 100; i++) { - if (Math.random() < 0.7 || aliveEntities.size === 0) { - // Add entity - const id = index.addEntity(); - aliveEntities.add(id); - } else { - // Remove random entity - const entities = Array.from(aliveEntities); - const randomEntity = entities[Math.floor(Math.random() * entities.length)] as number; - index.removeEntity(randomEntity); - aliveEntities.delete(randomEntity); - } - - // Validate consistency - expect(index.aliveCount).toBe(aliveEntities.size); - expect(index._validate()).toBe(true); - - // Check all tracked entities are alive - for (const entity of aliveEntities) { - expect(index.isEntityAlive(entity)).toBe(true); - } - } - }); - }); }); diff --git a/packages/feature-ecs/src/create-entity-index.ts b/packages/feature-ecs/src/create-entity-index.ts index 3fb45723..b977b448 100644 --- a/packages/feature-ecs/src/create-entity-index.ts +++ b/packages/feature-ecs/src/create-entity-index.ts @@ -38,8 +38,8 @@ export function createEntityIndex(options: TCreateEntityIndexOptions = {}): TEnt // Split 32-bit integer between entity ID and version const entityBits = 32 - versionBits; - const maxEid = (1 << entityBits) - 1; - const entityMask = maxEid; + const maxBaseEid = (1 << entityBits) - 1; + const entityMask = maxBaseEid; const versionMask = ((1 << versionBits) - 1) << entityBits; return { @@ -47,62 +47,62 @@ export function createEntityIndex(options: TCreateEntityIndexOptions = {}): TEnt versioning }, - _sparse: [] as number[], // Maps entity ID -> dense array index - _nextId: 1, // Next entity ID to assign (start from 1) + _sparse: [] as number[], // Maps base entity ID -> dense array index + _nextBaseEid: 1, // Next base entity ID to assign (start from 1) dense: [] as number[], // Dense array of entity IDs for iteration aliveCount: 0, // Number of currently alive entities _versionBits: versionBits, _entityBits: entityBits, - _maxEid: maxEid, + _maxBaseEid: maxBaseEid, _entityMask: entityMask, _versionMask: versionMask, - getEid(id: number): number { - return id & this._entityMask; + getBaseEid(eid: number): number { + return eid & this._entityMask; }, - getEidVersion(id: number): number { + getEidVersion(eid: number): number { return this._config.versioning - ? (id >>> this._entityBits) & ((1 << this._versionBits) - 1) + ? (eid >>> this._entityBits) & ((1 << this._versionBits) - 1) : 0; }, addEntity(): number { // Try to recycle a removed entity first if (this.aliveCount < this.dense.length) { - const recycledId = this.dense[this.aliveCount] as number; - const baseId = this.getEid(recycledId); + const recycledEid = this.dense[this.aliveCount] as number; + const baseEid = this.getBaseEid(recycledEid); // Restore the sparse mapping and increment alive count - this._sparse[baseId] = this.aliveCount; + this._sparse[baseEid] = this.aliveCount; this.aliveCount++; - return recycledId; + return recycledEid; } // Check if we've reached the maximum number of entities - if (this._nextId > this._maxEid) { - throw new Error(`Maximum number of entities exceeded (${this._maxEid})`); + if (this._nextBaseEid > this._maxBaseEid) { + throw new Error(`Maximum number of entities exceeded (${this._maxBaseEid})`); } // Create new entity with version 0 - const baseId = this._nextId++; - const id = this._createVersionedId(baseId, 0); + const baseEid = this._nextBaseEid++; + const eid = this._createVersionedEid(baseEid, 0); // Add to both dense and sparse arrays - this.dense.push(id); - this._sparse[baseId] = this.aliveCount; + this.dense.push(eid); + this._sparse[baseEid] = this.aliveCount; this.aliveCount++; - return id; + return eid; }, - removeEntity(id: number): boolean { - const baseId = this.getEid(id); - const denseIndex = this._sparse[baseId]; + removeEntity(eid: number): boolean { + const baseEid = this.getBaseEid(eid); + const denseIndex = this._sparse[baseEid]; // Check if entity exists and is alive - if (denseIndex == null || denseIndex >= this.aliveCount || this.dense[denseIndex] !== id) { + if (denseIndex == null || denseIndex >= this.aliveCount || this.dense[denseIndex] !== eid) { return false; } @@ -110,32 +110,32 @@ export function createEntityIndex(options: TCreateEntityIndexOptions = {}): TEnt // Swap-and-pop: move the last alive entity to fill the gap if (denseIndex !== lastIndex) { - const lastId = this.dense[lastIndex] as number; - const lastBaseId = this.getEid(lastId); + const lastEid = this.dense[lastIndex] as number; + const lastBaseEid = this.getBaseEid(lastEid); - this.dense[denseIndex] = lastId; - this._sparse[lastBaseId] = denseIndex; + this.dense[denseIndex] = lastEid; + this._sparse[lastBaseEid] = denseIndex; } // Increment version to invalidate old references and prepare for recycling - const currentVersion = this.getEidVersion(id); + const currentVersion = this.getEidVersion(eid); const newVersion = this._config.versioning ? (currentVersion + 1) & ((1 << this._versionBits) - 1) : 0; - const recycledId = this._createVersionedId(baseId, newVersion); + const recycledEid = this._createVersionedEid(baseEid, newVersion); // Place recycled entity in the "dead" section and clean up - this.dense[lastIndex] = recycledId; - delete this._sparse[baseId]; + this.dense[lastIndex] = recycledEid; + delete this._sparse[baseEid]; this.aliveCount--; return true; }, - isEntityAlive(id: number): boolean { - const baseId = this.getEid(id); - const denseIndex = this._sparse[baseId]; - return denseIndex != null && denseIndex < this.aliveCount && this.dense[denseIndex] === id; + isEntityAlive(eid: number): boolean { + const baseEid = this.getBaseEid(eid); + const denseIndex = this._sparse[baseEid]; + return denseIndex != null && denseIndex < this.aliveCount && this.dense[denseIndex] === eid; }, getAliveEntities(): number[] { @@ -146,15 +146,15 @@ export function createEntityIndex(options: TCreateEntityIndexOptions = {}): TEnt // Check that all alive entities have correct sparse mappings for (let i = 0; i < this.aliveCount; i++) { const eid = this.dense[i] as number; - const baseId = this.getEid(eid); - if (this._sparse[baseId] !== i) { + const baseEid = this.getBaseEid(eid); + if (this._sparse[baseEid] !== i) { return false; } } // Check that all entities in sparse array point to valid positions - for (let baseId = 1; baseId < this._nextId; baseId++) { - const denseIndex = this._sparse[baseId]; + for (let baseEid = 1; baseEid < this._nextBaseEid; baseEid++) { + const denseIndex = this._sparse[baseEid]; if (denseIndex != null) { // Check bounds if (denseIndex >= this.dense.length || denseIndex < 0) { @@ -162,8 +162,8 @@ export function createEntityIndex(options: TCreateEntityIndexOptions = {}): TEnt } // Check that the entity at this position has the correct base ID - const storedId = this.dense[denseIndex] as number; - if (this.getEid(storedId) !== baseId) { + const storedEid = this.dense[denseIndex] as number; + if (this.getBaseEid(storedEid) !== baseEid) { return false; } } @@ -172,8 +172,8 @@ export function createEntityIndex(options: TCreateEntityIndexOptions = {}): TEnt return true; }, - _createVersionedId(baseId: number, version: number): number { - return this._config.versioning ? baseId | (version << this._entityBits) : baseId; + _createVersionedEid(baseEid: number, version: number): number { + return this._config.versioning ? baseEid | (version << this._entityBits) : baseEid; } }; } @@ -197,11 +197,11 @@ export interface TEntityIndex { versioning: boolean; }; - /** Sparse array mapping entity IDs to their index in the dense array */ + /** Sparse array mapping base entity IDs to their index in the dense array */ _sparse: number[]; - /** The next entity ID to be assigned */ - _nextId: number; - /** Dense array of alive entity IDs for efficient iteration */ + /** The next base entity ID to be assigned */ + _nextBaseEid: number; + /** Dense array of entity IDs for efficient iteration */ dense: number[]; /** Number of currently alive entities */ aliveCount: number; @@ -210,8 +210,8 @@ export interface TEntityIndex { _versionBits: number; /** Number of bits used for entity ID */ _entityBits: number; - /** Maximum entity ID that can be assigned */ - _maxEid: number; + /** Maximum base entity ID that can be assigned */ + _maxBaseEid: number; /** Bit mask for extracting entity ID */ _entityMask: number; /** Bit mask for extracting version */ @@ -219,38 +219,38 @@ export interface TEntityIndex { /** * Creates a new entity ID or recycles a previously removed one. - * @returns A unique entity ID + * @returns A unique entity ID (potentially versioned) */ addEntity(): number; /** * Removes an entity from the index, making its ID available for recycling. * If versioning is enabled, increments the version to invalidate stale references. - * @param id - The entity ID to remove + * @param eid - The entity ID to remove * @returns True if the entity was removed, false if it wasn't alive */ - removeEntity(id: number): boolean; + removeEntity(eid: number): boolean; /** * Checks if an entity ID is currently alive. - * @param id - The entity ID to check + * @param eid - The entity ID to check * @returns True if the entity is alive, false otherwise */ - isEntityAlive(id: number): boolean; + isEntityAlive(eid: number): boolean; /** * Extracts the base entity ID without version information. - * @param id - The potentially versioned entity ID - * @returns The base entity ID + * @param eid - The potentially versioned entity ID + * @returns The base entity ID (without version bits) */ - getEid(id: number): number; + getBaseEid(eid: number): number; /** * Extracts the version from an entity ID. - * @param id - The entity ID + * @param eid - The entity ID * @returns The version number (0 if versioning is disabled) */ - getEidVersion(id: number): number; + getEidVersion(eid: number): number; /** * Gets all alive entity IDs for iteration. @@ -267,9 +267,9 @@ export interface TEntityIndex { /** * Creates a versioned entity ID by combining base ID and version. - * @param baseId - The base entity ID + * @param baseEid - The base entity ID * @param version - The version number * @returns The versioned entity ID */ - _createVersionedId(baseId: number, version: number): number; + _createVersionedEid(baseEid: number, version: number): number; } diff --git a/packages/rollup-presets/src/plugins/rollup-plugin-ts-declarations/README.md b/packages/rollup-presets/src/plugins/rollup-plugin-ts-declarations/README.md index 8213fbb0..1cbc16da 100644 --- a/packages/rollup-presets/src/plugins/rollup-plugin-ts-declarations/README.md +++ b/packages/rollup-presets/src/plugins/rollup-plugin-ts-declarations/README.md @@ -4,4 +4,4 @@ https://github.com/Microsoft/TypeScript/wiki/Using-the-Compiler-API ## 🌟 Credits -- [rollup-plugin-dts](https://github.com/Swatinem/rollup-plugin-dts) \ No newline at end of file +- [rollup-plugin-dts](https://github.com/Swatinem/rollup-plugin-dts) diff --git a/packages/rollup-presets/tsconfig.json b/packages/rollup-presets/tsconfig.json index c4cc81bc..4cf69469 100644 --- a/packages/rollup-presets/tsconfig.json +++ b/packages/rollup-presets/tsconfig.json @@ -6,4 +6,4 @@ }, "include": ["src"], "exclude": ["**/__tests__/*", "**/*.test.ts"] -} \ No newline at end of file +} From e7d90b2e0cfed776abc4dba6840e04ad8a371790 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Tue, 27 May 2025 06:26:33 +0200 Subject: [PATCH 04/39] #105 fixed typos --- packages/feature-ecs/README.md | 2 +- .../src/create-entity-index.test.ts | 135 ++++++++++++---- .../feature-ecs/src/create-entity-index.ts | 145 ++++++++++++++---- 3 files changed, 225 insertions(+), 57 deletions(-) diff --git a/packages/feature-ecs/README.md b/packages/feature-ecs/README.md index b20b0588..308204f1 100644 --- a/packages/feature-ecs/README.md +++ b/packages/feature-ecs/README.md @@ -16,7 +16,7 @@ Sparse Array: [_, 0, _, 2, 1, _, _] ← Maps entity ID → dense index Dense Array: [2, 5, 4, 7, 3] ← Alive entities (cache-friendly) [0, 1, 2, 3, 4] ← Indices - └─ alive ─┘ └ dead ┘ + └─alive─┘ └dead┘ aliveCount: 3 ← First 3 elements are alive ``` diff --git a/packages/feature-ecs/src/create-entity-index.test.ts b/packages/feature-ecs/src/create-entity-index.test.ts index ca52350d..b5f852f9 100644 --- a/packages/feature-ecs/src/create-entity-index.test.ts +++ b/packages/feature-ecs/src/create-entity-index.test.ts @@ -6,8 +6,8 @@ describe('createEntityIndex', () => { it('should create index with default options', () => { const index = createEntityIndex(); - expect(index.aliveCount).toBe(0); - expect(index.dense).toEqual([]); + expect(index._aliveCount).toBe(0); + expect(index._dense).toEqual([]); expect(index._sparse).toEqual([]); expect(index._nextBaseEid).toBe(1); expect(index._config.versioning).toBe(false); @@ -41,8 +41,8 @@ describe('createEntityIndex', () => { const id = index.addEntity(); expect(id).toBe(1); - expect(index.aliveCount).toBe(1); - expect(index.dense).toEqual([1]); + expect(index._aliveCount).toBe(1); + expect(index._dense).toEqual([1]); expect(index._sparse[1]).toBe(0); expect(index._nextBaseEid).toBe(2); }); @@ -56,8 +56,8 @@ describe('createEntityIndex', () => { expect(id1).toBe(1); expect(id2).toBe(2); expect(id3).toBe(3); - expect(index.aliveCount).toBe(3); - expect(index.dense).toEqual([1, 2, 3]); + expect(index._aliveCount).toBe(3); + expect(index._dense).toEqual([1, 2, 3]); }); it('should recycle removed entity IDs', () => { @@ -69,7 +69,7 @@ describe('createEntityIndex', () => { const recycledId = index.addEntity(); expect(recycledId).toBe(id1); - expect(index.aliveCount).toBe(2); + expect(index._aliveCount).toBe(2); }); it('should throw error when exceeding max entities', () => { @@ -96,7 +96,7 @@ describe('createEntityIndex', () => { const result = index.removeEntity(id); expect(result).toBe(true); - expect(index.aliveCount).toBe(0); + expect(index._aliveCount).toBe(0); expect(index.isEntityAlive(id)).toBe(false); }); @@ -106,7 +106,7 @@ describe('createEntityIndex', () => { const result = index.removeEntity(999); expect(result).toBe(false); - expect(index.aliveCount).toBe(0); + expect(index._aliveCount).toBe(0); }); it('should return false for already removed entity', () => { @@ -127,9 +127,9 @@ describe('createEntityIndex', () => { index.removeEntity(id2); // Remove middle entity - expect(index.aliveCount).toBe(2); - expect(index.dense[0]).toBe(id1); - expect(index.dense[1]).toBe(id3); // id3 moved to position 1 + expect(index._aliveCount).toBe(2); + expect(index._dense[0]).toBe(id1); + expect(index._dense[1]).toBe(id3); // id3 moved to position 1 expect(index._sparse[1]).toBe(0); // id1 at position 0 expect(index._sparse[3]).toBe(1); // id3 at position 1 }); @@ -267,30 +267,92 @@ describe('createEntityIndex', () => { }); }); - describe('_createVersionedId', () => { - it('should return base ID when versioning disabled', () => { + describe('formatEid', () => { + it('should format entity ID without version when versioning disabled', () => { const index = createEntityIndex({ versioning: false }); + const id1 = index.addEntity(); + const id2 = index.addEntity(); - expect(index._createVersionedEid(5, 3)).toBe(5); + expect(index.formatEid(id1)).toBe('1'); + expect(index.formatEid(id2)).toBe('2'); }); - it('should combine base ID and version when versioning enabled', () => { - const index = createEntityIndex({ versioning: true, versionBits: 8 }); - const baseId = 5; - const version = 3; + it('should format entity ID with version when versioning enabled', () => { + const index = createEntityIndex({ versioning: true }); + const id1 = index.addEntity(); + const id2 = index.addEntity(); - const versionedId = index._createVersionedEid(baseId, version); + expect(index.formatEid(id1)).toBe('1v0'); + expect(index.formatEid(id2)).toBe('2v0'); + }); - expect(index.getBaseEid(versionedId)).toBe(baseId); - expect(index.getEidVersion(versionedId)).toBe(version); + it('should format recycled entity with incremented version', () => { + const index = createEntityIndex({ versioning: true, versionBits: 4 }); + let currentId = index.addEntity(); + + // Cycle through multiple versions + for (let i = 0; i < 10; i++) { + index.removeEntity(currentId); + currentId = index.addEntity(); + expect(index.formatEid(currentId)).toBe(`1v${i + 1}`); + } + }); + + it('should format entity after version overflow', () => { + const index = createEntityIndex({ versioning: true, versionBits: 2 }); // Max version 3 + let currentId = index.addEntity(); + + // Cycle through versions 0, 1, 2, 3, then back to 0 + for (let i = 0; i < 4; i++) { + index.removeEntity(currentId); + currentId = index.addEntity(); + } + + expect(index.formatEid(currentId)).toBe('1v0'); // Wrapped around + }); + }); + + describe('debugState', () => { + it('should show empty state for new index', () => { + const index = createEntityIndex({ versioning: true }); + const state = index.debugState(); + + expect(state).toContain('Alive (0): []'); + expect(state).toContain('Dead (0): []'); + expect(state).toContain('Sparse: {}'); + expect(state).toContain('NextBaseEid: 1'); + expect(state).toContain('Versioning: enabled'); + }); + + it('should show alive entities', () => { + const index = createEntityIndex({ versioning: true }); + index.addEntity(); + index.addEntity(); + const state = index.debugState(); + + expect(state).toContain('Alive (2): [1v0, 2v0]'); + expect(state).toContain('Dead (0): []'); + expect(state).toContain('Sparse: {1→0, 2→1}'); + }); + + it('should show dead entities after removal', () => { + const index = createEntityIndex({ versioning: true }); + const id1 = index.addEntity(); + const id2 = index.addEntity(); + index.removeEntity(id1); + const state = index.debugState(); + + expect(state).toContain('Alive (1): [2v0]'); + expect(state).toContain('Dead (1): [1v1]'); + expect(state).toContain('Sparse: {2→0}'); }); }); - describe('_validate', () => { + describe('validate', () => { it('should return true for valid empty index', () => { const index = createEntityIndex(); - expect(index._validate()).toBe(true); + expect(index.validate()).toBe(true); }); it('should return true for valid index with entities', () => { @@ -299,7 +361,7 @@ describe('createEntityIndex', () => { index.addEntity(); index.addEntity(); - expect(index._validate()).toBe(true); + expect(index.validate()).toBe(true); }); it('should return true after remove operations', () => { @@ -310,7 +372,7 @@ describe('createEntityIndex', () => { index.removeEntity(id2); - expect(index._validate()).toBe(true); + expect(index.validate()).toBe(true); }); it('should return true after recycling', () => { @@ -320,7 +382,26 @@ describe('createEntityIndex', () => { index.removeEntity(id1); index.addEntity(); // Recycle - expect(index._validate()).toBe(true); + expect(index.validate()).toBe(true); + }); + }); + + describe('_createVersionedId', () => { + it('should return base ID when versioning disabled', () => { + const index = createEntityIndex({ versioning: false }); + + expect(index._createVersionedEid(5, 3)).toBe(5); + }); + + it('should combine base ID and version when versioning enabled', () => { + const index = createEntityIndex({ versioning: true, versionBits: 8 }); + const baseId = 5; + const version = 3; + + const versionedId = index._createVersionedEid(baseId, version); + + expect(index.getBaseEid(versionedId)).toBe(baseId); + expect(index.getEidVersion(versionedId)).toBe(version); }); }); }); diff --git a/packages/feature-ecs/src/create-entity-index.ts b/packages/feature-ecs/src/create-entity-index.ts index b977b448..9685fa74 100644 --- a/packages/feature-ecs/src/create-entity-index.ts +++ b/packages/feature-ecs/src/create-entity-index.ts @@ -47,10 +47,10 @@ export function createEntityIndex(options: TCreateEntityIndexOptions = {}): TEnt versioning }, - _sparse: [] as number[], // Maps base entity ID -> dense array index - _nextBaseEid: 1, // Next base entity ID to assign (start from 1) - dense: [] as number[], // Dense array of entity IDs for iteration - aliveCount: 0, // Number of currently alive entities + _sparse: [] as number[], + _nextBaseEid: 1, + _dense: [] as number[], + _aliveCount: 0, _versionBits: versionBits, _entityBits: entityBits, @@ -68,15 +68,43 @@ export function createEntityIndex(options: TCreateEntityIndexOptions = {}): TEnt : 0; }, + // Recycling Flow + // Before: sparse: [_, 0, _, 1] dense: [1, 3, 2v1] aliveCount: 2 + // └─alive─┘└dead┘ + // addEntity() - Recycling Path ↓ + // + // Step 1: Check: 2 < 3 ✓ (dead entities available) + // Step 2: Get recycled entity: dense[2] = 2v1, baseEid = 2 + // Step 3: Restore mapping: _sparse[2] = 2 + // Step 4: Expand alive section: aliveCount = 3 + // + // After: sparse: [_, 0, 2, 1] dense: [1, 3, 2v1] aliveCount: 3 + // └──alive───┘ + // Returns: 2v1 (recycled entity with incremented version) + // + // + // New Entity Flow + // Before: sparse: [_, 0, 2, 1] dense: [1, 3, 2v1] aliveCount: 3 + // └──alive───┘ nextBaseEid: 4 + // addEntity() - New Entity Path ↓ + // + // Step 1: Check: 3 < 3 ❌ (no dead entities) + // Step 2: Check: 4 <= maxBaseEid ✓ (within limits) + // Step 3: Create: baseEid = 4, eid = 4v0, _nextBaseEid = 5 + // Step 4: Add to arrays: push to dense, set sparse mapping + // + // After: sparse: [_, 0, 2, 1, 3] dense: [1, 3, 2v1, 4] aliveCount: 4 + // └───alive────┘ nextBaseEid: 5 + // Returns: 4 (new entity with version 0) addEntity(): number { // Try to recycle a removed entity first - if (this.aliveCount < this.dense.length) { - const recycledEid = this.dense[this.aliveCount] as number; + if (this._aliveCount < this._dense.length) { + const recycledEid = this._dense[this._aliveCount] as number; const baseEid = this.getBaseEid(recycledEid); // Restore the sparse mapping and increment alive count - this._sparse[baseEid] = this.aliveCount; - this.aliveCount++; + this._sparse[baseEid] = this._aliveCount; + this._aliveCount++; return recycledEid; } @@ -90,30 +118,45 @@ export function createEntityIndex(options: TCreateEntityIndexOptions = {}): TEnt const eid = this._createVersionedEid(baseEid, 0); // Add to both dense and sparse arrays - this.dense.push(eid); - this._sparse[baseEid] = this.aliveCount; - this.aliveCount++; + this._dense.push(eid); + this._sparse[baseEid] = this._aliveCount; + this._aliveCount++; return eid; }, + // Initial: sparse: [_, 0, 1, 2] dense: [1, 2, 3] aliveCount: 3 + // Remove entity 2 ↓ + // + // Step 1: Find entity 2 at index 1 + // Step 2: Swap entity 3 to index 1 + // sparse: [_, 0, 1, 1] dense: [1, 3, 3] aliveCount: 3 + // + // Step 3: Create recycled entity 2v1 + // Step 4: Place in dead section, clean up sparse + // sparse: [_, 0, _, 1] dense: [1, 3, 2v1] aliveCount: 2 + // └─alive─┘└dead┘ + // + // Later: Recycle entity 2v1 + // sparse: [_, 0, 2, 1] dense: [1, 3, 2v1] aliveCount: 3 + // └──alive───┘ removeEntity(eid: number): boolean { const baseEid = this.getBaseEid(eid); const denseIndex = this._sparse[baseEid]; // Check if entity exists and is alive - if (denseIndex == null || denseIndex >= this.aliveCount || this.dense[denseIndex] !== eid) { + if (denseIndex == null || denseIndex >= this._aliveCount || this._dense[denseIndex] !== eid) { return false; } - const lastIndex = this.aliveCount - 1; + const lastIndex = this._aliveCount - 1; // Swap-and-pop: move the last alive entity to fill the gap if (denseIndex !== lastIndex) { - const lastEid = this.dense[lastIndex] as number; + const lastEid = this._dense[lastIndex] as number; const lastBaseEid = this.getBaseEid(lastEid); - this.dense[denseIndex] = lastEid; + this._dense[denseIndex] = lastEid; this._sparse[lastBaseEid] = denseIndex; } @@ -125,9 +168,9 @@ export function createEntityIndex(options: TCreateEntityIndexOptions = {}): TEnt const recycledEid = this._createVersionedEid(baseEid, newVersion); // Place recycled entity in the "dead" section and clean up - this.dense[lastIndex] = recycledEid; + this._dense[lastIndex] = recycledEid; delete this._sparse[baseEid]; - this.aliveCount--; + this._aliveCount--; return true; }, @@ -135,34 +178,64 @@ export function createEntityIndex(options: TCreateEntityIndexOptions = {}): TEnt isEntityAlive(eid: number): boolean { const baseEid = this.getBaseEid(eid); const denseIndex = this._sparse[baseEid]; - return denseIndex != null && denseIndex < this.aliveCount && this.dense[denseIndex] === eid; + return denseIndex != null && denseIndex < this._aliveCount && this._dense[denseIndex] === eid; }, getAliveEntities(): number[] { - return this.dense.slice(0, this.aliveCount); + return this._dense.slice(0, this._aliveCount); }, - _validate(): boolean { - // Check that all alive entities have correct sparse mappings - for (let i = 0; i < this.aliveCount; i++) { - const eid = this.dense[i] as number; + formatEid(eid: number): string { + const baseEid = this.getBaseEid(eid); + const version = this.getEidVersion(eid); + return this._config.versioning ? `${baseEid}v${version}` : `${baseEid}`; + }, + + debugState(): string { + const aliveEntities = this._dense + .slice(0, this._aliveCount) + .map((eid) => this.formatEid(eid)); + const deadEntities = this._dense.slice(this._aliveCount).map((eid) => this.formatEid(eid)); + + const sparseEntries = []; + for (let baseEid = 1; baseEid < this._nextBaseEid; baseEid++) { + const denseIndex = this._sparse[baseEid]; + if (denseIndex != null) { + sparseEntries.push(`${baseEid}→${denseIndex}`); + } + } + + return [ + `EntityIndex State:`, + ` Alive (${this._aliveCount}): [${aliveEntities.join(', ')}]`, + ` Dead (${this._dense.length - this._aliveCount}): [${deadEntities.join(', ')}]`, + ` Sparse: {${sparseEntries.join(', ')}}`, + ` NextBaseEid: ${this._nextBaseEid}`, + ` Versioning: ${this._config.versioning ? 'enabled' : 'disabled'}` + ].join('\n'); + }, + + validate(): boolean { + // Check that all alive entities have correct sparse mappings (Dense -> Sparse) + for (let i = 0; i < this._aliveCount; i++) { + const eid = this._dense[i] as number; const baseEid = this.getBaseEid(eid); if (this._sparse[baseEid] !== i) { return false; } } - // Check that all entities in sparse array point to valid positions + // Check that all entities in sparse array point to valid positions (Sparse -> Dense) for (let baseEid = 1; baseEid < this._nextBaseEid; baseEid++) { const denseIndex = this._sparse[baseEid]; if (denseIndex != null) { // Check bounds - if (denseIndex >= this.dense.length || denseIndex < 0) { + if (denseIndex >= this._dense.length || denseIndex < 0) { return false; } // Check that the entity at this position has the correct base ID - const storedEid = this.dense[denseIndex] as number; + const storedEid = this._dense[denseIndex] as number; if (this.getBaseEid(storedEid) !== baseEid) { return false; } @@ -202,9 +275,9 @@ export interface TEntityIndex { /** The next base entity ID to be assigned */ _nextBaseEid: number; /** Dense array of entity IDs for efficient iteration */ - dense: number[]; + _dense: number[]; /** Number of currently alive entities */ - aliveCount: number; + _aliveCount: number; /** Number of bits reserved for version information */ _versionBits: number; @@ -258,12 +331,26 @@ export interface TEntityIndex { */ getAliveEntities(): number[]; + /** + * Formats an entity ID as a human-readable string. + * @param eid - The entity ID to format + * @returns Formatted string like "1v0", "2v3", or just "1" if versioning disabled + */ + formatEid(eid: number): string; + + /** + * Returns a human-readable debug representation of the entity index state. + * Shows alive entities, dead entities, sparse mappings, and configuration. + * @returns Multi-line string with formatted state information + */ + debugState(): string; + /** * Validates the internal data structure integrity. * Useful for debugging and testing. * @returns True if the data structure is valid, false otherwise */ - _validate(): boolean; + validate(): boolean; /** * Creates a versioned entity ID by combining base ID and version. From fceb09d2fc630b32c6d2fa47338da39ec56f223b Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Tue, 27 May 2025 06:49:02 +0200 Subject: [PATCH 05/39] #105 added reset function --- ...ity-index.test.ts => entity-index.test.ts} | 38 ++++++++++++- ...create-entity-index.ts => entity-index.ts} | 56 +++++++++++-------- packages/feature-ecs/src/index.ts | 3 +- packages/feature-ecs/src/world.ts | 23 ++++++++ 4 files changed, 94 insertions(+), 26 deletions(-) rename packages/feature-ecs/src/{create-entity-index.test.ts => entity-index.test.ts} (92%) rename packages/feature-ecs/src/{create-entity-index.ts => entity-index.ts} (93%) create mode 100644 packages/feature-ecs/src/world.ts diff --git a/packages/feature-ecs/src/create-entity-index.test.ts b/packages/feature-ecs/src/entity-index.test.ts similarity index 92% rename from packages/feature-ecs/src/create-entity-index.test.ts rename to packages/feature-ecs/src/entity-index.test.ts index b5f852f9..e42c271c 100644 --- a/packages/feature-ecs/src/create-entity-index.test.ts +++ b/packages/feature-ecs/src/entity-index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createEntityIndex } from './create-entity-index'; +import { createEntityIndex } from './entity-index'; describe('createEntityIndex', () => { describe('initialization', () => { @@ -348,6 +348,31 @@ describe('createEntityIndex', () => { }); }); + describe('reset', () => { + it('should reset to initial state and allow reuse', () => { + const index = createEntityIndex({ versioning: true }); + + // Create complex state: entities, removal, recycling + const id1 = index.addEntity(); + const id2 = index.addEntity(); + index.removeEntity(id1); + const recycled = index.addEntity(); + + index.reset(); + + // Verify clean state and reusable + expect(index._aliveCount).toBe(0); + expect(index._dense).toEqual([]); + expect(index._sparse).toEqual([]); + expect(index._nextBaseEid).toBe(1); + expect(index.validate()).toBe(true); + + const newId = index.addEntity(); + expect(newId).toBe(1); + expect(index.isEntityAlive(newId)).toBe(true); + }); + }); + describe('validate', () => { it('should return true for valid empty index', () => { const index = createEntityIndex(); @@ -384,6 +409,17 @@ describe('createEntityIndex', () => { expect(index.validate()).toBe(true); }); + + it('should return true after reset', () => { + const index = createEntityIndex(); + index.addEntity(); + index.addEntity(); + index.removeEntity(index.addEntity()); + + index.reset(); + + expect(index.validate()).toBe(true); + }); }); describe('_createVersionedId', () => { diff --git a/packages/feature-ecs/src/create-entity-index.ts b/packages/feature-ecs/src/entity-index.ts similarity index 93% rename from packages/feature-ecs/src/create-entity-index.ts rename to packages/feature-ecs/src/entity-index.ts index 9685fa74..f98e3ff7 100644 --- a/packages/feature-ecs/src/create-entity-index.ts +++ b/packages/feature-ecs/src/entity-index.ts @@ -58,11 +58,11 @@ export function createEntityIndex(options: TCreateEntityIndexOptions = {}): TEnt _entityMask: entityMask, _versionMask: versionMask, - getBaseEid(eid: number): number { + getBaseEid(eid) { return eid & this._entityMask; }, - getEidVersion(eid: number): number { + getEidVersion(eid) { return this._config.versioning ? (eid >>> this._entityBits) & ((1 << this._versionBits) - 1) : 0; @@ -96,7 +96,7 @@ export function createEntityIndex(options: TCreateEntityIndexOptions = {}): TEnt // After: sparse: [_, 0, 2, 1, 3] dense: [1, 3, 2v1, 4] aliveCount: 4 // └───alive────┘ nextBaseEid: 5 // Returns: 4 (new entity with version 0) - addEntity(): number { + addEntity() { // Try to recycle a removed entity first if (this._aliveCount < this._dense.length) { const recycledEid = this._dense[this._aliveCount] as number; @@ -140,7 +140,7 @@ export function createEntityIndex(options: TCreateEntityIndexOptions = {}): TEnt // Later: Recycle entity 2v1 // sparse: [_, 0, 2, 1] dense: [1, 3, 2v1] aliveCount: 3 // └──alive───┘ - removeEntity(eid: number): boolean { + removeEntity(eid) { const baseEid = this.getBaseEid(eid); const denseIndex = this._sparse[baseEid]; @@ -175,23 +175,23 @@ export function createEntityIndex(options: TCreateEntityIndexOptions = {}): TEnt return true; }, - isEntityAlive(eid: number): boolean { + isEntityAlive(eid) { const baseEid = this.getBaseEid(eid); const denseIndex = this._sparse[baseEid]; return denseIndex != null && denseIndex < this._aliveCount && this._dense[denseIndex] === eid; }, - getAliveEntities(): number[] { + getAliveEntities() { return this._dense.slice(0, this._aliveCount); }, - formatEid(eid: number): string { + formatEid(eid) { const baseEid = this.getBaseEid(eid); const version = this.getEidVersion(eid); return this._config.versioning ? `${baseEid}v${version}` : `${baseEid}`; }, - debugState(): string { + debugState() { const aliveEntities = this._dense .slice(0, this._aliveCount) .map((eid) => this.formatEid(eid)); @@ -215,7 +215,14 @@ export function createEntityIndex(options: TCreateEntityIndexOptions = {}): TEnt ].join('\n'); }, - validate(): boolean { + reset() { + this._sparse.length = 0; + this._dense.length = 0; + this._aliveCount = 0; + this._nextBaseEid = 1; + }, + + validate() { // Check that all alive entities have correct sparse mappings (Dense -> Sparse) for (let i = 0; i < this._aliveCount; i++) { const eid = this._dense[i] as number; @@ -245,25 +252,19 @@ export function createEntityIndex(options: TCreateEntityIndexOptions = {}): TEnt return true; }, - _createVersionedEid(baseEid: number, version: number): number { + _createVersionedEid(baseEid, version) { return this._config.versioning ? baseEid | (version << this._entityBits) : baseEid; } }; } -/** - * Configuration options for creating an entity index. - */ -interface TCreateEntityIndexOptions { +export interface TCreateEntityIndexOptions { /** Enable versioning to prevent stale entity references. Default: false */ versioning?: boolean; /** Number of bits reserved for version information. Default: 8 (allows 256 versions) */ versionBits?: number; } -/** - * Entity index interface providing efficient entity ID management. - */ export interface TEntityIndex { _config: { /** Whether versioning is enabled */ @@ -294,7 +295,7 @@ export interface TEntityIndex { * Creates a new entity ID or recycles a previously removed one. * @returns A unique entity ID (potentially versioned) */ - addEntity(): number; + addEntity(): TEntityId; /** * Removes an entity from the index, making its ID available for recycling. @@ -302,41 +303,41 @@ export interface TEntityIndex { * @param eid - The entity ID to remove * @returns True if the entity was removed, false if it wasn't alive */ - removeEntity(eid: number): boolean; + removeEntity(eid: TEntityId): boolean; /** * Checks if an entity ID is currently alive. * @param eid - The entity ID to check * @returns True if the entity is alive, false otherwise */ - isEntityAlive(eid: number): boolean; + isEntityAlive(eid: TEntityId): boolean; /** * Extracts the base entity ID without version information. * @param eid - The potentially versioned entity ID * @returns The base entity ID (without version bits) */ - getBaseEid(eid: number): number; + getBaseEid(eid: TEntityId): number; /** * Extracts the version from an entity ID. * @param eid - The entity ID * @returns The version number (0 if versioning is disabled) */ - getEidVersion(eid: number): number; + getEidVersion(eid: TEntityId): number; /** * Gets all alive entity IDs for iteration. * @returns Array of alive entity IDs */ - getAliveEntities(): number[]; + getAliveEntities(): TEntityId[]; /** * Formats an entity ID as a human-readable string. * @param eid - The entity ID to format * @returns Formatted string like "1v0", "2v3", or just "1" if versioning disabled */ - formatEid(eid: number): string; + formatEid(eid: TEntityId): string; /** * Returns a human-readable debug representation of the entity index state. @@ -345,6 +346,11 @@ export interface TEntityIndex { */ debugState(): string; + /** + * Resets the entity index to its initial empty state. + */ + reset(): void; + /** * Validates the internal data structure integrity. * Useful for debugging and testing. @@ -360,3 +366,5 @@ export interface TEntityIndex { */ _createVersionedEid(baseEid: number, version: number): number; } + +export type TEntityId = number; diff --git a/packages/feature-ecs/src/index.ts b/packages/feature-ecs/src/index.ts index e9fe0090..f2e35539 100644 --- a/packages/feature-ecs/src/index.ts +++ b/packages/feature-ecs/src/index.ts @@ -1 +1,2 @@ -console.log('Hello, world!'); +export * from './entity-index'; +export * from './world'; diff --git a/packages/feature-ecs/src/world.ts b/packages/feature-ecs/src/world.ts new file mode 100644 index 00000000..dc5e3aed --- /dev/null +++ b/packages/feature-ecs/src/world.ts @@ -0,0 +1,23 @@ +import { createEntityIndex, TEntityId, TEntityIndex } from './entity-index'; + +export function createWorld(): TWorld { + return { + entityIndex: createEntityIndex(), + + addEntity() { + return this.entityIndex.addEntity(); + }, + + reset() { + this.entityIndex.reset(); + } + }; +} + +export interface TWorld { + entityIndex: TEntityIndex; + + addEntity(): TEntityId; + + reset(): void; +} From a436830e12752bf688c770c4375a485495bb94cb Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Tue, 27 May 2025 08:02:59 +0200 Subject: [PATCH 06/39] #105 wip component registry --- .../src/component-registry.test.ts | 531 ++++++++++++++++ .../feature-ecs/src/component-registry.ts | 567 ++++++++++++++++++ packages/feature-ecs/src/index.ts | 2 + packages/feature-ecs/src/query.ts | 3 + packages/feature-ecs/src/world.ts | 30 +- 5 files changed, 1129 insertions(+), 4 deletions(-) create mode 100644 packages/feature-ecs/src/component-registry.test.ts create mode 100644 packages/feature-ecs/src/component-registry.ts create mode 100644 packages/feature-ecs/src/query.ts diff --git a/packages/feature-ecs/src/component-registry.test.ts b/packages/feature-ecs/src/component-registry.test.ts new file mode 100644 index 00000000..158ed351 --- /dev/null +++ b/packages/feature-ecs/src/component-registry.test.ts @@ -0,0 +1,531 @@ +import { beforeEach, describe, expect, test } from 'vitest'; +import { createComponentRegistry, TComponentRegistry } from './component-registry'; +import { createEntityIndex, TEntityIndex } from './entity-index'; + +describe('createComponentRegistry', () => { + let registry: TComponentRegistry; + let entityIndex: TEntityIndex; + + beforeEach(() => { + registry = createComponentRegistry(); + entityIndex = createEntityIndex(); + }); + + test('component registration and metadata', () => { + const Position: TPosition = { x: [], y: [] }; + const Transform: TTransform = []; + const Health: THealth = []; + const Player: TPlayer = {}; + + const posData = registry.registerComponent(Position); + const transformData = registry.registerComponent(Transform); + const healthData = registry.registerComponent(Health); + const playerData = registry.registerComponent(Player); + + expect(posData.id).toBe(0); + expect(posData.generationId).toBe(0); + expect(posData.bitflag).toBe(1); + expect(posData.ref).toBe(Position); + + expect(transformData.id).toBe(1); + expect(transformData.generationId).toBe(0); + expect(transformData.bitflag).toBe(2); + expect(transformData.ref).toBe(Transform); + + expect(healthData.id).toBe(2); + expect(healthData.generationId).toBe(0); + expect(healthData.bitflag).toBe(4); + expect(healthData.ref).toBe(Health); + + expect(playerData.id).toBe(3); + expect(playerData.generationId).toBe(0); + expect(playerData.bitflag).toBe(8); + expect(playerData.ref).toBe(Player); + + // Re-registering should return same data + const posData2 = registry.registerComponent(Position); + expect(posData2).toBe(posData); + }); + + test('object with array properties pattern', () => { + const Position: TPosition = { x: [], y: [] }; + const Velocity: TVelocity = { dx: [], dy: [] }; + + registry.registerComponent(Position); + registry.registerComponent(Velocity); + + const eid1 = entityIndex.addEntity(); + const eid2 = entityIndex.addEntity(); + + // Add components + registry.addComponent(eid1, Position); + registry.addComponent(eid1, Velocity); + registry.addComponent(eid2, Position); + + // Set data on separate arrays for each property + Position.x[eid1] = 10; + Position.y[eid1] = 20; + Velocity.dx[eid1] = 1; + Velocity.dy[eid1] = 2; + Position.x[eid2] = 30; + Position.y[eid2] = 40; + + // Check components + expect(registry.hasComponent(eid1, Position)).toBe(true); + expect(registry.hasComponent(eid1, Velocity)).toBe(true); + expect(registry.hasComponent(eid2, Position)).toBe(true); + expect(registry.hasComponent(eid2, Velocity)).toBe(false); + + // Verify data + expect(Position.x[eid1]).toBe(10); + expect(Position.y[eid1]).toBe(20); + expect(Velocity.dx[eid1]).toBe(1); + expect(Velocity.dy[eid1]).toBe(2); + expect(Position.x[eid2]).toBe(30); + expect(Position.y[eid2]).toBe(40); + }); + + test('array of objects pattern', () => { + const Transform: TTransform = []; + const RenderInfo: TRenderInfo = []; + + registry.registerComponent(Transform); + registry.registerComponent(RenderInfo); + + const eid1 = entityIndex.addEntity(); + const eid2 = entityIndex.addEntity(); + + // Add components + registry.addComponent(eid1, Transform); + registry.addComponent(eid1, RenderInfo); + registry.addComponent(eid2, Transform); + + // Set data as complete objects + Transform[eid1] = { x: 5, y: 15, rotation: 45 }; + RenderInfo[eid1] = { sprite: 'player.png', layer: 1, visible: true }; + Transform[eid2] = { x: 100, y: 200, rotation: 0 }; + + // Check components + expect(registry.hasComponent(eid1, Transform)).toBe(true); + expect(registry.hasComponent(eid1, RenderInfo)).toBe(true); + expect(registry.hasComponent(eid2, Transform)).toBe(true); + expect(registry.hasComponent(eid2, RenderInfo)).toBe(false); + + // Verify data + expect(Transform[eid1]).toEqual({ x: 5, y: 15, rotation: 45 }); + expect(RenderInfo[eid1]).toEqual({ sprite: 'player.png', layer: 1, visible: true }); + expect(Transform[eid2]).toEqual({ x: 100, y: 200, rotation: 0 }); + }); + + test('single value array pattern', () => { + const Health: THealth = []; + const Mana: TMana = []; + const Level: TLevel = []; + + registry.registerComponent(Health); + registry.registerComponent(Mana); + registry.registerComponent(Level); + + const eid1 = entityIndex.addEntity(); + const eid2 = entityIndex.addEntity(); + + // Add components + registry.addComponent(eid1, Health); + registry.addComponent(eid1, Mana); + registry.addComponent(eid1, Level); + registry.addComponent(eid2, Health); + + // Set single values + Health[eid1] = 100; + Mana[eid1] = 50; + Level[eid1] = 5; + Health[eid2] = 80; + + // Check components + expect(registry.hasComponent(eid1, Health)).toBe(true); + expect(registry.hasComponent(eid1, Mana)).toBe(true); + expect(registry.hasComponent(eid1, Level)).toBe(true); + expect(registry.hasComponent(eid2, Health)).toBe(true); + expect(registry.hasComponent(eid2, Mana)).toBe(false); + + // Verify data + expect(Health[eid1]).toBe(100); + expect(Mana[eid1]).toBe(50); + expect(Level[eid1]).toBe(5); + expect(Health[eid2]).toBe(80); + }); + + test('tag component pattern', () => { + const Player: TPlayer = {}; + const Enemy: TEnemy = {}; + const Frozen: TFrozen = {}; + + registry.registerComponent(Player); + registry.registerComponent(Enemy); + registry.registerComponent(Frozen); + + const eid1 = entityIndex.addEntity(); + const eid2 = entityIndex.addEntity(); + const eid3 = entityIndex.addEntity(); + + // Add tag components (no data, just flags) + registry.addComponent(eid1, Player); + registry.addComponent(eid1, Frozen); + registry.addComponent(eid2, Enemy); + registry.addComponent(eid3, Player); + + // Check components + expect(registry.hasComponent(eid1, Player)).toBe(true); + expect(registry.hasComponent(eid1, Enemy)).toBe(false); + expect(registry.hasComponent(eid1, Frozen)).toBe(true); + expect(registry.hasComponent(eid2, Player)).toBe(false); + expect(registry.hasComponent(eid2, Enemy)).toBe(true); + expect(registry.hasComponent(eid3, Player)).toBe(true); + expect(registry.hasComponent(eid3, Frozen)).toBe(false); + }); + + test('unlimited components with generation system', () => { + // Register 35 components to test generation overflow + const components = []; + for (let i = 0; i < 35; i++) { + const component = {}; + components.push(component); + const data = registry.registerComponent(component); + + if (i < 31) { + // First generation (0-30) + expect(data.generationId).toBe(0); + expect(data.bitflag).toBe(2 ** i); + } else { + // Second generation (31-34) + expect(data.generationId).toBe(1); + expect(data.bitflag).toBe(2 ** (i - 31)); + } + } + + const eid = entityIndex.addEntity(); + + // Add components from both generations + registry.addComponent(eid, components[0]!); // Gen 0, bitflag 1 + registry.addComponent(eid, components[30]!); // Gen 0, bitflag 2^30 + registry.addComponent(eid, components[31]!); // Gen 1, bitflag 1 + registry.addComponent(eid, components[34]!); // Gen 1, bitflag 8 + + // Verify components exist + expect(registry.hasComponent(eid, components[0]!)).toBe(true); + expect(registry.hasComponent(eid, components[30]!)).toBe(true); + expect(registry.hasComponent(eid, components[31]!)).toBe(true); + expect(registry.hasComponent(eid, components[34]!)).toBe(true); + + // Check entity masks across generations + const masks = registry.getEntityComponentMask(eid); + expect(masks).toHaveLength(2); + expect(masks[0]).toBe(1 + 2 ** 30); // Gen 0: component 0 + component 30 + expect(masks[1]).toBe(1 + 8); // Gen 1: component 31 + component 34 + }); + + test('mixed component patterns in queries across generations', () => { + const Position: TPosition = { x: [], y: [] }; + const Transform: TTransform = []; + const Health: THealth = []; + const Player: TPlayer = {}; + + // Register many components to force generation overflow + const extraComponents = []; + for (let i = 0; i < 30; i++) { + const component = {}; + extraComponents.push(component); + registry.registerComponent(component); + } + + // These will be in generation 1 + registry.registerComponent(Position); + registry.registerComponent(Transform); + registry.registerComponent(Health); + registry.registerComponent(Player); + + const eid1 = entityIndex.addEntity(); + const eid2 = entityIndex.addEntity(); + const eid3 = entityIndex.addEntity(); + + // eid1: Position + Health + Player (all in generation 1) + registry.addComponent(eid1, Position); + registry.addComponent(eid1, Health); + registry.addComponent(eid1, Player); + Position.x[eid1] = 10; + Position.y[eid1] = 20; + Health[eid1] = 100; + + // eid2: Transform + Health + some gen 0 components + registry.addComponent(eid2, Transform); + registry.addComponent(eid2, Health); + registry.addComponent(eid2, extraComponents[0]!); // Gen 0 + registry.addComponent(eid2, extraComponents[1]!); // Gen 0 + Transform[eid2] = { x: 5, y: 15, rotation: 0 }; + Health[eid2] = 80; + + // eid3: Position + Transform + Player + gen 0 components + registry.addComponent(eid3, Position); + registry.addComponent(eid3, Transform); + registry.addComponent(eid3, Player); + registry.addComponent(eid3, extraComponents[5]!); // Gen 0 + Position.x[eid3] = 50; + Position.y[eid3] = 60; + Transform[eid3] = { x: 25, y: 35, rotation: 90 }; + + // Query tests across generations + const playersWithHealth = registry.getEntitiesWithComponents([Player, Health]); + expect(playersWithHealth).toHaveLength(1); + expect(playersWithHealth).toContain(eid1); + + const entitiesWithTransform = registry.getEntitiesWithComponent(Transform); + expect(entitiesWithTransform).toHaveLength(2); + expect(entitiesWithTransform).toContain(eid2); + expect(entitiesWithTransform).toContain(eid3); + + const playersWithPosition = registry.getEntitiesWithComponents([Player, Position]); + expect(playersWithPosition).toHaveLength(2); + expect(playersWithPosition).toContain(eid1); + expect(playersWithPosition).toContain(eid3); + + // Cross-generation query + const crossGenQuery = registry.getEntitiesWithComponents([extraComponents[0]!, Health]); + expect(crossGenQuery).toHaveLength(1); + expect(crossGenQuery).toContain(eid2); + }); + + test('component removal across all patterns and generations', () => { + const Position: TPosition = { x: [], y: [] }; + const Transform: TTransform = []; + const Health: THealth = []; + const Player: TPlayer = {}; + + // Add some gen 0 components first + const gen0Components = []; + for (let i = 0; i < 31; i++) { + const component = {}; + gen0Components.push(component); + registry.registerComponent(component); + } + + // These will be in generation 1 + registry.registerComponent(Position); + registry.registerComponent(Transform); + registry.registerComponent(Health); + registry.registerComponent(Player); + + const eid = entityIndex.addEntity(); + + // Add components from both generations + registry.addComponent(eid, gen0Components[0]!); + registry.addComponent(eid, Position); + registry.addComponent(eid, Transform); + registry.addComponent(eid, Health); + registry.addComponent(eid, Player); + + // Set data + Position.x[eid] = 10; + Position.y[eid] = 20; + Transform[eid] = { x: 5, y: 15, rotation: 45 }; + Health[eid] = 100; + + // Verify all components exist + expect(registry.hasComponent(eid, gen0Components[0]!)).toBe(true); + expect(registry.hasComponent(eid, Position)).toBe(true); + expect(registry.hasComponent(eid, Transform)).toBe(true); + expect(registry.hasComponent(eid, Health)).toBe(true); + expect(registry.hasComponent(eid, Player)).toBe(true); + + // Remove components from different generations + registry.removeComponent(eid, Position); + expect(registry.hasComponent(eid, Position)).toBe(false); + expect(Position.x[eid]).toBeUndefined(); + expect(Position.y[eid]).toBeUndefined(); + + registry.removeComponent(eid, gen0Components[0]!); + expect(registry.hasComponent(eid, gen0Components[0]!)).toBe(false); + + // Other components should still exist + expect(registry.hasComponent(eid, Transform)).toBe(true); + expect(registry.hasComponent(eid, Health)).toBe(true); + expect(registry.hasComponent(eid, Player)).toBe(true); + + // Remove all remaining components + registry.removeAllComponents(eid); + expect(registry.hasComponent(eid, Transform)).toBe(false); + expect(registry.hasComponent(eid, Health)).toBe(false); + expect(registry.hasComponent(eid, Player)).toBe(false); + }); + + test('component masks with generation system', () => { + const Position: TPosition = { x: [], y: [] }; + const Transform: TTransform = []; + + // Add 31 components to fill first generation + const gen0Components = []; + for (let i = 0; i < 31; i++) { + const component = {}; + gen0Components.push(component); + registry.registerComponent(component); + } + + // These will be in generation 1 + registry.registerComponent(Position); // Gen 1, bitflag: 1 + registry.registerComponent(Transform); // Gen 1, bitflag: 2 + + const eid = entityIndex.addEntity(); + + let masks = registry.getEntityComponentMask(eid); + expect(masks).toEqual([0, 0]); // No components + + registry.addComponent(eid, gen0Components[0]!); // Gen 0, bitflag 1 + masks = registry.getEntityComponentMask(eid); + expect(masks).toEqual([1, 0]); + + registry.addComponent(eid, Position); // Gen 1, bitflag 1 + masks = registry.getEntityComponentMask(eid); + expect(masks).toEqual([1, 1]); + + registry.addComponent(eid, gen0Components[30]!); // Gen 0, bitflag 2^30 + masks = registry.getEntityComponentMask(eid); + expect(masks).toEqual([1 + 2 ** 30, 1]); + + registry.addComponent(eid, Transform); // Gen 1, bitflag 2 + masks = registry.getEntityComponentMask(eid); + expect(masks).toEqual([1 + 2 ** 30, 1 + 2]); + }); + + test('debug state with generations', () => { + const Position: TPosition = { x: [], y: [] }; + + // Add components to create multiple generations + const gen0Components = []; + for (let i = 0; i < 31; i++) { + const component = {}; + gen0Components.push(component); + registry.registerComponent(component); + } + + registry.registerComponent(Position); + + const eid1 = entityIndex.addEntity(); + const eid2 = entityIndex.addEntity(); + + registry.addComponent(eid1, gen0Components[0]!); + registry.addComponent(eid1, Position); + registry.addComponent(eid2, gen0Components[1]!); + + const debugState = registry.debugState(); + expect(debugState).toContain('ComponentRegistry State:'); + expect(debugState).toContain('Components (32):'); + expect(debugState).toContain('Generations: 2'); + expect(debugState).toContain('Gen0:'); + expect(debugState).toContain('Gen1:'); + }); + + test('performance with generation system', () => { + // Register components across multiple generations + const components = []; + for (let i = 0; i < 65; i++) { + // 2+ generations + const component = {}; + components.push(component); + registry.registerComponent(component); + } + + // Create many entities with mixed generation components + const entities = []; + for (let i = 0; i < 1000; i++) { + const eid = entityIndex.addEntity(); + entities.push(eid); + + // Add components from different generations + registry.addComponent(eid, components[i % 31]!); // Gen 0 + registry.addComponent(eid, components[31 + (i % 31)]!); // Gen 1 + if (i % 3 === 0) { + registry.addComponent(eid, components[62]!); // Gen 2 + } + } + + // Fast component checks across generations + const start = performance.now(); + for (let i = 0; i < 10000; i++) { + const eid = entities[i % entities.length]!; + registry.hasComponent(eid, components[0]!); + registry.hasComponent(eid, components[31]!); + registry.hasComponent(eid, components[62]!); + } + const end = performance.now(); + + // Should be very fast even with generations + expect(end - start).toBeLessThan(30); + + // Cross-generation query performance + const start2 = performance.now(); + const crossGenEntities = registry.getEntitiesWithComponents([ + components[0]!, // Gen 0 + components[31]!, // Gen 1 + components[62]! // Gen 2 + ]); + const end2 = performance.now(); + + expect(end2 - start2).toBeLessThan(20); + expect(crossGenEntities.length).toBeGreaterThan(0); + }); + + test('reset functionality with generations', () => { + const Position: TPosition = { x: [], y: [] }; + + // Create multiple generations + const gen0Components = []; + for (let i = 0; i < 31; i++) { + const component = {}; + gen0Components.push(component); + registry.registerComponent(component); + } + + registry.registerComponent(Position); + + const eid = entityIndex.addEntity(); + + registry.addComponent(eid, gen0Components[0]!); + registry.addComponent(eid, Position); + Position.x[eid] = 10; + + // Verify data exists + expect(Position.x[eid]).toBe(10); + expect(registry.hasComponent(eid, Position)).toBe(true); + + // Reset registry + registry.reset(); + + // All arrays should be cleared + expect(Position.x.length).toBe(0); + + // Registry should be empty with one generation + expect(registry.getAllComponents()).toHaveLength(0); + expect(registry.hasComponent(eid, Position)).toBe(false); + + // Should start with one generation again + const masks = registry.getEntityComponentMask(eid); + expect(masks).toEqual([0]); + }); +}); + +// 1. Object with Array Properties (Performance Optimized) +type TPosition = { x: number[]; y: number[] }; +type TVelocity = { dx: number[]; dy: number[] }; + +// 2. Array of Objects (Simple but Less Performant) +type TTransform = { x: number; y: number; rotation: number }[]; +type TRenderInfo = { sprite: string; layer: number; visible: boolean }[]; + +// 3. Single Value Array +type THealth = number[]; +type TMana = number[]; +type TLevel = number[]; + +// 4. Tag Components (Markers) +type TPlayer = {}; +type TEnemy = {}; +type TFrozen = {}; diff --git a/packages/feature-ecs/src/component-registry.ts b/packages/feature-ecs/src/component-registry.ts new file mode 100644 index 00000000..ffe80477 --- /dev/null +++ b/packages/feature-ecs/src/component-registry.ts @@ -0,0 +1,567 @@ +/** + * Component Registry for ECS (Entity Component System) + * + * Provides efficient component management with direct array access for maximum performance. + * Uses sparse arrays for component tracking and bitflags for fast component checks. + * + * Key features: + * - O(1) component checks using bitflags + * - Direct array access for component data + * - Memory-efficient sparse array storage + * - Cache-friendly iteration patterns + * - Unlimited components via generation system + * - Flexible component structure - supports multiple patterns + * + * ## Supported Component Patterns + * + * ### 1. Object with Array Properties (Performance Optimized) + * Best for components with multiple properties. Each property is stored in a separate array + * for maximum cache efficiency and performance. + * ```typescript + * type TPosition = { x: number[]; y: number[] }; + * const Position: TPosition = { x: [], y: [] }; + * + * // Usage: Position.x[eid] = 10; Position.y[eid] = 20; + * ``` + * + * ### 2. Array of Objects (Simple but Less Performant) + * Easier to understand but less cache-friendly. Good for prototyping or when performance + * isn't critical. + * ```typescript + * type TTransform = { x: number; y: number; rotation: number }[]; + * const Transform: TTransform = []; + * + * // Usage: Transform[eid] = { x: 10, y: 20, rotation: 0 }; + * ``` + * + * ### 3. Single Value Array + * For components that store a single value per entity. + * ```typescript + * type THealth = number[]; + * const Health: THealth = []; + * + * // Usage: Health[eid] = 100; + * ``` + * + * ### 4. Tag Components (Markers) + * For components that just mark entities as having a certain property. + * No data storage needed. + * ```typescript + * type TPlayer = {}; + * const Player: TPlayer = {}; + * + * // Usage: registry.addComponent(eid, Player); // Just marks entity as player + * ``` + */ + +import { TEntityId } from './entity-index'; + +/** + * Creates a new component registry. + * + * @returns A new component registry instance + * + * @example + * ```typescript + * const registry = createComponentRegistry(); + * + * // Define components using any supported pattern + * const Position: { x: number[]; y: number[] } = { x: [], y: [] }; // Object with arrays + * const Transform: { x: number; y: number }[] = []; // Array of objects + * const Health: number[] = []; // Single value array + * const Player: {} = {}; // Tag component + * + * // Register all components + * registry.registerComponent(Position); + * registry.registerComponent(Transform); + * registry.registerComponent(Health); + * registry.registerComponent(Player); + * + * const eid = 1; + * + * // Add components and set data + * registry.addComponent(eid, Position); + * Position.x[eid] = 10; + * Position.y[eid] = 20; + * + * registry.addComponent(eid, Transform); + * Transform[eid] = { x: 5, y: 15 }; + * + * registry.addComponent(eid, Health); + * Health[eid] = 100; + * + * registry.addComponent(eid, Player); // Just a flag, no data + * ``` + */ +export function createComponentRegistry(): TComponentRegistry { + return { + _componentMap: new Map(), + _entityMasks: [[]], + _componentCount: 0, + _currentBitflag: 1, + + registerComponent(component) { + if (this._componentMap.has(component)) { + return this._componentMap.get(component)!; + } + + const componentData: TComponentData = { + id: this._componentCount++, + generationId: this._entityMasks.length - 1, + bitflag: this._currentBitflag, + ref: component + }; + + this._componentMap.set(component, componentData); + + // When we exceed 31 bits, start a new generation + this._currentBitflag *= 2; + if (this._currentBitflag >= 2 ** 31) { + this._currentBitflag = 1; + this._entityMasks.push([]); + } + + return componentData; + }, + + hasComponent(eid, component) { + const componentData = this._componentMap.get(component); + if (componentData == null) { + return false; + } + + const { generationId, bitflag } = componentData; + const mask = this._entityMasks[generationId]?.[eid]; + return mask != null && (mask & bitflag) !== 0; + }, + + // Component Addition Flow with Generations + // Generation 0: [Position, Velocity, Health, ...] (components 0-30, bitflags 1-2^30) + // Generation 1: [Armor, Weapon, ...] (components 31+, bitflags 1-2^30) + // + // Before: entityMasks: [[0, 5, 0, 2], [0, 0, 1, 0]] + // addComponent(eid=2, Armor) where Armor.generationId=1, bitflag=1 ↓ + // + // Step 1: Get generation 1 mask: entityMasks[1][2] = 1 + // Step 2: Set component bit: 1 | 1 = 1 + // Step 3: Store new mask: entityMasks[1][2] = 1 + // + // After: entityMasks: [[0, 5, 0, 2], [0, 0, 1, 0]] (entity 2 has Armor) + addComponent(eid, component) { + const componentData = this._componentMap.get(component); + if (componentData == null) { + throw new Error('Component not registered. Call registerComponent() first.'); + } + + const { generationId, bitflag } = componentData; + + // Ensure generation exists and has capacity for this entity + this._ensureGenerationCapacity(generationId, eid); + + // Set component bit in the appropriate generation + const currentMask = this._entityMasks[generationId]![eid] || 0; + this._entityMasks[generationId]![eid] = currentMask | bitflag; + }, + + // Component Removal Flow with Generations + // Before: entityMasks: [[0, 5, 4, 2], [0, 0, 1, 0]] + // removeComponent(eid=2, Armor) where Armor.generationId=1, bitflag=1 ↓ + // + // Step 1: Get generation 1 mask: entityMasks[1][2] = 1 + // Step 2: Check component exists: 1 & 1 = 1 ✓ + // Step 3: Clear component bit: 1 & ~1 = 0 + // Step 4: Clear component data + // + // After: entityMasks: [[0, 5, 4, 2], [0, 0, 0, 0]] (entity 2 no longer has Armor) + removeComponent(eid, component) { + const componentData = this._componentMap.get(component); + if (componentData == null) { + return false; + } + + const { generationId, bitflag } = componentData; + + if ( + generationId >= this._entityMasks.length || + eid >= this._entityMasks[generationId]!.length + ) { + return false; + } + + const currentMask = this._entityMasks[generationId]![eid] || 0; + if ((currentMask & bitflag) === 0) return false; + + // Clear component bit + this._entityMasks[generationId]![eid] = currentMask & ~bitflag; + + // Clear component data - handle both single array and object with arrays + if (Array.isArray(component)) { + // Single array component: Health[eid] = undefined + component[eid] = undefined; + } else { + // Object with array properties: Position.x[eid] = undefined + for (const key in component) { + if (Array.isArray(component[key])) { + component[key][eid] = undefined; + } + } + } + + return true; + }, + + getEntitiesWithComponent(component) { + const componentData = this._componentMap.get(component); + if (componentData == null) { + return []; + } + + const entities: TEntityId[] = []; + const { generationId, bitflag } = componentData; + + if (generationId >= this._entityMasks.length) return []; + + const generation = this._entityMasks[generationId]!; + + // Iterate through entity masks in this generation to find matches + for (let eid = 0; eid < generation.length; eid++) { + const mask = generation[eid]; + if (mask != null && (mask & bitflag) !== 0) { + entities.push(eid); + } + } + + return entities; + }, + + // Multi-Component Query Flow with Generations + // Query: getEntitiesWithComponents([Position, Armor]) + // where Position.generationId=0, bitflag=4 and Armor.generationId=1, bitflag=1 + // + // Step 1: Group components by generation: + // Generation 0: [Position] -> mask = 4 + // Generation 1: [Armor] -> mask = 1 + // Step 2: Check each entity across all generations: + // Entity must have: entityMasks[0][eid] & 4 === 4 AND entityMasks[1][eid] & 1 === 1 + // + // Result: Only entities that satisfy ALL generation requirements + getEntitiesWithComponents(components) { + if (components.length === 0) { + return []; + } + if (components.length === 1) { + return this.getEntitiesWithComponent(components[0]); + } + + // Group components by generation and build required masks + const generationMasks = new Map(); + + for (const component of components) { + const componentData = this._componentMap.get(component); + if (!componentData) return []; // If any component not registered, no entities can have all + + const { generationId, bitflag } = componentData; + const currentMask = generationMasks.get(generationId) || 0; + generationMasks.set(generationId, currentMask | bitflag); + } + + const entities: TEntityId[] = []; + + // Find the maximum entity ID across all generations + let maxEntityId = 0; + for (const generation of this._entityMasks) { + maxEntityId = Math.max(maxEntityId, generation.length); + } + + // Check each entity against all generation requirements + entityLoop: for (let eid = 0; eid < maxEntityId; eid++) { + for (const [generationId, requiredMask] of generationMasks) { + if (generationId >= this._entityMasks.length) continue entityLoop; + + const generation = this._entityMasks[generationId]!; + const mask = generation[eid] || 0; + + if ((mask & requiredMask) !== requiredMask) { + continue entityLoop; + } + } + entities.push(eid); + } + + return entities; + }, + + removeAllComponents(eid) { + // Clear component data for all components this entity has across all generations + for (const [component, componentData] of this._componentMap) { + const { generationId, bitflag } = componentData; + + if ( + generationId >= this._entityMasks.length || + eid >= this._entityMasks[generationId]!.length + ) { + continue; + } + + const mask = this._entityMasks[generationId]![eid] || 0; + if ((mask & bitflag) !== 0) { + // Handle both single array and object with arrays + if (Array.isArray(component)) { + component[eid] = undefined; + } else { + for (const key in component) { + if (Array.isArray(component[key])) { + component[key][eid] = undefined; + } + } + } + } + } + + // Clear entity masks across all generations + for (let generationId = 0; generationId < this._entityMasks.length; generationId++) { + if (eid < this._entityMasks[generationId]!.length) { + this._entityMasks[generationId]![eid] = 0; + } + } + }, + + getEntityComponentMask(eid) { + // Return combined mask information across all generations + const masks: number[] = []; + for (let generationId = 0; generationId < this._entityMasks.length; generationId++) { + const generation = this._entityMasks[generationId]!; + masks.push(eid < generation.length ? generation[eid] || 0 : 0); + } + return masks; + }, + + getComponentData(component) { + return this._componentMap.get(component) || null; + }, + + getAllComponents() { + return Array.from(this._componentMap.keys()); + }, + + debugState() { + const componentEntries = []; + for (const [component, data] of this._componentMap) { + const name = (component as any).name || `Component${data.id}`; + componentEntries.push( + `${name}(id:${data.id}, gen:${data.generationId}, flag:${data.bitflag})` + ); + } + + const generationEntries = []; + for (let genId = 0; genId < this._entityMasks.length; genId++) { + const generation = this._entityMasks[genId]!; + const entityEntries = []; + for (let eid = 0; eid < generation.length; eid++) { + const mask = generation[eid]; + if (mask != null && mask !== 0) { + entityEntries.push(`${eid}→${mask.toString(2).padStart(8, '0')}`); + } + } + if (entityEntries.length > 0) { + generationEntries.push(`Gen${genId}: {${entityEntries.join(', ')}}`); + } + } + + return [ + `ComponentRegistry State:`, + ` Components (${this._componentCount}): [${componentEntries.join(', ')}]`, + ` Entity Masks: ${generationEntries.join(', ')}`, + ` Generations: ${this._entityMasks.length}`, + ` Next Bitflag: ${this._currentBitflag}` + ].join('\n'); + }, + + reset() { + // Clear all component data arrays + for (const component of this._componentMap.keys()) { + if (Array.isArray(component)) { + // Single array component + component.length = 0; + } else { + // Object with array properties + for (const key in component) { + if (Array.isArray(component[key])) { + component[key].length = 0; + } + } + } + } + + this._componentMap.clear(); + this._entityMasks.length = 0; + this._entityMasks.push([]); // Start with one generation + this._componentCount = 0; + this._currentBitflag = 1; + }, + + validate() { + // Validate generation structure + if (this._entityMasks.length === 0) return false; + + // Validate bitflag consistency within generations + const generationCounts = new Array(this._entityMasks.length).fill(0); + + for (const componentData of this._componentMap.values()) { + const { generationId, bitflag } = componentData; + + // Check generation ID is valid + if (generationId >= this._entityMasks.length || generationId < 0) return false; + + // Check bitflag is a power of 2 and within valid range + if (bitflag <= 0 || bitflag >= 2 ** 31 || (bitflag & (bitflag - 1)) !== 0) return false; + + generationCounts[generationId]++; + } + + // Validate current bitflag matches expected value for current generation + const currentGeneration = this._entityMasks.length - 1; + const componentsInCurrentGen = generationCounts[currentGeneration] || 0; + const expectedBitflag = componentsInCurrentGen === 0 ? 1 : 2 ** (componentsInCurrentGen % 31); + + if (this._currentBitflag !== expectedBitflag) return false; + + return true; + }, + + _ensureGenerationCapacity(generationId, eid) { + // Ensure the generation exists + while (generationId >= this._entityMasks.length) { + this._entityMasks.push([]); + } + + // Ensure the generation array can accommodate this entity ID + const generation = this._entityMasks[generationId]!; + while (eid >= generation.length) { + generation.push(0); + } + } + }; +} + +export interface TComponentRegistry { + /** Map of component references to their metadata */ + _componentMap: Map; + /** Array of entity component masks by generation */ + _entityMasks: number[][]; + /** Number of registered components */ + _componentCount: number; + /** Current bitflag value for next component */ + _currentBitflag: number; + + /** + * Registers a component and returns its metadata. + * Uses generation system to support unlimited components. + * @param component - The component to register (can be array or object with arrays) + * @returns Component metadata including ID, generation, and bitflag + */ + registerComponent(component: TComponentRef): TComponentData; + + /** + * Checks if an entity has a specific component. + * @param eid - The entity ID + * @param component - The component to check + * @returns True if entity has the component + */ + hasComponent(eid: TEntityId, component: TComponentRef): boolean; + + /** + * Adds a component to an entity (sets the component bit). + * Component data should be set directly on the component arrays. + * @param eid - The entity ID + * @param component - The component to add + */ + addComponent(eid: TEntityId, component: TComponentRef): void; + + /** + * Removes a component from an entity and clears its data. + * @param eid - The entity ID + * @param component - The component to remove + * @returns True if component was removed, false if entity didn't have it + */ + removeComponent(eid: TEntityId, component: TComponentRef): boolean; + + /** + * Gets all entities that have a specific component. + * @param component - The component to query + * @returns Array of entity IDs + */ + getEntitiesWithComponent(component: TComponentRef): TEntityId[]; + + /** + * Gets entities that have ALL specified components. + * Works across multiple generations efficiently. + * @param components - Array of components that entities must have + * @returns Array of entity IDs + */ + getEntitiesWithComponents(components: TComponentRef[]): TEntityId[]; + + /** + * Removes all components from an entity across all generations. + * @param eid - The entity ID + */ + removeAllComponents(eid: TEntityId): void; + + /** + * Gets the component bitmasks for an entity across all generations. + * @param eid - The entity ID + * @returns Array of bitmasks, one per generation + */ + getEntityComponentMask(eid: TEntityId): number[]; + + /** + * Gets metadata for a registered component. + * @param component - The component + * @returns Component metadata or null if not registered + */ + getComponentData(component: TComponentRef): TComponentData | null; + + /** + * Gets all registered components. + * @returns Array of all component references + */ + getAllComponents(): TComponentRef[]; + + /** + * Returns a human-readable debug representation of the registry state. + * Shows components with their generation and entity masks by generation. + * @returns Multi-line string with formatted state information + */ + debugState(): string; + + /** + * Resets the registry to its initial empty state. + */ + reset(): void; + + /** + * Validates the internal data structure integrity. + * @returns True if the data structure is valid, false otherwise + */ + validate(): boolean; + + /** + * Ensures a generation can accommodate the given entity ID. + * @param generationId - The generation ID + * @param eid - The entity ID + */ + _ensureGenerationCapacity(generationId: number, eid: TEntityId): void; +} + +export interface TComponentData { + /** Unique component ID */ + id: number; + /** Generation ID (which mask array this component uses) */ + generationId: number; + /** Bitflag for this component (power of 2) */ + bitflag: number; + /** Reference to the component object */ + ref: TComponentRef; +} + +export type TComponentRef = any; // Can be array or object with arrays diff --git a/packages/feature-ecs/src/index.ts b/packages/feature-ecs/src/index.ts index f2e35539..c092cbe7 100644 --- a/packages/feature-ecs/src/index.ts +++ b/packages/feature-ecs/src/index.ts @@ -1,2 +1,4 @@ +export * from './component-registry'; export * from './entity-index'; +export * from './query'; export * from './world'; diff --git a/packages/feature-ecs/src/query.ts b/packages/feature-ecs/src/query.ts new file mode 100644 index 00000000..c39f60ba --- /dev/null +++ b/packages/feature-ecs/src/query.ts @@ -0,0 +1,3 @@ +export interface TQuery { + // TODO: +} diff --git a/packages/feature-ecs/src/world.ts b/packages/feature-ecs/src/world.ts index dc5e3aed..5a9604ec 100644 --- a/packages/feature-ecs/src/world.ts +++ b/packages/feature-ecs/src/world.ts @@ -1,23 +1,45 @@ +import { createComponentRegistry, TComponentRegistry } from './component-registry'; import { createEntityIndex, TEntityId, TEntityIndex } from './entity-index'; export function createWorld(): TWorld { return { - entityIndex: createEntityIndex(), + _entityIndex: createEntityIndex(), + _componentRegistry: createComponentRegistry(), addEntity() { - return this.entityIndex.addEntity(); + return this._entityIndex.addEntity(); + }, + + removeEntity(eid) { + if (!this._entityIndex.isEntityAlive(eid)) { + return false; + } + + // Remove all components from entity + this._componentRegistry.removeAllComponents(eid); + + // Remove entity from index + return this._entityIndex.removeEntity(eid); + }, + + doesEntityExist(eid) { + return this._entityIndex.isEntityAlive(eid); }, reset() { - this.entityIndex.reset(); + this._entityIndex.reset(); + this._componentRegistry.reset(); } }; } export interface TWorld { - entityIndex: TEntityIndex; + _entityIndex: TEntityIndex; + _componentRegistry: TComponentRegistry; addEntity(): TEntityId; + removeEntity(eid: TEntityId): boolean; + doesEntityExist(eid: TEntityId): boolean; reset(): void; } From 5cc420b8a5e11ec3ba71020e495371a9a7d8ad63 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Tue, 27 May 2025 15:30:11 +0200 Subject: [PATCH 07/39] #105 fixed typos --- packages/feature-ecs/README.md | 34 + .../src/component-registry.test.ts | 944 +++++++++--------- .../feature-ecs/src/component-registry.ts | 318 +----- 3 files changed, 577 insertions(+), 719 deletions(-) diff --git a/packages/feature-ecs/README.md b/packages/feature-ecs/README.md index 308204f1..d7ca03bc 100644 --- a/packages/feature-ecs/README.md +++ b/packages/feature-ecs/README.md @@ -133,3 +133,37 @@ versionBits: 4; // 16 versions, ~256M entities max - Iteration over alive entities is cache-friendly (contiguous memory) - Sparse lookups may cause cache misses but are O(1) + + +## Component Registry + +https://en.wikipedia.org/wiki/AoS_and_SoA + +## 📚 Good to Know + +### Sparse vs Dense Arrays + +JavaScript sparse arrays store only assigned indices, making them memory-efficient: + +```ts +const sparse = []; +sparse[1000] = 5; // [<1000 empty items>, 5] + +console.log(sparse.length); // 1001 +console.log(sparse[500]); // undefined (no memory used) +``` + +In contrast, dense arrays allocate memory for every element, even if unused: + +```ts +const dense = new Array(1001).fill(0); // Allocates 1001 × 4 bytes = ~4KB + +console.log(dense.length); // 1001 +console.log(dense[500]); // 0 +``` + +Use sparse arrays for large, mostly empty datasets. Use dense arrays when you need consistent iteration and performance. + +## 💡 Resources / References + +- [BitECS](https://github.com/NateTheGreatt/bitECS) - High-performance ECS library that inspired our implementation \ No newline at end of file diff --git a/packages/feature-ecs/src/component-registry.test.ts b/packages/feature-ecs/src/component-registry.test.ts index 158ed351..a469a553 100644 --- a/packages/feature-ecs/src/component-registry.test.ts +++ b/packages/feature-ecs/src/component-registry.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { createComponentRegistry, TComponentRegistry } from './component-registry'; import { createEntityIndex, TEntityIndex } from './entity-index'; @@ -11,504 +11,544 @@ describe('createComponentRegistry', () => { entityIndex = createEntityIndex(); }); - test('component registration and metadata', () => { - const Position: TPosition = { x: [], y: [] }; - const Transform: TTransform = []; - const Health: THealth = []; - const Player: TPlayer = {}; - - const posData = registry.registerComponent(Position); - const transformData = registry.registerComponent(Transform); - const healthData = registry.registerComponent(Health); - const playerData = registry.registerComponent(Player); - - expect(posData.id).toBe(0); - expect(posData.generationId).toBe(0); - expect(posData.bitflag).toBe(1); - expect(posData.ref).toBe(Position); - - expect(transformData.id).toBe(1); - expect(transformData.generationId).toBe(0); - expect(transformData.bitflag).toBe(2); - expect(transformData.ref).toBe(Transform); - - expect(healthData.id).toBe(2); - expect(healthData.generationId).toBe(0); - expect(healthData.bitflag).toBe(4); - expect(healthData.ref).toBe(Health); - - expect(playerData.id).toBe(3); - expect(playerData.generationId).toBe(0); - expect(playerData.bitflag).toBe(8); - expect(playerData.ref).toBe(Player); - - // Re-registering should return same data - const posData2 = registry.registerComponent(Position); - expect(posData2).toBe(posData); + describe('registerComponent', () => { + it('should register components and return metadata', () => { + const Position: TPosition = { x: [], y: [] }; + const Transform: TTransform = []; + const Health: THealth = []; + const Player: TPlayer = {}; + + const posData = registry.registerComponent(Position); + const transformData = registry.registerComponent(Transform); + const healthData = registry.registerComponent(Health); + const playerData = registry.registerComponent(Player); + + expect(posData.id).toBe(0); + expect(posData.generationId).toBe(0); + expect(posData.bitflag).toBe(1); + expect(posData.ref).toBe(Position); + + expect(transformData.id).toBe(1); + expect(transformData.generationId).toBe(0); + expect(transformData.bitflag).toBe(2); + expect(transformData.ref).toBe(Transform); + + expect(healthData.id).toBe(2); + expect(healthData.generationId).toBe(0); + expect(healthData.bitflag).toBe(4); + expect(healthData.ref).toBe(Health); + + expect(playerData.id).toBe(3); + expect(playerData.generationId).toBe(0); + expect(playerData.bitflag).toBe(8); + expect(playerData.ref).toBe(Player); + + // Re-registering should return same data + const posData2 = registry.registerComponent(Position); + expect(posData2).toBe(posData); + }); + + it('should support unlimited components', () => { + // Register 35 components to test generation overflow + const components = []; + for (let i = 0; i < 35; i++) { + const component = {}; + components.push(component); + const data = registry.registerComponent(component); + + if (i < 31) { + // First generation (0-30) + expect(data.generationId).toBe(0); + expect(data.bitflag).toBe(2 ** i); + } else { + // Second generation (31-34) + expect(data.generationId).toBe(1); + expect(data.bitflag).toBe(2 ** (i - 31)); + } + } + }); }); - test('object with array properties pattern', () => { - const Position: TPosition = { x: [], y: [] }; - const Velocity: TVelocity = { dx: [], dy: [] }; - - registry.registerComponent(Position); - registry.registerComponent(Velocity); - - const eid1 = entityIndex.addEntity(); - const eid2 = entityIndex.addEntity(); - - // Add components - registry.addComponent(eid1, Position); - registry.addComponent(eid1, Velocity); - registry.addComponent(eid2, Position); - - // Set data on separate arrays for each property - Position.x[eid1] = 10; - Position.y[eid1] = 20; - Velocity.dx[eid1] = 1; - Velocity.dy[eid1] = 2; - Position.x[eid2] = 30; - Position.y[eid2] = 40; - - // Check components - expect(registry.hasComponent(eid1, Position)).toBe(true); - expect(registry.hasComponent(eid1, Velocity)).toBe(true); - expect(registry.hasComponent(eid2, Position)).toBe(true); - expect(registry.hasComponent(eid2, Velocity)).toBe(false); - - // Verify data - expect(Position.x[eid1]).toBe(10); - expect(Position.y[eid1]).toBe(20); - expect(Velocity.dx[eid1]).toBe(1); - expect(Velocity.dy[eid1]).toBe(2); - expect(Position.x[eid2]).toBe(30); - expect(Position.y[eid2]).toBe(40); + describe('hasComponent', () => { + it('should return true for entities with components', () => { + const Position: TPosition = { x: [], y: [] }; + const Health: THealth = []; + + registry.registerComponent(Position); + registry.registerComponent(Health); + + const eid1 = entityIndex.addEntity(); + const eid2 = entityIndex.addEntity(); + + registry.addComponent(eid1, Position); + registry.addComponent(eid1, Health); + registry.addComponent(eid2, Position); + + expect(registry.hasComponent(eid1, Position)).toBe(true); + expect(registry.hasComponent(eid1, Health)).toBe(true); + expect(registry.hasComponent(eid2, Position)).toBe(true); + expect(registry.hasComponent(eid2, Health)).toBe(false); + }); + + it('should return false for unregistered components', () => { + const Position: TPosition = { x: [], y: [] }; + const eid = entityIndex.addEntity(); + + expect(registry.hasComponent(eid, Position)).toBe(false); + }); }); - test('array of objects pattern', () => { - const Transform: TTransform = []; - const RenderInfo: TRenderInfo = []; + describe('addComponent', () => { + it('should support object with array properties pattern (AoS)', () => { + const Position: TPosition = { x: [], y: [] }; + const Velocity: TVelocity = { dx: [], dy: [] }; + + registry.registerComponent(Position); + registry.registerComponent(Velocity); + + const eid1 = entityIndex.addEntity(); + const eid2 = entityIndex.addEntity(); + + // Add components + registry.addComponent(eid1, Position); + registry.addComponent(eid1, Velocity); + registry.addComponent(eid2, Position); + + // Set data on separate arrays for each property + Position.x[eid1] = 10; + Position.y[eid1] = 20; + Velocity.dx[eid1] = 1; + Velocity.dy[eid1] = 2; + Position.x[eid2] = 30; + Position.y[eid2] = 40; + + // Check components + expect(registry.hasComponent(eid1, Position)).toBe(true); + expect(registry.hasComponent(eid1, Velocity)).toBe(true); + expect(registry.hasComponent(eid2, Position)).toBe(true); + expect(registry.hasComponent(eid2, Velocity)).toBe(false); + + // Verify data + expect(Position.x[eid1]).toBe(10); + expect(Position.y[eid1]).toBe(20); + expect(Velocity.dx[eid1]).toBe(1); + expect(Velocity.dy[eid1]).toBe(2); + expect(Position.x[eid2]).toBe(30); + expect(Position.y[eid2]).toBe(40); + }); + + it('should support array of objects pattern (SoA)', () => { + const Transform: TTransform = []; + const RenderInfo: TRenderInfo = []; + + registry.registerComponent(Transform); + registry.registerComponent(RenderInfo); + + const eid1 = entityIndex.addEntity(); + const eid2 = entityIndex.addEntity(); + + // Add components + registry.addComponent(eid1, Transform); + registry.addComponent(eid1, RenderInfo); + registry.addComponent(eid2, Transform); + + // Set data as complete objects + Transform[eid1] = { x: 5, y: 15, rotation: 45 }; + RenderInfo[eid1] = { sprite: 'player.png', layer: 1, visible: true }; + Transform[eid2] = { x: 100, y: 200, rotation: 0 }; + + // Check components + expect(registry.hasComponent(eid1, Transform)).toBe(true); + expect(registry.hasComponent(eid1, RenderInfo)).toBe(true); + expect(registry.hasComponent(eid2, Transform)).toBe(true); + expect(registry.hasComponent(eid2, RenderInfo)).toBe(false); + + // Verify data + expect(Transform[eid1]).toEqual({ x: 5, y: 15, rotation: 45 }); + expect(RenderInfo[eid1]).toEqual({ sprite: 'player.png', layer: 1, visible: true }); + expect(Transform[eid2]).toEqual({ x: 100, y: 200, rotation: 0 }); + }); + + it('should support single value array pattern', () => { + const Health: THealth = []; + const Mana: TMana = []; + const Level: TLevel = []; + + registry.registerComponent(Health); + registry.registerComponent(Mana); + registry.registerComponent(Level); + + const eid1 = entityIndex.addEntity(); + const eid2 = entityIndex.addEntity(); + + // Add components + registry.addComponent(eid1, Health); + registry.addComponent(eid1, Mana); + registry.addComponent(eid1, Level); + registry.addComponent(eid2, Health); + + // Set single values + Health[eid1] = 100; + Mana[eid1] = 50; + Level[eid1] = 5; + Health[eid2] = 80; + + // Check components + expect(registry.hasComponent(eid1, Health)).toBe(true); + expect(registry.hasComponent(eid1, Mana)).toBe(true); + expect(registry.hasComponent(eid1, Level)).toBe(true); + expect(registry.hasComponent(eid2, Health)).toBe(true); + expect(registry.hasComponent(eid2, Mana)).toBe(false); + + // Verify data + expect(Health[eid1]).toBe(100); + expect(Mana[eid1]).toBe(50); + expect(Level[eid1]).toBe(5); + expect(Health[eid2]).toBe(80); + }); + + it('should support tag component pattern', () => { + const Player: TPlayer = {}; + const Enemy: TEnemy = {}; + const Frozen: TFrozen = {}; + + registry.registerComponent(Player); + registry.registerComponent(Enemy); + registry.registerComponent(Frozen); + + const eid1 = entityIndex.addEntity(); + const eid2 = entityIndex.addEntity(); + const eid3 = entityIndex.addEntity(); + + // Add tag components (no data, just flags) + registry.addComponent(eid1, Player); + registry.addComponent(eid1, Frozen); + registry.addComponent(eid2, Enemy); + registry.addComponent(eid3, Player); + + // Check components + expect(registry.hasComponent(eid1, Player)).toBe(true); + expect(registry.hasComponent(eid1, Enemy)).toBe(false); + expect(registry.hasComponent(eid1, Frozen)).toBe(true); + expect(registry.hasComponent(eid2, Player)).toBe(false); + expect(registry.hasComponent(eid2, Enemy)).toBe(true); + expect(registry.hasComponent(eid3, Player)).toBe(true); + expect(registry.hasComponent(eid3, Frozen)).toBe(false); + }); + + it('should auto-register components when adding', () => { + const Position: TPosition = { x: [], y: [] }; + const eid = entityIndex.addEntity(); - registry.registerComponent(Transform); - registry.registerComponent(RenderInfo); + // Component should be auto-registered when adding + registry.addComponent(eid, Position); + expect(registry.hasComponent(eid, Position)).toBe(true); - const eid1 = entityIndex.addEntity(); - const eid2 = entityIndex.addEntity(); + // Should be able to set data + Position.x[eid] = 10; + Position.y[eid] = 20; + expect(Position.x[eid]).toBe(10); + expect(Position.y[eid]).toBe(20); + }); - // Add components - registry.addComponent(eid1, Transform); - registry.addComponent(eid1, RenderInfo); - registry.addComponent(eid2, Transform); + it('should handle duplicate component addition as idempotent', () => { + const Position: TPosition = { x: [], y: [] }; + const eid = entityIndex.addEntity(); - // Set data as complete objects - Transform[eid1] = { x: 5, y: 15, rotation: 45 }; - RenderInfo[eid1] = { sprite: 'player.png', layer: 1, visible: true }; - Transform[eid2] = { x: 100, y: 200, rotation: 0 }; + // Add component multiple times + registry.addComponent(eid, Position); + registry.addComponent(eid, Position); + registry.addComponent(eid, Position); - // Check components - expect(registry.hasComponent(eid1, Transform)).toBe(true); - expect(registry.hasComponent(eid1, RenderInfo)).toBe(true); - expect(registry.hasComponent(eid2, Transform)).toBe(true); - expect(registry.hasComponent(eid2, RenderInfo)).toBe(false); + // Should still only have it once + expect(registry.hasComponent(eid, Position)).toBe(true); - // Verify data - expect(Transform[eid1]).toEqual({ x: 5, y: 15, rotation: 45 }); - expect(RenderInfo[eid1]).toEqual({ sprite: 'player.png', layer: 1, visible: true }); - expect(Transform[eid2]).toEqual({ x: 100, y: 200, rotation: 0 }); - }); + // Set data + Position.x[eid] = 10; + Position.y[eid] = 20; - test('single value array pattern', () => { - const Health: THealth = []; - const Mana: TMana = []; - const Level: TLevel = []; - - registry.registerComponent(Health); - registry.registerComponent(Mana); - registry.registerComponent(Level); - - const eid1 = entityIndex.addEntity(); - const eid2 = entityIndex.addEntity(); - - // Add components - registry.addComponent(eid1, Health); - registry.addComponent(eid1, Mana); - registry.addComponent(eid1, Level); - registry.addComponent(eid2, Health); - - // Set single values - Health[eid1] = 100; - Mana[eid1] = 50; - Level[eid1] = 5; - Health[eid2] = 80; - - // Check components - expect(registry.hasComponent(eid1, Health)).toBe(true); - expect(registry.hasComponent(eid1, Mana)).toBe(true); - expect(registry.hasComponent(eid1, Level)).toBe(true); - expect(registry.hasComponent(eid2, Health)).toBe(true); - expect(registry.hasComponent(eid2, Mana)).toBe(false); - - // Verify data - expect(Health[eid1]).toBe(100); - expect(Mana[eid1]).toBe(50); - expect(Level[eid1]).toBe(5); - expect(Health[eid2]).toBe(80); - }); + // Remove once should remove it completely + expect(registry.removeComponent(eid, Position)).toBe(true); + expect(registry.hasComponent(eid, Position)).toBe(false); + expect(Position.x[eid]).toBeUndefined(); + expect(Position.y[eid]).toBeUndefined(); - test('tag component pattern', () => { - const Player: TPlayer = {}; - const Enemy: TEnemy = {}; - const Frozen: TFrozen = {}; - - registry.registerComponent(Player); - registry.registerComponent(Enemy); - registry.registerComponent(Frozen); - - const eid1 = entityIndex.addEntity(); - const eid2 = entityIndex.addEntity(); - const eid3 = entityIndex.addEntity(); - - // Add tag components (no data, just flags) - registry.addComponent(eid1, Player); - registry.addComponent(eid1, Frozen); - registry.addComponent(eid2, Enemy); - registry.addComponent(eid3, Player); - - // Check components - expect(registry.hasComponent(eid1, Player)).toBe(true); - expect(registry.hasComponent(eid1, Enemy)).toBe(false); - expect(registry.hasComponent(eid1, Frozen)).toBe(true); - expect(registry.hasComponent(eid2, Player)).toBe(false); - expect(registry.hasComponent(eid2, Enemy)).toBe(true); - expect(registry.hasComponent(eid3, Player)).toBe(true); - expect(registry.hasComponent(eid3, Frozen)).toBe(false); - }); + // Removing again should return false + expect(registry.removeComponent(eid, Position)).toBe(false); + }); + + it('should work across multiple generations', () => { + const eid = entityIndex.addEntity(); - test('unlimited components with generation system', () => { - // Register 35 components to test generation overflow - const components = []; - for (let i = 0; i < 35; i++) { - const component = {}; - components.push(component); - const data = registry.registerComponent(component); - - if (i < 31) { - // First generation (0-30) - expect(data.generationId).toBe(0); - expect(data.bitflag).toBe(2 ** i); - } else { - // Second generation (31-34) - expect(data.generationId).toBe(1); - expect(data.bitflag).toBe(2 ** (i - 31)); + // Register 35 components to test generation overflow + const components = []; + for (let i = 0; i < 35; i++) { + const component = {}; + components.push(component); + registry.registerComponent(component); } - } - - const eid = entityIndex.addEntity(); - - // Add components from both generations - registry.addComponent(eid, components[0]!); // Gen 0, bitflag 1 - registry.addComponent(eid, components[30]!); // Gen 0, bitflag 2^30 - registry.addComponent(eid, components[31]!); // Gen 1, bitflag 1 - registry.addComponent(eid, components[34]!); // Gen 1, bitflag 8 - - // Verify components exist - expect(registry.hasComponent(eid, components[0]!)).toBe(true); - expect(registry.hasComponent(eid, components[30]!)).toBe(true); - expect(registry.hasComponent(eid, components[31]!)).toBe(true); - expect(registry.hasComponent(eid, components[34]!)).toBe(true); - - // Check entity masks across generations - const masks = registry.getEntityComponentMask(eid); - expect(masks).toHaveLength(2); - expect(masks[0]).toBe(1 + 2 ** 30); // Gen 0: component 0 + component 30 - expect(masks[1]).toBe(1 + 8); // Gen 1: component 31 + component 34 - }); - test('mixed component patterns in queries across generations', () => { - const Position: TPosition = { x: [], y: [] }; - const Transform: TTransform = []; - const Health: THealth = []; - const Player: TPlayer = {}; - - // Register many components to force generation overflow - const extraComponents = []; - for (let i = 0; i < 30; i++) { - const component = {}; - extraComponents.push(component); - registry.registerComponent(component); - } - - // These will be in generation 1 - registry.registerComponent(Position); - registry.registerComponent(Transform); - registry.registerComponent(Health); - registry.registerComponent(Player); - - const eid1 = entityIndex.addEntity(); - const eid2 = entityIndex.addEntity(); - const eid3 = entityIndex.addEntity(); - - // eid1: Position + Health + Player (all in generation 1) - registry.addComponent(eid1, Position); - registry.addComponent(eid1, Health); - registry.addComponent(eid1, Player); - Position.x[eid1] = 10; - Position.y[eid1] = 20; - Health[eid1] = 100; - - // eid2: Transform + Health + some gen 0 components - registry.addComponent(eid2, Transform); - registry.addComponent(eid2, Health); - registry.addComponent(eid2, extraComponents[0]!); // Gen 0 - registry.addComponent(eid2, extraComponents[1]!); // Gen 0 - Transform[eid2] = { x: 5, y: 15, rotation: 0 }; - Health[eid2] = 80; - - // eid3: Position + Transform + Player + gen 0 components - registry.addComponent(eid3, Position); - registry.addComponent(eid3, Transform); - registry.addComponent(eid3, Player); - registry.addComponent(eid3, extraComponents[5]!); // Gen 0 - Position.x[eid3] = 50; - Position.y[eid3] = 60; - Transform[eid3] = { x: 25, y: 35, rotation: 90 }; - - // Query tests across generations - const playersWithHealth = registry.getEntitiesWithComponents([Player, Health]); - expect(playersWithHealth).toHaveLength(1); - expect(playersWithHealth).toContain(eid1); - - const entitiesWithTransform = registry.getEntitiesWithComponent(Transform); - expect(entitiesWithTransform).toHaveLength(2); - expect(entitiesWithTransform).toContain(eid2); - expect(entitiesWithTransform).toContain(eid3); - - const playersWithPosition = registry.getEntitiesWithComponents([Player, Position]); - expect(playersWithPosition).toHaveLength(2); - expect(playersWithPosition).toContain(eid1); - expect(playersWithPosition).toContain(eid3); - - // Cross-generation query - const crossGenQuery = registry.getEntitiesWithComponents([extraComponents[0]!, Health]); - expect(crossGenQuery).toHaveLength(1); - expect(crossGenQuery).toContain(eid2); + // Add components from both generations + registry.addComponent(eid, components[0]!); // Gen 0, bitflag 1 + registry.addComponent(eid, components[30]!); // Gen 0, bitflag 2^30 + registry.addComponent(eid, components[31]!); // Gen 1, bitflag 1 + registry.addComponent(eid, components[34]!); // Gen 1, bitflag 8 + + // Verify components exist + expect(registry.hasComponent(eid, components[0]!)).toBe(true); + expect(registry.hasComponent(eid, components[30]!)).toBe(true); + expect(registry.hasComponent(eid, components[31]!)).toBe(true); + expect(registry.hasComponent(eid, components[34]!)).toBe(true); + }); }); - test('component removal across all patterns and generations', () => { - const Position: TPosition = { x: [], y: [] }; - const Transform: TTransform = []; - const Health: THealth = []; - const Player: TPlayer = {}; - - // Add some gen 0 components first - const gen0Components = []; - for (let i = 0; i < 31; i++) { - const component = {}; - gen0Components.push(component); - registry.registerComponent(component); - } - - // These will be in generation 1 - registry.registerComponent(Position); - registry.registerComponent(Transform); - registry.registerComponent(Health); - registry.registerComponent(Player); - - const eid = entityIndex.addEntity(); - - // Add components from both generations - registry.addComponent(eid, gen0Components[0]!); - registry.addComponent(eid, Position); - registry.addComponent(eid, Transform); - registry.addComponent(eid, Health); - registry.addComponent(eid, Player); - - // Set data - Position.x[eid] = 10; - Position.y[eid] = 20; - Transform[eid] = { x: 5, y: 15, rotation: 45 }; - Health[eid] = 100; - - // Verify all components exist - expect(registry.hasComponent(eid, gen0Components[0]!)).toBe(true); - expect(registry.hasComponent(eid, Position)).toBe(true); - expect(registry.hasComponent(eid, Transform)).toBe(true); - expect(registry.hasComponent(eid, Health)).toBe(true); - expect(registry.hasComponent(eid, Player)).toBe(true); - - // Remove components from different generations - registry.removeComponent(eid, Position); - expect(registry.hasComponent(eid, Position)).toBe(false); - expect(Position.x[eid]).toBeUndefined(); - expect(Position.y[eid]).toBeUndefined(); - - registry.removeComponent(eid, gen0Components[0]!); - expect(registry.hasComponent(eid, gen0Components[0]!)).toBe(false); - - // Other components should still exist - expect(registry.hasComponent(eid, Transform)).toBe(true); - expect(registry.hasComponent(eid, Health)).toBe(true); - expect(registry.hasComponent(eid, Player)).toBe(true); - - // Remove all remaining components - registry.removeAllComponents(eid); - expect(registry.hasComponent(eid, Transform)).toBe(false); - expect(registry.hasComponent(eid, Health)).toBe(false); - expect(registry.hasComponent(eid, Player)).toBe(false); - }); + describe('removeComponent', () => { + it('should remove components and clear data', () => { + const Position: TPosition = { x: [], y: [] }; + const Transform: TTransform = []; + const Health: THealth = []; - test('component masks with generation system', () => { - const Position: TPosition = { x: [], y: [] }; - const Transform: TTransform = []; + registry.registerComponent(Position); + registry.registerComponent(Transform); + registry.registerComponent(Health); - // Add 31 components to fill first generation - const gen0Components = []; - for (let i = 0; i < 31; i++) { - const component = {}; - gen0Components.push(component); - registry.registerComponent(component); - } + const eid = entityIndex.addEntity(); - // These will be in generation 1 - registry.registerComponent(Position); // Gen 1, bitflag: 1 - registry.registerComponent(Transform); // Gen 1, bitflag: 2 + // Add components and set data + registry.addComponent(eid, Position); + registry.addComponent(eid, Transform); + registry.addComponent(eid, Health); + + Position.x[eid] = 10; + Position.y[eid] = 20; + Transform[eid] = { x: 5, y: 15, rotation: 45 }; + Health[eid] = 100; + + // Remove Position component + expect(registry.removeComponent(eid, Position)).toBe(true); + expect(registry.hasComponent(eid, Position)).toBe(false); + expect(Position.x[eid]).toBeUndefined(); + expect(Position.y[eid]).toBeUndefined(); + + // Other components should still exist + expect(registry.hasComponent(eid, Transform)).toBe(true); + expect(registry.hasComponent(eid, Health)).toBe(true); + expect(Transform[eid]).toEqual({ x: 5, y: 15, rotation: 45 }); + expect(Health[eid]).toBe(100); + }); + + it('should return false for non-existent components', () => { + const Position: TPosition = { x: [], y: [] }; + const eid = entityIndex.addEntity(); - const eid = entityIndex.addEntity(); + expect(registry.removeComponent(eid, Position)).toBe(false); + }); - let masks = registry.getEntityComponentMask(eid); - expect(masks).toEqual([0, 0]); // No components + it('should return false for already removed components', () => { + const Position: TPosition = { x: [], y: [] }; + const eid = entityIndex.addEntity(); - registry.addComponent(eid, gen0Components[0]!); // Gen 0, bitflag 1 - masks = registry.getEntityComponentMask(eid); - expect(masks).toEqual([1, 0]); + registry.addComponent(eid, Position); + expect(registry.removeComponent(eid, Position)).toBe(true); + expect(registry.removeComponent(eid, Position)).toBe(false); + }); + + it('should work across multiple generations', () => { + const Position: TPosition = { x: [], y: [] }; + const Transform: TTransform = []; + const Health: THealth = []; + const Player: TPlayer = {}; + + // Add some gen 0 components first + const gen0Components = []; + for (let i = 0; i < 31; i++) { + const component = {}; + gen0Components.push(component); + registry.registerComponent(component); + } - registry.addComponent(eid, Position); // Gen 1, bitflag 1 - masks = registry.getEntityComponentMask(eid); - expect(masks).toEqual([1, 1]); + // These will be in generation 1 + registry.registerComponent(Position); + registry.registerComponent(Transform); + registry.registerComponent(Health); + registry.registerComponent(Player); - registry.addComponent(eid, gen0Components[30]!); // Gen 0, bitflag 2^30 - masks = registry.getEntityComponentMask(eid); - expect(masks).toEqual([1 + 2 ** 30, 1]); + const eid = entityIndex.addEntity(); - registry.addComponent(eid, Transform); // Gen 1, bitflag 2 - masks = registry.getEntityComponentMask(eid); - expect(masks).toEqual([1 + 2 ** 30, 1 + 2]); + // Add components from both generations + registry.addComponent(eid, gen0Components[0]!); + registry.addComponent(eid, Position); + registry.addComponent(eid, Transform); + registry.addComponent(eid, Health); + registry.addComponent(eid, Player); + + // Set data + Position.x[eid] = 10; + Position.y[eid] = 20; + Transform[eid] = { x: 5, y: 15, rotation: 45 }; + Health[eid] = 100; + + // Remove components from different generations + registry.removeComponent(eid, Position); + expect(registry.hasComponent(eid, Position)).toBe(false); + expect(Position.x[eid]).toBeUndefined(); + expect(Position.y[eid]).toBeUndefined(); + + registry.removeComponent(eid, gen0Components[0]!); + expect(registry.hasComponent(eid, gen0Components[0]!)).toBe(false); + + // Other components should still exist + expect(registry.hasComponent(eid, Transform)).toBe(true); + expect(registry.hasComponent(eid, Health)).toBe(true); + expect(registry.hasComponent(eid, Player)).toBe(true); + }); }); - test('debug state with generations', () => { - const Position: TPosition = { x: [], y: [] }; + describe('removeAllComponents', () => { + it('should remove all components from entity', () => { + const Position: TPosition = { x: [], y: [] }; + const Transform: TTransform = []; + const Health: THealth = []; + const Player: TPlayer = {}; - // Add components to create multiple generations - const gen0Components = []; - for (let i = 0; i < 31; i++) { - const component = {}; - gen0Components.push(component); - registry.registerComponent(component); - } + registry.registerComponent(Position); + registry.registerComponent(Transform); + registry.registerComponent(Health); + registry.registerComponent(Player); - registry.registerComponent(Position); + const eid = entityIndex.addEntity(); - const eid1 = entityIndex.addEntity(); - const eid2 = entityIndex.addEntity(); + // Add components and set data + registry.addComponent(eid, Position); + registry.addComponent(eid, Transform); + registry.addComponent(eid, Health); + registry.addComponent(eid, Player); + + Position.x[eid] = 10; + Position.y[eid] = 20; + Transform[eid] = { x: 5, y: 15, rotation: 45 }; + Health[eid] = 100; + + // Remove all components + registry.removeAllComponents(eid); + + // All components should be removed + expect(registry.hasComponent(eid, Position)).toBe(false); + expect(registry.hasComponent(eid, Transform)).toBe(false); + expect(registry.hasComponent(eid, Health)).toBe(false); + expect(registry.hasComponent(eid, Player)).toBe(false); + + // All data should be cleared + expect(Position.x[eid]).toBeUndefined(); + expect(Position.y[eid]).toBeUndefined(); + expect(Transform[eid]).toBeUndefined(); + expect(Health[eid]).toBeUndefined(); + }); + }); - registry.addComponent(eid1, gen0Components[0]!); - registry.addComponent(eid1, Position); - registry.addComponent(eid2, gen0Components[1]!); + describe('reset', () => { + it('should reset to initial state and clear all data', () => { + const Position: TPosition = { x: [], y: [] }; - const debugState = registry.debugState(); - expect(debugState).toContain('ComponentRegistry State:'); - expect(debugState).toContain('Components (32):'); - expect(debugState).toContain('Generations: 2'); - expect(debugState).toContain('Gen0:'); - expect(debugState).toContain('Gen1:'); - }); + // Create multiple generations + const gen0Components = []; + for (let i = 0; i < 31; i++) { + const component = {}; + gen0Components.push(component); + registry.registerComponent(component); + } + + registry.registerComponent(Position); - test('performance with generation system', () => { - // Register components across multiple generations - const components = []; - for (let i = 0; i < 65; i++) { - // 2+ generations - const component = {}; - components.push(component); - registry.registerComponent(component); - } - - // Create many entities with mixed generation components - const entities = []; - for (let i = 0; i < 1000; i++) { const eid = entityIndex.addEntity(); - entities.push(eid); - // Add components from different generations - registry.addComponent(eid, components[i % 31]!); // Gen 0 - registry.addComponent(eid, components[31 + (i % 31)]!); // Gen 1 - if (i % 3 === 0) { - registry.addComponent(eid, components[62]!); // Gen 2 - } - } - - // Fast component checks across generations - const start = performance.now(); - for (let i = 0; i < 10000; i++) { - const eid = entities[i % entities.length]!; - registry.hasComponent(eid, components[0]!); - registry.hasComponent(eid, components[31]!); - registry.hasComponent(eid, components[62]!); - } - const end = performance.now(); - - // Should be very fast even with generations - expect(end - start).toBeLessThan(30); - - // Cross-generation query performance - const start2 = performance.now(); - const crossGenEntities = registry.getEntitiesWithComponents([ - components[0]!, // Gen 0 - components[31]!, // Gen 1 - components[62]! // Gen 2 - ]); - const end2 = performance.now(); - - expect(end2 - start2).toBeLessThan(20); - expect(crossGenEntities.length).toBeGreaterThan(0); + registry.addComponent(eid, gen0Components[0]!); + registry.addComponent(eid, Position); + Position.x[eid] = 10; + + // Verify data exists + expect(Position.x[eid]).toBe(10); + expect(registry.hasComponent(eid, Position)).toBe(true); + + // Reset registry + registry.reset(); + + // All arrays should be cleared + expect(Position.x.length).toBe(0); + + // Registry should be empty + expect(registry.hasComponent(eid, Position)).toBe(false); + expect(registry.validate()).toBe(true); + }); }); - test('reset functionality with generations', () => { - const Position: TPosition = { x: [], y: [] }; + describe('validate', () => { + it('should return true for valid empty registry', () => { + expect(registry.validate()).toBe(true); + }); + + it('should return true for valid registry with components', () => { + const Position: TPosition = { x: [], y: [] }; + const Health: THealth = []; - // Create multiple generations - const gen0Components = []; - for (let i = 0; i < 31; i++) { - const component = {}; - gen0Components.push(component); - registry.registerComponent(component); - } + registry.registerComponent(Position); + registry.registerComponent(Health); - registry.registerComponent(Position); + expect(registry.validate()).toBe(true); + }); - const eid = entityIndex.addEntity(); + it('should return true after component operations', () => { + const Position: TPosition = { x: [], y: [] }; + const Health: THealth = []; - registry.addComponent(eid, gen0Components[0]!); - registry.addComponent(eid, Position); - Position.x[eid] = 10; + registry.registerComponent(Position); + registry.registerComponent(Health); - // Verify data exists - expect(Position.x[eid]).toBe(10); - expect(registry.hasComponent(eid, Position)).toBe(true); + const eid1 = entityIndex.addEntity(); + const eid2 = entityIndex.addEntity(); - // Reset registry - registry.reset(); + registry.addComponent(eid1, Position); + registry.addComponent(eid1, Health); + registry.addComponent(eid2, Position); - // All arrays should be cleared - expect(Position.x.length).toBe(0); + expect(registry.validate()).toBe(true); + + registry.removeComponent(eid1, Health); + expect(registry.validate()).toBe(true); + + registry.removeAllComponents(eid2); + expect(registry.validate()).toBe(true); + }); + + it('should return true with generation system', () => { + // Register 35 components to test generation overflow + const components = []; + for (let i = 0; i < 35; i++) { + const component = {}; + components.push(component); + registry.registerComponent(component); + } + + expect(registry.validate()).toBe(true); + + const eid = entityIndex.addEntity(); + registry.addComponent(eid, components[0]!); // Gen 0 + registry.addComponent(eid, components[31]!); // Gen 1 + + expect(registry.validate()).toBe(true); + }); + + it('should return true after reset', () => { + const Position: TPosition = { x: [], y: [] }; + registry.registerComponent(Position); + + const eid = entityIndex.addEntity(); + registry.addComponent(eid, Position); - // Registry should be empty with one generation - expect(registry.getAllComponents()).toHaveLength(0); - expect(registry.hasComponent(eid, Position)).toBe(false); + registry.reset(); - // Should start with one generation again - const masks = registry.getEntityComponentMask(eid); - expect(masks).toEqual([0]); + expect(registry.validate()).toBe(true); + }); }); }); diff --git a/packages/feature-ecs/src/component-registry.ts b/packages/feature-ecs/src/component-registry.ts index ffe80477..acf4436d 100644 --- a/packages/feature-ecs/src/component-registry.ts +++ b/packages/feature-ecs/src/component-registry.ts @@ -14,7 +14,7 @@ * * ## Supported Component Patterns * - * ### 1. Object with Array Properties (Performance Optimized) + * ### 1. Object with Array Properties (AoS) * Best for components with multiple properties. Each property is stored in a separate array * for maximum cache efficiency and performance. * ```typescript @@ -24,7 +24,7 @@ * // Usage: Position.x[eid] = 10; Position.y[eid] = 20; * ``` * - * ### 2. Array of Objects (Simple but Less Performant) + * ### 2. Array of Objects (SoA) * Easier to understand but less cache-friendly. Good for prototyping or when performance * isn't critical. * ```typescript @@ -66,8 +66,8 @@ import { TEntityId } from './entity-index'; * const registry = createComponentRegistry(); * * // Define components using any supported pattern - * const Position: { x: number[]; y: number[] } = { x: [], y: [] }; // Object with arrays - * const Transform: { x: number; y: number }[] = []; // Array of objects + * const Position: { x: number[]; y: number[] } = { x: [], y: [] }; // Object with arrays (AoS) + * const Transform: { x: number; y: number }[] = []; // Array of objects (SoA) * const Health: number[] = []; // Single value array * const Player: {} = {}; // Tag component * @@ -102,7 +102,7 @@ export function createComponentRegistry(): TComponentRegistry { registerComponent(component) { if (this._componentMap.has(component)) { - return this._componentMap.get(component)!; + return this._componentMap.get(component) as TComponentData; } const componentData: TComponentData = { @@ -131,48 +131,53 @@ export function createComponentRegistry(): TComponentRegistry { } const { generationId, bitflag } = componentData; - const mask = this._entityMasks[generationId]?.[eid]; - return mask != null && (mask & bitflag) !== 0; + const mask = this._entityMasks[generationId]?.[eid] ?? 0; + return (mask & bitflag) !== 0; }, - // Component Addition Flow with Generations + // Component Addition Flow // Generation 0: [Position, Velocity, Health, ...] (components 0-30, bitflags 1-2^30) // Generation 1: [Armor, Weapon, ...] (components 31+, bitflags 1-2^30) // - // Before: entityMasks: [[0, 5, 0, 2], [0, 0, 1, 0]] - // addComponent(eid=2, Armor) where Armor.generationId=1, bitflag=1 ↓ + // Before: entityMasks: [[<1 empty>, 5, <3 empty>, 2], []] + // addComponent(eid=5, Armor) where Armor.generationId=1, bitflag=1 ↓ // - // Step 1: Get generation 1 mask: entityMasks[1][2] = 1 - // Step 2: Set component bit: 1 | 1 = 1 - // Step 3: Store new mask: entityMasks[1][2] = 1 + // Step 1: Get current mask: entityMasks[1][5] || 0 = 0 + // Step 2: Set component bit: 0 | 1 = 1 + // Step 3: Store new mask: entityMasks[1][5] = 1 // - // After: entityMasks: [[0, 5, 0, 2], [0, 0, 1, 0]] (entity 2 has Armor) + // After: entityMasks: [[<1 empty>, 5, <3 empty>, 2], [<5 empty>, 1]] (entity 5 has Armor) addComponent(eid, component) { - const componentData = this._componentMap.get(component); - if (componentData == null) { - throw new Error('Component not registered. Call registerComponent() first.'); + // Auto-register component if not already registered + if (!this._componentMap.has(component)) { + this.registerComponent(component); } + const componentData = this._componentMap.get(component) as TComponentData; const { generationId, bitflag } = componentData; - // Ensure generation exists and has capacity for this entity - this._ensureGenerationCapacity(generationId, eid); + // Check if entity already has this component + const currentMask = this._entityMasks[generationId]?.[eid] ?? 0; + if ((currentMask & bitflag) !== 0) { + return; + } // Set component bit in the appropriate generation - const currentMask = this._entityMasks[generationId]![eid] || 0; - this._entityMasks[generationId]![eid] = currentMask | bitflag; + // We don't prefill arrays to create sparse arrays for memory efficiency + // @ts-expect-error - generationId exists because we ensure it when registering the component + this._entityMasks[generationId][eid] = currentMask | bitflag; }, - // Component Removal Flow with Generations - // Before: entityMasks: [[0, 5, 4, 2], [0, 0, 1, 0]] - // removeComponent(eid=2, Armor) where Armor.generationId=1, bitflag=1 ↓ + // Component Removal Flow + // Before: entityMasks: [[<1 empty>, 5, <3 empty>, 2], [<5 empty>, 1]] + // removeComponent(eid=5, Armor) where Armor.generationId=1, bitflag=1 ↓ // - // Step 1: Get generation 1 mask: entityMasks[1][2] = 1 + // Step 1: Get current mask: entityMasks[1][5] = 1 // Step 2: Check component exists: 1 & 1 = 1 ✓ // Step 3: Clear component bit: 1 & ~1 = 0 // Step 4: Clear component data // - // After: entityMasks: [[0, 5, 4, 2], [0, 0, 0, 0]] (entity 2 no longer has Armor) + // After: entityMasks: [[<1 empty>, 5, <3 empty>, 2], [<5 empty>, 0]] (entity 5 no longer has Armor) removeComponent(eid, component) { const componentData = this._componentMap.get(component); if (componentData == null) { @@ -181,28 +186,25 @@ export function createComponentRegistry(): TComponentRegistry { const { generationId, bitflag } = componentData; - if ( - generationId >= this._entityMasks.length || - eid >= this._entityMasks[generationId]!.length - ) { + // Get current mask and check if entity actually has this component + const currentMask = this._entityMasks[generationId]?.[eid] ?? 0; + if ((currentMask & bitflag) === 0) { return false; } - const currentMask = this._entityMasks[generationId]![eid] || 0; - if ((currentMask & bitflag) === 0) return false; - // Clear component bit - this._entityMasks[generationId]![eid] = currentMask & ~bitflag; + // @ts-expect-error - generationId exists because we ensure it when registering the component + this._entityMasks[generationId][eid] = currentMask & ~bitflag; - // Clear component data - handle both single array and object with arrays + // Clear component data (AoS or SoA) if (Array.isArray(component)) { - // Single array component: Health[eid] = undefined - component[eid] = undefined; + // Single array component (AoS): delete Health[eid] + delete component[eid]; } else { - // Object with array properties: Position.x[eid] = undefined + // Object with array properties (SoA): delete Position.x[eid] for (const key in component) { if (Array.isArray(component[key])) { - component[key][eid] = undefined; + delete component[key][eid]; } } } @@ -210,171 +212,11 @@ export function createComponentRegistry(): TComponentRegistry { return true; }, - getEntitiesWithComponent(component) { - const componentData = this._componentMap.get(component); - if (componentData == null) { - return []; - } - - const entities: TEntityId[] = []; - const { generationId, bitflag } = componentData; - - if (generationId >= this._entityMasks.length) return []; - - const generation = this._entityMasks[generationId]!; - - // Iterate through entity masks in this generation to find matches - for (let eid = 0; eid < generation.length; eid++) { - const mask = generation[eid]; - if (mask != null && (mask & bitflag) !== 0) { - entities.push(eid); - } - } - - return entities; - }, - - // Multi-Component Query Flow with Generations - // Query: getEntitiesWithComponents([Position, Armor]) - // where Position.generationId=0, bitflag=4 and Armor.generationId=1, bitflag=1 - // - // Step 1: Group components by generation: - // Generation 0: [Position] -> mask = 4 - // Generation 1: [Armor] -> mask = 1 - // Step 2: Check each entity across all generations: - // Entity must have: entityMasks[0][eid] & 4 === 4 AND entityMasks[1][eid] & 1 === 1 - // - // Result: Only entities that satisfy ALL generation requirements - getEntitiesWithComponents(components) { - if (components.length === 0) { - return []; - } - if (components.length === 1) { - return this.getEntitiesWithComponent(components[0]); - } - - // Group components by generation and build required masks - const generationMasks = new Map(); - - for (const component of components) { - const componentData = this._componentMap.get(component); - if (!componentData) return []; // If any component not registered, no entities can have all - - const { generationId, bitflag } = componentData; - const currentMask = generationMasks.get(generationId) || 0; - generationMasks.set(generationId, currentMask | bitflag); - } - - const entities: TEntityId[] = []; - - // Find the maximum entity ID across all generations - let maxEntityId = 0; - for (const generation of this._entityMasks) { - maxEntityId = Math.max(maxEntityId, generation.length); - } - - // Check each entity against all generation requirements - entityLoop: for (let eid = 0; eid < maxEntityId; eid++) { - for (const [generationId, requiredMask] of generationMasks) { - if (generationId >= this._entityMasks.length) continue entityLoop; - - const generation = this._entityMasks[generationId]!; - const mask = generation[eid] || 0; - - if ((mask & requiredMask) !== requiredMask) { - continue entityLoop; - } - } - entities.push(eid); - } - - return entities; - }, - removeAllComponents(eid) { - // Clear component data for all components this entity has across all generations - for (const [component, componentData] of this._componentMap) { - const { generationId, bitflag } = componentData; - - if ( - generationId >= this._entityMasks.length || - eid >= this._entityMasks[generationId]!.length - ) { - continue; - } - - const mask = this._entityMasks[generationId]![eid] || 0; - if ((mask & bitflag) !== 0) { - // Handle both single array and object with arrays - if (Array.isArray(component)) { - component[eid] = undefined; - } else { - for (const key in component) { - if (Array.isArray(component[key])) { - component[key][eid] = undefined; - } - } - } - } - } - - // Clear entity masks across all generations - for (let generationId = 0; generationId < this._entityMasks.length; generationId++) { - if (eid < this._entityMasks[generationId]!.length) { - this._entityMasks[generationId]![eid] = 0; - } - } - }, - - getEntityComponentMask(eid) { - // Return combined mask information across all generations - const masks: number[] = []; - for (let generationId = 0; generationId < this._entityMasks.length; generationId++) { - const generation = this._entityMasks[generationId]!; - masks.push(eid < generation.length ? generation[eid] || 0 : 0); - } - return masks; - }, - - getComponentData(component) { - return this._componentMap.get(component) || null; - }, - - getAllComponents() { - return Array.from(this._componentMap.keys()); - }, - - debugState() { - const componentEntries = []; - for (const [component, data] of this._componentMap) { - const name = (component as any).name || `Component${data.id}`; - componentEntries.push( - `${name}(id:${data.id}, gen:${data.generationId}, flag:${data.bitflag})` - ); - } - - const generationEntries = []; - for (let genId = 0; genId < this._entityMasks.length; genId++) { - const generation = this._entityMasks[genId]!; - const entityEntries = []; - for (let eid = 0; eid < generation.length; eid++) { - const mask = generation[eid]; - if (mask != null && mask !== 0) { - entityEntries.push(`${eid}→${mask.toString(2).padStart(8, '0')}`); - } - } - if (entityEntries.length > 0) { - generationEntries.push(`Gen${genId}: {${entityEntries.join(', ')}}`); - } + // Use removeComponent to reuse logic and ensure consistency + for (const component of this._componentMap.keys()) { + this.removeComponent(eid, component); } - - return [ - `ComponentRegistry State:`, - ` Components (${this._componentCount}): [${componentEntries.join(', ')}]`, - ` Entity Masks: ${generationEntries.join(', ')}`, - ` Generations: ${this._entityMasks.length}`, - ` Next Bitflag: ${this._currentBitflag}` - ].join('\n'); }, reset() { @@ -402,7 +244,9 @@ export function createComponentRegistry(): TComponentRegistry { validate() { // Validate generation structure - if (this._entityMasks.length === 0) return false; + if (this._entityMasks.length === 0) { + return false; + } // Validate bitflag consistency within generations const generationCounts = new Array(this._entityMasks.length).fill(0); @@ -424,22 +268,11 @@ export function createComponentRegistry(): TComponentRegistry { const componentsInCurrentGen = generationCounts[currentGeneration] || 0; const expectedBitflag = componentsInCurrentGen === 0 ? 1 : 2 ** (componentsInCurrentGen % 31); - if (this._currentBitflag !== expectedBitflag) return false; - - return true; - }, - - _ensureGenerationCapacity(generationId, eid) { - // Ensure the generation exists - while (generationId >= this._entityMasks.length) { - this._entityMasks.push([]); + if (this._currentBitflag !== expectedBitflag) { + return false; } - // Ensure the generation array can accommodate this entity ID - const generation = this._entityMasks[generationId]!; - while (eid >= generation.length) { - generation.push(0); - } + return true; } }; } @@ -487,53 +320,11 @@ export interface TComponentRegistry { removeComponent(eid: TEntityId, component: TComponentRef): boolean; /** - * Gets all entities that have a specific component. - * @param component - The component to query - * @returns Array of entity IDs - */ - getEntitiesWithComponent(component: TComponentRef): TEntityId[]; - - /** - * Gets entities that have ALL specified components. - * Works across multiple generations efficiently. - * @param components - Array of components that entities must have - * @returns Array of entity IDs - */ - getEntitiesWithComponents(components: TComponentRef[]): TEntityId[]; - - /** - * Removes all components from an entity across all generations. + * Removes all components from an entity. * @param eid - The entity ID */ removeAllComponents(eid: TEntityId): void; - /** - * Gets the component bitmasks for an entity across all generations. - * @param eid - The entity ID - * @returns Array of bitmasks, one per generation - */ - getEntityComponentMask(eid: TEntityId): number[]; - - /** - * Gets metadata for a registered component. - * @param component - The component - * @returns Component metadata or null if not registered - */ - getComponentData(component: TComponentRef): TComponentData | null; - - /** - * Gets all registered components. - * @returns Array of all component references - */ - getAllComponents(): TComponentRef[]; - - /** - * Returns a human-readable debug representation of the registry state. - * Shows components with their generation and entity masks by generation. - * @returns Multi-line string with formatted state information - */ - debugState(): string; - /** * Resets the registry to its initial empty state. */ @@ -544,13 +335,6 @@ export interface TComponentRegistry { * @returns True if the data structure is valid, false otherwise */ validate(): boolean; - - /** - * Ensures a generation can accommodate the given entity ID. - * @param generationId - The generation ID - * @param eid - The entity ID - */ - _ensureGenerationCapacity(generationId: number, eid: TEntityId): void; } export interface TComponentData { From 2cb66e974aa920b71256469b75f634b60890ecf8 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Wed, 28 May 2025 05:49:35 +0200 Subject: [PATCH 08/39] #105 wip --- .../feature-ecs/src/component-registry.ts | 41 ------------------- .../feature-ecs/src/query-registry.test.ts | 0 packages/feature-ecs/src/query-registry.ts | 0 3 files changed, 41 deletions(-) create mode 100644 packages/feature-ecs/src/query-registry.test.ts create mode 100644 packages/feature-ecs/src/query-registry.ts diff --git a/packages/feature-ecs/src/component-registry.ts b/packages/feature-ecs/src/component-registry.ts index acf4436d..3e83b995 100644 --- a/packages/feature-ecs/src/component-registry.ts +++ b/packages/feature-ecs/src/component-registry.ts @@ -11,47 +11,6 @@ * - Cache-friendly iteration patterns * - Unlimited components via generation system * - Flexible component structure - supports multiple patterns - * - * ## Supported Component Patterns - * - * ### 1. Object with Array Properties (AoS) - * Best for components with multiple properties. Each property is stored in a separate array - * for maximum cache efficiency and performance. - * ```typescript - * type TPosition = { x: number[]; y: number[] }; - * const Position: TPosition = { x: [], y: [] }; - * - * // Usage: Position.x[eid] = 10; Position.y[eid] = 20; - * ``` - * - * ### 2. Array of Objects (SoA) - * Easier to understand but less cache-friendly. Good for prototyping or when performance - * isn't critical. - * ```typescript - * type TTransform = { x: number; y: number; rotation: number }[]; - * const Transform: TTransform = []; - * - * // Usage: Transform[eid] = { x: 10, y: 20, rotation: 0 }; - * ``` - * - * ### 3. Single Value Array - * For components that store a single value per entity. - * ```typescript - * type THealth = number[]; - * const Health: THealth = []; - * - * // Usage: Health[eid] = 100; - * ``` - * - * ### 4. Tag Components (Markers) - * For components that just mark entities as having a certain property. - * No data storage needed. - * ```typescript - * type TPlayer = {}; - * const Player: TPlayer = {}; - * - * // Usage: registry.addComponent(eid, Player); // Just marks entity as player - * ``` */ import { TEntityId } from './entity-index'; diff --git a/packages/feature-ecs/src/query-registry.test.ts b/packages/feature-ecs/src/query-registry.test.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/feature-ecs/src/query-registry.ts b/packages/feature-ecs/src/query-registry.ts new file mode 100644 index 00000000..e69de29b From e6b2672db433c323bc66c1bd9bff62fc7d20ad8a Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Wed, 28 May 2025 07:09:18 +0200 Subject: [PATCH 09/39] #105 wip --- packages/feature-ecs/package.json | 4 +- .../src/component-registry.test.ts | 331 ++++++++++++++++++ .../feature-ecs/src/component-registry.ts | 264 +++++++++++++- packages/feature-ecs/src/query-term.ts | 66 ++++ pnpm-lock.yaml | 9 + 5 files changed, 671 insertions(+), 3 deletions(-) create mode 100644 packages/feature-ecs/src/query-term.ts diff --git a/packages/feature-ecs/package.json b/packages/feature-ecs/package.json index 0316a70d..1faf31da 100644 --- a/packages/feature-ecs/package.json +++ b/packages/feature-ecs/package.json @@ -34,7 +34,9 @@ "test": "vitest run", "update:latest": "pnpm update --latest" }, - "dependencies": {}, + "dependencies": { + "@blgc/utils": "^0.0.52" + }, "devDependencies": { "@blgc/config": "workspace:*", "@types/node": "^22.15.21", diff --git a/packages/feature-ecs/src/component-registry.test.ts b/packages/feature-ecs/src/component-registry.test.ts index a469a553..31fcb0dc 100644 --- a/packages/feature-ecs/src/component-registry.test.ts +++ b/packages/feature-ecs/src/component-registry.test.ts @@ -550,6 +550,337 @@ describe('createComponentRegistry', () => { expect(registry.validate()).toBe(true); }); }); + + describe('change tracking', () => { + it('should track component additions', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const eid = 1; + + // Initially, component should not be marked as added + expect(registry.wasAdded(eid, Position)).toBe(false); + + // Add component + registry.addComponent(eid, Position); + + // Now it should be marked as added + expect(registry.wasAdded(eid, Position)).toBe(true); + expect(registry.hasComponent(eid, Position)).toBe(true); + }); + + it('should track component removals', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const eid = 1; + + // Add component first + registry.addComponent(eid, Position); + expect(registry.wasRemoved(eid, Position)).toBe(false); + + // Remove component + registry.removeComponent(eid, Position); + + // Now it should be marked as removed + expect(registry.wasRemoved(eid, Position)).toBe(true); + expect(registry.hasComponent(eid, Position)).toBe(false); + }); + + it('should track component changes', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const eid = 1; + + // Add component first + registry.addComponent(eid, Position); + expect(registry.wasChanged(eid, Position)).toBe(false); + + // Mark as changed + const result = registry.markChanged(eid, Position); + + // Should be marked as changed + expect(result).toBe(true); + expect(registry.wasChanged(eid, Position)).toBe(true); + }); + + it('should not mark non-existent components as changed', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const eid = 1; + + // Try to mark non-existent component as changed + const result = registry.markChanged(eid, Position); + + // Should fail + expect(result).toBe(false); + expect(registry.wasChanged(eid, Position)).toBe(false); + }); + + it('should clear frame changes', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const eid = 1; + + // Add components and mark changes + registry.addComponent(eid, Position); + registry.addComponent(eid, Health); + registry.markChanged(eid, Position); + registry.removeComponent(eid, Health); + + // Verify changes are tracked + expect(registry.wasAdded(eid, Position)).toBe(true); + expect(registry.wasChanged(eid, Position)).toBe(true); + expect(registry.wasRemoved(eid, Health)).toBe(true); + + // Clear frame changes + registry.clear(); + + // Changes should be cleared + expect(registry.wasAdded(eid, Position)).toBe(false); + expect(registry.wasChanged(eid, Position)).toBe(false); + expect(registry.wasRemoved(eid, Health)).toBe(false); + + // But component state should remain + expect(registry.hasComponent(eid, Position)).toBe(true); + expect(registry.hasComponent(eid, Health)).toBe(false); + }); + + it('should handle multiple entities and components', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const eid1 = 1; + const eid2 = 2; + + // Entity 1: Add Position, mark changed + registry.addComponent(eid1, Position); + registry.markChanged(eid1, Position); + + // Entity 2: Add Health, remove it + registry.addComponent(eid2, Health); + registry.removeComponent(eid2, Health); + + // Verify tracking for entity 1 + expect(registry.wasAdded(eid1, Position)).toBe(true); + expect(registry.wasChanged(eid1, Position)).toBe(true); + expect(registry.wasRemoved(eid1, Health)).toBe(false); + + // Verify tracking for entity 2 + expect(registry.wasAdded(eid2, Health)).toBe(true); + expect(registry.wasRemoved(eid2, Health)).toBe(true); + expect(registry.wasChanged(eid2, Position)).toBe(false); + }); + + it('should work with multi-generation components', () => { + // Create 32 components to trigger generation overflow + const components = Array.from({ length: 32 }, (_, i) => ({ [`prop${i}`]: [] as number[] })); + const eid = 1; + + // Register all components (this will create multiple generations) + components.forEach((comp) => registry.registerComponent(comp)); + + // Add components from different generations + registry.addComponent(eid, components[0]); // Generation 0 + registry.addComponent(eid, components[31]); // Generation 1 + + // Verify tracking works across generations + expect(registry.wasAdded(eid, components[0])).toBe(true); + expect(registry.wasAdded(eid, components[31])).toBe(true); + + // Mark changes and verify + registry.markChanged(eid, components[0]); + registry.markChanged(eid, components[31]); + + expect(registry.wasChanged(eid, components[0])).toBe(true); + expect(registry.wasChanged(eid, components[31])).toBe(true); + }); + }); + + describe('callback system', () => { + it('should call onAdd callbacks when components are added', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const eid1 = 1; + const eid2 = 2; + + // Setup callbacks + const positionAddedEntities: number[] = []; + const healthAddedEntities: number[] = []; + + registry.onComponentAdd(Position, (eid) => { + positionAddedEntities.push(eid); + }); + + registry.onComponentAdd(Health, (eid) => { + healthAddedEntities.push(eid); + }); + + // Add components + registry.addComponent(eid1, Position); + registry.addComponent(eid1, Health); + registry.addComponent(eid2, Position); + + // Verify callbacks were called + expect(positionAddedEntities).toEqual([eid1, eid2]); + expect(healthAddedEntities).toEqual([eid1]); + }); + + it('should call onChange callbacks when components are marked as changed', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const eid1 = 1; + const eid2 = 2; + + // Add components first + registry.addComponent(eid1, Position); + registry.addComponent(eid1, Health); + registry.addComponent(eid2, Position); + + // Setup callbacks + const positionChangedEntities: number[] = []; + const healthChangedEntities: number[] = []; + + registry.onComponentChange(Position, (eid) => { + positionChangedEntities.push(eid); + }); + + registry.onComponentChange(Health, (eid) => { + healthChangedEntities.push(eid); + }); + + // Mark components as changed + registry.markChanged(eid1, Position); + registry.markChanged(eid1, Health); + registry.markChanged(eid2, Position); + + // Verify callbacks were called + expect(positionChangedEntities).toEqual([eid1, eid2]); + expect(healthChangedEntities).toEqual([eid1]); + }); + + it('should call onRemove callbacks when components are removed', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const eid1 = 1; + const eid2 = 2; + + // Add components first + registry.addComponent(eid1, Position); + registry.addComponent(eid1, Health); + registry.addComponent(eid2, Position); + + // Setup callbacks + const positionRemovedEntities: number[] = []; + const healthRemovedEntities: number[] = []; + + registry.onComponentRemove(Position, (eid) => { + positionRemovedEntities.push(eid); + }); + + registry.onComponentRemove(Health, (eid) => { + healthRemovedEntities.push(eid); + }); + + // Remove components + registry.removeComponent(eid1, Position); + registry.removeComponent(eid1, Health); + registry.removeComponent(eid2, Position); + + // Verify callbacks were called + expect(positionRemovedEntities).toEqual([eid1, eid2]); + expect(healthRemovedEntities).toEqual([eid1]); + }); + + it('should not call callbacks for non-existent operations', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const eid = 1; + + // Setup callbacks + let addCallCount = 0; + let changeCallCount = 0; + let removeCallCount = 0; + + registry.onComponentAdd(Position, () => addCallCount++); + registry.onComponentChange(Position, () => changeCallCount++); + registry.onComponentRemove(Position, () => removeCallCount++); + + // Try to mark non-existent component as changed + registry.markChanged(eid, Position); + expect(changeCallCount).toBe(0); + + // Try to remove non-existent component + registry.removeComponent(eid, Position); + expect(removeCallCount).toBe(0); + + // Add component (should trigger callback) + registry.addComponent(eid, Position); + expect(addCallCount).toBe(1); + + // Try to add same component again (should not trigger callback) + registry.addComponent(eid, Position); + expect(addCallCount).toBe(1); + }); + + it('should handle multiple callbacks for same component', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const eid = 1; + + // Setup multiple callbacks + const callback1Calls: number[] = []; + const callback2Calls: number[] = []; + + registry.onComponentAdd(Position, (eid) => callback1Calls.push(eid)); + registry.onComponentAdd(Position, (eid) => callback2Calls.push(eid)); + + // Add component + registry.addComponent(eid, Position); + + // Both callbacks should be called + expect(callback1Calls).toEqual([eid]); + expect(callback2Calls).toEqual([eid]); + }); + + it('should support unregister functions', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const eid = 1; + + const callbackResults: string[] = []; + + // Register multiple callbacks + const unregister1 = registry.onComponentAdd(Position, () => { + callbackResults.push('callback1'); + }); + + const unregister2 = registry.onComponentAdd(Position, () => { + callbackResults.push('callback2'); + }); + + const unregister3 = registry.onComponentAdd(Position, () => { + callbackResults.push('callback3'); + }); + + // Add component - all callbacks should fire + registry.addComponent(eid, Position); + expect(callbackResults).toEqual(['callback1', 'callback2', 'callback3']); + + // Unregister middle callback + unregister2(); + + // Reset and test again + callbackResults.length = 0; + registry.removeComponent(eid, Position); + registry.addComponent(eid, Position); + + // Only callback1 and callback3 should fire + expect(callbackResults).toEqual(['callback1', 'callback3']); + + // Unregister all remaining + unregister1(); + unregister3(); + + // Reset and test again + callbackResults.length = 0; + registry.removeComponent(eid, Position); + registry.addComponent(eid, Position); + + // No callbacks should fire + expect(callbackResults).toEqual([]); + }); + }); }); // 1. Object with Array Properties (Performance Optimized) diff --git a/packages/feature-ecs/src/component-registry.ts b/packages/feature-ecs/src/component-registry.ts index 3e83b995..f7fc3848 100644 --- a/packages/feature-ecs/src/component-registry.ts +++ b/packages/feature-ecs/src/component-registry.ts @@ -59,6 +59,12 @@ export function createComponentRegistry(): TComponentRegistry { _componentCount: 0, _currentBitflag: 1, + _addedMasks: [[]], + _changedMasks: [[]], + _removedMasks: [[]], + + _callbacks: new Map(), + registerComponent(component) { if (this._componentMap.has(component)) { return this._componentMap.get(component) as TComponentData; @@ -78,6 +84,9 @@ export function createComponentRegistry(): TComponentRegistry { if (this._currentBitflag >= 2 ** 31) { this._currentBitflag = 1; this._entityMasks.push([]); + this._addedMasks.push([]); + this._changedMasks.push([]); + this._removedMasks.push([]); } return componentData; @@ -125,6 +134,19 @@ export function createComponentRegistry(): TComponentRegistry { // We don't prefill arrays to create sparse arrays for memory efficiency // @ts-expect-error - generationId exists because we ensure it when registering the component this._entityMasks[generationId][eid] = currentMask | bitflag; + + // Track that this component was added this frame + const currentAddedMask = this._addedMasks[generationId]?.[eid] ?? 0; + // @ts-expect-error - generationId exists because we ensure it when registering the component + this._addedMasks[generationId][eid] = currentAddedMask | bitflag; + + // Fire callbacks if registered + const callbacks = this._callbacks.get(component); + if (callbacks?.onAdd != null) { + for (const callback of callbacks.onAdd) { + callback(eid); + } + } }, // Component Removal Flow @@ -151,6 +173,19 @@ export function createComponentRegistry(): TComponentRegistry { return false; } + // Track that this component was removed this frame + const currentRemovedMask = this._removedMasks[generationId]?.[eid] ?? 0; + // @ts-expect-error - generationId exists because we ensure it when registering the component + this._removedMasks[generationId][eid] = currentRemovedMask | bitflag; + + // Fire callbacks if registered + const callbacks = this._callbacks.get(component); + if (callbacks?.onRemove != null) { + for (const callback of callbacks.onRemove) { + callback(eid); + } + } + // Clear component bit // @ts-expect-error - generationId exists because we ensure it when registering the component this._entityMasks[generationId][eid] = currentMask & ~bitflag; @@ -172,12 +207,149 @@ export function createComponentRegistry(): TComponentRegistry { }, removeAllComponents(eid) { - // Use removeComponent to reuse logic and ensure consistency for (const component of this._componentMap.keys()) { this.removeComponent(eid, component); } }, + markChanged(eid, component) { + const componentData = this._componentMap.get(component); + if (componentData == null) { + return false; + } + + const { generationId, bitflag } = componentData; + + // Only mark as changed if entity actually has this component + const currentMask = this._entityMasks[generationId]?.[eid] ?? 0; + if ((currentMask & bitflag) === 0) { + return false; + } + + // Track that this component was changed this frame + const currentChangedMask = this._changedMasks[generationId]?.[eid] ?? 0; + // @ts-expect-error - generationId exists because we ensure it when registering the component + this._changedMasks[generationId][eid] = currentChangedMask | bitflag; + + // Fire callbacks if registered + const callbacks = this._callbacks.get(component); + if (callbacks?.onChange != null) { + for (const callback of callbacks.onChange) { + callback(eid); + } + } + + return true; + }, + + wasAdded(eid, component) { + const componentData = this._componentMap.get(component); + if (componentData == null) { + return false; + } + + const { generationId, bitflag } = componentData; + const mask = this._addedMasks[generationId]?.[eid] ?? 0; + return (mask & bitflag) !== 0; + }, + + wasChanged(eid, component) { + const componentData = this._componentMap.get(component); + if (componentData == null) { + return false; + } + + const { generationId, bitflag } = componentData; + const mask = this._changedMasks[generationId]?.[eid] ?? 0; + return (mask & bitflag) !== 0; + }, + + wasRemoved(eid, component) { + const componentData = this._componentMap.get(component); + if (componentData == null) { + return false; + } + + const { generationId, bitflag } = componentData; + const mask = this._removedMasks[generationId]?.[eid] ?? 0; + return (mask & bitflag) !== 0; + }, + + onComponentAdd(component, callback) { + if (!this._callbacks.has(component)) { + this._callbacks.set(component, {}); + } + const componentCallbacks = this._callbacks.get(component) as TComponentCallbacks; + if (componentCallbacks.onAdd == null) { + componentCallbacks.onAdd = []; + } + componentCallbacks.onAdd.push(callback); + + // Return unregister function + return () => { + const index = componentCallbacks.onAdd?.indexOf(callback); + if (index != null && index !== -1) { + componentCallbacks.onAdd?.splice(index, 1); + } + }; + }, + + onComponentChange(component, callback) { + if (!this._callbacks.has(component)) { + this._callbacks.set(component, {}); + } + const componentCallbacks = this._callbacks.get(component) as TComponentCallbacks; + if (componentCallbacks.onChange == null) { + componentCallbacks.onChange = []; + } + componentCallbacks.onChange.push(callback); + + // Return unregister function + return () => { + const index = componentCallbacks.onChange?.indexOf(callback); + if (index != null && index !== -1) { + componentCallbacks.onChange?.splice(index, 1); + } + }; + }, + + onComponentRemove(component, callback) { + if (!this._callbacks.has(component)) { + this._callbacks.set(component, {}); + } + const componentCallbacks = this._callbacks.get(component) as TComponentCallbacks; + if (componentCallbacks.onRemove == null) { + componentCallbacks.onRemove = []; + } + componentCallbacks.onRemove.push(callback); + + // Return unregister function + return () => { + const index = componentCallbacks.onRemove?.indexOf(callback); + if (index != null && index !== -1) { + componentCallbacks.onRemove?.splice(index, 1); + } + }; + }, + + clear() { + // Clear all change tracking for the current frame + for (let generationId = 0; generationId < this._addedMasks.length; generationId++) { + if (this._addedMasks[generationId] != null) { + // @ts-expect-error - generationId exists because we checked above + this._addedMasks[generationId].length = 0; + } + if (this._changedMasks[generationId] != null) { + // @ts-expect-error - generationId exists because we checked above + this._changedMasks[generationId].length = 0; + } + if (this._removedMasks[generationId] != null) { + // @ts-expect-error - generationId exists because we checked above + this._removedMasks[generationId].length = 0; + } + } + }, + reset() { // Clear all component data arrays for (const component of this._componentMap.keys()) { @@ -196,9 +368,16 @@ export function createComponentRegistry(): TComponentRegistry { this._componentMap.clear(); this._entityMasks.length = 0; - this._entityMasks.push([]); // Start with one generation + this._entityMasks.push([]); + this._addedMasks.length = 0; + this._addedMasks.push([]); + this._changedMasks.length = 0; + this._changedMasks.push([]); + this._removedMasks.length = 0; + this._removedMasks.push([]); this._componentCount = 0; this._currentBitflag = 1; + this._callbacks.clear(); }, validate() { @@ -207,6 +386,15 @@ export function createComponentRegistry(): TComponentRegistry { return false; } + // Validate change tracking arrays match entity masks + if ( + this._addedMasks.length !== this._entityMasks.length || + this._changedMasks.length !== this._entityMasks.length || + this._removedMasks.length !== this._entityMasks.length + ) { + return false; + } + // Validate bitflag consistency within generations const generationCounts = new Array(this._entityMasks.length).fill(0); @@ -245,6 +433,14 @@ export interface TComponentRegistry { _componentCount: number; /** Current bitflag value for next component */ _currentBitflag: number; + /** Array of added component masks by generation */ + _addedMasks: number[][]; + /** Array of changed component masks by generation */ + _changedMasks: number[][]; + /** Array of removed component masks by generation */ + _removedMasks: number[][]; + /** Optional callback system for real-time reactions */ + _callbacks: Map; /** * Registers a component and returns its metadata. @@ -284,6 +480,64 @@ export interface TComponentRegistry { */ removeAllComponents(eid: TEntityId): void; + /** + * Marks a component as changed for the current frame. + * @param eid - The entity ID + * @param component - The component to mark as changed + * @returns True if component was marked as changed, false if entity didn't have it + */ + markChanged(eid: TEntityId, component: TComponentRef): boolean; + + /** + * Checks if a component was added to an entity in the current frame. + * @param eid - The entity ID + * @param component - The component to check + * @returns True if component was added to the entity in the current frame + */ + wasAdded(eid: TEntityId, component: TComponentRef): boolean; + + /** + * Checks if a component was changed for an entity in the current frame. + * @param eid - The entity ID + * @param component - The component to check + * @returns True if component was changed for the entity in the current frame + */ + wasChanged(eid: TEntityId, component: TComponentRef): boolean; + + /** + * Checks if a component was removed from an entity in the current frame. + * @param eid - The entity ID + * @param component - The component to check + * @returns True if component was removed from the entity in the current frame + */ + wasRemoved(eid: TEntityId, component: TComponentRef): boolean; + + /** + * Registers a callback for when a component is added to an entity. + * @param component - The component to register the callback for + * @param callback - The callback function to register + */ + onComponentAdd(component: TComponentRef, callback: (eid: TEntityId) => void): () => void; + + /** + * Registers a callback for when a component is changed for an entity. + * @param component - The component to register the callback for + * @param callback - The callback function to register + */ + onComponentChange(component: TComponentRef, callback: (eid: TEntityId) => void): () => void; + + /** + * Registers a callback for when a component is removed from an entity. + * @param component - The component to register the callback for + * @param callback - The callback function to register + */ + onComponentRemove(component: TComponentRef, callback: (eid: TEntityId) => void): () => void; + + /** + * Clears all change tracking for the current frame. + */ + clear(): void; + /** * Resets the registry to its initial empty state. */ @@ -308,3 +562,9 @@ export interface TComponentData { } export type TComponentRef = any; // Can be array or object with arrays + +export interface TComponentCallbacks { + onAdd?: ((eid: TEntityId) => void)[]; + onChange?: ((eid: TEntityId) => void)[]; + onRemove?: ((eid: TEntityId) => void)[]; +} diff --git a/packages/feature-ecs/src/query-term.ts b/packages/feature-ecs/src/query-term.ts new file mode 100644 index 00000000..9ff457ce --- /dev/null +++ b/packages/feature-ecs/src/query-term.ts @@ -0,0 +1,66 @@ +import { TComponentRef } from './component-registry'; + +// Filter types for different query operations +export type TFilter = + | { type: 'With'; component: TComponentRef } + | { type: 'Without'; component: TComponentRef } + | { type: 'Added'; component: TComponentRef } + | { type: 'Changed'; component: TComponentRef } + | { type: 'Removed'; component: TComponentRef } + | { type: 'And'; filters: TFilter[] } + | { type: 'Or'; filters: TFilter[] } + | { type: 'Not'; filters: TFilter[] }; + +// Component filter constructors +export const With = (component: T): TFilter => ({ + type: 'With', + component +}); + +export const Without = (component: T): TFilter => ({ + type: 'Without', + component +}); + +export const Added = (component: T): TFilter => ({ + type: 'Added', + component +}); + +export const Changed = (component: T): TFilter => ({ + type: 'Changed', + component +}); + +export const Removed = (component: T): TFilter => ({ + type: 'Removed', + component +}); + +// Logical filter constructors +export const And = (...filters: TFilter[]): TFilter => ({ + type: 'And', + filters +}); + +export const All = And; // Alias for And + +export const Or = (...filters: TFilter[]): TFilter => ({ + type: 'Or', + filters +}); + +export const Any = Or; // Alias for Or + +export const Not = (...filters: TFilter[]): TFilter => ({ + type: 'Not', + filters +}); + +export const None = Not; // Alias for Not + +// Special entity symbol for queries +export const Entity = Symbol('Entity'); + +// Query term can be a component or a filter +export type TQueryTerm = TComponentRef | TFilter; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae58f7bb..7d8d19f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -391,6 +391,10 @@ importers: version: link:../rollup-presets packages/feature-ecs: + dependencies: + '@blgc/utils': + specifier: ^0.0.52 + version: 0.0.52 devDependencies: '@blgc/config': specifier: workspace:* @@ -867,6 +871,9 @@ packages: resolution: {integrity: sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==} engines: {node: '>=6.9.0'} + '@blgc/utils@0.0.52': + resolution: {integrity: sha512-wshoO58fjGVbsVw8Y24vMKAHCwUdL7052/q/iESONDPd9s+Q8dEHGQTL7RGSPWFtpc8nTIeW69G8pUd1wM0ZLg==} + '@bundled-es-modules/cookie@2.0.1': resolution: {integrity: sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==} @@ -4535,6 +4542,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@blgc/utils@0.0.52': {} + '@bundled-es-modules/cookie@2.0.1': dependencies: cookie: 0.7.2 From de16756794d248a99eac97e84e19f99377ebbc35 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Wed, 28 May 2025 08:12:40 +0200 Subject: [PATCH 10/39] #105 wip --- packages/feature-ecs/src/index.ts | 3 +- packages/feature-ecs/src/query-filter.ts | 112 ++++ .../feature-ecs/src/query-registry.test.ts | 489 +++++++++++++++++ packages/feature-ecs/src/query-registry.ts | 490 ++++++++++++++++++ packages/feature-ecs/src/query-term.ts | 85 ++- packages/feature-ecs/src/query.ts | 3 - packages/feature-ecs/src/world.ts | 62 ++- 7 files changed, 1215 insertions(+), 29 deletions(-) create mode 100644 packages/feature-ecs/src/query-filter.ts delete mode 100644 packages/feature-ecs/src/query.ts diff --git a/packages/feature-ecs/src/index.ts b/packages/feature-ecs/src/index.ts index c092cbe7..aa16db95 100644 --- a/packages/feature-ecs/src/index.ts +++ b/packages/feature-ecs/src/index.ts @@ -1,4 +1,5 @@ export * from './component-registry'; export * from './entity-index'; -export * from './query'; +export * from './query-filter'; +export * from './query-registry'; export * from './world'; diff --git a/packages/feature-ecs/src/query-filter.ts b/packages/feature-ecs/src/query-filter.ts new file mode 100644 index 00000000..657f41e0 --- /dev/null +++ b/packages/feature-ecs/src/query-filter.ts @@ -0,0 +1,112 @@ +import { TComponentRef } from './component-registry'; + +export type TGetComponentId = (component: TComponentRef) => number; + +/** + * A query filter represents a condition used to select entities in ECS queries. + * Query filters can be simple conditions (With, Without) or complex combinations (And, Or, Not). + */ +export type TQueryFilter = + | { type: 'With'; component: TComponentRef; toString(getComponentId: TGetComponentId): string } + | { type: 'Without'; component: TComponentRef; toString(getComponentId: TGetComponentId): string } + | { type: 'Added'; component: TComponentRef; toString(getComponentId: TGetComponentId): string } + | { type: 'Changed'; component: TComponentRef; toString(getComponentId: TGetComponentId): string } + | { type: 'Removed'; component: TComponentRef; toString(getComponentId: TGetComponentId): string } + | { type: 'And'; filters: TQueryFilter[]; toString(getComponentId: TGetComponentId): string } + | { type: 'Or'; filters: TQueryFilter[]; toString(getComponentId: TGetComponentId): string } + | { type: 'Not'; filters: TQueryFilter[]; toString(getComponentId: TGetComponentId): string } + | { type: 'None'; toString(): string }; + +export const With = (component: T): TQueryFilter => ({ + type: 'With', + component, + toString(getComponentId) { + return `with(${getComponentId(component)})`; + } +}); + +export const Without = (component: T): TQueryFilter => ({ + type: 'Without', + component, + toString(getComponentId) { + return `without(${getComponentId(component)})`; + } +}); + +export const Added = (component: T): TQueryFilter => ({ + type: 'Added', + component, + toString(getComponentId) { + return `added(${getComponentId(component)})`; + } +}); + +export const Changed = (component: T): TQueryFilter => ({ + type: 'Changed', + component, + toString(getComponentId) { + return `changed(${getComponentId(component)})`; + } +}); + +export const Removed = (component: T): TQueryFilter => ({ + type: 'Removed', + component, + toString(getComponentId) { + return `removed(${getComponentId(component)})`; + } +}); + +export const And = (...filters: TQueryFilter[]): TQueryFilter => ({ + type: 'And', + filters, + toString(getComponentId) { + return `and(${filters.map((f) => f.toString(getComponentId)).join(',')})`; + } +}); + +export const All = And; // Alias for And + +export const Or = (...filters: TQueryFilter[]): TQueryFilter => ({ + type: 'Or', + filters, + toString(getComponentId) { + return `or(${filters.map((f) => f.toString(getComponentId)).join(',')})`; + } +}); + +export const Any = Or; // Alias for Or + +export const Not = (...filters: TQueryFilter[]): TQueryFilter => ({ + type: 'Not', + filters, + toString(getComponentId: TGetComponentId) { + return `not(${filters.map((f) => f.toString(getComponentId)).join(',')})`; + } +}); + +export const None = (): TQueryFilter => ({ + type: 'None', + toString() { + return 'none()'; + } +}); + +// Special entity symbol for queries +// TODO: Put into query-data or so? +export const Entity = Symbol('Entity'); + +/** + * Type guard to check if a value is a query filter. + */ +export function isQueryFilter(value: any): value is TQueryFilter { + return ( + typeof value === 'object' && + value !== null && + 'type' in value && + typeof value.type === 'string' && + ['With', 'Without', 'Added', 'Changed', 'Removed', 'And', 'Or', 'Not', 'None'].includes( + value.type + ) + ); +} diff --git a/packages/feature-ecs/src/query-registry.test.ts b/packages/feature-ecs/src/query-registry.test.ts index e69de29b..a6762516 100644 --- a/packages/feature-ecs/src/query-registry.test.ts +++ b/packages/feature-ecs/src/query-registry.test.ts @@ -0,0 +1,489 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { Added, And, Changed, None, Or, Removed, With, Without } from './query-filter'; +import { createWorld, TWorld } from './world'; + +describe('createQueryRegistry', () => { + let world: TWorld; + + beforeEach(() => { + world = createWorld(); + }); + + describe('generateQueryHash', () => { + it('should generate unique hashes for different filter types', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + // Different filter types should generate different hashes + const withHash = world._queryRegistry.generateQueryHash(With(Position)); + const withoutHash = world._queryRegistry.generateQueryHash(Without(Position)); + const addedHash = world._queryRegistry.generateQueryHash(Added(Position)); + const changedHash = world._queryRegistry.generateQueryHash(Changed(Position)); + const removedHash = world._queryRegistry.generateQueryHash(Removed(Position)); + + // All hashes should be different + const hashes = [withHash, withoutHash, addedHash, changedHash, removedHash]; + const uniqueHashes = new Set(hashes); + expect(uniqueHashes.size).toBe(hashes.length); + }); + + it('should generate same hash for identical queries', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Velocity = { x: [] as number[], y: [] as number[] }; + + const filter1 = And(With(Position), With(Velocity)); + const filter2 = And(With(Position), With(Velocity)); + + const hash1 = world._queryRegistry.generateQueryHash(filter1); + const hash2 = world._queryRegistry.generateQueryHash(filter2); + + expect(hash1).toBe(hash2); + }); + + it('should auto-register components when generating hash', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + // Component should not be registered initially + expect(world._componentRegistry._componentMap.has(Position)).toBe(false); + + // Generating hash should auto-register component + world._queryRegistry.generateQueryHash(With(Position)); + expect(world._componentRegistry._componentMap.has(Position)).toBe(true); + }); + }); + + describe('registerQuery', () => { + it('should register and cache queries', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Velocity = { x: [] as number[], y: [] as number[] }; + + const queryData1 = world._queryRegistry.registerQuery(And(With(Position), With(Velocity))); + const queryData2 = world._queryRegistry.registerQuery(And(With(Position), With(Velocity))); + + // Should return same cached query data + expect(queryData1).toBe(queryData2); + expect(world._queryRegistry._queryCache.size).toBe(1); + }); + + it('should store filter', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const filter = And(With(Position), With(Health)); + const queryData = world._queryRegistry.registerQuery(filter); + + // Should store the exact filter + expect(queryData.filter).toBe(filter); + expect(queryData.filter.type).toBe('And'); + }); + + it('should handle single component filters', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const filter = With(Position); + const queryData = world._queryRegistry.registerQuery(filter); + + // Should store the With filter + expect(queryData.filter).toBe(filter); + expect(queryData.filter.type).toBe('With'); + }); + + it('should handle None filter', () => { + const filter = None(); + const queryData = world._queryRegistry.registerQuery(filter); + + // Should store the None filter + expect(queryData.filter).toBe(filter); + expect(queryData.filter.type).toBe('None'); + }); + }); + + describe('executeQuery', () => { + it('should return no entities for None filter', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + // Create entities with components + const eid1 = world.addEntity(); + const eid2 = world.addEntity(); + world._componentRegistry.addComponent(eid1, Position); + world._componentRegistry.addComponent(eid2, Position); + + // None filter should return no entities + const emptyResult = world.query(None()); + expect(emptyResult).toEqual([]); + }); + + it('should execute basic With queries', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Velocity = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + // Create entities + const eid1 = world.addEntity(); + const eid2 = world.addEntity(); + const eid3 = world.addEntity(); + + // Add components + world._componentRegistry.addComponent(eid1, Position); + world._componentRegistry.addComponent(eid1, Velocity); + + world._componentRegistry.addComponent(eid2, Position); + world._componentRegistry.addComponent(eid2, Health); + + world._componentRegistry.addComponent(eid3, Velocity); + world._componentRegistry.addComponent(eid3, Health); + + // Test basic WITH queries + const positionAndVelocity = world.query(And(With(Position), With(Velocity))); + expect(positionAndVelocity).toEqual([eid1]); + + const positionOnly = world.query(With(Position)); + expect(positionOnly.sort()).toEqual([eid1, eid2].sort()); + }); + + it('should execute Without queries', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid1 = world.addEntity(); + const eid2 = world.addEntity(); + + world._componentRegistry.addComponent(eid1, Position); + world._componentRegistry.addComponent(eid2, Position); + world._componentRegistry.addComponent(eid2, Health); + + // Test WITHOUT filter + const hasPositionButNotHealth = world.query(And(With(Position), Without(Health))); + expect(hasPositionButNotHealth).toEqual([eid1]); + }); + + it('should execute And queries', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid1 = world.addEntity(); + const eid2 = world.addEntity(); + + world._componentRegistry.addComponent(eid1, Position); + world._componentRegistry.addComponent(eid2, Position); + world._componentRegistry.addComponent(eid2, Health); + + // Test explicit AND filter + const explicitAnd = world.query(And(With(Position), With(Health))); + expect(explicitAnd).toEqual([eid2]); + }); + + it('should execute Or queries', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid1 = world.addEntity(); + const eid2 = world.addEntity(); + const eid3 = world.addEntity(); + + world._componentRegistry.addComponent(eid1, Position); + world._componentRegistry.addComponent(eid2, Health); + world._componentRegistry.addComponent(eid3, Position); + world._componentRegistry.addComponent(eid3, Health); + + // Test OR filter + const positionOrHealth = world.query(Or(With(Position), With(Health))); + expect(positionOrHealth.sort()).toEqual([eid1, eid2, eid3].sort()); + }); + + it('should execute complex filter combinations', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const Shield = [] as number[]; + const Stunned = {}; + const Paralyzed = {}; + + // Create entities + const eid1 = world.addEntity(); + const eid2 = world.addEntity(); + const eid3 = world.addEntity(); + + // Setup entity 1: Position + Health + Stunned + world._componentRegistry.addComponent(eid1, Position); + world._componentRegistry.addComponent(eid1, Health); + world._componentRegistry.addComponent(eid1, Stunned); + + // Setup entity 2: Position + Shield + world._componentRegistry.addComponent(eid2, Position); + world._componentRegistry.addComponent(eid2, Shield); + + // Setup entity 3: Position + Health + Paralyzed + world._componentRegistry.addComponent(eid3, Position); + world._componentRegistry.addComponent(eid3, Health); + world._componentRegistry.addComponent(eid3, Paralyzed); + + // Complex query: Position + (Health OR Shield) + WITHOUT(Stunned) + WITHOUT(Paralyzed) + const complexQuery = world.query( + And(With(Position), Or(With(Health), With(Shield)), Without(Stunned), Without(Paralyzed)) + ); + + // Only eid2 should match (has Position + Shield, no Stunned, no Paralyzed) + expect(complexQuery).toEqual([eid2]); + }); + + it('should cache queries for performance', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Velocity = { x: [] as number[], y: [] as number[] }; + + const eid = world.addEntity(); + world._componentRegistry.addComponent(eid, Position); + world._componentRegistry.addComponent(eid, Velocity); + + // Execute same query multiple times + const filter = And(With(Position), With(Velocity)); + const result1 = world.query(filter); + const result2 = world.query(filter); + + // Should return same results + expect(result1).toEqual(result2); + expect(result1).toEqual([eid]); + + // Verify query was cached + expect(world._queryRegistry._queryCache.size).toBe(1); + }); + + it('should auto-register components in filters', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Velocity = { x: [] as number[], y: [] as number[] }; + + // Query should auto-register components + const result = world.query(And(With(Position), With(Velocity))); + expect(result).toEqual([]); // No entities have these components yet + + // Verify components were auto-registered + expect(world._componentRegistry._componentMap.has(Position)).toBe(true); + expect(world._componentRegistry._componentMap.has(Velocity)).toBe(true); + }); + }); + + describe('executeInnerQuery', () => { + it('should execute queries without committing removals', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const eid = world.addEntity(); + world._componentRegistry.addComponent(eid, Position); + + // Inner query should work the same as regular query for now + const result = world.innerQuery(With(Position)); + expect(result).toEqual([eid]); + }); + }); + + describe('checkEntity', () => { + it('should check if entity matches query data', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid1 = world.addEntity(); + const eid2 = world.addEntity(); + + world._componentRegistry.addComponent(eid1, Position); + world._componentRegistry.addComponent(eid1, Health); + world._componentRegistry.addComponent(eid2, Position); + + const queryData = world._queryRegistry.registerQuery(And(With(Position), With(Health))); + + expect(world._queryRegistry.checkEntity(queryData, eid1)).toBe(true); + expect(world._queryRegistry.checkEntity(queryData, eid2)).toBe(false); + }); + }); + + describe('change detection filters', () => { + it('should support Added filters', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid1 = world.addEntity(); + const eid2 = world.addEntity(); + + // Add Position to eid1 (should be tracked as added) + world._componentRegistry.addComponent(eid1, Position); + + // Add Health to eid2 (should be tracked as added) + world._componentRegistry.addComponent(eid2, Health); + + // Query for entities with added Position + const addedPosition = world.query(Added(Position)); + expect(addedPosition).toEqual([eid1]); + + // Query for entities with added Health + const addedHealth = world.query(Added(Health)); + expect(addedHealth).toEqual([eid2]); + + // Clear frame changes + world.clear(); + + // After clearing, no entities should have added components + const addedPositionAfter = world.query(Added(Position)); + const addedHealthAfter = world.query(Added(Health)); + expect(addedPositionAfter).toEqual([]); + expect(addedHealthAfter).toEqual([]); + }); + + it('should support Changed filters', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid1 = world.addEntity(); + const eid2 = world.addEntity(); + + // Add components first + world._componentRegistry.addComponent(eid1, Position); + world._componentRegistry.addComponent(eid2, Health); + + // Clear initial "added" tracking + world.clear(); + + // Mark Position as changed for eid1 + world.markChanged(eid1, Position); + + // Query for entities with changed Position + const changedPosition = world.query(Changed(Position)); + expect(changedPosition).toEqual([eid1]); + + // Query for entities with changed Health (should be empty) + const changedHealth = world.query(Changed(Health)); + expect(changedHealth).toEqual([]); + }); + + it('should support Removed filters', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid1 = world.addEntity(); + const eid2 = world.addEntity(); + + // Add components first + world._componentRegistry.addComponent(eid1, Position); + world._componentRegistry.addComponent(eid1, Health); + world._componentRegistry.addComponent(eid2, Position); + + // Clear initial "added" tracking + world.clear(); + + // Remove Health from eid1 + world._componentRegistry.removeComponent(eid1, Health); + + // Query for entities with removed Health + const removedHealth = world.query(Removed(Health)); + expect(removedHealth).toEqual([eid1]); + + // Query for entities with removed Position (should be empty) + const removedPosition = world.query(Removed(Position)); + expect(removedPosition).toEqual([]); + }); + + it('should support complex change detection queries', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const Shield = [] as number[]; + + const eid1 = world.addEntity(); + const eid2 = world.addEntity(); + const eid3 = world.addEntity(); + + // Setup initial state + world._componentRegistry.addComponent(eid1, Position); + world._componentRegistry.addComponent(eid1, Health); + + world._componentRegistry.addComponent(eid2, Position); + world._componentRegistry.addComponent(eid2, Shield); + + // Clear initial "added" tracking + world.clear(); + + // Frame changes: + // - Add Shield to eid1 + // - Mark Position as changed for eid2 + // - Add Position to eid3 (new entity) + world._componentRegistry.addComponent(eid1, Shield); + world.markChanged(eid2, Position); + world._componentRegistry.addComponent(eid3, Position); + + // Query: Entities with Position AND (Added Shield OR Changed Position) + const complexQuery = world.query(And(With(Position), Or(Added(Shield), Changed(Position)))); + + // Should match eid1 (has Position + added Shield) and eid2 (has Position + changed Position) + expect(complexQuery.sort()).toEqual([eid1, eid2].sort()); + }); + + it('should combine change detection with regular filters', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const Enemy = {}; + + const eid1 = world.addEntity(); + const eid2 = world.addEntity(); + + // Setup: Both entities have Position and Health + world._componentRegistry.addComponent(eid1, Position); + world._componentRegistry.addComponent(eid1, Health); + world._componentRegistry.addComponent(eid2, Position); + world._componentRegistry.addComponent(eid2, Health); + + // Only eid2 is an enemy + world._componentRegistry.addComponent(eid2, Enemy); + + // Clear initial "added" tracking + world.clear(); + + // Mark Health as changed for both entities + world.markChanged(eid1, Health); + world.markChanged(eid2, Health); + + // Query: Entities with changed Health but WITHOUT Enemy tag + const nonEnemiesWithChangedHealth = world.query(And(Changed(Health), Without(Enemy))); + + // Should only match eid1 (has changed Health but is not an Enemy) + expect(nonEnemiesWithChangedHealth).toEqual([eid1]); + }); + }); + + describe('reset', () => { + it('should reset to initial state', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const eid = world.addEntity(); + world._componentRegistry.addComponent(eid, Position); + + // Execute query to populate cache + world.query(With(Position)); + expect(world._queryRegistry._queryCache.size).toBeGreaterThan(0); + + // Reset should clear everything + world.reset(); + expect(world._queryRegistry._queryCache.size).toBe(0); + expect(world._componentRegistry._componentMap.size).toBe(0); + expect(world._entityIndex.getAliveEntities()).toEqual([]); + }); + }); + + describe('validate', () => { + it('should return true for valid registry', () => { + expect(world._queryRegistry.validate()).toBe(true); + }); + + it('should return true after query operations', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const eid = world.addEntity(); + world._componentRegistry.addComponent(eid, Position); + + world.query(With(Position)); + expect(world._queryRegistry.validate()).toBe(true); + }); + }); + + describe('world integration', () => { + it('should have access to world context', () => { + // Verify query registry has world reference + expect(world._queryRegistry._world).toBe(world); + + // Verify it can access all world properties through the reference + expect(world._queryRegistry._world._componentRegistry).toBe(world._componentRegistry); + expect(world._queryRegistry._world._entityIndex).toBe(world._entityIndex); + }); + }); +}); diff --git a/packages/feature-ecs/src/query-registry.ts b/packages/feature-ecs/src/query-registry.ts index e69de29b..134f9414 100644 --- a/packages/feature-ecs/src/query-registry.ts +++ b/packages/feature-ecs/src/query-registry.ts @@ -0,0 +1,490 @@ +/** + * Query Registry for ECS (Entity Component System) + * + * Provides efficient query execution with bitflag-based entity matching. + * Supports complex query filters including change detection and lifecycle events. + * + * Key features: + * - O(1) query lookup via hash-based caching + * - Complex query filters (With, Without, Added, Changed, Removed, And, Or, Not) + * - Multi-generation bitflag support + * - Change detection for reactive systems + * - Deferred entity removals for safe iteration + * + * ## Query Patterns + * + * ### Basic Queries + * ```typescript + * // Entities with Position AND Velocity (default behavior) + * query(world, [With(Position), With(Velocity)]) + * + * // Same as above using And filter + * query(world, [And(With(Position), With(Velocity))]) + * ``` + * + * ### Change Detection Queries + * ```typescript + * // Entities where Position changed this frame + * query(world, [With(Position), Changed(Position)]) + * + * // Newly spawned entities with Health + * query(world, [Added(Health)]) + * + * // Entities that lost their Weapon component + * query(world, [Removed(Weapon)]) + * ``` + * + * ### Complex Queries + * ```typescript + * // Entities with Position OR Velocity + * query(world, [Or(With(Position), With(Velocity))]) + * + * // Entities with Position but WITHOUT Velocity + * query(world, [With(Position), Without(Velocity)]) + * + * // Complex combinations + * query(world, [ + * With(Position), // Must have Position + * Or(With(Health), With(Shield)), // Must have either Health OR Shield + * Not(With(Stunned), With(Paralyzed)) // Must NOT have Stunned AND must NOT have Paralyzed + * ]) + * ``` + */ + +import { TComponentRef } from './component-registry'; +import { TEntityId } from './entity-index'; +import { TQueryFilter } from './query-filter'; +import { TWorld } from './world'; + +/** + * Creates a new query registry. + * + * @param world - The world instance to use for component and entity access + * @returns A new query registry instance + * + * @example + * ```typescript + * const queryRegistry = createQueryRegistry(world); + * + * // Execute queries with explicit filter syntax + * const entities = queryRegistry.executeQuery(And(With(Position), With(Velocity))); + * + * // Change detection queries + * const changedEntities = queryRegistry.executeQuery( + * And(With(Position), Changed(Position)) + * ); + * ``` + */ +export function createQueryRegistry(world: TWorld): TQueryRegistry { + return { + _world: world, + _queryCache: new Map(), + _dirtyQueries: new Set(), + + generateQueryHash(filter) { + const getComponentId = (component: TComponentRef): number => { + if (!this._world._componentRegistry._componentMap.has(component)) { + this._world._componentRegistry.registerComponent(component); + } + return this._world._componentRegistry._componentMap.get(component)?.id ?? 0; + }; + + return filter.toString(getComponentId); + }, + + registerQuery(filter) { + const hash = this.generateQueryHash(filter); + + if (this._queryCache.has(hash)) { + return this._queryCache.get(hash) as TQueryData; + } + + // Parse the filter into different categories + const withComponents: TComponentRef[] = []; + const withoutComponents: TComponentRef[] = []; + const addedComponents: TComponentRef[] = []; + const changedComponents: TComponentRef[] = []; + const removedComponents: TComponentRef[] = []; + const nestedFilters: TQueryFilter[] = []; + + this._categorizeFilter(filter, { + withComponents, + withoutComponents, + addedComponents, + changedComponents, + removedComponents, + nestedFilters + }); + + // Build generation masks for efficient querying + const withMasks = this._buildGenerationMasks(withComponents); + const withoutMasks = this._buildGenerationMasks(withoutComponents); + const addedMasks = this._buildGenerationMasks(addedComponents); + const changedMasks = this._buildGenerationMasks(changedComponents); + const removedMasks = this._buildGenerationMasks(removedComponents); + + const queryData: TQueryData = { + hash, + filter, + withMasks, + withoutMasks, + addedMasks, + changedMasks, + removedMasks, + nestedFilters, + toRemove: new Set() + }; + + this._queryCache.set(hash, queryData); + return queryData; + }, + + executeQuery(filter) { + // Commit any pending removals first + this._commitRemovals(); + + const queryData = this.registerQuery(filter); + return this._findMatchingEntities(queryData); + }, + + executeInnerQuery(filter) { + // Execute query without committing removals (for nested iteration) + const queryData = this.registerQuery(filter); + return this._findMatchingEntities(queryData); + }, + + checkEntity(queryData, eid) { + // First check basic component requirements (fastest) + if (!this._checkBasicRequirements(queryData, eid)) { + return false; + } + + // Then check change detection requirements + if (!this._checkChangeRequirements(queryData, eid)) { + return false; + } + + // Finally check nested filters (And/Or/Not) + if (!this._checkNestedFilters(queryData, eid)) { + return false; + } + + return true; + }, + + reset() { + this._queryCache.clear(); + this._dirtyQueries.clear(); + }, + + validate() { + // Validate query cache integrity + return this._queryCache.size >= 0; // Basic validation for now + }, + + _categorizeFilter(filter, categories) { + switch (filter.type) { + case 'With': + categories.withComponents.push(filter.component); + break; + case 'Without': + categories.withoutComponents.push(filter.component); + break; + case 'Added': + categories.addedComponents.push(filter.component); + break; + case 'Changed': + categories.changedComponents.push(filter.component); + break; + case 'Removed': + categories.removedComponents.push(filter.component); + break; + case 'And': + case 'Or': + case 'Not': + // Handle nested filters separately - do NOT recursively categorize + // The nested filters will be evaluated entirely by the nested filter logic + categories.nestedFilters.push(filter); + break; + case 'None': + // None filter doesn't contribute to any category + // It will be handled specially to return no entities + categories.nestedFilters.push(filter); + break; + } + }, + + _buildGenerationMasks(components) { + const masks = new Map(); + + for (const component of components) { + // Auto-register component if needed + if (!this._world._componentRegistry._componentMap.has(component)) { + this._world._componentRegistry.registerComponent(component); + } + + const componentData = this._world._componentRegistry._componentMap.get(component)!; + const { generationId, bitflag } = componentData; + + const currentMask = masks.get(generationId) ?? 0; + masks.set(generationId, currentMask | bitflag); + } + + return masks; + }, + + _checkBasicRequirements(queryData, eid) { + const { withMasks, withoutMasks } = queryData; + + // Check across all generations + for ( + let generationId = 0; + generationId < this._world._componentRegistry._entityMasks.length; + generationId++ + ) { + const entityMask = this._world._componentRegistry._entityMasks[generationId]?.[eid] ?? 0; + + // Check WITH requirements (must have ALL) + const withMask = withMasks.get(generationId) ?? 0; + if (withMask !== 0 && (entityMask & withMask) !== withMask) { + return false; + } + + // Check WITHOUT requirements (must have NONE) + const withoutMask = withoutMasks.get(generationId) ?? 0; + if (withoutMask !== 0 && (entityMask & withoutMask) !== 0) { + return false; + } + } + + return true; + }, + + _checkChangeRequirements(queryData, eid) { + const { addedMasks, changedMasks, removedMasks } = queryData; + + // Check across all generations + for ( + let generationId = 0; + generationId < this._world._componentRegistry._entityMasks.length; + generationId++ + ) { + // Check ADDED requirements (component was added this frame) + const addedMask = addedMasks.get(generationId) ?? 0; + if (addedMask !== 0) { + const entityAddedMask = + this._world._componentRegistry._addedMasks[generationId]?.[eid] ?? 0; + if ((entityAddedMask & addedMask) !== addedMask) { + return false; + } + } + + // Check CHANGED requirements (component was modified this frame) + const changedMask = changedMasks.get(generationId) ?? 0; + if (changedMask !== 0) { + const entityChangedMask = + this._world._componentRegistry._changedMasks[generationId]?.[eid] ?? 0; + if ((entityChangedMask & changedMask) !== changedMask) { + return false; + } + } + + // Check REMOVED requirements (component was removed this frame) + const removedMask = removedMasks.get(generationId) ?? 0; + if (removedMask !== 0) { + const entityRemovedMask = + this._world._componentRegistry._removedMasks[generationId]?.[eid] ?? 0; + if ((entityRemovedMask & removedMask) !== removedMask) { + return false; + } + } + } + + return true; + }, + + _checkNestedFilters(queryData, eid) { + const { nestedFilters } = queryData; + + for (const filter of nestedFilters) { + if (!this._evaluateNestedFilter(filter, eid)) { + return false; + } + } + + return true; + }, + + _evaluateNestedFilter(filter, eid) { + switch (filter.type) { + case 'And': + // ALL nested filters must match + return filter.filters.every((nestedFilter: TQueryFilter) => + this._evaluateSingleFilter(nestedFilter, eid) + ); + + case 'Or': + // ANY nested filter must match + return filter.filters.some((nestedFilter: TQueryFilter) => + this._evaluateSingleFilter(nestedFilter, eid) + ); + + case 'Not': + // NONE of the nested filters must match + return !filter.filters.some((nestedFilter: TQueryFilter) => + this._evaluateSingleFilter(nestedFilter, eid) + ); + + case 'None': + // None filter matches no entities + return false; + + default: + // For any other filter type, delegate to _evaluateSingleFilter + return this._evaluateSingleFilter(filter, eid); + } + }, + + _evaluateSingleFilter(filter, eid) { + switch (filter.type) { + case 'With': + return this._world._componentRegistry.hasComponent(eid, filter.component); + + case 'Without': + return !this._world._componentRegistry.hasComponent(eid, filter.component); + + case 'Added': + return this._world._componentRegistry.wasAdded(eid, filter.component); + + case 'Changed': + return this._world._componentRegistry.wasChanged(eid, filter.component); + + case 'Removed': + return this._world._componentRegistry.wasRemoved(eid, filter.component); + + case 'And': + case 'Or': + case 'Not': + return this._evaluateNestedFilter(filter, eid); + + case 'None': + // None filter matches no entities + return false; + + default: + return true; + } + }, + + _findMatchingEntities(queryData) { + const matchingEntities: TEntityId[] = []; + const aliveEntities = this._world._entityIndex.getAliveEntities(); + + for (const eid of aliveEntities) { + if (this.checkEntity(queryData, eid)) { + matchingEntities.push(eid); + } + } + + return matchingEntities; + }, + + _commitRemovals() { + // Simple deferred removal system + for (const queryData of this._dirtyQueries) { + queryData.toRemove.clear(); + } + this._dirtyQueries.clear(); + } + }; +} + +export interface TQueryRegistry { + /** Reference to the world */ + _world: TWorld; + /** Cache of registered queries by hash */ + _queryCache: Map; + /** Set of queries with pending removals */ + _dirtyQueries: Set; + + /** + * Generates a unique hash for query terms for caching. + * @param filter - The normalized filter + * @returns Unique hash string + */ + generateQueryHash(filter: TQueryFilter): string; + + /** + * Registers a query and returns its data structure. + * Uses caching to avoid recomputing query masks. + * @param filter - The query filter + * @returns Query data structure + */ + registerQuery(filter: TQueryFilter): TQueryData; + + /** + * Executes a query and returns matching entities. + * Commits pending removals before execution. + * @param filter - The query filter + * @returns Array of matching entity IDs + */ + executeQuery(filter: TQueryFilter): TEntityId[]; + + /** + * Executes a query without committing removals. + * Used for nested queries during iteration. + * @param filter - The query filter + * @returns Array of matching entity IDs + */ + executeInnerQuery(filter: TQueryFilter): TEntityId[]; + + /** + * Checks if an entity matches a query. + * @param queryData - The query data structure + * @param eid - The entity ID to check + * @returns True if entity matches the query + */ + checkEntity(queryData: TQueryData, eid: TEntityId): boolean; + + /** + * Resets the query registry to its initial state. + */ + reset(): void; + + /** + * Validates the query registry integrity. + * @returns True if the registry is valid + */ + validate(): boolean; + + _categorizeFilter(filter: TQueryFilter, categories: any): void; + _buildGenerationMasks(components: TComponentRef[]): Map; + _checkBasicRequirements(queryData: TQueryData, eid: TEntityId): boolean; + _checkChangeRequirements(queryData: TQueryData, eid: TEntityId): boolean; + _checkNestedFilters(queryData: TQueryData, eid: TEntityId): boolean; + _evaluateNestedFilter(filter: TQueryFilter, eid: TEntityId): boolean; + _evaluateSingleFilter(filter: TQueryFilter, eid: TEntityId): boolean; + _findMatchingEntities(queryData: TQueryData): TEntityId[]; + _commitRemovals(): void; +} + +export interface TQueryData { + /** Unique hash for this query */ + hash: string; + /** Normalized query filter */ + filter: TQueryFilter; + /** Bitflag masks for With components by generation */ + withMasks: Map; + /** Bitflag masks for Without components by generation */ + withoutMasks: Map; + /** Bitflag masks for Added components by generation */ + addedMasks: Map; + /** Bitflag masks for Changed components by generation */ + changedMasks: Map; + /** Bitflag masks for Removed components by generation */ + removedMasks: Map; + /** Nested filters (And/Or/Not) */ + nestedFilters: TQueryFilter[]; + /** Set of entities pending removal */ + toRemove: Set; +} diff --git a/packages/feature-ecs/src/query-term.ts b/packages/feature-ecs/src/query-term.ts index 9ff457ce..d10e3b99 100644 --- a/packages/feature-ecs/src/query-term.ts +++ b/packages/feature-ecs/src/query-term.ts @@ -1,66 +1,107 @@ import { TComponentRef } from './component-registry'; -// Filter types for different query operations +export type TGetComponentId = (component: TComponentRef) => number; + export type TFilter = - | { type: 'With'; component: TComponentRef } - | { type: 'Without'; component: TComponentRef } - | { type: 'Added'; component: TComponentRef } - | { type: 'Changed'; component: TComponentRef } - | { type: 'Removed'; component: TComponentRef } - | { type: 'And'; filters: TFilter[] } - | { type: 'Or'; filters: TFilter[] } - | { type: 'Not'; filters: TFilter[] }; + | { type: 'With'; component: TComponentRef; toString(getComponentId: TGetComponentId): string } + | { type: 'Without'; component: TComponentRef; toString(getComponentId: TGetComponentId): string } + | { type: 'Added'; component: TComponentRef; toString(getComponentId: TGetComponentId): string } + | { type: 'Changed'; component: TComponentRef; toString(getComponentId: TGetComponentId): string } + | { type: 'Removed'; component: TComponentRef; toString(getComponentId: TGetComponentId): string } + | { type: 'And'; filters: TFilter[]; toString(getComponentId: TGetComponentId): string } + | { type: 'Or'; filters: TFilter[]; toString(getComponentId: TGetComponentId): string } + | { type: 'Not'; filters: TFilter[]; toString(getComponentId: TGetComponentId): string } + | { type: 'None'; toString(): string }; -// Component filter constructors export const With = (component: T): TFilter => ({ type: 'With', - component + component, + toString(getComponentId) { + return `with(${getComponentId(component)})`; + } }); export const Without = (component: T): TFilter => ({ type: 'Without', - component + component, + toString(getComponentId) { + return `without(${getComponentId(component)})`; + } }); export const Added = (component: T): TFilter => ({ type: 'Added', - component + component, + toString(getComponentId) { + return `added(${getComponentId(component)})`; + } }); export const Changed = (component: T): TFilter => ({ type: 'Changed', - component + component, + toString(getComponentId) { + return `changed(${getComponentId(component)})`; + } }); export const Removed = (component: T): TFilter => ({ type: 'Removed', - component + component, + toString(getComponentId) { + return `removed(${getComponentId(component)})`; + } }); -// Logical filter constructors export const And = (...filters: TFilter[]): TFilter => ({ type: 'And', - filters + filters, + toString(getComponentId) { + return `and(${filters.map((f) => f.toString(getComponentId)).join(',')})`; + } }); export const All = And; // Alias for And export const Or = (...filters: TFilter[]): TFilter => ({ type: 'Or', - filters + filters, + toString(getComponentId) { + return `or(${filters.map((f) => f.toString(getComponentId)).join(',')})`; + } }); export const Any = Or; // Alias for Or export const Not = (...filters: TFilter[]): TFilter => ({ type: 'Not', - filters + filters, + toString(getComponentId: TGetComponentId) { + return `not(${filters.map((f) => f.toString(getComponentId)).join(',')})`; + } }); -export const None = Not; // Alias for Not +export const None = (): TFilter => ({ + type: 'None', + toString() { + return 'none()'; + } +}); // Special entity symbol for queries export const Entity = Symbol('Entity'); -// Query term can be a component or a filter -export type TQueryTerm = TComponentRef | TFilter; +/** + * Type guard to check if a value is a filter. + */ +export function isFilter(value: any): value is TFilter { + return ( + typeof value === 'object' && + value !== null && + 'type' in value && + typeof value.type === 'string' && + ['With', 'Without', 'Added', 'Changed', 'Removed', 'And', 'Or', 'Not', 'None'].includes( + value.type + ) + ); +} diff --git a/packages/feature-ecs/src/query.ts b/packages/feature-ecs/src/query.ts deleted file mode 100644 index c39f60ba..00000000 --- a/packages/feature-ecs/src/query.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface TQuery { - // TODO: -} diff --git a/packages/feature-ecs/src/world.ts b/packages/feature-ecs/src/world.ts index 5a9604ec..b78393f1 100644 --- a/packages/feature-ecs/src/world.ts +++ b/packages/feature-ecs/src/world.ts @@ -1,10 +1,18 @@ -import { createComponentRegistry, TComponentRegistry } from './component-registry'; +import { withNew } from '@blgc/utils'; +import { createComponentRegistry, TComponentRef, TComponentRegistry } from './component-registry'; import { createEntityIndex, TEntityId, TEntityIndex } from './entity-index'; +import { TQueryFilter } from './query-filter'; +import { createQueryRegistry, TQueryRegistry } from './query-registry'; export function createWorld(): TWorld { - return { + return withNew({ _entityIndex: createEntityIndex(), _componentRegistry: createComponentRegistry(), + _queryRegistry: null as unknown as TQueryRegistry, + + _new() { + this._queryRegistry = createQueryRegistry(this); + }, addEntity() { return this._entityIndex.addEntity(); @@ -26,20 +34,68 @@ export function createWorld(): TWorld { return this._entityIndex.isEntityAlive(eid); }, + markChanged(eid: TEntityId, component: TComponentRef) { + return this._componentRegistry.markChanged(eid, component); + }, + + clear() { + this._componentRegistry.clear(); + }, + + query(filter) { + return this._queryRegistry.executeQuery(filter); + }, + + innerQuery(filter) { + return this._queryRegistry.executeInnerQuery(filter); + }, + reset() { this._entityIndex.reset(); this._componentRegistry.reset(); + this._queryRegistry.reset(); } - }; + }); } export interface TWorld { _entityIndex: TEntityIndex; _componentRegistry: TComponentRegistry; + _queryRegistry: TQueryRegistry; addEntity(): TEntityId; removeEntity(eid: TEntityId): boolean; doesEntityExist(eid: TEntityId): boolean; + /** + * Marks a component as changed for the current frame. + * @param eid - The entity ID + * @param component - The component to mark as changed + * @returns True if component was marked as changed, false if entity didn't have it + */ + markChanged(eid: TEntityId, component: TComponentRef): boolean; + + /** + * Clears all change tracking for the current frame. + * Should be called at the end of each frame/update cycle. + */ + clear(): void; + + /** + * Execute a query and return matching entities. + * Commits pending removals before execution. + * @param filter - The query filter + * @returns Array of matching entity IDs + */ + query(filter: TQueryFilter): TEntityId[]; + + /** + * Execute a query without committing removals. + * Used for nested queries during iteration. + * @param filter - The query filter + * @returns Array of matching entity IDs + */ + innerQuery(filter: TQueryFilter): TEntityId[]; + reset(): void; } From 28455dd57c7d77bbab8a852ee12a3f05b0282b0b Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Wed, 28 May 2025 17:19:28 +0200 Subject: [PATCH 11/39] #105 wip move filter logic into query filters --- .cursor/rules/style-guide.mdc | 319 +++++++++++ packages/feature-ecs/src/query-filter.ts | 397 ++++++++++---- .../feature-ecs/src/query-registry.test.ts | 196 +++---- packages/feature-ecs/src/query-registry.ts | 503 +++--------------- packages/feature-ecs/src/query-term.ts | 107 ---- packages/feature-ecs/src/world.ts | 141 +++-- 6 files changed, 900 insertions(+), 763 deletions(-) create mode 100644 .cursor/rules/style-guide.mdc delete mode 100644 packages/feature-ecs/src/query-term.ts diff --git a/.cursor/rules/style-guide.mdc b/.cursor/rules/style-guide.mdc new file mode 100644 index 00000000..9828a3af --- /dev/null +++ b/.cursor/rules/style-guide.mdc @@ -0,0 +1,319 @@ +--- +description: +globs: +alwaysApply: true +--- +# Style Guide + +Core coding standards and style guidelines for our TypeScript codebase. These guidelines ensure consistency, maintainability, and high code quality across the project. + +## Core Principles +- **KISS (Keep It Simple, Stupid)** - Always choose the simplest, most maintainable solution +- **TypeScript First** - Always use TypeScript with strict typing (`strict: true`) everywhere +- **Less is More** - Always avoid unnecessary complexity, the best code is no code +- **Self-Documenting** - Always make code obvious and clear without comments + +## File Organization + +### Directory Structure +- Always organize code in a predictable and scalable way +- Always keep related code close together +- Always use clear, descriptive directory names +- Always follow consistent patterns across the project +- Always use singular for categories/domains (e.g., `auth/`, `user/`, `product/`) +- Always use plural for collections/lists (e.g., `components/`, `hooks/`, `utils/`) + +✅ Good: +```typescript +src/ + auth/ # Singular: domain + components/ # Plural: collection + hooks/ # Plural: collection + lib/ # Singular: category + + user/ # Singular: domain + components/ # Plural: collection + hooks/ # Plural: collection + lib/ # Singular: category + + lib/ # Singular: core category + components/ # Plural: shared collection + hooks/ # Plural: shared collection +``` + +❌ Bad: +```typescript +src/ + auths/ # Wrong: Category should be singular + component/ # Wrong: Collection should be plural + + users/ # Wrong: Category should be singular + hook/ # Wrong: Collection should be plural + + libraries/ # Wrong: Category should be singular + shared-components/ # Wrong: Use simple plural +``` + +### Files & Directories +- Always use consistent and predictable naming patterns +- Always make names descriptive and purpose-indicating +- Always follow established community conventions + +✅ Good: +```typescript +// Directories (kebab-case) +src/ + auth/ + components/ + hooks/ + lib/ + +// Regular Files (kebab-case) +user-service.ts +jwt-utils.ts +date-formatter.ts +api-client.ts + +// Component Files (PascalCase) +UserProfileCard.tsx +OrderSummaryTable.tsx +PaymentMethodSelector.tsx +ButtonPrimary.tsx + +// Class Files (PascalCase) +OrderProcessor.ts +PaymentGateway.ts +CacheManager.ts +``` + +❌ Bad: +```typescript +// Directories (mixed case) +src/ + UserManagement/ # Wrong: PascalCase directory + order_processing/ # Wrong: snake_case directory + PAYMENT/ # Wrong: UPPERCASE directory + Shared-Utils/ # Wrong: Mixed kebab-case and PascalCase + +// Files (inconsistent) +userService.ts # Wrong: camelCase +USER_HELPERS.ts # Wrong: SNAKE_CASE +payment.utilities.ts # Wrong: dot notation +Api.Client.ts # Wrong: PascalCase with dots +``` + +### Code Identifiers +- Always use clear, descriptive names that indicate purpose +- Always follow TypeScript community standards +- Always maintain consistent prefixing for special types + +✅ Good: +```typescript +// Variables & Functions (camelCase) +const currentUser = getCurrentUser(); +const isValidEmail = validateEmail(email); +const calculateTotalPrice = (items: TOrderItem[]): number => { + return items.reduce((sum, item) => sum + item.price, 0); +}; + +// Interfaces & Types (T prefix) +type TUser = { + id: string; + email: string; + profile: TUserProfile; +}; + +type TOrderItem = { + id: string; + productId: string; + quantity: number; + price: number; +}; + +// Enums (E prefix) +enum EOrderStatus { + Pending = 'pending', + Processing = 'processing', + Completed = 'completed', + Cancelled = 'cancelled' +} + +enum EUserRole { + Admin = 'admin', + Customer = 'customer', + Guest = 'guest' +} + +// Schemas (S prefix) +const SUserProfile = z.object({ + firstName: z.string().min(2), + lastName: z.string().min(2), + dateOfBirth: z.date().optional(), + phoneNumber: z.string().regex(/^\+?[1-9]\d{1,14}$/).optional() +}); + +const SOrderCreate = z.object({ + userId: z.string().uuid(), + items: z.array(z.object({ + productId: z.string().uuid(), + quantity: z.number().int().positive() + })) +}); +``` + +❌ Bad: +```typescript +// Variables & Functions (inconsistent) +const CurrentUser = getCurrentUser(); # Wrong: PascalCase +const valid_email = validate_email(); # Wrong: snake_case +const CALCULATE_PRICE = () => {}; # Wrong: UPPER_CASE + +// Types & Interfaces (missing prefix) +type User = { # Wrong: Missing T prefix + ID: string; # Wrong: UPPER_CASE + Email: string; # Wrong: PascalCase +}; + +interface OrderItem { # Wrong: Missing T prefix + product_id: string; # Wrong: snake_case + Quantity: number; # Wrong: PascalCase +} + +// Enums (inconsistent) +enum OrderStatus { # Wrong: Missing E prefix + PENDING = 'PENDING', # Wrong: All caps + Processing = 'Processing', # Wrong: PascalCase value + completed = 'completed' # Wrong: camelCase +} + +// Schemas (inconsistent) +const userSchema = z.object({ # Wrong: Missing S prefix + FirstName: z.string(), # Wrong: PascalCase + last_name: z.string(), # Wrong: snake_case + DOB: z.date() # Wrong: Abbreviation +}); +``` + +## Code Style + +### Type Safety +- Always define explicit types for better maintainability +- Always use TypeScript's type system to prevent runtime errors +- Always make code intentions clear through typing + +✅ Good: +```typescript +async function getUser(id: string): Promise { + const user = await db.users.findUnique({ where: { id } }); + if (user == null) { + throw new Error('User not found'); + } + return user; +} +``` + +❌ Bad: +```typescript +async function getUser(id) { + const user = await db.users.findUnique({ where: { id } }); + if (!user) throw new Error('User not found'); + return user; +} +``` + +### Null Checks +- Always be explicit about null/undefined checks +- Always handle edge cases clearly and consistently +- Always prevent runtime null/undefined errors + +✅ Good: +```typescript +if (user == null) { + throw new Error('User is required'); +} + +const name = user.name ?? 'Anonymous'; +``` + +❌ Bad: +```typescript +if (!user) { + throw new Error('User is required'); +} + +const name = user.name || 'Anonymous'; +``` + +### Functions +- Always keep functions focused and single-purpose +- Always use function declarations for named functions +- Always use arrow functions only for callbacks and inline functions + +✅ Good: +```typescript +function processUser(user: TUser): void { + // Implementation +} + +users.map(user => user.name); +``` + +❌ Bad: +```typescript +const processUser = (user: TUser): void => { + // Implementation +} + +users.map(function(user) { return user.name; }); +``` + +### Conditionals +- Always keep conditionals simple and flat +- Always use early returns to reduce nesting +- Always avoid deeply nested conditions + +✅ Good: +```typescript +// Early return pattern +function processUser(user: TUser): void { + if (user == null) { + return; + } + + if (!user.isActive) { + return; + } + + processActiveUser(user); +} + +// Simple boolean check +function isValidUser(user: TUser): boolean { + return user != null && user.isActive; +} +``` + +❌ Bad: +```typescript +// Deeply nested conditions +function processUser(user: TUser): void { + if (user != null) { + if (user.isActive) { + if (user.permissions != null) { + if (user.permissions.canEdit) { + processActiveUser(user); + } + } + } + } +} + +// Complex nested ternary +const userName = user + ? user.profile + ? user.profile.name + ? user.profile.name + : 'No name' + : 'No profile' + : 'No user'; +``` \ No newline at end of file diff --git a/packages/feature-ecs/src/query-filter.ts b/packages/feature-ecs/src/query-filter.ts index 657f41e0..2db73254 100644 --- a/packages/feature-ecs/src/query-filter.ts +++ b/packages/feature-ecs/src/query-filter.ts @@ -1,112 +1,325 @@ import { TComponentRef } from './component-registry'; +import { TEntityId } from './entity-index'; +import { TWorld } from './world'; -export type TGetComponentId = (component: TComponentRef) => number; +/** + * Checks if entity has component using bitmask + */ +export function With(component: T): TQueryFilter { + return { + type: 'With', + component, + + evaluate(world: TWorld, eid: TEntityId): boolean { + const registry = world._componentRegistry; + const componentData = registry._componentMap.get(component); + + if (componentData == null) { + return false; + } + + const { generationId, bitflag } = componentData; + const entityMask = registry._entityMasks[generationId]?.[eid] ?? 0; + return (entityMask & bitflag) !== 0; + }, + + getComponents(): TComponentRef[] { + return [component]; + }, + + register(world: TWorld, queryData: TQueryData): void { + const registry = world._componentRegistry; + const componentData = registry._componentMap.get(component); + + if (componentData != null) { + const { generationId, bitflag } = componentData; + queryData.withMasks[generationId] = (queryData.withMasks[generationId] ?? 0) | bitflag; + } + }, + + getHash(world: TWorld): string { + const componentId = getComponentId(world, component); + return `with(${componentId})`; + } + }; +} /** - * A query filter represents a condition used to select entities in ECS queries. - * Query filters can be simple conditions (With, Without) or complex combinations (And, Or, Not). + * Checks if entity lacks component using bitmask */ -export type TQueryFilter = - | { type: 'With'; component: TComponentRef; toString(getComponentId: TGetComponentId): string } - | { type: 'Without'; component: TComponentRef; toString(getComponentId: TGetComponentId): string } - | { type: 'Added'; component: TComponentRef; toString(getComponentId: TGetComponentId): string } - | { type: 'Changed'; component: TComponentRef; toString(getComponentId: TGetComponentId): string } - | { type: 'Removed'; component: TComponentRef; toString(getComponentId: TGetComponentId): string } - | { type: 'And'; filters: TQueryFilter[]; toString(getComponentId: TGetComponentId): string } - | { type: 'Or'; filters: TQueryFilter[]; toString(getComponentId: TGetComponentId): string } - | { type: 'Not'; filters: TQueryFilter[]; toString(getComponentId: TGetComponentId): string } - | { type: 'None'; toString(): string }; - -export const With = (component: T): TQueryFilter => ({ - type: 'With', - component, - toString(getComponentId) { - return `with(${getComponentId(component)})`; - } -}); +export function Without(component: T): TQueryFilter { + return { + type: 'Without', + component, -export const Without = (component: T): TQueryFilter => ({ - type: 'Without', - component, - toString(getComponentId) { - return `without(${getComponentId(component)})`; - } -}); + evaluate(world: TWorld, eid: TEntityId): boolean { + const registry = world._componentRegistry; + const componentData = registry._componentMap.get(component); -export const Added = (component: T): TQueryFilter => ({ - type: 'Added', - component, - toString(getComponentId) { - return `added(${getComponentId(component)})`; - } -}); + if (componentData == null) { + return true; + } -export const Changed = (component: T): TQueryFilter => ({ - type: 'Changed', - component, - toString(getComponentId) { - return `changed(${getComponentId(component)})`; - } -}); + const { generationId, bitflag } = componentData; + const entityMask = registry._entityMasks[generationId]?.[eid] ?? 0; + return (entityMask & bitflag) === 0; + }, -export const Removed = (component: T): TQueryFilter => ({ - type: 'Removed', - component, - toString(getComponentId) { - return `removed(${getComponentId(component)})`; - } -}); + getComponents(): TComponentRef[] { + return [component]; + }, -export const And = (...filters: TQueryFilter[]): TQueryFilter => ({ - type: 'And', - filters, - toString(getComponentId) { - return `and(${filters.map((f) => f.toString(getComponentId)).join(',')})`; - } -}); + register(world: TWorld, queryData: TQueryData): void { + const registry = world._componentRegistry; + const componentData = registry._componentMap.get(component); -export const All = And; // Alias for And + if (componentData != null) { + const { generationId, bitflag } = componentData; + queryData.withoutMasks[generationId] = + (queryData.withoutMasks[generationId] ?? 0) | bitflag; + } + }, -export const Or = (...filters: TQueryFilter[]): TQueryFilter => ({ - type: 'Or', - filters, - toString(getComponentId) { - return `or(${filters.map((f) => f.toString(getComponentId)).join(',')})`; - } -}); + getHash(world: TWorld): string { + const componentId = getComponentId(world, component); + return `without(${componentId})`; + } + }; +} -export const Any = Or; // Alias for Or +/** + * Checks if component was added this frame + */ +export function Added(component: T): TQueryFilter { + return { + type: 'Added', + component, -export const Not = (...filters: TQueryFilter[]): TQueryFilter => ({ - type: 'Not', - filters, - toString(getComponentId: TGetComponentId) { - return `not(${filters.map((f) => f.toString(getComponentId)).join(',')})`; - } -}); + evaluate(world: TWorld, eid: TEntityId): boolean { + return world._componentRegistry.wasAdded(eid, component); + }, -export const None = (): TQueryFilter => ({ - type: 'None', - toString() { - return 'none()'; - } -}); + getComponents(): TComponentRef[] { + return [component]; + }, + + getHash(world: TWorld): string { + const componentId = getComponentId(world, component); + return `added(${componentId})`; + } + }; +} + +/** + * Checks if component was changed this frame + */ +export function Changed(component: T): TQueryFilter { + return { + type: 'Changed', + component, + + evaluate(world: TWorld, eid: TEntityId): boolean { + return world._componentRegistry.wasChanged(eid, component); + }, + + getComponents(): TComponentRef[] { + return [component]; + }, + + getHash(world: TWorld): string { + const componentId = getComponentId(world, component); + return `changed(${componentId})`; + } + }; +} + +/** + * Checks if component was removed this frame + */ +export function Removed(component: T): TQueryFilter { + return { + type: 'Removed', + component, + + evaluate(world: TWorld, eid: TEntityId): boolean { + return world._componentRegistry.wasRemoved(eid, component); + }, + + getComponents(): TComponentRef[] { + return [component]; + }, -// Special entity symbol for queries -// TODO: Put into query-data or so? -export const Entity = Symbol('Entity'); + getHash(world: TWorld): string { + const componentId = getComponentId(world, component); + return `removed(${componentId})`; + } + }; +} /** - * Type guard to check if a value is a query filter. + * All filters must match (uses batched bitmask checking when possible) */ -export function isQueryFilter(value: any): value is TQueryFilter { - return ( - typeof value === 'object' && - value !== null && - 'type' in value && - typeof value.type === 'string' && - ['With', 'Without', 'Added', 'Changed', 'Removed', 'And', 'Or', 'Not', 'None'].includes( - value.type - ) - ); +export function And(...filters: TQueryFilter[]): TQueryFilter { + return { + type: 'And', + filters, + + evaluate(world: TWorld, eid: TEntityId, queryData: TQueryData): boolean { + // Use batched bitmask checking if all filters are With/Without + const allSimple = filters.every((f) => f.type === 'With' || f.type === 'Without'); + + if ( + allSimple && + (Object.keys(queryData.withMasks).length > 0 || + Object.keys(queryData.withoutMasks).length > 0) + ) { + const registry = world._componentRegistry; + + // Check all required components in batches + for (const [generationId, withMask] of Object.entries(queryData.withMasks)) { + const entityMask = registry._entityMasks[+generationId]?.[eid] ?? 0; + if ((entityMask & withMask) !== withMask) { + return false; + } + } + + // Check all forbidden components in batches + for (const [generationId, withoutMask] of Object.entries(queryData.withoutMasks)) { + const entityMask = registry._entityMasks[+generationId]?.[eid] ?? 0; + if ((entityMask & withoutMask) !== 0) { + return false; + } + } + + return true; + } + + // Fallback to individual filter evaluation + return filters.every((filter) => filter.evaluate(world, eid, queryData)); + }, + + getComponents(): TComponentRef[] { + return filters.flatMap((filter) => filter.getComponents()); + }, + + register(world: TWorld, queryData: TQueryData): void { + // Let child filters register their bitmasks + for (const filter of filters) { + if (filter.register) { + filter.register(world, queryData); + } + } + }, + + getHash(world: TWorld): string { + const childHashes = filters + .map((f) => f.getHash(world)) + .sort() + .join(','); + return `and(${childHashes})`; + } + }; +} + +/** + * Any filter must match (requires individual evaluation) + */ +export function Or(...filters: TQueryFilter[]): TQueryFilter { + return { + type: 'Or', + filters, + + evaluate(world: TWorld, eid: TEntityId, queryData: TQueryData): boolean { + return filters.some((filter) => filter.evaluate(world, eid, queryData)); + }, + + getComponents(): TComponentRef[] { + return filters.flatMap((filter) => filter.getComponents()); + }, + + register(world: TWorld, queryData: TQueryData): void { + for (const filter of filters) { + if (filter.register) { + filter.register(world, queryData); + } + } + }, + + getHash(world: TWorld): string { + const childHashes = filters + .map((f) => f.getHash(world)) + .sort() + .join(','); + return `or(${childHashes})`; + } + }; +} + +/** + * No filter must match (requires individual evaluation) + */ +export function Not(...filters: TQueryFilter[]): TQueryFilter { + return { + type: 'Not', + filters, + + evaluate(world: TWorld, eid: TEntityId, queryData: TQueryData): boolean { + return !filters.some((filter) => filter.evaluate(world, eid, queryData)); + }, + + getComponents(): TComponentRef[] { + return filters.flatMap((filter) => filter.getComponents()); + }, + + getHash(world: TWorld): string { + const childHashes = filters + .map((f) => f.getHash(world)) + .sort() + .join(','); + return `not(${childHashes})`; + } + }; +} + +// Aliases for convenience +export const All = And; +export const Any = Or; +export const None = Not; + +export interface TQueryData { + hash: string; + cachedResult: TEntityId[] | null; + isDirty: boolean; + allComponents: TComponentRef[]; + withMasks: Record; + withoutMasks: Record; +} + +export interface TBaseQueryFilter { + type: string; + evaluate(world: TWorld, eid: TEntityId, queryData: TQueryData): boolean; + getComponents(): TComponentRef[]; + register?(world: TWorld, queryData: TQueryData): void; + getHash(world: TWorld): string; +} + +export type TQueryFilter = + | (TBaseQueryFilter & { type: 'With'; component: TComponentRef }) + | (TBaseQueryFilter & { type: 'Without'; component: TComponentRef }) + | (TBaseQueryFilter & { type: 'Added'; component: TComponentRef }) + | (TBaseQueryFilter & { type: 'Changed'; component: TComponentRef }) + | (TBaseQueryFilter & { type: 'Removed'; component: TComponentRef }) + | (TBaseQueryFilter & { type: 'And'; filters: TQueryFilter[] }) + | (TBaseQueryFilter & { type: 'Or'; filters: TQueryFilter[] }) + | (TBaseQueryFilter & { type: 'Not'; filters: TQueryFilter[] }); + +/** + * Helper to get component ID, registering if needed + */ +function getComponentId(world: TWorld, component: TComponentRef): number { + const registry = world._componentRegistry; + if (!registry._componentMap.has(component)) { + registry.registerComponent(component); + } + return registry._componentMap.get(component)!.id; } diff --git a/packages/feature-ecs/src/query-registry.test.ts b/packages/feature-ecs/src/query-registry.test.ts index a6762516..d821f6f7 100644 --- a/packages/feature-ecs/src/query-registry.test.ts +++ b/packages/feature-ecs/src/query-registry.test.ts @@ -102,10 +102,10 @@ describe('createQueryRegistry', () => { const Position = { x: [] as number[], y: [] as number[] }; // Create entities with components - const eid1 = world.addEntity(); - const eid2 = world.addEntity(); - world._componentRegistry.addComponent(eid1, Position); - world._componentRegistry.addComponent(eid2, Position); + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + world.addComponent(eid1, Position); + world.addComponent(eid2, Position); // None filter should return no entities const emptyResult = world.query(None()); @@ -118,19 +118,19 @@ describe('createQueryRegistry', () => { const Health = [] as number[]; // Create entities - const eid1 = world.addEntity(); - const eid2 = world.addEntity(); - const eid3 = world.addEntity(); + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); // Add components - world._componentRegistry.addComponent(eid1, Position); - world._componentRegistry.addComponent(eid1, Velocity); + world.addComponent(eid1, Position); + world.addComponent(eid1, Velocity); - world._componentRegistry.addComponent(eid2, Position); - world._componentRegistry.addComponent(eid2, Health); + world.addComponent(eid2, Position); + world.addComponent(eid2, Health); - world._componentRegistry.addComponent(eid3, Velocity); - world._componentRegistry.addComponent(eid3, Health); + world.addComponent(eid3, Velocity); + world.addComponent(eid3, Health); // Test basic WITH queries const positionAndVelocity = world.query(And(With(Position), With(Velocity))); @@ -144,12 +144,12 @@ describe('createQueryRegistry', () => { const Position = { x: [] as number[], y: [] as number[] }; const Health = [] as number[]; - const eid1 = world.addEntity(); - const eid2 = world.addEntity(); + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); - world._componentRegistry.addComponent(eid1, Position); - world._componentRegistry.addComponent(eid2, Position); - world._componentRegistry.addComponent(eid2, Health); + world.addComponent(eid1, Position); + world.addComponent(eid2, Position); + world.addComponent(eid2, Health); // Test WITHOUT filter const hasPositionButNotHealth = world.query(And(With(Position), Without(Health))); @@ -160,12 +160,12 @@ describe('createQueryRegistry', () => { const Position = { x: [] as number[], y: [] as number[] }; const Health = [] as number[]; - const eid1 = world.addEntity(); - const eid2 = world.addEntity(); + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); - world._componentRegistry.addComponent(eid1, Position); - world._componentRegistry.addComponent(eid2, Position); - world._componentRegistry.addComponent(eid2, Health); + world.addComponent(eid1, Position); + world.addComponent(eid2, Position); + world.addComponent(eid2, Health); // Test explicit AND filter const explicitAnd = world.query(And(With(Position), With(Health))); @@ -176,14 +176,14 @@ describe('createQueryRegistry', () => { const Position = { x: [] as number[], y: [] as number[] }; const Health = [] as number[]; - const eid1 = world.addEntity(); - const eid2 = world.addEntity(); - const eid3 = world.addEntity(); + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); - world._componentRegistry.addComponent(eid1, Position); - world._componentRegistry.addComponent(eid2, Health); - world._componentRegistry.addComponent(eid3, Position); - world._componentRegistry.addComponent(eid3, Health); + world.addComponent(eid1, Position); + world.addComponent(eid2, Health); + world.addComponent(eid3, Position); + world.addComponent(eid3, Health); // Test OR filter const positionOrHealth = world.query(Or(With(Position), With(Health))); @@ -198,23 +198,23 @@ describe('createQueryRegistry', () => { const Paralyzed = {}; // Create entities - const eid1 = world.addEntity(); - const eid2 = world.addEntity(); - const eid3 = world.addEntity(); + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); // Setup entity 1: Position + Health + Stunned - world._componentRegistry.addComponent(eid1, Position); - world._componentRegistry.addComponent(eid1, Health); - world._componentRegistry.addComponent(eid1, Stunned); + world.addComponent(eid1, Position); + world.addComponent(eid1, Health); + world.addComponent(eid1, Stunned); // Setup entity 2: Position + Shield - world._componentRegistry.addComponent(eid2, Position); - world._componentRegistry.addComponent(eid2, Shield); + world.addComponent(eid2, Position); + world.addComponent(eid2, Shield); // Setup entity 3: Position + Health + Paralyzed - world._componentRegistry.addComponent(eid3, Position); - world._componentRegistry.addComponent(eid3, Health); - world._componentRegistry.addComponent(eid3, Paralyzed); + world.addComponent(eid3, Position); + world.addComponent(eid3, Health); + world.addComponent(eid3, Paralyzed); // Complex query: Position + (Health OR Shield) + WITHOUT(Stunned) + WITHOUT(Paralyzed) const complexQuery = world.query( @@ -229,9 +229,9 @@ describe('createQueryRegistry', () => { const Position = { x: [] as number[], y: [] as number[] }; const Velocity = { x: [] as number[], y: [] as number[] }; - const eid = world.addEntity(); - world._componentRegistry.addComponent(eid, Position); - world._componentRegistry.addComponent(eid, Velocity); + const eid = world.createEntity(); + world.addComponent(eid, Position); + world.addComponent(eid, Velocity); // Execute same query multiple times const filter = And(With(Position), With(Velocity)); @@ -264,8 +264,8 @@ describe('createQueryRegistry', () => { it('should execute queries without committing removals', () => { const Position = { x: [] as number[], y: [] as number[] }; - const eid = world.addEntity(); - world._componentRegistry.addComponent(eid, Position); + const eid = world.createEntity(); + world.addComponent(eid, Position); // Inner query should work the same as regular query for now const result = world.innerQuery(With(Position)); @@ -278,12 +278,12 @@ describe('createQueryRegistry', () => { const Position = { x: [] as number[], y: [] as number[] }; const Health = [] as number[]; - const eid1 = world.addEntity(); - const eid2 = world.addEntity(); + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); - world._componentRegistry.addComponent(eid1, Position); - world._componentRegistry.addComponent(eid1, Health); - world._componentRegistry.addComponent(eid2, Position); + world.addComponent(eid1, Position); + world.addComponent(eid1, Health); + world.addComponent(eid2, Position); const queryData = world._queryRegistry.registerQuery(And(With(Position), With(Health))); @@ -297,14 +297,14 @@ describe('createQueryRegistry', () => { const Position = { x: [] as number[], y: [] as number[] }; const Health = [] as number[]; - const eid1 = world.addEntity(); - const eid2 = world.addEntity(); + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); // Add Position to eid1 (should be tracked as added) - world._componentRegistry.addComponent(eid1, Position); + world.addComponent(eid1, Position); // Add Health to eid2 (should be tracked as added) - world._componentRegistry.addComponent(eid2, Health); + world.addComponent(eid2, Health); // Query for entities with added Position const addedPosition = world.query(Added(Position)); @@ -315,7 +315,7 @@ describe('createQueryRegistry', () => { expect(addedHealth).toEqual([eid2]); // Clear frame changes - world.clear(); + world._componentRegistry.clear(); // After clearing, no entities should have added components const addedPositionAfter = world.query(Added(Position)); @@ -328,18 +328,18 @@ describe('createQueryRegistry', () => { const Position = { x: [] as number[], y: [] as number[] }; const Health = [] as number[]; - const eid1 = world.addEntity(); - const eid2 = world.addEntity(); + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); // Add components first - world._componentRegistry.addComponent(eid1, Position); - world._componentRegistry.addComponent(eid2, Health); + world.addComponent(eid1, Position); + world.addComponent(eid2, Health); // Clear initial "added" tracking - world.clear(); + world._componentRegistry.clear(); // Mark Position as changed for eid1 - world.markChanged(eid1, Position); + world._componentRegistry.markChanged(eid1, Position); // Query for entities with changed Position const changedPosition = world.query(Changed(Position)); @@ -354,19 +354,19 @@ describe('createQueryRegistry', () => { const Position = { x: [] as number[], y: [] as number[] }; const Health = [] as number[]; - const eid1 = world.addEntity(); - const eid2 = world.addEntity(); + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); // Add components first - world._componentRegistry.addComponent(eid1, Position); - world._componentRegistry.addComponent(eid1, Health); - world._componentRegistry.addComponent(eid2, Position); + world.addComponent(eid1, Position); + world.addComponent(eid1, Health); + world.addComponent(eid2, Position); // Clear initial "added" tracking - world.clear(); + world._componentRegistry.clear(); // Remove Health from eid1 - world._componentRegistry.removeComponent(eid1, Health); + world.removeComponent(eid1, Health); // Query for entities with removed Health const removedHealth = world.query(Removed(Health)); @@ -382,27 +382,27 @@ describe('createQueryRegistry', () => { const Health = [] as number[]; const Shield = [] as number[]; - const eid1 = world.addEntity(); - const eid2 = world.addEntity(); - const eid3 = world.addEntity(); + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); // Setup initial state - world._componentRegistry.addComponent(eid1, Position); - world._componentRegistry.addComponent(eid1, Health); + world.addComponent(eid1, Position); + world.addComponent(eid1, Health); - world._componentRegistry.addComponent(eid2, Position); - world._componentRegistry.addComponent(eid2, Shield); + world.addComponent(eid2, Position); + world.addComponent(eid2, Shield); // Clear initial "added" tracking - world.clear(); + world._componentRegistry.clear(); - // Frame changes: - // - Add Shield to eid1 + // Trigger changes: + // - Add Shield to eid1 (new component) // - Mark Position as changed for eid2 // - Add Position to eid3 (new entity) - world._componentRegistry.addComponent(eid1, Shield); - world.markChanged(eid2, Position); - world._componentRegistry.addComponent(eid3, Position); + world.addComponent(eid1, Shield); + world._componentRegistry.markChanged(eid2, Position); + world.addComponent(eid3, Position); // Query: Entities with Position AND (Added Shield OR Changed Position) const complexQuery = world.query(And(With(Position), Or(Added(Shield), Changed(Position)))); @@ -416,24 +416,24 @@ describe('createQueryRegistry', () => { const Health = [] as number[]; const Enemy = {}; - const eid1 = world.addEntity(); - const eid2 = world.addEntity(); + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); // Setup: Both entities have Position and Health - world._componentRegistry.addComponent(eid1, Position); - world._componentRegistry.addComponent(eid1, Health); - world._componentRegistry.addComponent(eid2, Position); - world._componentRegistry.addComponent(eid2, Health); + world.addComponent(eid1, Position); + world.addComponent(eid1, Health); + world.addComponent(eid2, Position); + world.addComponent(eid2, Health); - // Only eid2 is an enemy - world._componentRegistry.addComponent(eid2, Enemy); + // Mark eid2 as Enemy (should be tracked as added) + world.addComponent(eid2, Enemy); // Clear initial "added" tracking - world.clear(); + world._componentRegistry.clear(); - // Mark Health as changed for both entities - world.markChanged(eid1, Health); - world.markChanged(eid2, Health); + // Mark components as changed + world._componentRegistry.markChanged(eid1, Health); + world._componentRegistry.markChanged(eid2, Health); // Query: Entities with changed Health but WITHOUT Enemy tag const nonEnemiesWithChangedHealth = world.query(And(Changed(Health), Without(Enemy))); @@ -446,8 +446,8 @@ describe('createQueryRegistry', () => { describe('reset', () => { it('should reset to initial state', () => { const Position = { x: [] as number[], y: [] as number[] }; - const eid = world.addEntity(); - world._componentRegistry.addComponent(eid, Position); + const eid = world.createEntity(); + world.addComponent(eid, Position); // Execute query to populate cache world.query(With(Position)); @@ -468,8 +468,8 @@ describe('createQueryRegistry', () => { it('should return true after query operations', () => { const Position = { x: [] as number[], y: [] as number[] }; - const eid = world.addEntity(); - world._componentRegistry.addComponent(eid, Position); + const eid = world.createEntity(); + world.addComponent(eid, Position); world.query(With(Position)); expect(world._queryRegistry.validate()).toBe(true); diff --git a/packages/feature-ecs/src/query-registry.ts b/packages/feature-ecs/src/query-registry.ts index 134f9414..c317a971 100644 --- a/packages/feature-ecs/src/query-registry.ts +++ b/packages/feature-ecs/src/query-registry.ts @@ -1,387 +1,84 @@ /** - * Query Registry for ECS (Entity Component System) + * Query Registry for ECS * - * Provides efficient query execution with bitflag-based entity matching. - * Supports complex query filters including change detection and lifecycle events. - * - * Key features: - * - O(1) query lookup via hash-based caching - * - Complex query filters (With, Without, Added, Changed, Removed, And, Or, Not) - * - Multi-generation bitflag support - * - Change detection for reactive systems - * - Deferred entity removals for safe iteration - * - * ## Query Patterns - * - * ### Basic Queries - * ```typescript - * // Entities with Position AND Velocity (default behavior) - * query(world, [With(Position), With(Velocity)]) - * - * // Same as above using And filter - * query(world, [And(With(Position), With(Velocity))]) - * ``` - * - * ### Change Detection Queries - * ```typescript - * // Entities where Position changed this frame - * query(world, [With(Position), Changed(Position)]) - * - * // Newly spawned entities with Health - * query(world, [Added(Health)]) - * - * // Entities that lost their Weapon component - * query(world, [Removed(Weapon)]) - * ``` - * - * ### Complex Queries - * ```typescript - * // Entities with Position OR Velocity - * query(world, [Or(With(Position), With(Velocity))]) - * - * // Entities with Position but WITHOUT Velocity - * query(world, [With(Position), Without(Velocity)]) - * - * // Complex combinations - * query(world, [ - * With(Position), // Must have Position - * Or(With(Health), With(Shield)), // Must have either Health OR Shield - * Not(With(Stunned), With(Paralyzed)) // Must NOT have Stunned AND must NOT have Paralyzed - * ]) - * ``` + * Manages compiled queries with bitmask optimizations and smart cache invalidation. + * Uses the new filter system for maximum performance. */ import { TComponentRef } from './component-registry'; import { TEntityId } from './entity-index'; -import { TQueryFilter } from './query-filter'; +import { TQueryData, TQueryFilter } from './query-filter'; import { TWorld } from './world'; /** - * Creates a new query registry. - * - * @param world - The world instance to use for component and entity access - * @returns A new query registry instance - * - * @example - * ```typescript - * const queryRegistry = createQueryRegistry(world); - * - * // Execute queries with explicit filter syntax - * const entities = queryRegistry.executeQuery(And(With(Position), With(Velocity))); - * - * // Change detection queries - * const changedEntities = queryRegistry.executeQuery( - * And(With(Position), Changed(Position)) - * ); - * ``` + * Creates a new query registry */ export function createQueryRegistry(world: TWorld): TQueryRegistry { return { _world: world, _queryCache: new Map(), - _dirtyQueries: new Set(), + _componentCallbacks: new Map(), - generateQueryHash(filter) { - const getComponentId = (component: TComponentRef): number => { - if (!this._world._componentRegistry._componentMap.has(component)) { - this._world._componentRegistry.registerComponent(component); - } - return this._world._componentRegistry._componentMap.get(component)?.id ?? 0; - }; - - return filter.toString(getComponentId); - }, - - registerQuery(filter) { - const hash = this.generateQueryHash(filter); + executeQuery(filter) { + const query = this.getOrCreateQuery(filter); - if (this._queryCache.has(hash)) { - return this._queryCache.get(hash) as TQueryData; + // Return cached result if available and not dirty + if (!query.isDirty && query.cachedResult) { + return query.cachedResult; } - // Parse the filter into different categories - const withComponents: TComponentRef[] = []; - const withoutComponents: TComponentRef[] = []; - const addedComponents: TComponentRef[] = []; - const changedComponents: TComponentRef[] = []; - const removedComponents: TComponentRef[] = []; - const nestedFilters: TQueryFilter[] = []; - - this._categorizeFilter(filter, { - withComponents, - withoutComponents, - addedComponents, - changedComponents, - removedComponents, - nestedFilters - }); + // Calculate fresh results and cache them + query.cachedResult = this._findMatchingEntities(query, filter); + query.isDirty = false; - // Build generation masks for efficient querying - const withMasks = this._buildGenerationMasks(withComponents); - const withoutMasks = this._buildGenerationMasks(withoutComponents); - const addedMasks = this._buildGenerationMasks(addedComponents); - const changedMasks = this._buildGenerationMasks(changedComponents); - const removedMasks = this._buildGenerationMasks(removedComponents); + return query.cachedResult; + }, + getOrCreateQuery(filter) { const queryData: TQueryData = { - hash, - filter, - withMasks, - withoutMasks, - addedMasks, - changedMasks, - removedMasks, - nestedFilters, - toRemove: new Set() + hash: filter.getHash(world), + cachedResult: null, + isDirty: true, + allComponents: filter.getComponents(), + withMasks: {}, + withoutMasks: {} }; - this._queryCache.set(hash, queryData); - return queryData; - }, - - executeQuery(filter) { - // Commit any pending removals first - this._commitRemovals(); - - const queryData = this.registerQuery(filter); - return this._findMatchingEntities(queryData); - }, - - executeInnerQuery(filter) { - // Execute query without committing removals (for nested iteration) - const queryData = this.registerQuery(filter); - return this._findMatchingEntities(queryData); - }, - - checkEntity(queryData, eid) { - // First check basic component requirements (fastest) - if (!this._checkBasicRequirements(queryData, eid)) { - return false; + if (filter.register) { + filter.register(world, queryData); } - // Then check change detection requirements - if (!this._checkChangeRequirements(queryData, eid)) { - return false; + if (this._queryCache.has(queryData.hash)) { + return this._queryCache.get(queryData.hash)!; } - // Finally check nested filters (And/Or/Not) - if (!this._checkNestedFilters(queryData, eid)) { - return false; - } + this._queryCache.set(queryData.hash, queryData); + + // Register component callbacks for smart invalidation + this._registerComponentCallbacks(queryData); - return true; + return queryData; }, reset() { + // Unregister all component callbacks + for (const [component, unregisterFn] of this._componentCallbacks) { + unregisterFn(); + } + this._componentCallbacks.clear(); this._queryCache.clear(); - this._dirtyQueries.clear(); }, validate() { - // Validate query cache integrity - return this._queryCache.size >= 0; // Basic validation for now - }, - - _categorizeFilter(filter, categories) { - switch (filter.type) { - case 'With': - categories.withComponents.push(filter.component); - break; - case 'Without': - categories.withoutComponents.push(filter.component); - break; - case 'Added': - categories.addedComponents.push(filter.component); - break; - case 'Changed': - categories.changedComponents.push(filter.component); - break; - case 'Removed': - categories.removedComponents.push(filter.component); - break; - case 'And': - case 'Or': - case 'Not': - // Handle nested filters separately - do NOT recursively categorize - // The nested filters will be evaluated entirely by the nested filter logic - categories.nestedFilters.push(filter); - break; - case 'None': - // None filter doesn't contribute to any category - // It will be handled specially to return no entities - categories.nestedFilters.push(filter); - break; - } - }, - - _buildGenerationMasks(components) { - const masks = new Map(); - - for (const component of components) { - // Auto-register component if needed - if (!this._world._componentRegistry._componentMap.has(component)) { - this._world._componentRegistry.registerComponent(component); - } - - const componentData = this._world._componentRegistry._componentMap.get(component)!; - const { generationId, bitflag } = componentData; - - const currentMask = masks.get(generationId) ?? 0; - masks.set(generationId, currentMask | bitflag); - } - - return masks; - }, - - _checkBasicRequirements(queryData, eid) { - const { withMasks, withoutMasks } = queryData; - - // Check across all generations - for ( - let generationId = 0; - generationId < this._world._componentRegistry._entityMasks.length; - generationId++ - ) { - const entityMask = this._world._componentRegistry._entityMasks[generationId]?.[eid] ?? 0; - - // Check WITH requirements (must have ALL) - const withMask = withMasks.get(generationId) ?? 0; - if (withMask !== 0 && (entityMask & withMask) !== withMask) { - return false; - } - - // Check WITHOUT requirements (must have NONE) - const withoutMask = withoutMasks.get(generationId) ?? 0; - if (withoutMask !== 0 && (entityMask & withoutMask) !== 0) { - return false; - } - } - - return true; - }, - - _checkChangeRequirements(queryData, eid) { - const { addedMasks, changedMasks, removedMasks } = queryData; - - // Check across all generations - for ( - let generationId = 0; - generationId < this._world._componentRegistry._entityMasks.length; - generationId++ - ) { - // Check ADDED requirements (component was added this frame) - const addedMask = addedMasks.get(generationId) ?? 0; - if (addedMask !== 0) { - const entityAddedMask = - this._world._componentRegistry._addedMasks[generationId]?.[eid] ?? 0; - if ((entityAddedMask & addedMask) !== addedMask) { - return false; - } - } - - // Check CHANGED requirements (component was modified this frame) - const changedMask = changedMasks.get(generationId) ?? 0; - if (changedMask !== 0) { - const entityChangedMask = - this._world._componentRegistry._changedMasks[generationId]?.[eid] ?? 0; - if ((entityChangedMask & changedMask) !== changedMask) { - return false; - } - } - - // Check REMOVED requirements (component was removed this frame) - const removedMask = removedMasks.get(generationId) ?? 0; - if (removedMask !== 0) { - const entityRemovedMask = - this._world._componentRegistry._removedMasks[generationId]?.[eid] ?? 0; - if ((entityRemovedMask & removedMask) !== removedMask) { - return false; - } - } - } - - return true; + return this._queryCache.size >= 0; }, - _checkNestedFilters(queryData, eid) { - const { nestedFilters } = queryData; - - for (const filter of nestedFilters) { - if (!this._evaluateNestedFilter(filter, eid)) { - return false; - } - } - - return true; - }, - - _evaluateNestedFilter(filter, eid) { - switch (filter.type) { - case 'And': - // ALL nested filters must match - return filter.filters.every((nestedFilter: TQueryFilter) => - this._evaluateSingleFilter(nestedFilter, eid) - ); - - case 'Or': - // ANY nested filter must match - return filter.filters.some((nestedFilter: TQueryFilter) => - this._evaluateSingleFilter(nestedFilter, eid) - ); - - case 'Not': - // NONE of the nested filters must match - return !filter.filters.some((nestedFilter: TQueryFilter) => - this._evaluateSingleFilter(nestedFilter, eid) - ); - - case 'None': - // None filter matches no entities - return false; - - default: - // For any other filter type, delegate to _evaluateSingleFilter - return this._evaluateSingleFilter(filter, eid); - } - }, - - _evaluateSingleFilter(filter, eid) { - switch (filter.type) { - case 'With': - return this._world._componentRegistry.hasComponent(eid, filter.component); - - case 'Without': - return !this._world._componentRegistry.hasComponent(eid, filter.component); - - case 'Added': - return this._world._componentRegistry.wasAdded(eid, filter.component); - - case 'Changed': - return this._world._componentRegistry.wasChanged(eid, filter.component); - - case 'Removed': - return this._world._componentRegistry.wasRemoved(eid, filter.component); - - case 'And': - case 'Or': - case 'Not': - return this._evaluateNestedFilter(filter, eid); - - case 'None': - // None filter matches no entities - return false; - - default: - return true; - } - }, - - _findMatchingEntities(queryData) { + _findMatchingEntities(queryData, filter) { const matchingEntities: TEntityId[] = []; const aliveEntities = this._world._entityIndex.getAliveEntities(); for (const eid of aliveEntities) { - if (this.checkEntity(queryData, eid)) { + if (filter.evaluate(this._world, eid, queryData)) { matchingEntities.push(eid); } } @@ -389,12 +86,43 @@ export function createQueryRegistry(world: TWorld): TQueryRegistry { return matchingEntities; }, - _commitRemovals() { - // Simple deferred removal system - for (const queryData of this._dirtyQueries) { - queryData.toRemove.clear(); + /** + * Registers component callbacks for smart cache invalidation + */ + _registerComponentCallbacks(queryData) { + const components = queryData.allComponents; + + for (const component of components) { + // Skip if already registered for this component + if (this._componentCallbacks.has(component)) continue; + + // Register callback to invalidate queries when component changes + const unregisterAdd = this._world._componentRegistry.onComponentAdd(component, () => { + this._invalidateQueriesForComponent(component); + }); + + const unregisterRemove = this._world._componentRegistry.onComponentRemove(component, () => { + this._invalidateQueriesForComponent(component); + }); + + // Store combined unregister function + this._componentCallbacks.set(component, () => { + unregisterAdd(); + unregisterRemove(); + }); + } + }, + + /** + * Invalidates all queries that use a specific component + */ + _invalidateQueriesForComponent(component) { + for (const queryData of this._queryCache.values()) { + const queryComponents = queryData.allComponents; + if (queryComponents.includes(component)) { + queryData.isDirty = true; + } } - this._dirtyQueries.clear(); } }; } @@ -402,89 +130,32 @@ export function createQueryRegistry(world: TWorld): TQueryRegistry { export interface TQueryRegistry { /** Reference to the world */ _world: TWorld; - /** Cache of registered queries by hash */ + /** Cache of compiled queries by hash */ _queryCache: Map; - /** Set of queries with pending removals */ - _dirtyQueries: Set; + /** Map of component callbacks for smart cache invalidation */ + _componentCallbacks: Map void>; /** - * Generates a unique hash for query terms for caching. - * @param filter - The normalized filter - * @returns Unique hash string - */ - generateQueryHash(filter: TQueryFilter): string; - - /** - * Registers a query and returns its data structure. - * Uses caching to avoid recomputing query masks. - * @param filter - The query filter - * @returns Query data structure - */ - registerQuery(filter: TQueryFilter): TQueryData; - - /** - * Executes a query and returns matching entities. - * Commits pending removals before execution. - * @param filter - The query filter - * @returns Array of matching entity IDs + * Executes a query and returns matching entities */ executeQuery(filter: TQueryFilter): TEntityId[]; /** - * Executes a query without committing removals. - * Used for nested queries during iteration. - * @param filter - The query filter - * @returns Array of matching entity IDs - */ - executeInnerQuery(filter: TQueryFilter): TEntityId[]; - - /** - * Checks if an entity matches a query. - * @param queryData - The query data structure - * @param eid - The entity ID to check - * @returns True if entity matches the query + * Gets or creates a compiled query */ - checkEntity(queryData: TQueryData, eid: TEntityId): boolean; + getOrCreateQuery(filter: TQueryFilter): TQueryData; /** - * Resets the query registry to its initial state. + * Resets the query registry to its initial state */ reset(): void; /** - * Validates the query registry integrity. - * @returns True if the registry is valid + * Validates the query registry integrity */ validate(): boolean; - _categorizeFilter(filter: TQueryFilter, categories: any): void; - _buildGenerationMasks(components: TComponentRef[]): Map; - _checkBasicRequirements(queryData: TQueryData, eid: TEntityId): boolean; - _checkChangeRequirements(queryData: TQueryData, eid: TEntityId): boolean; - _checkNestedFilters(queryData: TQueryData, eid: TEntityId): boolean; - _evaluateNestedFilter(filter: TQueryFilter, eid: TEntityId): boolean; - _evaluateSingleFilter(filter: TQueryFilter, eid: TEntityId): boolean; - _findMatchingEntities(queryData: TQueryData): TEntityId[]; - _commitRemovals(): void; -} - -export interface TQueryData { - /** Unique hash for this query */ - hash: string; - /** Normalized query filter */ - filter: TQueryFilter; - /** Bitflag masks for With components by generation */ - withMasks: Map; - /** Bitflag masks for Without components by generation */ - withoutMasks: Map; - /** Bitflag masks for Added components by generation */ - addedMasks: Map; - /** Bitflag masks for Changed components by generation */ - changedMasks: Map; - /** Bitflag masks for Removed components by generation */ - removedMasks: Map; - /** Nested filters (And/Or/Not) */ - nestedFilters: TQueryFilter[]; - /** Set of entities pending removal */ - toRemove: Set; + _findMatchingEntities(queryData: TQueryData, filter: TQueryFilter): TEntityId[]; + _registerComponentCallbacks(queryData: TQueryData): void; + _invalidateQueriesForComponent(component: TComponentRef): void; } diff --git a/packages/feature-ecs/src/query-term.ts b/packages/feature-ecs/src/query-term.ts deleted file mode 100644 index d10e3b99..00000000 --- a/packages/feature-ecs/src/query-term.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { TComponentRef } from './component-registry'; - -export type TGetComponentId = (component: TComponentRef) => number; - -export type TFilter = - | { type: 'With'; component: TComponentRef; toString(getComponentId: TGetComponentId): string } - | { type: 'Without'; component: TComponentRef; toString(getComponentId: TGetComponentId): string } - | { type: 'Added'; component: TComponentRef; toString(getComponentId: TGetComponentId): string } - | { type: 'Changed'; component: TComponentRef; toString(getComponentId: TGetComponentId): string } - | { type: 'Removed'; component: TComponentRef; toString(getComponentId: TGetComponentId): string } - | { type: 'And'; filters: TFilter[]; toString(getComponentId: TGetComponentId): string } - | { type: 'Or'; filters: TFilter[]; toString(getComponentId: TGetComponentId): string } - | { type: 'Not'; filters: TFilter[]; toString(getComponentId: TGetComponentId): string } - | { type: 'None'; toString(): string }; - -export const With = (component: T): TFilter => ({ - type: 'With', - component, - toString(getComponentId) { - return `with(${getComponentId(component)})`; - } -}); - -export const Without = (component: T): TFilter => ({ - type: 'Without', - component, - toString(getComponentId) { - return `without(${getComponentId(component)})`; - } -}); - -export const Added = (component: T): TFilter => ({ - type: 'Added', - component, - toString(getComponentId) { - return `added(${getComponentId(component)})`; - } -}); - -export const Changed = (component: T): TFilter => ({ - type: 'Changed', - component, - toString(getComponentId) { - return `changed(${getComponentId(component)})`; - } -}); - -export const Removed = (component: T): TFilter => ({ - type: 'Removed', - component, - toString(getComponentId) { - return `removed(${getComponentId(component)})`; - } -}); - -export const And = (...filters: TFilter[]): TFilter => ({ - type: 'And', - filters, - toString(getComponentId) { - return `and(${filters.map((f) => f.toString(getComponentId)).join(',')})`; - } -}); - -export const All = And; // Alias for And - -export const Or = (...filters: TFilter[]): TFilter => ({ - type: 'Or', - filters, - toString(getComponentId) { - return `or(${filters.map((f) => f.toString(getComponentId)).join(',')})`; - } -}); - -export const Any = Or; // Alias for Or - -export const Not = (...filters: TFilter[]): TFilter => ({ - type: 'Not', - filters, - toString(getComponentId: TGetComponentId) { - return `not(${filters.map((f) => f.toString(getComponentId)).join(',')})`; - } -}); - -export const None = (): TFilter => ({ - type: 'None', - toString() { - return 'none()'; - } -}); - -// Special entity symbol for queries -export const Entity = Symbol('Entity'); - -/** - * Type guard to check if a value is a filter. - */ -export function isFilter(value: any): value is TFilter { - return ( - typeof value === 'object' && - value !== null && - 'type' in value && - typeof value.type === 'string' && - ['With', 'Without', 'Added', 'Changed', 'Removed', 'And', 'Or', 'Not', 'None'].includes( - value.type - ) - ); -} diff --git a/packages/feature-ecs/src/world.ts b/packages/feature-ecs/src/world.ts index b78393f1..2fb522f1 100644 --- a/packages/feature-ecs/src/world.ts +++ b/packages/feature-ecs/src/world.ts @@ -1,101 +1,142 @@ -import { withNew } from '@blgc/utils'; import { createComponentRegistry, TComponentRef, TComponentRegistry } from './component-registry'; import { createEntityIndex, TEntityId, TEntityIndex } from './entity-index'; import { TQueryFilter } from './query-filter'; import { createQueryRegistry, TQueryRegistry } from './query-registry'; +/** + * Creates a new ECS world. + * + * @returns A new world instance with component registry, entity index, and query registry + * + * @example + * ```typescript + * const world = createWorld(); + * + * // Create entities and add components + * const entity = world.createEntity(); + * world.addComponent(entity, Position); + * world.addComponent(entity, Velocity); + * + * // Query entities + * const entities = world.query(And(With(Position), With(Velocity))); + * ``` + */ export function createWorld(): TWorld { - return withNew({ - _entityIndex: createEntityIndex(), - _componentRegistry: createComponentRegistry(), - _queryRegistry: null as unknown as TQueryRegistry, + const componentRegistry = createComponentRegistry(); + const entityIndex = createEntityIndex(); - _new() { - this._queryRegistry = createQueryRegistry(this); - }, + const world: TWorld = { + _componentRegistry: componentRegistry, + _entityIndex: entityIndex, + _queryRegistry: null as any, // Will be set below - addEntity() { - return this._entityIndex.addEntity(); + createEntity() { + const eid = this._entityIndex.addEntity(); + return eid; }, - removeEntity(eid) { - if (!this._entityIndex.isEntityAlive(eid)) { - return false; - } - - // Remove all components from entity + destroyEntity(eid) { this._componentRegistry.removeAllComponents(eid); - - // Remove entity from index - return this._entityIndex.removeEntity(eid); + this._entityIndex.removeEntity(eid); }, - doesEntityExist(eid) { - return this._entityIndex.isEntityAlive(eid); + addComponent(eid, component) { + this._componentRegistry.addComponent(eid, component); }, - markChanged(eid: TEntityId, component: TComponentRef) { - return this._componentRegistry.markChanged(eid, component); + removeComponent(eid, component) { + const result = this._componentRegistry.removeComponent(eid, component); + return result; }, - clear() { - this._componentRegistry.clear(); + hasComponent(eid, component) { + return this._componentRegistry.hasComponent(eid, component); }, query(filter) { return this._queryRegistry.executeQuery(filter); }, - innerQuery(filter) { - return this._queryRegistry.executeInnerQuery(filter); - }, - reset() { - this._entityIndex.reset(); this._componentRegistry.reset(); + this._entityIndex.reset(); this._queryRegistry.reset(); + }, + + validate() { + return ( + this._componentRegistry.validate() && + this._entityIndex.validate() && + this._queryRegistry.validate() + ); } - }); + }; + + // Create query registry with the world reference + const queryRegistry = createQueryRegistry(world); + world._queryRegistry = queryRegistry; + + return world; } export interface TWorld { - _entityIndex: TEntityIndex; + /** Component registry for managing component data */ _componentRegistry: TComponentRegistry; + /** Entity index for managing entity lifecycle */ + _entityIndex: TEntityIndex; + /** Query registry for efficient entity queries */ _queryRegistry: TQueryRegistry; - addEntity(): TEntityId; - removeEntity(eid: TEntityId): boolean; - doesEntityExist(eid: TEntityId): boolean; + /** + * Creates a new entity and returns its ID. + * @returns The new entity ID + */ + createEntity(): TEntityId; /** - * Marks a component as changed for the current frame. + * Destroys an entity and removes all its components. + * @param eid - The entity ID to destroy + */ + destroyEntity(eid: TEntityId): void; + + /** + * Adds a component to an entity. * @param eid - The entity ID - * @param component - The component to mark as changed - * @returns True if component was marked as changed, false if entity didn't have it + * @param component - The component to add */ - markChanged(eid: TEntityId, component: TComponentRef): boolean; + addComponent(eid: TEntityId, component: TComponentRef): void; /** - * Clears all change tracking for the current frame. - * Should be called at the end of each frame/update cycle. + * Removes a component from an entity. + * @param eid - The entity ID + * @param component - The component to remove + * @returns True if component was removed, false if entity didn't have it */ - clear(): void; + removeComponent(eid: TEntityId, component: TComponentRef): boolean; /** - * Execute a query and return matching entities. - * Commits pending removals before execution. - * @param filter - The query filter - * @returns Array of matching entity IDs + * Checks if an entity has a specific component. + * @param eid - The entity ID + * @param component - The component to check + * @returns True if entity has the component */ - query(filter: TQueryFilter): TEntityId[]; + hasComponent(eid: TEntityId, component: TComponentRef): boolean; /** - * Execute a query without committing removals. - * Used for nested queries during iteration. + * Executes a query and returns matching entities. * @param filter - The query filter * @returns Array of matching entity IDs */ - innerQuery(filter: TQueryFilter): TEntityId[]; + query(filter: TQueryFilter): TEntityId[]; + /** + * Resets the world to its initial state. + */ reset(): void; + + /** + * Validates the world integrity. + * @returns True if the world is valid + */ + validate(): boolean; } From 2ad07c1ec811dd1052a1a78b0207a5ff13697afc Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Wed, 28 May 2025 18:16:51 +0200 Subject: [PATCH 12/39] #105 fixed typos --- .../feature-ecs/src/component-registry.ts | 4 +- packages/feature-ecs/src/query-filter.ts | 47 ++++++- .../feature-ecs/src/query-registry.test.ts | 10 +- packages/feature-ecs/src/query-registry.ts | 130 +++++++++--------- packages/feature-ecs/src/world.ts | 20 +++ 5 files changed, 137 insertions(+), 74 deletions(-) diff --git a/packages/feature-ecs/src/component-registry.ts b/packages/feature-ecs/src/component-registry.ts index f7fc3848..dfb57b6e 100644 --- a/packages/feature-ecs/src/component-registry.ts +++ b/packages/feature-ecs/src/component-registry.ts @@ -332,7 +332,7 @@ export function createComponentRegistry(): TComponentRegistry { }; }, - clear() { + flush() { // Clear all change tracking for the current frame for (let generationId = 0; generationId < this._addedMasks.length; generationId++) { if (this._addedMasks[generationId] != null) { @@ -536,7 +536,7 @@ export interface TComponentRegistry { /** * Clears all change tracking for the current frame. */ - clear(): void; + flush(): void; /** * Resets the registry to its initial empty state. diff --git a/packages/feature-ecs/src/query-filter.ts b/packages/feature-ecs/src/query-filter.ts index 2db73254..38df978d 100644 --- a/packages/feature-ecs/src/query-filter.ts +++ b/packages/feature-ecs/src/query-filter.ts @@ -103,6 +103,13 @@ export function Added(component: T): TQueryFilter { return [component]; }, + register(world: TWorld, queryData: TQueryData): void { + // Register callback to invalidate this query when components are added + world._componentRegistry.onComponentAdd(component, () => { + queryData.isDirty = true; + }); + }, + getHash(world: TWorld): string { const componentId = getComponentId(world, component); return `added(${componentId})`; @@ -126,6 +133,13 @@ export function Changed(component: T): TQueryFilter { return [component]; }, + register(world: TWorld, queryData: TQueryData): void { + // Register callback to invalidate this query when components are changed + world._componentRegistry.onComponentChange(component, () => { + queryData.isDirty = true; + }); + }, + getHash(world: TWorld): string { const componentId = getComponentId(world, component); return `changed(${componentId})`; @@ -149,6 +163,13 @@ export function Removed(component: T): TQueryFilter { return [component]; }, + register(world: TWorld, queryData: TQueryData): void { + // Register callback to invalidate this query when components are removed + world._componentRegistry.onComponentRemove(component, () => { + queryData.isDirty = true; + }); + }, + getHash(world: TWorld): string { const componentId = getComponentId(world, component); return `removed(${componentId})`; @@ -281,13 +302,34 @@ export function Not(...filters: TQueryFilter[]): TQueryFilter { }; } +/** + * Special filter that matches no entities + */ +export function None(): TQueryFilter { + return { + type: 'None', + + evaluate(): boolean { + return false; + }, + + getComponents(): TComponentRef[] { + return []; + }, + + getHash(): string { + return 'none()'; + } + }; +} + // Aliases for convenience export const All = And; export const Any = Or; -export const None = Not; export interface TQueryData { hash: string; + filter: TQueryFilter; cachedResult: TEntityId[] | null; isDirty: boolean; allComponents: TComponentRef[]; @@ -311,7 +353,8 @@ export type TQueryFilter = | (TBaseQueryFilter & { type: 'Removed'; component: TComponentRef }) | (TBaseQueryFilter & { type: 'And'; filters: TQueryFilter[] }) | (TBaseQueryFilter & { type: 'Or'; filters: TQueryFilter[] }) - | (TBaseQueryFilter & { type: 'Not'; filters: TQueryFilter[] }); + | (TBaseQueryFilter & { type: 'Not'; filters: TQueryFilter[] }) + | (TBaseQueryFilter & { type: 'None' }); /** * Helper to get component ID, registering if needed diff --git a/packages/feature-ecs/src/query-registry.test.ts b/packages/feature-ecs/src/query-registry.test.ts index d821f6f7..70bb8e31 100644 --- a/packages/feature-ecs/src/query-registry.test.ts +++ b/packages/feature-ecs/src/query-registry.test.ts @@ -315,7 +315,7 @@ describe('createQueryRegistry', () => { expect(addedHealth).toEqual([eid2]); // Clear frame changes - world._componentRegistry.clear(); + world.flush(); // After clearing, no entities should have added components const addedPositionAfter = world.query(Added(Position)); @@ -336,7 +336,7 @@ describe('createQueryRegistry', () => { world.addComponent(eid2, Health); // Clear initial "added" tracking - world._componentRegistry.clear(); + world.flush(); // Mark Position as changed for eid1 world._componentRegistry.markChanged(eid1, Position); @@ -363,7 +363,7 @@ describe('createQueryRegistry', () => { world.addComponent(eid2, Position); // Clear initial "added" tracking - world._componentRegistry.clear(); + world.flush(); // Remove Health from eid1 world.removeComponent(eid1, Health); @@ -394,7 +394,7 @@ describe('createQueryRegistry', () => { world.addComponent(eid2, Shield); // Clear initial "added" tracking - world._componentRegistry.clear(); + world.flush(); // Trigger changes: // - Add Shield to eid1 (new component) @@ -429,7 +429,7 @@ describe('createQueryRegistry', () => { world.addComponent(eid2, Enemy); // Clear initial "added" tracking - world._componentRegistry.clear(); + world.flush(); // Mark components as changed world._componentRegistry.markChanged(eid1, Health); diff --git a/packages/feature-ecs/src/query-registry.ts b/packages/feature-ecs/src/query-registry.ts index c317a971..c51d6ebc 100644 --- a/packages/feature-ecs/src/query-registry.ts +++ b/packages/feature-ecs/src/query-registry.ts @@ -1,11 +1,10 @@ /** * Query Registry for ECS * - * Manages compiled queries with bitmask optimizations and smart cache invalidation. - * Uses the new filter system for maximum performance. + * Simple and fast query registry with bitmask optimizations. + * Follows KISS principle - Keep It Simple, Stupid. */ -import { TComponentRef } from './component-registry'; import { TEntityId } from './entity-index'; import { TQueryData, TQueryFilter } from './query-filter'; import { TWorld } from './world'; @@ -17,26 +16,34 @@ export function createQueryRegistry(world: TWorld): TQueryRegistry { return { _world: world, _queryCache: new Map(), - _componentCallbacks: new Map(), executeQuery(filter) { - const query = this.getOrCreateQuery(filter); + const queryData = this.getOrCreateQuery(filter); // Return cached result if available and not dirty - if (!query.isDirty && query.cachedResult) { - return query.cachedResult; + if (!queryData.isDirty && queryData.cachedResult != null) { + return queryData.cachedResult; } // Calculate fresh results and cache them - query.cachedResult = this._findMatchingEntities(query, filter); - query.isDirty = false; + queryData.cachedResult = this._findMatchingEntities(queryData, filter); + queryData.isDirty = false; - return query.cachedResult; + return queryData.cachedResult; }, getOrCreateQuery(filter) { + const hash = filter.getHash(this._world); + + // Return cached query if exists + if (this._queryCache.has(hash)) { + return this._queryCache.get(hash)!; + } + + // Create new query data const queryData: TQueryData = { - hash: filter.getHash(world), + hash, + filter, cachedResult: null, isDirty: true, allComponents: filter.getComponents(), @@ -44,28 +51,38 @@ export function createQueryRegistry(world: TWorld): TQueryRegistry { withoutMasks: {} }; + // Let filter register its bitmasks if (filter.register) { - filter.register(world, queryData); + filter.register(this._world, queryData); } - if (this._queryCache.has(queryData.hash)) { - return this._queryCache.get(queryData.hash)!; - } + // Cache the query + this._queryCache.set(hash, queryData); - this._queryCache.set(queryData.hash, queryData); + return queryData; + }, - // Register component callbacks for smart invalidation - this._registerComponentCallbacks(queryData); + registerQuery(filter) { + return this.getOrCreateQuery(filter); + }, - return queryData; + generateQueryHash(filter) { + return filter.getHash(this._world); }, - reset() { - // Unregister all component callbacks - for (const [component, unregisterFn] of this._componentCallbacks) { - unregisterFn(); + checkEntity(queryData, eid) { + // Use the stored filter's evaluate method with the query data + return queryData.filter.evaluate(this._world, eid, queryData); + }, + + invalidateQueries() { + // Mark all queries as dirty + for (const queryData of this._queryCache.values()) { + queryData.isDirty = true; } - this._componentCallbacks.clear(); + }, + + reset() { this._queryCache.clear(); }, @@ -86,43 +103,9 @@ export function createQueryRegistry(world: TWorld): TQueryRegistry { return matchingEntities; }, - /** - * Registers component callbacks for smart cache invalidation - */ - _registerComponentCallbacks(queryData) { - const components = queryData.allComponents; - - for (const component of components) { - // Skip if already registered for this component - if (this._componentCallbacks.has(component)) continue; - - // Register callback to invalidate queries when component changes - const unregisterAdd = this._world._componentRegistry.onComponentAdd(component, () => { - this._invalidateQueriesForComponent(component); - }); - - const unregisterRemove = this._world._componentRegistry.onComponentRemove(component, () => { - this._invalidateQueriesForComponent(component); - }); - - // Store combined unregister function - this._componentCallbacks.set(component, () => { - unregisterAdd(); - unregisterRemove(); - }); - } - }, - - /** - * Invalidates all queries that use a specific component - */ - _invalidateQueriesForComponent(component) { - for (const queryData of this._queryCache.values()) { - const queryComponents = queryData.allComponents; - if (queryComponents.includes(component)) { - queryData.isDirty = true; - } - } + _getFilterFromQueryData(queryData) { + // Return the stored filter + return queryData.filter; } }; } @@ -132,8 +115,6 @@ export interface TQueryRegistry { _world: TWorld; /** Cache of compiled queries by hash */ _queryCache: Map; - /** Map of component callbacks for smart cache invalidation */ - _componentCallbacks: Map void>; /** * Executes a query and returns matching entities @@ -145,6 +126,26 @@ export interface TQueryRegistry { */ getOrCreateQuery(filter: TQueryFilter): TQueryData; + /** + * Registers a query (alias for getOrCreateQuery) + */ + registerQuery(filter: TQueryFilter): TQueryData; + + /** + * Generates a hash for a query filter + */ + generateQueryHash(filter: TQueryFilter): string; + + /** + * Checks if an entity matches a query + */ + checkEntity(queryData: TQueryData, eid: TEntityId): boolean; + + /** + * Invalidates all cached queries + */ + invalidateQueries(): void; + /** * Resets the query registry to its initial state */ @@ -156,6 +157,5 @@ export interface TQueryRegistry { validate(): boolean; _findMatchingEntities(queryData: TQueryData, filter: TQueryFilter): TEntityId[]; - _registerComponentCallbacks(queryData: TQueryData): void; - _invalidateQueriesForComponent(component: TComponentRef): void; + _getFilterFromQueryData(queryData: TQueryData): TQueryFilter; } diff --git a/packages/feature-ecs/src/world.ts b/packages/feature-ecs/src/world.ts index 2fb522f1..f9f2df8c 100644 --- a/packages/feature-ecs/src/world.ts +++ b/packages/feature-ecs/src/world.ts @@ -57,6 +57,14 @@ export function createWorld(): TWorld { return this._queryRegistry.executeQuery(filter); }, + innerQuery(filter) { + return this._queryRegistry.executeQuery(filter); + }, + + flush() { + this._componentRegistry.flush(); + }, + reset() { this._componentRegistry.reset(); this._entityIndex.reset(); @@ -129,6 +137,18 @@ export interface TWorld { */ query(filter: TQueryFilter): TEntityId[]; + /** + * Executes a query and returns matching entities. + * @param filter - The query filter + * @returns Array of matching entity IDs + */ + innerQuery(filter: TQueryFilter): TEntityId[]; + + /** + * Clears the world. + */ + flush(): void; + /** * Resets the world to its initial state. */ From 0ca0eaccfbeda9adbfe1f0e726337070465a18d5 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Wed, 28 May 2025 20:43:44 +0200 Subject: [PATCH 13/39] #105 fixed typos --- packages/feature-ecs/src/query-filter.ts | 46 +--------------------- packages/feature-ecs/src/query-registry.ts | 41 +++++++------------ 2 files changed, 15 insertions(+), 72 deletions(-) diff --git a/packages/feature-ecs/src/query-filter.ts b/packages/feature-ecs/src/query-filter.ts index 38df978d..5a7b0bd9 100644 --- a/packages/feature-ecs/src/query-filter.ts +++ b/packages/feature-ecs/src/query-filter.ts @@ -23,10 +23,6 @@ export function With(component: T): TQueryFilter { return (entityMask & bitflag) !== 0; }, - getComponents(): TComponentRef[] { - return [component]; - }, - register(world: TWorld, queryData: TQueryData): void { const registry = world._componentRegistry; const componentData = registry._componentMap.get(component); @@ -65,10 +61,6 @@ export function Without(component: T): TQueryFilter { return (entityMask & bitflag) === 0; }, - getComponents(): TComponentRef[] { - return [component]; - }, - register(world: TWorld, queryData: TQueryData): void { const registry = world._componentRegistry; const componentData = registry._componentMap.get(component); @@ -99,10 +91,6 @@ export function Added(component: T): TQueryFilter { return world._componentRegistry.wasAdded(eid, component); }, - getComponents(): TComponentRef[] { - return [component]; - }, - register(world: TWorld, queryData: TQueryData): void { // Register callback to invalidate this query when components are added world._componentRegistry.onComponentAdd(component, () => { @@ -129,10 +117,6 @@ export function Changed(component: T): TQueryFilter { return world._componentRegistry.wasChanged(eid, component); }, - getComponents(): TComponentRef[] { - return [component]; - }, - register(world: TWorld, queryData: TQueryData): void { // Register callback to invalidate this query when components are changed world._componentRegistry.onComponentChange(component, () => { @@ -159,10 +143,6 @@ export function Removed(component: T): TQueryFilter { return world._componentRegistry.wasRemoved(eid, component); }, - getComponents(): TComponentRef[] { - return [component]; - }, - register(world: TWorld, queryData: TQueryData): void { // Register callback to invalidate this query when components are removed world._componentRegistry.onComponentRemove(component, () => { @@ -187,13 +167,7 @@ export function And(...filters: TQueryFilter[]): TQueryFilter { evaluate(world: TWorld, eid: TEntityId, queryData: TQueryData): boolean { // Use batched bitmask checking if all filters are With/Without - const allSimple = filters.every((f) => f.type === 'With' || f.type === 'Without'); - - if ( - allSimple && - (Object.keys(queryData.withMasks).length > 0 || - Object.keys(queryData.withoutMasks).length > 0) - ) { + if (filters.every((f) => f.type === 'With' || f.type === 'Without')) { const registry = world._componentRegistry; // Check all required components in batches @@ -219,10 +193,6 @@ export function And(...filters: TQueryFilter[]): TQueryFilter { return filters.every((filter) => filter.evaluate(world, eid, queryData)); }, - getComponents(): TComponentRef[] { - return filters.flatMap((filter) => filter.getComponents()); - }, - register(world: TWorld, queryData: TQueryData): void { // Let child filters register their bitmasks for (const filter of filters) { @@ -254,10 +224,6 @@ export function Or(...filters: TQueryFilter[]): TQueryFilter { return filters.some((filter) => filter.evaluate(world, eid, queryData)); }, - getComponents(): TComponentRef[] { - return filters.flatMap((filter) => filter.getComponents()); - }, - register(world: TWorld, queryData: TQueryData): void { for (const filter of filters) { if (filter.register) { @@ -288,10 +254,6 @@ export function Not(...filters: TQueryFilter[]): TQueryFilter { return !filters.some((filter) => filter.evaluate(world, eid, queryData)); }, - getComponents(): TComponentRef[] { - return filters.flatMap((filter) => filter.getComponents()); - }, - getHash(world: TWorld): string { const childHashes = filters .map((f) => f.getHash(world)) @@ -313,10 +275,6 @@ export function None(): TQueryFilter { return false; }, - getComponents(): TComponentRef[] { - return []; - }, - getHash(): string { return 'none()'; } @@ -332,7 +290,6 @@ export interface TQueryData { filter: TQueryFilter; cachedResult: TEntityId[] | null; isDirty: boolean; - allComponents: TComponentRef[]; withMasks: Record; withoutMasks: Record; } @@ -340,7 +297,6 @@ export interface TQueryData { export interface TBaseQueryFilter { type: string; evaluate(world: TWorld, eid: TEntityId, queryData: TQueryData): boolean; - getComponents(): TComponentRef[]; register?(world: TWorld, queryData: TQueryData): void; getHash(world: TWorld): string; } diff --git a/packages/feature-ecs/src/query-registry.ts b/packages/feature-ecs/src/query-registry.ts index c51d6ebc..57ad73fa 100644 --- a/packages/feature-ecs/src/query-registry.ts +++ b/packages/feature-ecs/src/query-registry.ts @@ -25,11 +25,20 @@ export function createQueryRegistry(world: TWorld): TQueryRegistry { return queryData.cachedResult; } - // Calculate fresh results and cache them - queryData.cachedResult = this._findMatchingEntities(queryData, filter); + // Find matching entities + const matchingEntities: TEntityId[] = []; + const aliveEntities = this._world._entityIndex.getAliveEntities(); + for (const eid of aliveEntities) { + if (filter.evaluate(this._world, eid, queryData)) { + matchingEntities.push(eid); + } + } + + // Cache results + queryData.cachedResult = matchingEntities; queryData.isDirty = false; - return queryData.cachedResult; + return matchingEntities; }, getOrCreateQuery(filter) { @@ -46,13 +55,12 @@ export function createQueryRegistry(world: TWorld): TQueryRegistry { filter, cachedResult: null, isDirty: true, - allComponents: filter.getComponents(), withMasks: {}, withoutMasks: {} }; - // Let filter register its bitmasks - if (filter.register) { + // Let filter register + if (filter.register != null) { filter.register(this._world, queryData); } @@ -88,24 +96,6 @@ export function createQueryRegistry(world: TWorld): TQueryRegistry { validate() { return this._queryCache.size >= 0; - }, - - _findMatchingEntities(queryData, filter) { - const matchingEntities: TEntityId[] = []; - const aliveEntities = this._world._entityIndex.getAliveEntities(); - - for (const eid of aliveEntities) { - if (filter.evaluate(this._world, eid, queryData)) { - matchingEntities.push(eid); - } - } - - return matchingEntities; - }, - - _getFilterFromQueryData(queryData) { - // Return the stored filter - return queryData.filter; } }; } @@ -155,7 +145,4 @@ export interface TQueryRegistry { * Validates the query registry integrity */ validate(): boolean; - - _findMatchingEntities(queryData: TQueryData, filter: TQueryFilter): TEntityId[]; - _getFilterFromQueryData(queryData: TQueryData): TQueryFilter; } From fc80233aa4d5c5d3d1399b68233cea54a1b89892 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Thu, 29 May 2025 07:50:04 +0200 Subject: [PATCH 14/39] #105 more bitmasks --- .../src/component-registry.test.ts | 2 +- packages/feature-ecs/src/query-filter.ts | 192 ++++++++++++++++-- packages/feature-ecs/src/query-registry.ts | 50 ++++- packages/feature-ecs/src/world.ts | 1 + 4 files changed, 212 insertions(+), 33 deletions(-) diff --git a/packages/feature-ecs/src/component-registry.test.ts b/packages/feature-ecs/src/component-registry.test.ts index 31fcb0dc..c656c73e 100644 --- a/packages/feature-ecs/src/component-registry.test.ts +++ b/packages/feature-ecs/src/component-registry.test.ts @@ -628,7 +628,7 @@ describe('createComponentRegistry', () => { expect(registry.wasRemoved(eid, Health)).toBe(true); // Clear frame changes - registry.clear(); + registry.flush(); // Changes should be cleared expect(registry.wasAdded(eid, Position)).toBe(false); diff --git a/packages/feature-ecs/src/query-filter.ts b/packages/feature-ecs/src/query-filter.ts index 5a7b0bd9..36308470 100644 --- a/packages/feature-ecs/src/query-filter.ts +++ b/packages/feature-ecs/src/query-filter.ts @@ -3,7 +3,7 @@ import { TEntityId } from './entity-index'; import { TWorld } from './world'; /** - * Checks if entity has component using bitmask + * Requires entity to have component */ export function With(component: T): TQueryFilter { return { @@ -30,6 +30,11 @@ export function With(component: T): TQueryFilter { if (componentData != null) { const { generationId, bitflag } = componentData; queryData.withMasks[generationId] = (queryData.withMasks[generationId] ?? 0) | bitflag; + + // Add to generations array if not already present + if (!queryData.generations.includes(generationId)) { + queryData.generations.push(generationId); + } } }, @@ -41,7 +46,7 @@ export function With(component: T): TQueryFilter { } /** - * Checks if entity lacks component using bitmask + * Requires entity to lack component */ export function Without(component: T): TQueryFilter { return { @@ -69,6 +74,11 @@ export function Without(component: T): TQueryFilter { const { generationId, bitflag } = componentData; queryData.withoutMasks[generationId] = (queryData.withoutMasks[generationId] ?? 0) | bitflag; + + // Add to generations array if not already present + if (!queryData.generations.includes(generationId)) { + queryData.generations.push(generationId); + } } }, @@ -158,7 +168,7 @@ export function Removed(component: T): TQueryFilter { } /** - * All filters must match (uses batched bitmask checking when possible) + * Requires all child filters to match (uses fast bitmask checking when possible) */ export function And(...filters: TQueryFilter[]): TQueryFilter { return { @@ -166,22 +176,36 @@ export function And(...filters: TQueryFilter[]): TQueryFilter { filters, evaluate(world: TWorld, eid: TEntityId, queryData: TQueryData): boolean { - // Use batched bitmask checking if all filters are With/Without - if (filters.every((f) => f.type === 'With' || f.type === 'Without')) { + // Fast path: Use pre-computed bitmasks for compatible queries + if (queryData.evaluationStrategy === 'bitmask') { const registry = world._componentRegistry; + const { withMasks, withoutMasks, notMasks, orMasks, generations } = queryData; - // Check all required components in batches - for (const [generationId, withMask] of Object.entries(queryData.withMasks)) { - const entityMask = registry._entityMasks[+generationId]?.[eid] ?? 0; - if ((entityMask & withMask) !== withMask) { + for (let i = 0; i < generations.length; i++) { + const generationId = generations[i] as number; + const entityMask = registry._entityMasks[generationId]?.[eid] ?? 0; + + // Required components: entity must have ALL + const withMask = withMasks[generationId]; + if (withMask != null && (entityMask & withMask) !== withMask) { + return false; + } + + // Forbidden components (Without): entity must have NONE + const withoutMask = withoutMasks[generationId]; + if (withoutMask != null && (entityMask & withoutMask) !== 0) { return false; } - } - // Check all forbidden components in batches - for (const [generationId, withoutMask] of Object.entries(queryData.withoutMasks)) { - const entityMask = registry._entityMasks[+generationId]?.[eid] ?? 0; - if ((entityMask & withoutMask) !== 0) { + // Forbidden components (Not): entity must have NONE + const notMask = notMasks[generationId]; + if (notMask != null && (entityMask & notMask) !== 0) { + return false; + } + + // OR components: entity must have AT LEAST ONE + const orMask = orMasks[generationId]; + if (orMask != null && (entityMask & orMask) === 0) { return false; } } @@ -189,14 +213,14 @@ export function And(...filters: TQueryFilter[]): TQueryFilter { return true; } - // Fallback to individual filter evaluation + // Fallback: Evaluate filters individually return filters.every((filter) => filter.evaluate(world, eid, queryData)); }, register(world: TWorld, queryData: TQueryData): void { // Let child filters register their bitmasks for (const filter of filters) { - if (filter.register) { + if (filter.register != null) { filter.register(world, queryData); } } @@ -213,7 +237,7 @@ export function And(...filters: TQueryFilter[]): TQueryFilter { } /** - * Any filter must match (requires individual evaluation) + * Requires any child filter to match */ export function Or(...filters: TQueryFilter[]): TQueryFilter { return { @@ -225,9 +249,24 @@ export function Or(...filters: TQueryFilter[]): TQueryFilter { }, register(world: TWorld, queryData: TQueryData): void { - for (const filter of filters) { - if (filter.register) { - filter.register(world, queryData); + // If parent uses bitmask evaluation, populate orMasks from With children + if (queryData.evaluationStrategy === 'bitmask') { + for (const filter of filters) { + if (filter.type === 'With') { + const registry = world._componentRegistry; + const componentData = registry._componentMap.get(filter.component); + if (componentData != null) { + const { generationId, bitflag } = componentData; + queryData.orMasks[generationId] = (queryData.orMasks[generationId] ?? 0) | bitflag; + } + } + } + } else { + // For individual evaluation, register child filters normally + for (const filter of filters) { + if (filter.register != null) { + filter.register(world, queryData); + } } } }, @@ -243,7 +282,7 @@ export function Or(...filters: TQueryFilter[]): TQueryFilter { } /** - * No filter must match (requires individual evaluation) + * Requires no child filter to match */ export function Not(...filters: TQueryFilter[]): TQueryFilter { return { @@ -254,6 +293,29 @@ export function Not(...filters: TQueryFilter[]): TQueryFilter { return !filters.some((filter) => filter.evaluate(world, eid, queryData)); }, + register(world: TWorld, queryData: TQueryData): void { + // If parent uses bitmask evaluation, populate notMasks from With children + if (queryData.evaluationStrategy === 'bitmask') { + for (const filter of filters) { + if (filter.type === 'With') { + const registry = world._componentRegistry; + const componentData = registry._componentMap.get(filter.component); + if (componentData != null) { + const { generationId, bitflag } = componentData; + queryData.notMasks[generationId] = (queryData.notMasks[generationId] ?? 0) | bitflag; + } + } + } + } else { + // For individual evaluation, register child filters normally + for (const filter of filters) { + if (filter.register != null) { + filter.register(world, queryData); + } + } + } + }, + getHash(world: TWorld): string { const childHashes = filters .map((f) => f.getHash(world)) @@ -286,12 +348,46 @@ export const All = And; export const Any = Or; export interface TQueryData { + /** Unique hash identifying this query filter combination */ hash: string; + + /** The original query filter that was compiled into this data */ filter: TQueryFilter; - cachedResult: TEntityId[] | null; + + /** Cached array of entity IDs that match this query */ + cachedResult: TEntityId[]; + + /** True when cached results are stale and need re-evaluation */ isDirty: boolean; + + /** + * True if this query contains Added/Changed/Removed filters and needs + * cache invalidation when world.flush() clears change tracking masks. + * Pre-computed during registration for O(1) flush performance. + */ + needsFlushInvalidation: boolean; + + /** + * Pre-computed evaluation strategy for optimal performance: + * - 'bitmask': Fast bitwise operations for With/Without combinations + * - 'individual': Filter-by-filter evaluation for complex queries + */ + evaluationStrategy: 'bitmask' | 'individual'; + + /** Pre-computed generations array for optimal bitmask lookup */ + generations: number[]; + + /** Bitmasks for required components (With filters) by generation ID */ withMasks: Record; + + /** Bitmasks for forbidden components (Without filters) by generation ID */ withoutMasks: Record; + + /** Bitmasks for OR components by generation ID */ + orMasks: Record; + + /** Bitmasks for NOT components by generation ID */ + notMasks: Record; } export interface TBaseQueryFilter { @@ -322,3 +418,55 @@ function getComponentId(world: TWorld, component: TComponentRef): number { } return registry._componentMap.get(component)!.id; } + +/** + * Pre-categorizes a query's evaluation strategy for optimal performance. + * + * Strategies: + * - 'bitmask': All filters can use bitwise operations (With/Without/Or(With...)/Not(With...)) + * - 'individual': Contains change detection or complex nested filters requiring individual evaluation + */ +export function categorizeEvaluationStrategy(filter: TQueryFilter): 'bitmask' | 'individual' { + // Simple bitmask-compatible filters + if (filter.type === 'With' || filter.type === 'Without') { + return 'bitmask'; + } + + // Or is bitmask-compatible if all children are With filters + if (filter.type === 'Or') { + return filter.filters.every((f) => f.type === 'With') ? 'bitmask' : 'individual'; + } + + // Not is bitmask-compatible if all children are With filters + if (filter.type === 'Not') { + return filter.filters.every((f) => f.type === 'With') ? 'bitmask' : 'individual'; + } + + // And is bitmask-compatible if ALL children are bitmask-compatible + if (filter.type === 'And') { + return filter.filters.every((f) => categorizeEvaluationStrategy(f) === 'bitmask') + ? 'bitmask' + : 'individual'; + } + + // Change detection filters require individual evaluation + return 'individual'; +} + +/** + * Checks if a filter contains change detection filters (Added/Changed/Removed). + * Used to pre-compute needsFlushInvalidation flag for O(1) flush performance. + */ +export function hasChangeDetectionFilter(filter: TQueryFilter): boolean { + // Direct change detection filters + if (filter.type === 'Added' || filter.type === 'Changed' || filter.type === 'Removed') { + return true; + } + + // Check composite filters recursively + if ('filters' in filter && Array.isArray(filter.filters)) { + return filter.filters.some((f) => hasChangeDetectionFilter(f)); + } + + return false; +} diff --git a/packages/feature-ecs/src/query-registry.ts b/packages/feature-ecs/src/query-registry.ts index 57ad73fa..1d143566 100644 --- a/packages/feature-ecs/src/query-registry.ts +++ b/packages/feature-ecs/src/query-registry.ts @@ -2,11 +2,15 @@ * Query Registry for ECS * * Simple and fast query registry with bitmask optimizations. - * Follows KISS principle - Keep It Simple, Stupid. */ import { TEntityId } from './entity-index'; -import { TQueryData, TQueryFilter } from './query-filter'; +import { + categorizeEvaluationStrategy, + hasChangeDetectionFilter, + TQueryData, + TQueryFilter +} from './query-filter'; import { TWorld } from './world'; /** @@ -18,16 +22,23 @@ export function createQueryRegistry(world: TWorld): TQueryRegistry { _queryCache: new Map(), executeQuery(filter) { - const queryData = this.getOrCreateQuery(filter); + const queryData = this.getQuery(filter); // Return cached result if available and not dirty - if (!queryData.isDirty && queryData.cachedResult != null) { + if (!queryData.isDirty) { return queryData.cachedResult; } + // Exit if no entities exist + const aliveEntities = this._world._entityIndex.getAliveEntities(); + if (aliveEntities.length === 0) { + queryData.cachedResult = []; + queryData.isDirty = false; + return []; + } + // Find matching entities const matchingEntities: TEntityId[] = []; - const aliveEntities = this._world._entityIndex.getAliveEntities(); for (const eid of aliveEntities) { if (filter.evaluate(this._world, eid, queryData)) { matchingEntities.push(eid); @@ -41,7 +52,7 @@ export function createQueryRegistry(world: TWorld): TQueryRegistry { return matchingEntities; }, - getOrCreateQuery(filter) { + getQuery(filter) { const hash = filter.getHash(this._world); // Return cached query if exists @@ -53,10 +64,15 @@ export function createQueryRegistry(world: TWorld): TQueryRegistry { const queryData: TQueryData = { hash, filter, - cachedResult: null, + cachedResult: [], isDirty: true, + needsFlushInvalidation: hasChangeDetectionFilter(filter), + evaluationStrategy: categorizeEvaluationStrategy(filter), + generations: [], withMasks: {}, - withoutMasks: {} + withoutMasks: {}, + notMasks: {}, + orMasks: {} }; // Let filter register @@ -71,7 +87,7 @@ export function createQueryRegistry(world: TWorld): TQueryRegistry { }, registerQuery(filter) { - return this.getOrCreateQuery(filter); + return this.getQuery(filter); }, generateQueryHash(filter) { @@ -90,6 +106,15 @@ export function createQueryRegistry(world: TWorld): TQueryRegistry { } }, + flush() { + // Invalidate queries that were pre-marked as needing flush invalidation during registration. + for (const queryData of this._queryCache.values()) { + if (queryData.needsFlushInvalidation) { + queryData.isDirty = true; + } + } + }, + reset() { this._queryCache.clear(); }, @@ -114,7 +139,7 @@ export interface TQueryRegistry { /** * Gets or creates a compiled query */ - getOrCreateQuery(filter: TQueryFilter): TQueryData; + getQuery(filter: TQueryFilter): TQueryData; /** * Registers a query (alias for getOrCreateQuery) @@ -136,6 +161,11 @@ export interface TQueryRegistry { */ invalidateQueries(): void; + /** + * Flushes the query registry + */ + flush(): void; + /** * Resets the query registry to its initial state */ diff --git a/packages/feature-ecs/src/world.ts b/packages/feature-ecs/src/world.ts index f9f2df8c..6596f040 100644 --- a/packages/feature-ecs/src/world.ts +++ b/packages/feature-ecs/src/world.ts @@ -63,6 +63,7 @@ export function createWorld(): TWorld { flush() { this._componentRegistry.flush(); + this._queryRegistry.flush(); }, reset() { From 1c28284d2f15575e19040dad9534fd391362d656 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Thu, 29 May 2025 11:32:02 +0200 Subject: [PATCH 15/39] #105 fixed typos --- packages/feature-ecs/src/query-filter.ts | 553 +++++++++++++----- .../feature-ecs/src/query-registry.test.ts | 25 +- packages/feature-ecs/src/query-registry.ts | 8 +- 3 files changed, 416 insertions(+), 170 deletions(-) diff --git a/packages/feature-ecs/src/query-filter.ts b/packages/feature-ecs/src/query-filter.ts index 36308470..bcae71a3 100644 --- a/packages/feature-ecs/src/query-filter.ts +++ b/packages/feature-ecs/src/query-filter.ts @@ -29,7 +29,18 @@ export function With(component: T): TQueryFilter { if (componentData != null) { const { generationId, bitflag } = componentData; + + // Lazy allocation: only create objects when needed + if (queryData.withMasks == null) { + queryData.withMasks = {}; + } + if (queryData.affectedMasks == null) { + queryData.affectedMasks = {}; + } + queryData.withMasks[generationId] = (queryData.withMasks[generationId] ?? 0) | bitflag; + queryData.affectedMasks[generationId] = + (queryData.affectedMasks[generationId] ?? 0) | bitflag; // Add to generations array if not already present if (!queryData.generations.includes(generationId)) { @@ -72,8 +83,19 @@ export function Without(component: T): TQueryFilter { if (componentData != null) { const { generationId, bitflag } = componentData; + + // Lazy allocation: only create objects when needed + if (queryData.withoutMasks == null) { + queryData.withoutMasks = {}; + } + if (queryData.affectedMasks == null) { + queryData.affectedMasks = {}; + } + queryData.withoutMasks[generationId] = (queryData.withoutMasks[generationId] ?? 0) | bitflag; + queryData.affectedMasks[generationId] = + (queryData.affectedMasks[generationId] ?? 0) | bitflag; // Add to generations array if not already present if (!queryData.generations.includes(generationId)) { @@ -106,6 +128,30 @@ export function Added(component: T): TQueryFilter { world._componentRegistry.onComponentAdd(component, () => { queryData.isDirty = true; }); + + // Register in change detection masks for potential bitmask evaluation + const registry = world._componentRegistry; + const componentData = registry._componentMap.get(component); + if (componentData != null) { + const { generationId, bitflag } = componentData; + + // Lazy allocation: only create objects when needed + if (queryData.addedMasks == null) { + queryData.addedMasks = {}; + } + if (queryData.affectedMasks == null) { + queryData.affectedMasks = {}; + } + + queryData.addedMasks[generationId] = (queryData.addedMasks[generationId] ?? 0) | bitflag; + queryData.affectedMasks[generationId] = + (queryData.affectedMasks[generationId] ?? 0) | bitflag; + + // Add to generations array if not already present + if (!queryData.generations.includes(generationId)) { + queryData.generations.push(generationId); + } + } }, getHash(world: TWorld): string { @@ -132,6 +178,31 @@ export function Changed(component: T): TQueryFilter { world._componentRegistry.onComponentChange(component, () => { queryData.isDirty = true; }); + + // Register in change detection masks for potential bitmask evaluation + const registry = world._componentRegistry; + const componentData = registry._componentMap.get(component); + if (componentData != null) { + const { generationId, bitflag } = componentData; + + // Lazy allocation: only create objects when needed + if (queryData.changedMasks == null) { + queryData.changedMasks = {}; + } + if (queryData.affectedMasks == null) { + queryData.affectedMasks = {}; + } + + queryData.changedMasks[generationId] = + (queryData.changedMasks[generationId] ?? 0) | bitflag; + queryData.affectedMasks[generationId] = + (queryData.affectedMasks[generationId] ?? 0) | bitflag; + + // Add to generations array if not already present + if (!queryData.generations.includes(generationId)) { + queryData.generations.push(generationId); + } + } }, getHash(world: TWorld): string { @@ -158,6 +229,31 @@ export function Removed(component: T): TQueryFilter { world._componentRegistry.onComponentRemove(component, () => { queryData.isDirty = true; }); + + // Register in change detection masks for potential bitmask evaluation + const registry = world._componentRegistry; + const componentData = registry._componentMap.get(component); + if (componentData != null) { + const { generationId, bitflag } = componentData; + + // Lazy allocation: only create objects when needed + if (queryData.removedMasks == null) { + queryData.removedMasks = {}; + } + if (queryData.affectedMasks == null) { + queryData.affectedMasks = {}; + } + + queryData.removedMasks[generationId] = + (queryData.removedMasks[generationId] ?? 0) | bitflag; + queryData.affectedMasks[generationId] = + (queryData.affectedMasks[generationId] ?? 0) | bitflag; + + // Add to generations array if not already present + if (!queryData.generations.includes(generationId)) { + queryData.generations.push(generationId); + } + } }, getHash(world: TWorld): string { @@ -176,49 +272,114 @@ export function And(...filters: TQueryFilter[]): TQueryFilter { filters, evaluate(world: TWorld, eid: TEntityId, queryData: TQueryData): boolean { - // Fast path: Use pre-computed bitmasks for compatible queries if (queryData.evaluationStrategy === 'bitmask') { - const registry = world._componentRegistry; - const { withMasks, withoutMasks, notMasks, orMasks, generations } = queryData; - + const { + withMasks, + withoutMasks, + orMasks, + addedMasks, + changedMasks, + removedMasks, + generations + } = queryData; + const entityMasks = world._componentRegistry._entityMasks; + const registryAddedMasks = world._componentRegistry._addedMasks; + const registryChangedMasks = world._componentRegistry._changedMasks; + const registryRemovedMasks = world._componentRegistry._removedMasks; + + // Check each generation for any AND match for (let i = 0; i < generations.length; i++) { const generationId = generations[i] as number; - const entityMask = registry._entityMasks[generationId]?.[eid] ?? 0; + const entityMask = entityMasks[generationId]?.[eid] ?? 0; - // Required components: entity must have ALL - const withMask = withMasks[generationId]; + // WITH check: entity must have ALL required components + const withMask = withMasks?.[generationId]; if (withMask != null && (entityMask & withMask) !== withMask) { return false; } - // Forbidden components (Without): entity must have NONE - const withoutMask = withoutMasks[generationId]; + // WITHOUT check: entity must have NONE of the forbidden components + const withoutMask = withoutMasks?.[generationId]; if (withoutMask != null && (entityMask & withoutMask) !== 0) { return false; } - // Forbidden components (Not): entity must have NONE - const notMask = notMasks[generationId]; - if (notMask != null && (entityMask & notMask) !== 0) { - return false; + // OR check: entity must satisfy AT LEAST ONE OR requirement + const orMask = orMasks?.[generationId]; + if (orMask != null) { + let hasOrMatch = false; + + // OR WITH: entity has AT LEAST ONE of the OR components + if (orMask.with != null && (entityMask & orMask.with) !== 0) { + hasOrMatch = true; + } + + // OR WITHOUT: entity lacks AT LEAST ONE of the OR-forbidden components + if ( + !hasOrMatch && + orMask.without != null && + (entityMask & orMask.without) !== orMask.without + ) { + hasOrMatch = true; + } + + // OR change detection checks + if (!hasOrMatch && orMask.added != null) { + const entityAddedMask = registryAddedMasks[generationId]?.[eid] ?? 0; + if ((entityAddedMask & orMask.added) !== 0) hasOrMatch = true; + } + + if (!hasOrMatch && orMask.changed != null) { + const entityChangedMask = registryChangedMasks[generationId]?.[eid] ?? 0; + if ((entityChangedMask & orMask.changed) !== 0) hasOrMatch = true; + } + + if (!hasOrMatch && orMask.removed != null) { + const entityRemovedMask = registryRemovedMasks[generationId]?.[eid] ?? 0; + if ((entityRemovedMask & orMask.removed) !== 0) hasOrMatch = true; + } + + if (!hasOrMatch) { + return false; + } } - // OR components: entity must have AT LEAST ONE - const orMask = orMasks[generationId]; - if (orMask != null && (entityMask & orMask) === 0) { - return false; + // ADDED check: entity must have ALL added components + const addedMask = addedMasks?.[generationId]; + if (addedMask != null) { + const entityAddedMask = registryAddedMasks[generationId]?.[eid] ?? 0; + if ((entityAddedMask & addedMask) !== addedMask) { + return false; + } + } + + // CHANGED check: entity must have ALL changed components + const changedMask = changedMasks?.[generationId]; + if (changedMask != null) { + const entityChangedMask = registryChangedMasks[generationId]?.[eid] ?? 0; + if ((entityChangedMask & changedMask) !== changedMask) { + return false; + } + } + + // REMOVED check: entity must have ALL removed components + const removedMask = removedMasks?.[generationId]; + if (removedMask != null) { + const entityRemovedMask = registryRemovedMasks[generationId]?.[eid] ?? 0; + if ((entityRemovedMask & removedMask) !== removedMask) { + return false; + } } } return true; } - // Fallback: Evaluate filters individually + // Individual filter evaluation for more complex queries return filters.every((filter) => filter.evaluate(world, eid, queryData)); }, register(world: TWorld, queryData: TQueryData): void { - // Let child filters register their bitmasks for (const filter of filters) { if (filter.register != null) { filter.register(world, queryData); @@ -245,72 +406,153 @@ export function Or(...filters: TQueryFilter[]): TQueryFilter { filters, evaluate(world: TWorld, eid: TEntityId, queryData: TQueryData): boolean { - return filters.some((filter) => filter.evaluate(world, eid, queryData)); - }, - - register(world: TWorld, queryData: TQueryData): void { - // If parent uses bitmask evaluation, populate orMasks from With children if (queryData.evaluationStrategy === 'bitmask') { - for (const filter of filters) { - if (filter.type === 'With') { - const registry = world._componentRegistry; - const componentData = registry._componentMap.get(filter.component); - if (componentData != null) { - const { generationId, bitflag } = componentData; - queryData.orMasks[generationId] = (queryData.orMasks[generationId] ?? 0) | bitflag; + const { orMasks, generations } = queryData; + + if (orMasks == null) { + return false; + } + + const entityMasks = world._componentRegistry._entityMasks; + const registryAddedMasks = world._componentRegistry._addedMasks; + const registryChangedMasks = world._componentRegistry._changedMasks; + const registryRemovedMasks = world._componentRegistry._removedMasks; + + // Check each generation for any OR match + for (let i = 0; i < generations.length; i++) { + const generationId = generations[i] as number; + const entityMask = entityMasks[generationId]?.[eid] ?? 0; + const orMask = orMasks[generationId]; + + if (orMask == null) { + continue; + } + + // OR WITH: entity has AT LEAST ONE of the OR components + if (orMask.with != null && (entityMask & orMask.with) !== 0) { + return true; + } + + // OR WITHOUT: entity lacks AT LEAST ONE of the OR-forbidden components + if (orMask.without != null && (entityMask & orMask.without) !== orMask.without) { + return true; + } + + // OR ADDED: entity has AT LEAST ONE OR-added component + if (orMask.added != null) { + const entityAddedMask = registryAddedMasks[generationId]?.[eid] ?? 0; + if ((entityAddedMask & orMask.added) !== 0) { + return true; } } - } - } else { - // For individual evaluation, register child filters normally - for (const filter of filters) { - if (filter.register != null) { - filter.register(world, queryData); + + // OR CHANGED: entity has AT LEAST ONE OR-changed component + if (orMask.changed != null) { + const entityChangedMask = registryChangedMasks[generationId]?.[eid] ?? 0; + if ((entityChangedMask & orMask.changed) !== 0) { + return true; + } } - } - } - }, - getHash(world: TWorld): string { - const childHashes = filters - .map((f) => f.getHash(world)) - .sort() - .join(','); - return `or(${childHashes})`; - } - }; -} + // OR REMOVED: entity has AT LEAST ONE OR-removed component + if (orMask.removed != null) { + const entityRemovedMask = registryRemovedMasks[generationId]?.[eid] ?? 0; + if ((entityRemovedMask & orMask.removed) !== 0) { + return true; + } + } + } -/** - * Requires no child filter to match - */ -export function Not(...filters: TQueryFilter[]): TQueryFilter { - return { - type: 'Not', - filters, + return false; + } - evaluate(world: TWorld, eid: TEntityId, queryData: TQueryData): boolean { - return !filters.some((filter) => filter.evaluate(world, eid, queryData)); + // Individual filter evaluation for more complex queries + return filters.some((filter) => filter.evaluate(world, eid, queryData)); }, register(world: TWorld, queryData: TQueryData): void { - // If parent uses bitmask evaluation, populate notMasks from With children + for (const filter of filters) { + if (filter.register != null) { + filter.register(world, queryData); + } + } + + // For bitmask evaluation, move individual masks to OR masks for all supported types if (queryData.evaluationStrategy === 'bitmask') { for (const filter of filters) { - if (filter.type === 'With') { - const registry = world._componentRegistry; - const componentData = registry._componentMap.get(filter.component); - if (componentData != null) { - const { generationId, bitflag } = componentData; - queryData.notMasks[generationId] = (queryData.notMasks[generationId] ?? 0) | bitflag; - } + // Only handle component filters that have a component property + if ( + filter.type !== 'With' && + filter.type !== 'Without' && + filter.type !== 'Added' && + filter.type !== 'Changed' && + filter.type !== 'Removed' + ) { + continue; } - } - } else { - // For individual evaluation, register child filters normally - for (const filter of filters) { - if (filter.register != null) { - filter.register(world, queryData); + + const registry = world._componentRegistry; + const componentData = registry._componentMap.get(filter.component); + if (componentData == null) { + continue; + } + + const { generationId, bitflag } = componentData; + + // Lazy allocation for orMasks + if (queryData.orMasks == null) { + queryData.orMasks = {}; + } + if (queryData.orMasks[generationId] == null) { + queryData.orMasks[generationId] = {}; + } + + switch (filter.type) { + case 'With': + // Move from withMasks to orMasks.with + queryData.orMasks[generationId].with = + (queryData.orMasks[generationId].with ?? 0) | bitflag; + if (queryData.withMasks?.[generationId] != null) { + queryData.withMasks[generationId] = + (queryData.withMasks[generationId] ?? 0) & ~bitflag; + } + break; + case 'Without': + // Move from withoutMasks to orMasks.without + queryData.orMasks[generationId].without = + (queryData.orMasks[generationId].without ?? 0) | bitflag; + if (queryData.withoutMasks?.[generationId] != null) { + queryData.withoutMasks[generationId] = + (queryData.withoutMasks[generationId] ?? 0) & ~bitflag; + } + break; + case 'Added': + // Move from addedMasks to orMasks.added + queryData.orMasks[generationId].added = + (queryData.orMasks[generationId].added ?? 0) | bitflag; + if (queryData.addedMasks?.[generationId] != null) { + queryData.addedMasks[generationId] = + (queryData.addedMasks[generationId] ?? 0) & ~bitflag; + } + break; + case 'Changed': + // Move from changedMasks to orMasks.changed + queryData.orMasks[generationId].changed = + (queryData.orMasks[generationId].changed ?? 0) | bitflag; + if (queryData.changedMasks?.[generationId] != null) { + queryData.changedMasks[generationId] = + (queryData.changedMasks[generationId] ?? 0) & ~bitflag; + } + break; + case 'Removed': + // Move from removedMasks to orMasks.removed + queryData.orMasks[generationId].removed = + (queryData.orMasks[generationId].removed ?? 0) | bitflag; + if (queryData.removedMasks?.[generationId] != null) { + queryData.removedMasks[generationId] = + (queryData.removedMasks[generationId] ?? 0) & ~bitflag; + } + break; } } } @@ -321,24 +563,7 @@ export function Not(...filters: TQueryFilter[]): TQueryFilter { .map((f) => f.getHash(world)) .sort() .join(','); - return `not(${childHashes})`; - } - }; -} - -/** - * Special filter that matches no entities - */ -export function None(): TQueryFilter { - return { - type: 'None', - - evaluate(): boolean { - return false; - }, - - getHash(): string { - return 'none()'; + return `or(${childHashes})`; } }; } @@ -350,44 +575,52 @@ export const Any = Or; export interface TQueryData { /** Unique hash identifying this query filter combination */ hash: string; - /** The original query filter that was compiled into this data */ filter: TQueryFilter; + /** + * Pre-computed evaluation strategy for optimal performance: + * - 'bitmask': Fast bitwise operations for component/change filters + * - 'individual': Filter-by-filter evaluation for complex queries + */ + evaluationStrategy: 'bitmask' | 'individual'; /** Cached array of entity IDs that match this query */ cachedResult: TEntityId[]; - /** True when cached results are stale and need re-evaluation */ isDirty: boolean; - /** * True if this query contains Added/Changed/Removed filters and needs * cache invalidation when world.flush() clears change tracking masks. - * Pre-computed during registration for O(1) flush performance. */ needsFlushInvalidation: boolean; - /** - * Pre-computed evaluation strategy for optimal performance: - * - 'bitmask': Fast bitwise operations for With/Without combinations - * - 'individual': Filter-by-filter evaluation for complex queries - */ - evaluationStrategy: 'bitmask' | 'individual'; - - /** Pre-computed generations array for optimal bitmask lookup */ + /** Pre-computed generations array for optimal bitmask iteration */ generations: number[]; - /** Bitmasks for required components (With filters) by generation ID */ - withMasks: Record; - - /** Bitmasks for forbidden components (Without filters) by generation ID */ - withoutMasks: Record; + /** Bitmasks for required components (AND logic: entity must have ALL) */ + withMasks?: Record; + /** Bitmasks for forbidden components (AND logic: entity must have NONE) */ + withoutMasks?: Record; + + /** Combined OR masks for all filter types (OR logic: entity must satisfy AT LEAST ONE per type) */ + orMasks?: Record< + number, + { + with?: number; // Components entity must HAVE (any) + without?: number; // Components entity must LACK (any) + added?: number; // Components entity ADDED this frame (any) + changed?: number; // Components entity CHANGED this frame (any) + removed?: number; // Components entity REMOVED this frame (any) + } + >; - /** Bitmasks for OR components by generation ID */ - orMasks: Record; + /** Bitmasks for change detection (AND logic: entity must have ALL changed) */ + addedMasks?: Record; + changedMasks?: Record; + removedMasks?: Record; - /** Bitmasks for NOT components by generation ID */ - notMasks: Record; + /** Components that can affect this query - enables O(1) invalidation checks */ + affectedMasks?: Record; } export interface TBaseQueryFilter { @@ -404,9 +637,7 @@ export type TQueryFilter = | (TBaseQueryFilter & { type: 'Changed'; component: TComponentRef }) | (TBaseQueryFilter & { type: 'Removed'; component: TComponentRef }) | (TBaseQueryFilter & { type: 'And'; filters: TQueryFilter[] }) - | (TBaseQueryFilter & { type: 'Or'; filters: TQueryFilter[] }) - | (TBaseQueryFilter & { type: 'Not'; filters: TQueryFilter[] }) - | (TBaseQueryFilter & { type: 'None' }); + | (TBaseQueryFilter & { type: 'Or'; filters: TQueryFilter[] }); /** * Helper to get component ID, registering if needed @@ -416,41 +647,55 @@ function getComponentId(world: TWorld, component: TComponentRef): number { if (!registry._componentMap.has(component)) { registry.registerComponent(component); } - return registry._componentMap.get(component)!.id; + return registry._componentMap.get(component)?.id as number; } /** * Pre-categorizes a query's evaluation strategy for optimal performance. * * Strategies: - * - 'bitmask': All filters can use bitwise operations (With/Without/Or(With...)/Not(With...)) - * - 'individual': Contains change detection or complex nested filters requiring individual evaluation + * - 'bitmask': All filters can use bitwise operations (With/Without/Added/Changed/Removed) + * - 'individual': Contains complex nested filters requiring individual evaluation */ export function categorizeEvaluationStrategy(filter: TQueryFilter): 'bitmask' | 'individual' { - // Simple bitmask-compatible filters - if (filter.type === 'With' || filter.type === 'Without') { - return 'bitmask'; - } - - // Or is bitmask-compatible if all children are With filters - if (filter.type === 'Or') { - return filter.filters.every((f) => f.type === 'With') ? 'bitmask' : 'individual'; + switch (filter.type) { + case 'With': + case 'Without': + case 'Added': + case 'Changed': + case 'Removed': + // Simple component and change detection filters are bitmask-compatible + return 'bitmask'; + + case 'And': + // And is bitmask-compatible if ALL children are bitmask-compatible + // Nested And filters work because And(And(A,B),C) === And(A,B,C) logically + return filter.filters.every((f) => categorizeEvaluationStrategy(f) === 'bitmask') + ? 'bitmask' + : 'individual'; + + case 'Or': + // Or is bitmask-compatible ONLY for simple component/change filters + // + // Why Or doesn't support nested And/Or: + // - Or(And(A,B), C) cannot be flattened to simple bitmasks + // - Would require complex mask structures: { andGroups: [..], .. } + // - The performance benefit diminishes while code complexity explodes + return filter.filters.every( + (f) => + f.type === 'With' || + f.type === 'Without' || + f.type === 'Added' || + f.type === 'Changed' || + f.type === 'Removed' + ) + ? 'bitmask' + : 'individual'; + + default: + // Unknown filter types default to individual evaluation + return 'individual'; } - - // Not is bitmask-compatible if all children are With filters - if (filter.type === 'Not') { - return filter.filters.every((f) => f.type === 'With') ? 'bitmask' : 'individual'; - } - - // And is bitmask-compatible if ALL children are bitmask-compatible - if (filter.type === 'And') { - return filter.filters.every((f) => categorizeEvaluationStrategy(f) === 'bitmask') - ? 'bitmask' - : 'individual'; - } - - // Change detection filters require individual evaluation - return 'individual'; } /** @@ -470,3 +715,31 @@ export function hasChangeDetectionFilter(filter: TQueryFilter): boolean { return false; } + +/** + * Can a component change affect this query? + * Returns false if query definitely doesn't care about this component. + * Used to skip expensive query re-evaluation when possible. + */ +export function canComponentAffectQuery( + queryData: TQueryData, + component: TComponentRef, + world: TWorld +): boolean { + const registry = world._componentRegistry; + const componentData = registry._componentMap.get(component); + + if (componentData == null) { + return false; + } + + const { generationId, bitflag } = componentData; + + // Check if affectedMasks exists and has this generation + if (queryData.affectedMasks == null) { + return false; + } + + const affectedMask = queryData.affectedMasks[generationId]; + return affectedMask != null && (affectedMask & bitflag) !== 0; +} diff --git a/packages/feature-ecs/src/query-registry.test.ts b/packages/feature-ecs/src/query-registry.test.ts index 70bb8e31..11109b26 100644 --- a/packages/feature-ecs/src/query-registry.test.ts +++ b/packages/feature-ecs/src/query-registry.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { Added, And, Changed, None, Or, Removed, With, Without } from './query-filter'; +import { Added, And, Changed, Or, Removed, With, Without } from './query-filter'; import { createWorld, TWorld } from './world'; describe('createQueryRegistry', () => { @@ -86,32 +86,9 @@ describe('createQueryRegistry', () => { expect(queryData.filter).toBe(filter); expect(queryData.filter.type).toBe('With'); }); - - it('should handle None filter', () => { - const filter = None(); - const queryData = world._queryRegistry.registerQuery(filter); - - // Should store the None filter - expect(queryData.filter).toBe(filter); - expect(queryData.filter.type).toBe('None'); - }); }); describe('executeQuery', () => { - it('should return no entities for None filter', () => { - const Position = { x: [] as number[], y: [] as number[] }; - - // Create entities with components - const eid1 = world.createEntity(); - const eid2 = world.createEntity(); - world.addComponent(eid1, Position); - world.addComponent(eid2, Position); - - // None filter should return no entities - const emptyResult = world.query(None()); - expect(emptyResult).toEqual([]); - }); - it('should execute basic With queries', () => { const Position = { x: [] as number[], y: [] as number[] }; const Velocity = { x: [] as number[], y: [] as number[] }; diff --git a/packages/feature-ecs/src/query-registry.ts b/packages/feature-ecs/src/query-registry.ts index 1d143566..ce303950 100644 --- a/packages/feature-ecs/src/query-registry.ts +++ b/packages/feature-ecs/src/query-registry.ts @@ -64,15 +64,11 @@ export function createQueryRegistry(world: TWorld): TQueryRegistry { const queryData: TQueryData = { hash, filter, + evaluationStrategy: categorizeEvaluationStrategy(filter), cachedResult: [], isDirty: true, needsFlushInvalidation: hasChangeDetectionFilter(filter), - evaluationStrategy: categorizeEvaluationStrategy(filter), - generations: [], - withMasks: {}, - withoutMasks: {}, - notMasks: {}, - orMasks: {} + generations: [] }; // Let filter register From dd9a7ea1fe64ff2aaba4a47b15b6585eb80566d9 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Thu, 29 May 2025 16:18:56 +0200 Subject: [PATCH 16/39] #105 fixed typos --- .../feature-ecs/src/component-registry.ts | 51 +- packages/feature-ecs/src/query-filter.test.ts | 774 ++++++++++++++++++ packages/feature-ecs/src/query-filter.ts | 60 +- .../feature-ecs/src/query-registry.test.ts | 509 +++++------- packages/feature-ecs/src/query-registry.ts | 62 +- packages/feature-ecs/src/world.ts | 21 +- 6 files changed, 1082 insertions(+), 395 deletions(-) create mode 100644 packages/feature-ecs/src/query-filter.test.ts diff --git a/packages/feature-ecs/src/component-registry.ts b/packages/feature-ecs/src/component-registry.ts index dfb57b6e..e09b3db8 100644 --- a/packages/feature-ecs/src/component-registry.ts +++ b/packages/feature-ecs/src/component-registry.ts @@ -64,6 +64,7 @@ export function createComponentRegistry(): TComponentRegistry { _removedMasks: [[]], _callbacks: new Map(), + _componentsToFlush: new Set(), registerComponent(component) { if (this._componentMap.has(component)) { @@ -147,6 +148,7 @@ export function createComponentRegistry(): TComponentRegistry { callback(eid); } } + this._componentsToFlush.add(component); }, // Component Removal Flow @@ -185,6 +187,7 @@ export function createComponentRegistry(): TComponentRegistry { callback(eid); } } + this._componentsToFlush.add(component); // Clear component bit // @ts-expect-error - generationId exists because we ensure it when registering the component @@ -238,6 +241,7 @@ export function createComponentRegistry(): TComponentRegistry { callback(eid); } } + this._componentsToFlush.add(component); return true; }, @@ -332,8 +336,27 @@ export function createComponentRegistry(): TComponentRegistry { }; }, + onComponentFlush(component, callback) { + if (!this._callbacks.has(component)) { + this._callbacks.set(component, {}); + } + const componentCallbacks = this._callbacks.get(component) as TComponentCallbacks; + if (componentCallbacks.onFlush == null) { + componentCallbacks.onFlush = []; + } + componentCallbacks.onFlush.push(callback); + + // Return unregister function + return () => { + const index = componentCallbacks.onFlush?.indexOf(callback); + if (index != null && index !== -1) { + componentCallbacks.onFlush?.splice(index, 1); + } + }; + }, + flush() { - // Clear all change tracking for the current frame + // Clear all change tracking for the next frame for (let generationId = 0; generationId < this._addedMasks.length; generationId++) { if (this._addedMasks[generationId] != null) { // @ts-expect-error - generationId exists because we checked above @@ -348,6 +371,19 @@ export function createComponentRegistry(): TComponentRegistry { this._removedMasks[generationId].length = 0; } } + + // Call flush callbacks for components that had changes + for (const component of this._componentsToFlush) { + const callbacks = this._callbacks.get(component); + if (callbacks?.onFlush != null) { + for (const callback of callbacks.onFlush) { + callback(); + } + } + } + + // Clear the set for next frame + this._componentsToFlush.clear(); }, reset() { @@ -378,6 +414,7 @@ export function createComponentRegistry(): TComponentRegistry { this._componentCount = 0; this._currentBitflag = 1; this._callbacks.clear(); + this._componentsToFlush.clear(); }, validate() { @@ -433,14 +470,18 @@ export interface TComponentRegistry { _componentCount: number; /** Current bitflag value for next component */ _currentBitflag: number; + /** Array of added component masks by generation */ _addedMasks: number[][]; /** Array of changed component masks by generation */ _changedMasks: number[][]; /** Array of removed component masks by generation */ _removedMasks: number[][]; + /** Optional callback system for real-time reactions */ _callbacks: Map; + /** Set of components that need to be flushed for the next frame */ + _componentsToFlush: Set; /** * Registers a component and returns its metadata. @@ -533,6 +574,13 @@ export interface TComponentRegistry { */ onComponentRemove(component: TComponentRef, callback: (eid: TEntityId) => void): () => void; + /** + * Registers a callback for when a component is flushed for an entity. + * @param component - The component to register the callback for + * @param callback - The callback function to register + */ + onComponentFlush(component: TComponentRef, callback: () => void): () => void; + /** * Clears all change tracking for the current frame. */ @@ -567,4 +615,5 @@ export interface TComponentCallbacks { onAdd?: ((eid: TEntityId) => void)[]; onChange?: ((eid: TEntityId) => void)[]; onRemove?: ((eid: TEntityId) => void)[]; + onFlush?: (() => void)[]; } diff --git a/packages/feature-ecs/src/query-filter.test.ts b/packages/feature-ecs/src/query-filter.test.ts new file mode 100644 index 00000000..bcea2397 --- /dev/null +++ b/packages/feature-ecs/src/query-filter.test.ts @@ -0,0 +1,774 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { Added, And, Changed, Or, Removed, With, Without } from './query-filter'; +import { createWorld, TWorld } from './world'; + +describe('Query Filters', () => { + let world: TWorld; + + beforeEach(() => { + world = createWorld(); + }); + + describe('With filter', () => { + it('should match entities that have the component', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); + + // eid1: Position only + world.addComponent(eid1, Position); + + // eid2: Health only + world.addComponent(eid2, Health); + + // eid3: Both components + world.addComponent(eid3, Position); + world.addComponent(eid3, Health); + + const positionEntities = world.query(With(Position)); + expect(positionEntities.sort()).toEqual([eid1, eid3].sort()); + + const healthEntities = world.query(With(Health)); + expect(healthEntities.sort()).toEqual([eid2, eid3].sort()); + }); + + it('should return empty array when no entities have component', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const result = world.query(With(Position)); + expect(result).toEqual([]); + }); + + it('should auto-register components', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + expect(world._componentRegistry._componentMap.has(Position)).toBe(false); + world.query(With(Position)); + expect(world._componentRegistry._componentMap.has(Position)).toBe(true); + }); + }); + + describe('Without filter', () => { + it('should match entities that lack the component', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); + + // eid1: Position only + world.addComponent(eid1, Position); + + // eid2: Health only + world.addComponent(eid2, Health); + + // eid3: Both components + + const withoutHealth = world.query(Without(Health)); + expect(withoutHealth.sort()).toEqual([eid1, eid3].sort()); + + const withoutPosition = world.query(Without(Position)); + expect(withoutPosition.sort()).toEqual([eid2, eid3].sort()); + }); + + it('should match all entities when component doesnt exist', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const NonExistent = { value: [] as number[] }; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + + world.addComponent(eid1, Position); + + const withoutNonExistent = world.query(Without(NonExistent)); + expect(withoutNonExistent.sort()).toEqual([eid1, eid2].sort()); + }); + }); + + describe('Added filter', () => { + it('should match entities with components added this frame', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + + // Add Position to eid1 (should be tracked as added) + world.addComponent(eid1, Position); + + // Add Health to eid2 (should be tracked as added) + world.addComponent(eid2, Health); + + const addedPosition = world.query(Added(Position)); + expect(addedPosition).toEqual([eid1]); + + const addedHealth = world.query(Added(Health)); + expect(addedHealth).toEqual([eid2]); + }); + + it('should return empty after flush clears tracking', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const eid = world.createEntity(); + world.addComponent(eid, Position); + + // Before flush: should find the entity + const beforeFlush = world.query(Added(Position)); + expect(beforeFlush).toEqual([eid]); + + // After flush: should be empty + world.flush(); + const afterFlush = world.query(Added(Position)); + expect(afterFlush).toEqual([]); + }); + + it('should track multiple adds in same frame', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); + + world.addComponent(eid1, Position); + world.addComponent(eid2, Position); + world.addComponent(eid3, Position); + + const addedPosition = world.query(Added(Position)); + expect(addedPosition.sort()).toEqual([eid1, eid2, eid3].sort()); + }); + }); + + describe('Changed filter', () => { + it('should match entities with components changed this frame', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + + // Add components first + world.addComponent(eid1, Position); + world.addComponent(eid2, Health); + + // Clear initial "added" tracking + world.flush(); + + // Mark Position as changed for eid1 + world._componentRegistry.markChanged(eid1, Position); + + const changedPosition = world.query(Changed(Position)); + expect(changedPosition).toEqual([eid1]); + + const changedHealth = world.query(Changed(Health)); + expect(changedHealth).toEqual([]); + }); + + it('should clear tracking after flush', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const eid = world.createEntity(); + world.addComponent(eid, Position); + world.flush(); + + world._componentRegistry.markChanged(eid, Position); + + // Before flush: should find the entity + const beforeFlush = world.query(Changed(Position)); + expect(beforeFlush).toEqual([eid]); + + // After flush: should be empty + world.flush(); + const afterFlush = world.query(Changed(Position)); + expect(afterFlush).toEqual([]); + }); + }); + + describe('Removed filter', () => { + it('should match entities with components removed this frame', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + + // Add components first + world.addComponent(eid1, Position); + world.addComponent(eid1, Health); + world.addComponent(eid2, Position); + + // Clear initial "added" tracking + world.flush(); + + // Remove Health from eid1 + world.removeComponent(eid1, Health); + + const removedHealth = world.query(Removed(Health)); + expect(removedHealth).toEqual([eid1]); + + const removedPosition = world.query(Removed(Position)); + expect(removedPosition).toEqual([]); + }); + + it('should clear tracking after flush', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const eid = world.createEntity(); + world.addComponent(eid, Position); + world.flush(); + + world.removeComponent(eid, Position); + + // Before flush: should find the entity + const beforeFlush = world.query(Removed(Position)); + expect(beforeFlush).toEqual([eid]); + + // After flush: should be empty + world.flush(); + const afterFlush = world.query(Removed(Position)); + expect(afterFlush).toEqual([]); + }); + }); + + describe('And filter', () => { + it('should match entities that have ALL specified components', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Velocity = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); + + // eid1: Position + Velocity + world.addComponent(eid1, Position); + world.addComponent(eid1, Velocity); + + // eid2: Position + Health + world.addComponent(eid2, Position); + world.addComponent(eid2, Health); + + // eid3: All three + world.addComponent(eid3, Position); + world.addComponent(eid3, Velocity); + world.addComponent(eid3, Health); + + const positionAndVelocity = world.query(And(With(Position), With(Velocity))); + expect(positionAndVelocity.sort()).toEqual([eid1, eid3].sort()); + + const allThree = world.query(And(With(Position), With(Velocity), With(Health))); + expect(allThree).toEqual([eid3]); + }); + + it('should support nested And filters', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Velocity = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid = world.createEntity(); + world.addComponent(eid, Position); + world.addComponent(eid, Velocity); + world.addComponent(eid, Health); + + // And(And(Position, Velocity), Health) should work + const nestedAnd = world.query(And(And(With(Position), With(Velocity)), With(Health))); + expect(nestedAnd).toEqual([eid]); + }); + + it('should support And with Without filters', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const Enemy = {}; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + + // eid1: Position + Health + world.addComponent(eid1, Position); + world.addComponent(eid1, Health); + + // eid2: Position + Health + Enemy + world.addComponent(eid2, Position); + world.addComponent(eid2, Health); + world.addComponent(eid2, Enemy); + + const healthyNonEnemies = world.query(And(With(Position), With(Health), Without(Enemy))); + expect(healthyNonEnemies).toEqual([eid1]); + }); + + it('should support And with change detection', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + + // Add components + world.addComponent(eid1, Position); + world.addComponent(eid1, Health); + world.addComponent(eid2, Position); + + // Clear initial tracking + world.flush(); + + // Mark Health as changed for eid1 + world._componentRegistry.markChanged(eid1, Health); + + const positionWithChangedHealth = world.query(And(With(Position), Changed(Health))); + expect(positionWithChangedHealth).toEqual([eid1]); + }); + }); + + describe('Or filter', () => { + it('should match entities that have ANY of the specified components', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const Shield = [] as number[]; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); + const eid4 = world.createEntity(); + + // eid1: Position only + world.addComponent(eid1, Position); + + // eid2: Health only + world.addComponent(eid2, Health); + + // eid3: Shield only + world.addComponent(eid3, Shield); + + // eid4: No components + + const positionOrHealth = world.query(Or(With(Position), With(Health))); + expect(positionOrHealth.sort()).toEqual([eid1, eid2].sort()); + + const anyOfThree = world.query(Or(With(Position), With(Health), With(Shield))); + expect(anyOfThree.sort()).toEqual([eid1, eid2, eid3].sort()); + }); + + it('should support Or with Without filters', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const Enemy = {}; + const Ally = {}; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); + + // eid1: Position + Enemy + world.addComponent(eid1, Position); + world.addComponent(eid1, Enemy); + + // eid2: Position + Ally + world.addComponent(eid2, Position); + world.addComponent(eid2, Ally); + + // eid3: Position + Health + Enemy + world.addComponent(eid3, Position); + world.addComponent(eid3, Health); + world.addComponent(eid3, Enemy); + + // Entities that lack Enemy OR lack Ally + const notEnemyOrNotAlly = world.query(Or(Without(Enemy), Without(Ally))); + expect(notEnemyOrNotAlly.sort()).toEqual([eid1, eid2, eid3].sort()); // All match since each lacks at least one + }); + + it('should support Or with change detection', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const Shield = [] as number[]; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); + + // Setup initial state + world.addComponent(eid1, Position); + world.addComponent(eid1, Health); + world.addComponent(eid2, Position); + world.addComponent(eid2, Shield); + world.addComponent(eid3, Position); + + // Clear initial tracking + world.flush(); + + // Add Shield to eid1, mark Position as changed for eid2 + world.addComponent(eid1, Shield); + world._componentRegistry.markChanged(eid2, Position); + + const addedShieldOrChangedPosition = world.query(Or(Added(Shield), Changed(Position))); + expect(addedShieldOrChangedPosition.sort()).toEqual([eid1, eid2].sort()); + }); + + it('should return empty when no entities match any conditions', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid = world.createEntity(); + // Entity has no components + + const result = world.query(Or(With(Position), With(Health))); + expect(result).toEqual([]); + }); + }); + + describe('Complex filter combinations', () => { + it('should handle And(Or(...), With(...))', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const Shield = [] as number[]; + const Alive = {}; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); + const eid4 = world.createEntity(); + + // eid1: Position + Health + Alive + world.addComponent(eid1, Position); + world.addComponent(eid1, Health); + world.addComponent(eid1, Alive); + + // eid2: Position + Shield + Alive + world.addComponent(eid2, Position); + world.addComponent(eid2, Shield); + world.addComponent(eid2, Alive); + + // eid3: Position + Health (no Alive) + world.addComponent(eid3, Position); + world.addComponent(eid3, Health); + + // eid4: Shield + Alive (no Position) + world.addComponent(eid4, Shield); + world.addComponent(eid4, Alive); + + // Entities with Position AND (Health OR Shield) AND Alive + const complex = world.query(And(With(Position), Or(With(Health), With(Shield)), With(Alive))); + + expect(complex.sort()).toEqual([eid1, eid2].sort()); + }); + + it('should handle Or(And(...), With(...))', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Velocity = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); + + // eid1: Position + Velocity + world.addComponent(eid1, Position); + world.addComponent(eid1, Velocity); + + // eid2: Health only + world.addComponent(eid2, Health); + + // eid3: Position only (missing Velocity) + world.addComponent(eid3, Position); + + // Entities that have (Position AND Velocity) OR Health + const complex = world.query(Or(And(With(Position), With(Velocity)), With(Health))); + + expect(complex.sort()).toEqual([eid1, eid2].sort()); + }); + + it('should handle deeply nested filters', () => { + const A = {}; + const B = {}; + const C = {}; + const D = {}; + + const eid = world.createEntity(); + world.addComponent(eid, A); + world.addComponent(eid, B); + world.addComponent(eid, C); + + // And(And(A, B), And(C, Without(D))) + const deeplyNested = world.query(And(And(With(A), With(B)), And(With(C), Without(D)))); + + expect(deeplyNested).toEqual([eid]); + }); + + it('should handle mixed change detection and regular filters', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const Shield = [] as number[]; + const Enemy = {}; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); + + // Setup initial state + world.addComponent(eid1, Position); + world.addComponent(eid1, Health); + + world.addComponent(eid2, Position); + world.addComponent(eid2, Health); + world.addComponent(eid2, Enemy); + + world.addComponent(eid3, Position); + world.addComponent(eid3, Shield); + + // Clear initial tracking + world.flush(); + + // Add Shield to eid1, mark Health as changed for eid2 + world.addComponent(eid1, Shield); + world._componentRegistry.markChanged(eid2, Health); + + // Entities with Position AND (Added Shield OR Changed Health) AND Without Enemy + const complex = world.query( + And(With(Position), Or(Added(Shield), Changed(Health)), Without(Enemy)) + ); + + // Should match eid1 (has Position + added Shield + not Enemy) + // Should NOT match eid2 (has Position + changed Health but IS Enemy) + expect(complex).toEqual([eid1]); + }); + }); + + describe('Edge cases', () => { + it('should handle empty And filter', () => { + const result = world.query(And()); + expect(result).toEqual([]); + }); + + it('should handle empty Or filter', () => { + const result = world.query(Or()); + expect(result).toEqual([]); + }); + + it('should handle single filter in And', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const eid = world.createEntity(); + world.addComponent(eid, Position); + + const result = world.query(And(With(Position))); + expect(result).toEqual([eid]); + }); + + it('should handle single filter in Or', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const eid = world.createEntity(); + world.addComponent(eid, Position); + + const result = world.query(Or(With(Position))); + expect(result).toEqual([eid]); + }); + + it('should handle queries with non-existent components', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const NonExistent = { value: [] as number[] }; + + const eid = world.createEntity(); + world.addComponent(eid, Position); + + // With non-existent should return empty + const withNonExistent = world.query(With(NonExistent)); + expect(withNonExistent).toEqual([]); + + // Without non-existent should return all entities + const withoutNonExistent = world.query(Without(NonExistent)); + expect(withoutNonExistent).toEqual([eid]); + }); + }); + + describe('Performance and caching', () => { + it('should cache identical queries', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid = world.createEntity(); + world.addComponent(eid, Position); + world.addComponent(eid, Health); + + // Execute same query multiple times + const filter = And(With(Position), With(Health)); + const result1 = world.query(filter); + const result2 = world.query(filter); + const result3 = world.query(filter); + + // Should return consistent results + expect(result1).toEqual([eid]); + expect(result2).toEqual([eid]); + expect(result3).toEqual([eid]); + + // Verify caching occurred + expect(world._queryRegistry._queryCache.size).toBe(1); + }); + + it('should invalidate cache when components change', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + + world.addComponent(eid1, Position); + + // Initial query + const result1 = world.query(With(Position)); + expect(result1).toEqual([eid1]); + + // Add component to another entity + world.addComponent(eid2, Position); + + // Query should reflect change + const result2 = world.query(With(Position)); + expect(result2.sort()).toEqual([eid1, eid2].sort()); + }); + + it('should only invalidate affected queries (selective invalidation)', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const Velocity = { x: [] as number[], y: [] as number[] }; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + + // Add Position and Health to eid1 + world.addComponent(eid1, Position); + world.addComponent(eid1, Health); + + // Execute different queries to populate cache + const positionQuery = world.query(With(Position)); + const healthQuery = world.query(With(Health)); + const velocityQuery = world.query(With(Velocity)); + + // Verify initial state + expect(positionQuery).toEqual([eid1]); + expect(healthQuery).toEqual([eid1]); + expect(velocityQuery).toEqual([]); + + // Get query data to check dirty flags + const positionQueryData = world._queryRegistry.registerQuery(With(Position)); + const healthQueryData = world._queryRegistry.registerQuery(With(Health)); + const velocityQueryData = world._queryRegistry.registerQuery(With(Velocity)); + + // Queries should not be dirty after execution + expect(positionQueryData.isDirty).toBe(false); + expect(healthQueryData.isDirty).toBe(false); + expect(velocityQueryData.isDirty).toBe(false); + + // Add Velocity to eid2 - should ONLY affect Velocity query + world.addComponent(eid2, Velocity); + + // Only Velocity query should be marked as dirty + expect(positionQueryData.isDirty).toBe(false); // Should NOT be dirty + expect(healthQueryData.isDirty).toBe(false); // Should NOT be dirty + expect(velocityQueryData.isDirty).toBe(true); // Should be dirty + + // Execute queries to verify results + const newPositionQuery = world.query(With(Position)); + const newHealthQuery = world.query(With(Health)); + const newVelocityQuery = world.query(With(Velocity)); + + expect(newPositionQuery).toEqual([eid1]); // No change + expect(newHealthQuery).toEqual([eid1]); // No change + expect(newVelocityQuery).toEqual([eid2]); // Changed + }); + + it('should invalidate multiple queries when shared component changes', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid1 = world.createEntity(); + world.addComponent(eid1, Health); + + // Create multiple queries that depend on Position + const positionOnlyQuery = world.query(With(Position)); + const positionAndHealthQuery = world.query(And(With(Position), With(Health))); + const positionOrHealthQuery = world.query(Or(With(Position), With(Health))); + + // Get query data + const positionOnlyData = world._queryRegistry.registerQuery(With(Position)); + const positionAndHealthData = world._queryRegistry.registerQuery( + And(With(Position), With(Health)) + ); + const positionOrHealthData = world._queryRegistry.registerQuery( + Or(With(Position), With(Health)) + ); + + // All should be clean after execution + expect(positionOnlyData.isDirty).toBe(false); + expect(positionAndHealthData.isDirty).toBe(false); + expect(positionOrHealthData.isDirty).toBe(false); + + // Add Position component - should invalidate all Position-related queries + world.addComponent(eid1, Position); + + // All Position-related queries should be dirty + expect(positionOnlyData.isDirty).toBe(true); + expect(positionAndHealthData.isDirty).toBe(true); + expect(positionOrHealthData.isDirty).toBe(true); + }); + }); + + describe('Bitmask vs Individual evaluation strategies', () => { + it('should use bitmask evaluation for simple filters', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const queryData = world._queryRegistry.registerQuery(And(With(Position), With(Health))); + expect(queryData.evaluationStrategy).toBe('bitmask'); + }); + + it('should use bitmask evaluation for simple Or filters', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const queryData = world._queryRegistry.registerQuery(Or(With(Position), With(Health))); + expect(queryData.evaluationStrategy).toBe('bitmask'); + }); + + it('should use individual evaluation for complex nested filters', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const Shield = [] as number[]; + + // Or(And(...), ...) should fall back to individual evaluation + const queryData = world._queryRegistry.registerQuery( + Or(And(With(Position), With(Health)), With(Shield)) + ); + expect(queryData.evaluationStrategy).toBe('individual'); + }); + + it('should produce same results regardless of evaluation strategy', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const Shield = [] as number[]; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); + + // eid1: Position + Health + world.addComponent(eid1, Position); + world.addComponent(eid1, Health); + + // eid2: Shield only + world.addComponent(eid2, Shield); + + // eid3: Nothing + + // Bitmask-compatible query + const bitmaskResult = world.query(Or(With(Position), With(Shield))); + + // Individual evaluation query (same logic) + const individualResult = world.query( + Or(And(With(Position)), With(Shield)) // Forces individual evaluation + ); + + expect(bitmaskResult.sort()).toEqual([eid1, eid2].sort()); + expect(individualResult.sort()).toEqual([eid1, eid2].sort()); + }); + }); +}); diff --git a/packages/feature-ecs/src/query-filter.ts b/packages/feature-ecs/src/query-filter.ts index bcae71a3..dad6b32e 100644 --- a/packages/feature-ecs/src/query-filter.ts +++ b/packages/feature-ecs/src/query-filter.ts @@ -2,6 +2,9 @@ import { TComponentRef } from './component-registry'; import { TEntityId } from './entity-index'; import { TWorld } from './world'; +// TODO: Add option to disable bitmask evaluation and cache +// TODO: Add performance tests using vite comparing performance between bitmask and individual evaluation + /** * Requires entity to have component */ @@ -24,6 +27,14 @@ export function With(component: T): TQueryFilter { }, register(world: TWorld, queryData: TQueryData): void { + // Register callbacks to invalidate this query when components are added/removed + world._componentRegistry.onComponentAdd(component, () => { + queryData.isDirty = true; + }); + world._componentRegistry.onComponentRemove(component, () => { + queryData.isDirty = true; + }); + const registry = world._componentRegistry; const componentData = registry._componentMap.get(component); @@ -78,6 +89,14 @@ export function Without(component: T): TQueryFilter { }, register(world: TWorld, queryData: TQueryData): void { + // Register callbacks to invalidate this query when components are added/removed + world._componentRegistry.onComponentAdd(component, () => { + queryData.isDirty = true; + }); + world._componentRegistry.onComponentRemove(component, () => { + queryData.isDirty = true; + }); + const registry = world._componentRegistry; const componentData = registry._componentMap.get(component); @@ -129,6 +148,11 @@ export function Added(component: T): TQueryFilter { queryData.isDirty = true; }); + // Register callback to invalidate when change tracking is flushed + world._componentRegistry.onComponentFlush(component, () => { + queryData.isDirty = true; + }); + // Register in change detection masks for potential bitmask evaluation const registry = world._componentRegistry; const componentData = registry._componentMap.get(component); @@ -179,6 +203,11 @@ export function Changed(component: T): TQueryFilter { queryData.isDirty = true; }); + // Register callback to invalidate when change tracking is flushed + world._componentRegistry.onComponentFlush(component, () => { + queryData.isDirty = true; + }); + // Register in change detection masks for potential bitmask evaluation const registry = world._componentRegistry; const componentData = registry._componentMap.get(component); @@ -230,6 +259,11 @@ export function Removed(component: T): TQueryFilter { queryData.isDirty = true; }); + // Register callback to invalidate when change tracking is flushed + world._componentRegistry.onComponentFlush(component, () => { + queryData.isDirty = true; + }); + // Register in change detection masks for potential bitmask evaluation const registry = world._componentRegistry; const componentData = registry._componentMap.get(component); @@ -588,11 +622,6 @@ export interface TQueryData { cachedResult: TEntityId[]; /** True when cached results are stale and need re-evaluation */ isDirty: boolean; - /** - * True if this query contains Added/Changed/Removed filters and needs - * cache invalidation when world.flush() clears change tracking masks. - */ - needsFlushInvalidation: boolean; /** Pre-computed generations array for optimal bitmask iteration */ generations: number[]; @@ -651,7 +680,7 @@ function getComponentId(world: TWorld, component: TComponentRef): number { } /** - * Pre-categorizes a query's evaluation strategy for optimal performance. + * Pre-categorizes a query's evaluation strategy. * * Strategies: * - 'bitmask': All filters can use bitwise operations (With/Without/Added/Changed/Removed) @@ -698,28 +727,9 @@ export function categorizeEvaluationStrategy(filter: TQueryFilter): 'bitmask' | } } -/** - * Checks if a filter contains change detection filters (Added/Changed/Removed). - * Used to pre-compute needsFlushInvalidation flag for O(1) flush performance. - */ -export function hasChangeDetectionFilter(filter: TQueryFilter): boolean { - // Direct change detection filters - if (filter.type === 'Added' || filter.type === 'Changed' || filter.type === 'Removed') { - return true; - } - - // Check composite filters recursively - if ('filters' in filter && Array.isArray(filter.filters)) { - return filter.filters.some((f) => hasChangeDetectionFilter(f)); - } - - return false; -} - /** * Can a component change affect this query? * Returns false if query definitely doesn't care about this component. - * Used to skip expensive query re-evaluation when possible. */ export function canComponentAffectQuery( queryData: TQueryData, diff --git a/packages/feature-ecs/src/query-registry.test.ts b/packages/feature-ecs/src/query-registry.test.ts index 11109b26..2f78a3dc 100644 --- a/packages/feature-ecs/src/query-registry.test.ts +++ b/packages/feature-ecs/src/query-registry.test.ts @@ -9,458 +9,343 @@ describe('createQueryRegistry', () => { world = createWorld(); }); - describe('generateQueryHash', () => { - it('should generate unique hashes for different filter types', () => { + describe('executeQuery', () => { + it('should return matching entities', () => { const Position = { x: [] as number[], y: [] as number[] }; - // Different filter types should generate different hashes - const withHash = world._queryRegistry.generateQueryHash(With(Position)); - const withoutHash = world._queryRegistry.generateQueryHash(Without(Position)); - const addedHash = world._queryRegistry.generateQueryHash(Added(Position)); - const changedHash = world._queryRegistry.generateQueryHash(Changed(Position)); - const removedHash = world._queryRegistry.generateQueryHash(Removed(Position)); + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); - // All hashes should be different - const hashes = [withHash, withoutHash, addedHash, changedHash, removedHash]; - const uniqueHashes = new Set(hashes); - expect(uniqueHashes.size).toBe(hashes.length); + world.addComponent(eid1, Position); + + const result = world._queryRegistry.executeQuery(With(Position)); + expect(result).toEqual([eid1]); }); - it('should generate same hash for identical queries', () => { + it('should return empty array when no entities match', () => { const Position = { x: [] as number[], y: [] as number[] }; - const Velocity = { x: [] as number[], y: [] as number[] }; - - const filter1 = And(With(Position), With(Velocity)); - const filter2 = And(With(Position), With(Velocity)); - const hash1 = world._queryRegistry.generateQueryHash(filter1); - const hash2 = world._queryRegistry.generateQueryHash(filter2); - - expect(hash1).toBe(hash2); + const result = world._queryRegistry.executeQuery(With(Position)); + expect(result).toEqual([]); }); - it('should auto-register components when generating hash', () => { + it('should handle complex filters', () => { const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; - // Component should not be registered initially - expect(world._componentRegistry._componentMap.has(Position)).toBe(false); + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); - // Generating hash should auto-register component - world._queryRegistry.generateQueryHash(With(Position)); - expect(world._componentRegistry._componentMap.has(Position)).toBe(true); + world.addComponent(eid1, Position); + world.addComponent(eid1, Health); + world.addComponent(eid2, Position); + + const result = world._queryRegistry.executeQuery(And(With(Position), With(Health))); + expect(result).toEqual([eid1]); }); - }); - describe('registerQuery', () => { - it('should register and cache queries', () => { + it('should use cached results when not dirty', () => { const Position = { x: [] as number[], y: [] as number[] }; - const Velocity = { x: [] as number[], y: [] as number[] }; - const queryData1 = world._queryRegistry.registerQuery(And(With(Position), With(Velocity))); - const queryData2 = world._queryRegistry.registerQuery(And(With(Position), With(Velocity))); + const eid = world.createEntity(); + world.addComponent(eid, Position); - // Should return same cached query data - expect(queryData1).toBe(queryData2); - expect(world._queryRegistry._queryCache.size).toBe(1); + // First execution should cache result + const result1 = world._queryRegistry.executeQuery(With(Position)); + expect(result1).toEqual([eid]); + + // Second execution should use cache + const result2 = world._queryRegistry.executeQuery(With(Position)); + expect(result2).toEqual([eid]); }); - it('should store filter', () => { + it('should rebuild when cache is dirty', () => { const Position = { x: [] as number[], y: [] as number[] }; - const Health = [] as number[]; - - const filter = And(With(Position), With(Health)); - const queryData = world._queryRegistry.registerQuery(filter); - // Should store the exact filter - expect(queryData.filter).toBe(filter); - expect(queryData.filter.type).toBe('And'); - }); + const eid1 = world.createEntity(); + world.addComponent(eid1, Position); - it('should handle single component filters', () => { - const Position = { x: [] as number[], y: [] as number[] }; + // First execution + const result1 = world._queryRegistry.executeQuery(With(Position)); + expect(result1).toEqual([eid1]); - const filter = With(Position); - const queryData = world._queryRegistry.registerQuery(filter); + // Add another entity (makes cache dirty) + const eid2 = world.createEntity(); + world.addComponent(eid2, Position); - // Should store the With filter - expect(queryData.filter).toBe(filter); - expect(queryData.filter.type).toBe('With'); + // Should rebuild with new entity + const result2 = world._queryRegistry.executeQuery(With(Position)); + expect(result2.sort()).toEqual([eid1, eid2].sort()); }); - }); - describe('executeQuery', () => { - it('should execute basic With queries', () => { + it('should bypass cache when cache=false', () => { const Position = { x: [] as number[], y: [] as number[] }; - const Velocity = { x: [] as number[], y: [] as number[] }; - const Health = [] as number[]; - // Create entities - const eid1 = world.createEntity(); - const eid2 = world.createEntity(); - const eid3 = world.createEntity(); + const eid = world.createEntity(); + world.addComponent(eid, Position); - // Add components - world.addComponent(eid1, Position); - world.addComponent(eid1, Velocity); + // Query with cache enabled + const result1 = world._queryRegistry.executeQuery(With(Position)); + expect(result1).toEqual([eid]); + // Add another entity + const eid2 = world.createEntity(); world.addComponent(eid2, Position); - world.addComponent(eid2, Health); - - world.addComponent(eid3, Velocity); - world.addComponent(eid3, Health); - - // Test basic WITH queries - const positionAndVelocity = world.query(And(With(Position), With(Velocity))); - expect(positionAndVelocity).toEqual([eid1]); - const positionOnly = world.query(With(Position)); - expect(positionOnly.sort()).toEqual([eid1, eid2].sort()); + // Query with cache disabled - should always rebuild + const result2 = world._queryRegistry.executeQuery(With(Position), { cache: false }); + expect(result2.sort()).toEqual([eid, eid2].sort()); }); - it('should execute Without queries', () => { + it('should respect evaluationStrategy option', () => { const Position = { x: [] as number[], y: [] as number[] }; const Health = [] as number[]; - const eid1 = world.createEntity(); - const eid2 = world.createEntity(); + const eid = world.createEntity(); + world.addComponent(eid, Position); + world.addComponent(eid, Health); - world.addComponent(eid1, Position); - world.addComponent(eid2, Position); - world.addComponent(eid2, Health); + const filter = And(With(Position), With(Health)); + + // Force individual evaluation + const individualResult = world._queryRegistry.executeQuery(filter, { + evaluationStrategy: 'individual' + }); + + // Force bitmask evaluation + const bitmaskResult = world._queryRegistry.executeQuery(filter, { + evaluationStrategy: 'bitmask' + }); - // Test WITHOUT filter - const hasPositionButNotHealth = world.query(And(With(Position), Without(Health))); - expect(hasPositionButNotHealth).toEqual([eid1]); + expect(individualResult).toEqual([eid]); + expect(bitmaskResult).toEqual([eid]); + expect(individualResult).toEqual(bitmaskResult); }); + }); - it('should execute And queries', () => { + describe('getQuery', () => { + it('should create and cache query data', () => { const Position = { x: [] as number[], y: [] as number[] }; - const Health = [] as number[]; - - const eid1 = world.createEntity(); - const eid2 = world.createEntity(); - world.addComponent(eid1, Position); - world.addComponent(eid2, Position); - world.addComponent(eid2, Health); + const queryData = world._queryRegistry.getQuery(With(Position)); - // Test explicit AND filter - const explicitAnd = world.query(And(With(Position), With(Health))); - expect(explicitAnd).toEqual([eid2]); + expect(queryData.filter.type).toBe('With'); + expect(typeof queryData.hash).toBe('string'); + expect(queryData.hash.length).toBeGreaterThan(0); + expect(Array.isArray(queryData.cachedResult)).toBe(true); + expect(typeof queryData.isDirty).toBe('boolean'); }); - it('should execute Or queries', () => { + it('should return same cached query for identical filters', () => { const Position = { x: [] as number[], y: [] as number[] }; - const Health = [] as number[]; - const eid1 = world.createEntity(); - const eid2 = world.createEntity(); - const eid3 = world.createEntity(); - - world.addComponent(eid1, Position); - world.addComponent(eid2, Health); - world.addComponent(eid3, Position); - world.addComponent(eid3, Health); + const queryData1 = world._queryRegistry.getQuery(With(Position)); + const queryData2 = world._queryRegistry.getQuery(With(Position)); - // Test OR filter - const positionOrHealth = world.query(Or(With(Position), With(Health))); - expect(positionOrHealth.sort()).toEqual([eid1, eid2, eid3].sort()); + expect(queryData1).toBe(queryData2); + expect(world._queryRegistry._queryCache.size).toBe(1); }); - it('should execute complex filter combinations', () => { + it('should create separate entries for different filters', () => { const Position = { x: [] as number[], y: [] as number[] }; const Health = [] as number[]; - const Shield = [] as number[]; - const Stunned = {}; - const Paralyzed = {}; - // Create entities - const eid1 = world.createEntity(); - const eid2 = world.createEntity(); - const eid3 = world.createEntity(); + const query1 = world._queryRegistry.getQuery(With(Position)); + const query2 = world._queryRegistry.getQuery(With(Health)); - // Setup entity 1: Position + Health + Stunned - world.addComponent(eid1, Position); - world.addComponent(eid1, Health); - world.addComponent(eid1, Stunned); + expect(query1).not.toBe(query2); + expect(world._queryRegistry._queryCache.size).toBe(2); + }); - // Setup entity 2: Position + Shield - world.addComponent(eid2, Position); - world.addComponent(eid2, Shield); + it('should set correct evaluation strategy by default', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; - // Setup entity 3: Position + Health + Paralyzed - world.addComponent(eid3, Position); - world.addComponent(eid3, Health); - world.addComponent(eid3, Paralyzed); + // Simple And should use bitmask + const simpleQuery = world._queryRegistry.getQuery(And(With(Position), With(Health))); + expect(simpleQuery.evaluationStrategy).toBe('bitmask'); - // Complex query: Position + (Health OR Shield) + WITHOUT(Stunned) + WITHOUT(Paralyzed) - const complexQuery = world.query( - And(With(Position), Or(With(Health), With(Shield)), Without(Stunned), Without(Paralyzed)) + // Complex nested should use individual + const complexQuery = world._queryRegistry.getQuery( + Or(And(With(Position), With(Health)), With(Position)) ); - - // Only eid2 should match (has Position + Shield, no Stunned, no Paralyzed) - expect(complexQuery).toEqual([eid2]); + expect(complexQuery.evaluationStrategy).toBe('individual'); }); - it('should cache queries for performance', () => { + it('should respect evaluationStrategy option', () => { const Position = { x: [] as number[], y: [] as number[] }; - const Velocity = { x: [] as number[], y: [] as number[] }; - - const eid = world.createEntity(); - world.addComponent(eid, Position); - world.addComponent(eid, Velocity); - - // Execute same query multiple times - const filter = And(With(Position), With(Velocity)); - const result1 = world.query(filter); - const result2 = world.query(filter); + const Health = [] as number[]; - // Should return same results - expect(result1).toEqual(result2); - expect(result1).toEqual([eid]); + // Force individual strategy + const queryData = world._queryRegistry.getQuery(And(With(Position), With(Health)), { + evaluationStrategy: 'individual' + }); - // Verify query was cached - expect(world._queryRegistry._queryCache.size).toBe(1); + expect(queryData.evaluationStrategy).toBe('individual'); }); - it('should auto-register components in filters', () => { + it('should auto-register components', () => { const Position = { x: [] as number[], y: [] as number[] }; - const Velocity = { x: [] as number[], y: [] as number[] }; - // Query should auto-register components - const result = world.query(And(With(Position), With(Velocity))); - expect(result).toEqual([]); // No entities have these components yet + // Component should not be registered initially + expect(world._componentRegistry._componentMap.has(Position)).toBe(false); - // Verify components were auto-registered + // getQuery should auto-register component + world._queryRegistry.getQuery(With(Position)); expect(world._componentRegistry._componentMap.has(Position)).toBe(true); - expect(world._componentRegistry._componentMap.has(Velocity)).toBe(true); }); }); - describe('executeInnerQuery', () => { - it('should execute queries without committing removals', () => { + describe('registerQuery', () => { + it('should be an alias for getQuery', () => { const Position = { x: [] as number[], y: [] as number[] }; - const eid = world.createEntity(); - world.addComponent(eid, Position); + const queryData1 = world._queryRegistry.registerQuery(With(Position)); + const queryData2 = world._queryRegistry.getQuery(With(Position)); - // Inner query should work the same as regular query for now - const result = world.innerQuery(With(Position)); - expect(result).toEqual([eid]); + expect(queryData1).toBe(queryData2); }); }); - describe('checkEntity', () => { - it('should check if entity matches query data', () => { + describe('generateQueryHash', () => { + it('should generate string hashes', () => { const Position = { x: [] as number[], y: [] as number[] }; - const Health = [] as number[]; - - const eid1 = world.createEntity(); - const eid2 = world.createEntity(); - world.addComponent(eid1, Position); - world.addComponent(eid1, Health); - world.addComponent(eid2, Position); + const hash = world._queryRegistry.generateQueryHash(With(Position)); - const queryData = world._queryRegistry.registerQuery(And(With(Position), With(Health))); - - expect(world._queryRegistry.checkEntity(queryData, eid1)).toBe(true); - expect(world._queryRegistry.checkEntity(queryData, eid2)).toBe(false); + expect(typeof hash).toBe('string'); + expect(hash.length).toBeGreaterThan(0); }); - }); - describe('change detection filters', () => { - it('should support Added filters', () => { + it('should generate same hash for identical filters', () => { const Position = { x: [] as number[], y: [] as number[] }; - const Health = [] as number[]; - const eid1 = world.createEntity(); - const eid2 = world.createEntity(); - - // Add Position to eid1 (should be tracked as added) - world.addComponent(eid1, Position); - - // Add Health to eid2 (should be tracked as added) - world.addComponent(eid2, Health); - - // Query for entities with added Position - const addedPosition = world.query(Added(Position)); - expect(addedPosition).toEqual([eid1]); + const hash1 = world._queryRegistry.generateQueryHash(With(Position)); + const hash2 = world._queryRegistry.generateQueryHash(With(Position)); - // Query for entities with added Health - const addedHealth = world.query(Added(Health)); - expect(addedHealth).toEqual([eid2]); - - // Clear frame changes - world.flush(); - - // After clearing, no entities should have added components - const addedPositionAfter = world.query(Added(Position)); - const addedHealthAfter = world.query(Added(Health)); - expect(addedPositionAfter).toEqual([]); - expect(addedHealthAfter).toEqual([]); + expect(hash1).toBe(hash2); }); - it('should support Changed filters', () => { + it('should generate different hashes for different filters', () => { const Position = { x: [] as number[], y: [] as number[] }; const Health = [] as number[]; - const eid1 = world.createEntity(); - const eid2 = world.createEntity(); - - // Add components first - world.addComponent(eid1, Position); - world.addComponent(eid2, Health); + const withHash = world._queryRegistry.generateQueryHash(With(Position)); + const withoutHash = world._queryRegistry.generateQueryHash(Without(Position)); + const addedHash = world._queryRegistry.generateQueryHash(Added(Position)); + const changedHash = world._queryRegistry.generateQueryHash(Changed(Position)); + const removedHash = world._queryRegistry.generateQueryHash(Removed(Position)); + const healthHash = world._queryRegistry.generateQueryHash(With(Health)); - // Clear initial "added" tracking - world.flush(); + const hashes = [withHash, withoutHash, addedHash, changedHash, removedHash, healthHash]; + const uniqueHashes = new Set(hashes); + expect(uniqueHashes.size).toBe(hashes.length); + }); - // Mark Position as changed for eid1 - world._componentRegistry.markChanged(eid1, Position); + it('should handle component order consistently', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Velocity = { x: [] as number[], y: [] as number[] }; - // Query for entities with changed Position - const changedPosition = world.query(Changed(Position)); - expect(changedPosition).toEqual([eid1]); + // Order shouldn't matter due to sorting in And + const hash1 = world._queryRegistry.generateQueryHash(And(With(Position), With(Velocity))); + const hash2 = world._queryRegistry.generateQueryHash(And(With(Velocity), With(Position))); - // Query for entities with changed Health (should be empty) - const changedHealth = world.query(Changed(Health)); - expect(changedHealth).toEqual([]); + expect(hash1).toBe(hash2); }); + }); - it('should support Removed filters', () => { + describe('checkEntity', () => { + it('should return true when entity matches query', () => { const Position = { x: [] as number[], y: [] as number[] }; const Health = [] as number[]; - const eid1 = world.createEntity(); - const eid2 = world.createEntity(); - - // Add components first - world.addComponent(eid1, Position); - world.addComponent(eid1, Health); - world.addComponent(eid2, Position); - - // Clear initial "added" tracking - world.flush(); - - // Remove Health from eid1 - world.removeComponent(eid1, Health); + const eid = world.createEntity(); + world.addComponent(eid, Position); + world.addComponent(eid, Health); - // Query for entities with removed Health - const removedHealth = world.query(Removed(Health)); - expect(removedHealth).toEqual([eid1]); + const queryData = world._queryRegistry.getQuery(And(With(Position), With(Health))); + const result = world._queryRegistry.checkEntity(queryData, eid); - // Query for entities with removed Position (should be empty) - const removedPosition = world.query(Removed(Position)); - expect(removedPosition).toEqual([]); + expect(result).toBe(true); }); - it('should support complex change detection queries', () => { + it('should return false when entity does not match query', () => { const Position = { x: [] as number[], y: [] as number[] }; const Health = [] as number[]; - const Shield = [] as number[]; - const eid1 = world.createEntity(); - const eid2 = world.createEntity(); - const eid3 = world.createEntity(); - - // Setup initial state - world.addComponent(eid1, Position); - world.addComponent(eid1, Health); + const eid = world.createEntity(); + world.addComponent(eid, Position); + // Missing Health component - world.addComponent(eid2, Position); - world.addComponent(eid2, Shield); + const queryData = world._queryRegistry.getQuery(And(With(Position), With(Health))); + const result = world._queryRegistry.checkEntity(queryData, eid); - // Clear initial "added" tracking - world.flush(); + expect(result).toBe(false); + }); - // Trigger changes: - // - Add Shield to eid1 (new component) - // - Mark Position as changed for eid2 - // - Add Position to eid3 (new entity) - world.addComponent(eid1, Shield); - world._componentRegistry.markChanged(eid2, Position); - world.addComponent(eid3, Position); + it('should handle non-existent entities', () => { + const Position = { x: [] as number[], y: [] as number[] }; - // Query: Entities with Position AND (Added Shield OR Changed Position) - const complexQuery = world.query(And(With(Position), Or(Added(Shield), Changed(Position)))); + const queryData = world._queryRegistry.getQuery(With(Position)); + const result = world._queryRegistry.checkEntity(queryData, 999); - // Should match eid1 (has Position + added Shield) and eid2 (has Position + changed Position) - expect(complexQuery.sort()).toEqual([eid1, eid2].sort()); + expect(result).toBe(false); }); + }); - it('should combine change detection with regular filters', () => { + describe('reset', () => { + it('should clear all cached queries', () => { const Position = { x: [] as number[], y: [] as number[] }; const Health = [] as number[]; - const Enemy = {}; - const eid1 = world.createEntity(); - const eid2 = world.createEntity(); - - // Setup: Both entities have Position and Health - world.addComponent(eid1, Position); - world.addComponent(eid1, Health); - world.addComponent(eid2, Position); - world.addComponent(eid2, Health); + // Populate cache with multiple queries + world._queryRegistry.getQuery(With(Position)); + world._queryRegistry.getQuery(With(Health)); + world._queryRegistry.getQuery(And(With(Position), With(Health))); - // Mark eid2 as Enemy (should be tracked as added) - world.addComponent(eid2, Enemy); + expect(world._queryRegistry._queryCache.size).toBe(3); - // Clear initial "added" tracking - world.flush(); + world._queryRegistry.reset(); - // Mark components as changed - world._componentRegistry.markChanged(eid1, Health); - world._componentRegistry.markChanged(eid2, Health); - - // Query: Entities with changed Health but WITHOUT Enemy tag - const nonEnemiesWithChangedHealth = world.query(And(Changed(Health), Without(Enemy))); - - // Should only match eid1 (has changed Health but is not an Enemy) - expect(nonEnemiesWithChangedHealth).toEqual([eid1]); + expect(world._queryRegistry._queryCache.size).toBe(0); }); - }); - describe('reset', () => { - it('should reset to initial state', () => { + it('should allow normal operation after reset', () => { const Position = { x: [] as number[], y: [] as number[] }; - const eid = world.createEntity(); - world.addComponent(eid, Position); - // Execute query to populate cache - world.query(With(Position)); - expect(world._queryRegistry._queryCache.size).toBeGreaterThan(0); + // Use registry, then reset + world._queryRegistry.getQuery(With(Position)); + world._queryRegistry.reset(); - // Reset should clear everything - world.reset(); - expect(world._queryRegistry._queryCache.size).toBe(0); - expect(world._componentRegistry._componentMap.size).toBe(0); - expect(world._entityIndex.getAliveEntities()).toEqual([]); + // Should work normally after reset + const queryData = world._queryRegistry.getQuery(With(Position)); + expect(queryData.filter.type).toBe('With'); + expect(world._queryRegistry._queryCache.size).toBe(1); }); }); describe('validate', () => { - it('should return true for valid registry', () => { + it('should return true for new registry', () => { expect(world._queryRegistry.validate()).toBe(true); }); it('should return true after query operations', () => { const Position = { x: [] as number[], y: [] as number[] }; - const eid = world.createEntity(); - world.addComponent(eid, Position); - world.query(With(Position)); + world._queryRegistry.getQuery(With(Position)); + world._queryRegistry.executeQuery(With(Position)); + world._queryRegistry.generateQueryHash(With(Position)); + expect(world._queryRegistry.validate()).toBe(true); }); - }); - describe('world integration', () => { - it('should have access to world context', () => { - // Verify query registry has world reference - expect(world._queryRegistry._world).toBe(world); + it('should return true after reset', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + world._queryRegistry.getQuery(With(Position)); + world._queryRegistry.reset(); - // Verify it can access all world properties through the reference - expect(world._queryRegistry._world._componentRegistry).toBe(world._componentRegistry); - expect(world._queryRegistry._world._entityIndex).toBe(world._entityIndex); + expect(world._queryRegistry.validate()).toBe(true); }); }); }); diff --git a/packages/feature-ecs/src/query-registry.ts b/packages/feature-ecs/src/query-registry.ts index ce303950..9e52a924 100644 --- a/packages/feature-ecs/src/query-registry.ts +++ b/packages/feature-ecs/src/query-registry.ts @@ -5,12 +5,7 @@ */ import { TEntityId } from './entity-index'; -import { - categorizeEvaluationStrategy, - hasChangeDetectionFilter, - TQueryData, - TQueryFilter -} from './query-filter'; +import { categorizeEvaluationStrategy, TQueryData, TQueryFilter } from './query-filter'; import { TWorld } from './world'; /** @@ -21,11 +16,12 @@ export function createQueryRegistry(world: TWorld): TQueryRegistry { _world: world, _queryCache: new Map(), - executeQuery(filter) { - const queryData = this.getQuery(filter); + executeQuery(filter, options = {}) { + const { cache = true, ...getQueryOptions } = options; + const queryData = this.getQuery(filter, getQueryOptions); // Return cached result if available and not dirty - if (!queryData.isDirty) { + if (!queryData.isDirty && cache) { return queryData.cachedResult; } @@ -52,22 +48,22 @@ export function createQueryRegistry(world: TWorld): TQueryRegistry { return matchingEntities; }, - getQuery(filter) { + getQuery(filter, options = {}) { + const { evaluationStrategy = categorizeEvaluationStrategy(filter) } = options; const hash = filter.getHash(this._world); // Return cached query if exists if (this._queryCache.has(hash)) { - return this._queryCache.get(hash)!; + return this._queryCache.get(hash) as TQueryData; } // Create new query data const queryData: TQueryData = { hash, filter, - evaluationStrategy: categorizeEvaluationStrategy(filter), + evaluationStrategy, cachedResult: [], isDirty: true, - needsFlushInvalidation: hasChangeDetectionFilter(filter), generations: [] }; @@ -95,22 +91,6 @@ export function createQueryRegistry(world: TWorld): TQueryRegistry { return queryData.filter.evaluate(this._world, eid, queryData); }, - invalidateQueries() { - // Mark all queries as dirty - for (const queryData of this._queryCache.values()) { - queryData.isDirty = true; - } - }, - - flush() { - // Invalidate queries that were pre-marked as needing flush invalidation during registration. - for (const queryData of this._queryCache.values()) { - if (queryData.needsFlushInvalidation) { - queryData.isDirty = true; - } - } - }, - reset() { this._queryCache.clear(); }, @@ -130,12 +110,12 @@ export interface TQueryRegistry { /** * Executes a query and returns matching entities */ - executeQuery(filter: TQueryFilter): TEntityId[]; + executeQuery(filter: TQueryFilter, options?: TExecuteQueryOptions): TEntityId[]; /** * Gets or creates a compiled query */ - getQuery(filter: TQueryFilter): TQueryData; + getQuery(filter: TQueryFilter, options?: TGetQueryOptions): TQueryData; /** * Registers a query (alias for getOrCreateQuery) @@ -152,16 +132,6 @@ export interface TQueryRegistry { */ checkEntity(queryData: TQueryData, eid: TEntityId): boolean; - /** - * Invalidates all cached queries - */ - invalidateQueries(): void; - - /** - * Flushes the query registry - */ - flush(): void; - /** * Resets the query registry to its initial state */ @@ -172,3 +142,13 @@ export interface TQueryRegistry { */ validate(): boolean; } + +export interface TGetQueryOptions { + /** Evaluation strategy to use for the query */ + evaluationStrategy?: 'bitmask' | 'individual'; +} + +export interface TExecuteQueryOptions extends TGetQueryOptions { + /** Whether to cache the query result */ + cache?: boolean; +} diff --git a/packages/feature-ecs/src/world.ts b/packages/feature-ecs/src/world.ts index 6596f040..51e6a6b7 100644 --- a/packages/feature-ecs/src/world.ts +++ b/packages/feature-ecs/src/world.ts @@ -1,7 +1,7 @@ import { createComponentRegistry, TComponentRef, TComponentRegistry } from './component-registry'; import { createEntityIndex, TEntityId, TEntityIndex } from './entity-index'; import { TQueryFilter } from './query-filter'; -import { createQueryRegistry, TQueryRegistry } from './query-registry'; +import { createQueryRegistry, TExecuteQueryOptions, TQueryRegistry } from './query-registry'; /** * Creates a new ECS world. @@ -53,17 +53,12 @@ export function createWorld(): TWorld { return this._componentRegistry.hasComponent(eid, component); }, - query(filter) { - return this._queryRegistry.executeQuery(filter); - }, - - innerQuery(filter) { - return this._queryRegistry.executeQuery(filter); + query(filter, options) { + return this._queryRegistry.executeQuery(filter, options); }, flush() { this._componentRegistry.flush(); - this._queryRegistry.flush(); }, reset() { @@ -134,16 +129,10 @@ export interface TWorld { /** * Executes a query and returns matching entities. * @param filter - The query filter + * @param options - Query execution options * @returns Array of matching entity IDs */ - query(filter: TQueryFilter): TEntityId[]; - - /** - * Executes a query and returns matching entities. - * @param filter - The query filter - * @returns Array of matching entity IDs - */ - innerQuery(filter: TQueryFilter): TEntityId[]; + query(filter: TQueryFilter, options?: TExecuteQueryOptions): TEntityId[]; /** * Clears the world. From 33edc0c9b457136cc84b567e942bd61b4edbacd6 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Fri, 30 May 2025 09:26:21 +0200 Subject: [PATCH 17/39] #105 basic bench --- .../__tests__/component-variant.bench.ts | 161 ++++++++++ .../__tests__/ecs-comparison.bench.ts | 172 +++++++++++ .../feature-ecs/__tests__/playground.test.ts | 48 +++ packages/feature-ecs/__tests__/query.bench.ts | 138 +++++++++ .../__tests__/utils/create-seeded-random.ts | 15 + packages/feature-ecs/__tests__/utils/index.ts | 1 + packages/feature-ecs/package.json | 1 + packages/feature-ecs/src/query-filter.ts | 279 +++++++++--------- pnpm-lock.yaml | 9 + 9 files changed, 689 insertions(+), 135 deletions(-) create mode 100644 packages/feature-ecs/__tests__/component-variant.bench.ts create mode 100644 packages/feature-ecs/__tests__/ecs-comparison.bench.ts create mode 100644 packages/feature-ecs/__tests__/playground.test.ts create mode 100644 packages/feature-ecs/__tests__/query.bench.ts create mode 100644 packages/feature-ecs/__tests__/utils/create-seeded-random.ts create mode 100644 packages/feature-ecs/__tests__/utils/index.ts diff --git a/packages/feature-ecs/__tests__/component-variant.bench.ts b/packages/feature-ecs/__tests__/component-variant.bench.ts new file mode 100644 index 00000000..94370481 --- /dev/null +++ b/packages/feature-ecs/__tests__/component-variant.bench.ts @@ -0,0 +1,161 @@ +import { bench, describe, expect } from 'vitest'; +import { createWorld, With } from '../src'; +import { createSeededRandom } from './utils'; + +describe('Component Variants Performance', () => { + const seed = Math.random() * 1000000; + const random = createSeededRandom(seed); + + // Different component patterns + const Position: { x: number[]; y: number[] } = { x: [], y: [] }; // Object with arrays (AoS) + const Transform: { x: number; y: number }[] = []; // Array of objects (SoA) + const Health: number[] = []; // Single value array + const Player: {} = {}; // Tag component + + describe('Add Component', () => { + bench('AoS - Position', () => { + const world = createWorld(); + const eid = world.createEntity(); + + world.addComponent(eid, Position); + Position.x[eid] = random.next() * 1000; + Position.y[eid] = random.next() * 1000; + + expect(world.hasComponent(eid, Position)).toBe(true); + }); + + bench('SoA - Transform', () => { + const world = createWorld(); + const eid = world.createEntity(); + + world.addComponent(eid, Transform); + Transform[eid] = { x: random.next() * 1000, y: random.next() * 1000 }; + + expect(world.hasComponent(eid, Transform)).toBe(true); + }); + + bench('Single Array - Health', () => { + const world = createWorld(); + const eid = world.createEntity(); + + world.addComponent(eid, Health); + Health[eid] = Math.floor(random.next() * 100) + 1; + + expect(world.hasComponent(eid, Health)).toBe(true); + }); + + bench('Tag - Player', () => { + const world = createWorld(); + const eid = world.createEntity(); + + world.addComponent(eid, Player); + + expect(world.hasComponent(eid, Player)).toBe(true); + }); + }); + + describe('Remove Component', () => { + bench('AoS - Position', () => { + const world = createWorld(); + const eid = world.createEntity(); + world.addComponent(eid, Position); + Position.x[eid] = 100; + Position.y[eid] = 200; + + const removed = world.removeComponent(eid, Position); + expect(removed).toBe(true); + }); + + bench('SoA - Transform', () => { + const world = createWorld(); + const eid = world.createEntity(); + world.addComponent(eid, Transform); + Transform[eid] = { x: 100, y: 200 }; + + const removed = world.removeComponent(eid, Transform); + expect(removed).toBe(true); + }); + + bench('Single Array - Health', () => { + const world = createWorld(); + const eid = world.createEntity(); + world.addComponent(eid, Health); + Health[eid] = 100; + + const removed = world.removeComponent(eid, Health); + expect(removed).toBe(true); + }); + + bench('Tag - Player', () => { + const world = createWorld(); + const eid = world.createEntity(); + world.addComponent(eid, Player); + + const removed = world.removeComponent(eid, Player); + expect(removed).toBe(true); + }); + }); + + describe('Query Component', () => { + const worldAoS = createWorld(); + const worldSoA = createWorld(); + const worldSingle = createWorld(); + const worldTag = createWorld(); + + // AoS setup + for (let i = 0; i < 500; i++) { + const eid = worldAoS.createEntity(); + if (random.nextBool(0.7)) { + worldAoS.addComponent(eid, Position); + Position.x[eid] = random.next() * 1000; + Position.y[eid] = random.next() * 1000; + } + } + + // SoA setup + for (let i = 0; i < 500; i++) { + const eid = worldSoA.createEntity(); + if (random.nextBool(0.7)) { + worldSoA.addComponent(eid, Transform); + Transform[eid] = { x: random.next() * 1000, y: random.next() * 1000 }; + } + } + + // Single array setup + for (let i = 0; i < 500; i++) { + const eid = worldSingle.createEntity(); + if (random.nextBool(0.7)) { + worldSingle.addComponent(eid, Health); + Health[eid] = Math.floor(random.next() * 100) + 1; + } + } + + // Tag setup + for (let i = 0; i < 500; i++) { + const eid = worldTag.createEntity(); + if (random.nextBool(0.7)) { + worldTag.addComponent(eid, Player); + } + } + + bench('AoS - Position', () => { + const entities = worldAoS.query(With(Position)); + expect(entities.length).toBeGreaterThan(0); + }); + + bench('SoA - Transform', () => { + const entities = worldSoA.query(With(Transform)); + expect(entities.length).toBeGreaterThan(0); + }); + + bench('Single Array - Health', () => { + const entities = worldSingle.query(With(Health)); + expect(entities.length).toBeGreaterThan(0); + }); + + bench('Tag - Player', () => { + const entities = worldTag.query(With(Player)); + expect(entities.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/feature-ecs/__tests__/ecs-comparison.bench.ts b/packages/feature-ecs/__tests__/ecs-comparison.bench.ts new file mode 100644 index 00000000..79a5f396 --- /dev/null +++ b/packages/feature-ecs/__tests__/ecs-comparison.bench.ts @@ -0,0 +1,172 @@ +import { + addComponent as bitECSAddComponent, + addEntity as bitECSAddEntity, + query as bitECSQuery, + createWorld as createBitEcsWorld +} from 'bitecs'; +import { bench, describe, expect } from 'vitest'; +import { And, createWorld, With } from '../src'; +import { createSeededRandom } from './utils'; + +describe('ECS Performance Comparison', () => { + const seed = Math.random() * 1000000; + + // FeatureEcs setup + const featureEcsRandom = createSeededRandom(seed); + const featureEcsWorld = createWorld(); + const FeatureEcsPosition = { x: [] as number[], y: [] as number[] }; + const FeatureEcsVelocity = { x: [] as number[], y: [] as number[] }; + const FeatureEcsHealth: number[] = []; + + // BitEcs setup + const bitEcsRandom = createSeededRandom(seed); + const bitECSWorld = createBitEcsWorld(); + const BitEcsPosition = { x: [] as number[], y: [] as number[] }; + const BitEcsVelocity = { x: [] as number[], y: [] as number[] }; + const BitEcsHealth = [] as number[]; + + describe('Entity Creation', () => { + bench('FeatureEcs - Create entity', () => { + const eid = featureEcsWorld.createEntity(); + expect(eid).toBeGreaterThanOrEqual(0); + }); + + bench('BitEcs - Create entity', () => { + const eid = bitECSAddEntity(bitECSWorld); + expect(eid).toBeGreaterThanOrEqual(0); + }); + }); + + describe('Component Addition', () => { + bench('FeatureEcs - Add Position component', () => { + const eid = featureEcsWorld.createEntity(); + featureEcsWorld.addComponent(eid, FeatureEcsPosition); + FeatureEcsPosition.x[eid] = 100; + FeatureEcsPosition.y[eid] = 200; + }); + + bench('BitEcs - Add Position component', () => { + const eid = bitECSAddEntity(bitECSWorld); + bitECSAddComponent(bitECSWorld, eid, BitEcsPosition); + BitEcsPosition.x[eid] = 100; + BitEcsPosition.y[eid] = 200; + }); + }); + + describe('Component Queries', () => { + for (let i = 0; i < 1000; i++) { + // FeatureEcs entities + const featureEcsEid = featureEcsWorld.createEntity(); + if (featureEcsRandom.nextBool(0.7)) { + featureEcsWorld.addComponent(featureEcsEid, FeatureEcsPosition); + FeatureEcsPosition.x[featureEcsEid] = i; + FeatureEcsPosition.y[featureEcsEid] = i * 2; + } + if (featureEcsRandom.nextBool(0.5)) { + featureEcsWorld.addComponent(featureEcsEid, FeatureEcsVelocity); + FeatureEcsVelocity.x[featureEcsEid] = 1.5; + FeatureEcsVelocity.y[featureEcsEid] = 2.0; + } + if (featureEcsRandom.nextBool(0.3)) { + featureEcsWorld.addComponent(featureEcsEid, FeatureEcsHealth); + FeatureEcsHealth[featureEcsEid] = 100; + } + + // BitEcs entities + const bitEcsEid = bitECSAddEntity(bitECSWorld); + if (bitEcsRandom.nextBool(0.7)) { + bitECSAddComponent(bitECSWorld, bitEcsEid, BitEcsPosition); + BitEcsPosition.x[bitEcsEid] = i; + BitEcsPosition.y[bitEcsEid] = i * 2; + } + if (bitEcsRandom.nextBool(0.5)) { + bitECSAddComponent(bitECSWorld, bitEcsEid, BitEcsVelocity); + BitEcsVelocity.x[bitEcsEid] = 1.5; + BitEcsVelocity.y[bitEcsEid] = 2.0; + } + if (bitEcsRandom.nextBool(0.3)) { + bitECSAddComponent(bitECSWorld, bitEcsEid, BitEcsHealth); + BitEcsHealth[bitEcsEid] = 100; + } + } + + bench('FeatureEcs - Query Position components', () => { + const entities = featureEcsWorld.query(With(FeatureEcsPosition)); + expect(entities.length).toBeGreaterThan(0); + }); + + bench('BitEcs - Query Position components', () => { + const entities = Array.from(bitECSQuery(bitECSWorld, [BitEcsPosition])); + expect(entities.length).toBeGreaterThan(0); + }); + + bench('FeatureEcs - Query Position + Velocity', () => { + const entities = featureEcsWorld.query( + And(With(FeatureEcsPosition), With(FeatureEcsVelocity)) + ); + expect(entities.length).toBeGreaterThanOrEqual(0); + }); + + bench('BitEcs - Query Position + Velocity', () => { + const entities = bitECSQuery(bitECSWorld, [BitEcsPosition, BitEcsVelocity]); + expect(entities.length).toBeGreaterThanOrEqual(0); + }); + }); + + describe('System Iteration Performance', () => { + for (let i = 0; i < 5000; i++) { + const posX = featureEcsRandom.next() * 1000; + const posY = featureEcsRandom.next() * 1000; + const velX = (featureEcsRandom.next() - 0.5) * 10; + const velY = (featureEcsRandom.next() - 0.5) * 10; + + // FeatureEcs + const featureEcsEid = featureEcsWorld.createEntity(); + featureEcsWorld.addComponent(featureEcsEid, FeatureEcsPosition); + featureEcsWorld.addComponent(featureEcsEid, FeatureEcsVelocity); + FeatureEcsPosition.x[featureEcsEid] = posX; + FeatureEcsPosition.y[featureEcsEid] = posY; + FeatureEcsVelocity.x[featureEcsEid] = velX; + FeatureEcsVelocity.y[featureEcsEid] = velY; + + // BitEcs + const bitEcsEid = bitECSAddEntity(bitECSWorld); + bitECSAddComponent(bitECSWorld, bitEcsEid, BitEcsPosition); + bitECSAddComponent(bitECSWorld, bitEcsEid, BitEcsVelocity); + BitEcsPosition.x[bitEcsEid] = posX; + BitEcsPosition.y[bitEcsEid] = posY; + BitEcsVelocity.x[bitEcsEid] = velX; + BitEcsVelocity.y[bitEcsEid] = velY; + } + + bench('FeatureEcs - Movement system iteration', () => { + let updateCount = 0; + + for (const eid of featureEcsWorld.query( + And(With(FeatureEcsPosition), With(FeatureEcsVelocity)) + )) { + const velX = FeatureEcsVelocity.x[eid] ?? 0; + const velY = FeatureEcsVelocity.y[eid] ?? 0; + FeatureEcsPosition.x[eid] = (FeatureEcsPosition.x[eid] ?? 0) + velX * 0.016; // 60fps delta + FeatureEcsPosition.y[eid] = (FeatureEcsPosition.y[eid] ?? 0) + velY * 0.016; + updateCount++; + } + + expect(updateCount).toBeGreaterThan(0); + }); + + bench('BitEcs - Movement system iteration', () => { + let updateCount = 0; + + for (const eid of bitECSQuery(bitECSWorld, [BitEcsPosition, BitEcsVelocity])) { + const velX = BitEcsVelocity.x[eid] ?? 0; + const velY = BitEcsVelocity.y[eid] ?? 0; + BitEcsPosition.x[eid] = (BitEcsPosition.x[eid] ?? 0) + velX * 0.016; // 60fps delta + BitEcsPosition.y[eid] = (BitEcsPosition.y[eid] ?? 0) + velY * 0.016; + updateCount++; + } + + expect(updateCount).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/feature-ecs/__tests__/playground.test.ts b/packages/feature-ecs/__tests__/playground.test.ts new file mode 100644 index 00000000..20e0e2b7 --- /dev/null +++ b/packages/feature-ecs/__tests__/playground.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { createWorld, With } from '../src'; +import { createSeededRandom } from './utils'; + +describe('playground', () => { + it('should pass', () => { + expect(true).toBe(true); + }); + + it.skip('should work', () => { + const seed = Math.random() * 1000000; + const random = createSeededRandom(seed); + const world = createWorld(); + + // Component definitions + const Position = { x: [] as number[], y: [] as number[] }; + const Velocity = { x: [] as number[], y: [] as number[] }; + const Health: number[] = []; + + // Create 2000 test entities with deterministic distribution + for (let i = 0; i < 2000; i++) { + const eid = world.createEntity(); + + if (random.nextBool(0.8)) { + world.addComponent(eid, Position); + Position.x[eid] = random.next() * 1000; + Position.y[eid] = random.next() * 1000; + } + + if (random.nextBool(0.6)) { + world.addComponent(eid, Velocity); + Velocity.x[eid] = (random.next() - 0.5) * 10; + Velocity.y[eid] = (random.next() - 0.5) * 10; + } + + if (random.nextBool(0.7)) { + world.addComponent(eid, Health); + Health[eid] = Math.floor(random.next() * 100) + 1; + } + } + + const entities = world.query(With(Position), { + evaluationStrategy: 'bitmask', + cache: true + }); + expect(entities.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/feature-ecs/__tests__/query.bench.ts b/packages/feature-ecs/__tests__/query.bench.ts new file mode 100644 index 00000000..bdf18d6a --- /dev/null +++ b/packages/feature-ecs/__tests__/query.bench.ts @@ -0,0 +1,138 @@ +import { bench, describe, expect } from 'vitest'; +import { And, createWorld, With, Without } from '../src'; +import { createSeededRandom } from './utils'; + +describe('Query Performance', () => { + const seed = Math.random() * 1000000; + const random = createSeededRandom(seed); + const world = createWorld(); + + // Component definitions + const Position = { x: [] as number[], y: [] as number[] }; + const Velocity = { x: [] as number[], y: [] as number[] }; + const Health: number[] = []; + + // Create 2000 test entities with deterministic distribution + for (let i = 0; i < 2000; i++) { + const eid = world.createEntity(); + + if (random.nextBool(0.8)) { + world.addComponent(eid, Position); + Position.x[eid] = random.next() * 1000; + Position.y[eid] = random.next() * 1000; + } + + if (random.nextBool(0.6)) { + world.addComponent(eid, Velocity); + Velocity.x[eid] = (random.next() - 0.5) * 10; + Velocity.y[eid] = (random.next() - 0.5) * 10; + } + + if (random.nextBool(0.7)) { + world.addComponent(eid, Health); + Health[eid] = Math.floor(random.next() * 100) + 1; + } + } + + describe('With(Position)', () => { + bench('bitmask + cached', () => { + const entities = world.query(With(Position), { + evaluationStrategy: 'bitmask', + cache: true + }); + expect(entities.length).toBeGreaterThan(0); + }); + + bench('bitmask + no cache', () => { + const entities = world.query(With(Position), { + evaluationStrategy: 'bitmask', + cache: false + }); + expect(entities.length).toBeGreaterThan(0); + }); + + bench('individual + cached', () => { + const entities = world.query(With(Position), { + evaluationStrategy: 'individual', + cache: true + }); + expect(entities.length).toBeGreaterThan(0); + }); + + bench('individual + no cache', () => { + const entities = world.query(With(Position), { + evaluationStrategy: 'individual', + cache: false + }); + expect(entities.length).toBeGreaterThan(0); + }); + }); + + describe('And(With(Position), With(Velocity))', () => { + bench('bitmask + cached', () => { + const entities = world.query(And(With(Position), With(Velocity)), { + evaluationStrategy: 'bitmask', + cache: true + }); + expect(entities.length).toBeGreaterThan(0); + }); + + bench('bitmask + no cache', () => { + const entities = world.query(And(With(Position), With(Velocity)), { + evaluationStrategy: 'bitmask', + cache: false + }); + expect(entities.length).toBeGreaterThan(0); + }); + + bench('individual + cached', () => { + const entities = world.query(And(With(Position), With(Velocity)), { + evaluationStrategy: 'individual', + cache: true + }); + expect(entities.length).toBeGreaterThan(0); + }); + + bench('individual + no cache', () => { + const entities = world.query(And(With(Position), With(Velocity)), { + evaluationStrategy: 'individual', + cache: false + }); + expect(entities.length).toBeGreaterThan(0); + }); + }); + + describe('And(With(Position), Without(Health))', () => { + bench('bitmask + cached', () => { + const entities = world.query(And(With(Position), Without(Health)), { + evaluationStrategy: 'bitmask', + cache: true + }); + expect(entities.length).toBeGreaterThanOrEqual(0); + }); + + bench('bitmask + no cache', () => { + const entities = world.query(And(With(Position), Without(Health)), { + evaluationStrategy: 'bitmask', + cache: false + }); + expect(entities.length).toBeGreaterThanOrEqual(0); + }); + + bench('individual + cached', () => { + const entities = world.query(And(With(Position), Without(Health)), { + evaluationStrategy: 'individual', + cache: true + }); + expect(entities.length).toBeGreaterThanOrEqual(0); + }); + + bench('individual + no cache', () => { + const entities = world.query(And(With(Position), Without(Health)), { + evaluationStrategy: 'individual', + cache: false + }); + expect(entities.length).toBeGreaterThanOrEqual(0); + }); + }); +}); diff --git a/packages/feature-ecs/__tests__/utils/create-seeded-random.ts b/packages/feature-ecs/__tests__/utils/create-seeded-random.ts new file mode 100644 index 00000000..c157b970 --- /dev/null +++ b/packages/feature-ecs/__tests__/utils/create-seeded-random.ts @@ -0,0 +1,15 @@ +export function createSeededRandom(seed: number) { + const random = function (): number { + seed |= 0; + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + + return { + next: random, + nextBool: (probability = 0.5) => random() < probability, + nextInt: (max: number) => Math.floor(random() * max) + }; +} diff --git a/packages/feature-ecs/__tests__/utils/index.ts b/packages/feature-ecs/__tests__/utils/index.ts new file mode 100644 index 00000000..b42a3c97 --- /dev/null +++ b/packages/feature-ecs/__tests__/utils/index.ts @@ -0,0 +1 @@ +export * from './create-seeded-random'; diff --git a/packages/feature-ecs/package.json b/packages/feature-ecs/package.json index 1faf31da..4f6dedcf 100644 --- a/packages/feature-ecs/package.json +++ b/packages/feature-ecs/package.json @@ -40,6 +40,7 @@ "devDependencies": { "@blgc/config": "workspace:*", "@types/node": "^22.15.21", + "bitecs": "github:NateTheGreatt/bitECS#rc-0-4-0", "rollup-presets": "workspace:*" }, "size-limit": [ diff --git a/packages/feature-ecs/src/query-filter.ts b/packages/feature-ecs/src/query-filter.ts index dad6b32e..0ca8a558 100644 --- a/packages/feature-ecs/src/query-filter.ts +++ b/packages/feature-ecs/src/query-filter.ts @@ -2,9 +2,6 @@ import { TComponentRef } from './component-registry'; import { TEntityId } from './entity-index'; import { TWorld } from './world'; -// TODO: Add option to disable bitmask evaluation and cache -// TODO: Add performance tests using vite comparing performance between bitmask and individual evaluation - /** * Requires entity to have component */ @@ -306,111 +303,120 @@ export function And(...filters: TQueryFilter[]): TQueryFilter { filters, evaluate(world: TWorld, eid: TEntityId, queryData: TQueryData): boolean { - if (queryData.evaluationStrategy === 'bitmask') { - const { - withMasks, - withoutMasks, - orMasks, - addedMasks, - changedMasks, - removedMasks, - generations - } = queryData; - const entityMasks = world._componentRegistry._entityMasks; - const registryAddedMasks = world._componentRegistry._addedMasks; - const registryChangedMasks = world._componentRegistry._changedMasks; - const registryRemovedMasks = world._componentRegistry._removedMasks; - - // Check each generation for any AND match - for (let i = 0; i < generations.length; i++) { - const generationId = generations[i] as number; - const entityMask = entityMasks[generationId]?.[eid] ?? 0; - - // WITH check: entity must have ALL required components - const withMask = withMasks?.[generationId]; - if (withMask != null && (entityMask & withMask) !== withMask) { - return false; - } + switch (queryData.evaluationStrategy) { + case 'bitmask': { + const { + withMasks, + withoutMasks, + orMasks, + addedMasks, + changedMasks, + removedMasks, + generations + } = queryData; + const entityMasks = world._componentRegistry._entityMasks; + const registryAddedMasks = world._componentRegistry._addedMasks; + const registryChangedMasks = world._componentRegistry._changedMasks; + const registryRemovedMasks = world._componentRegistry._removedMasks; + + // Check each generation for any AND match + for (let i = 0; i < generations.length; i++) { + const generationId = generations[i] as number; + const entityMask = entityMasks[generationId]?.[eid] ?? 0; + + // WITH check: entity must have ALL required components + const withMask = withMasks?.[generationId]; + if (withMask != null && (entityMask & withMask) !== withMask) { + return false; + } - // WITHOUT check: entity must have NONE of the forbidden components - const withoutMask = withoutMasks?.[generationId]; - if (withoutMask != null && (entityMask & withoutMask) !== 0) { - return false; - } + // WITHOUT check: entity must have NONE of the forbidden components + const withoutMask = withoutMasks?.[generationId]; + if (withoutMask != null && (entityMask & withoutMask) !== 0) { + return false; + } - // OR check: entity must satisfy AT LEAST ONE OR requirement - const orMask = orMasks?.[generationId]; - if (orMask != null) { - let hasOrMatch = false; + // OR check: entity must satisfy AT LEAST ONE OR requirement + const orMask = orMasks?.[generationId]; + if (orMask != null) { + let hasOrMatch = false; - // OR WITH: entity has AT LEAST ONE of the OR components - if (orMask.with != null && (entityMask & orMask.with) !== 0) { - hasOrMatch = true; - } + // OR WITH: entity has AT LEAST ONE of the OR components + if (orMask.with != null && (entityMask & orMask.with) !== 0) { + hasOrMatch = true; + } - // OR WITHOUT: entity lacks AT LEAST ONE of the OR-forbidden components - if ( - !hasOrMatch && - orMask.without != null && - (entityMask & orMask.without) !== orMask.without - ) { - hasOrMatch = true; - } + // OR WITHOUT: entity lacks AT LEAST ONE of the OR-forbidden components + if ( + !hasOrMatch && + orMask.without != null && + (entityMask & orMask.without) !== orMask.without + ) { + hasOrMatch = true; + } - // OR change detection checks - if (!hasOrMatch && orMask.added != null) { - const entityAddedMask = registryAddedMasks[generationId]?.[eid] ?? 0; - if ((entityAddedMask & orMask.added) !== 0) hasOrMatch = true; - } + // OR change detection checks + if (!hasOrMatch && orMask.added != null) { + const entityAddedMask = registryAddedMasks[generationId]?.[eid] ?? 0; + if ((entityAddedMask & orMask.added) !== 0) { + hasOrMatch = true; + } + } - if (!hasOrMatch && orMask.changed != null) { - const entityChangedMask = registryChangedMasks[generationId]?.[eid] ?? 0; - if ((entityChangedMask & orMask.changed) !== 0) hasOrMatch = true; - } + if (!hasOrMatch && orMask.changed != null) { + const entityChangedMask = registryChangedMasks[generationId]?.[eid] ?? 0; + if ((entityChangedMask & orMask.changed) !== 0) { + hasOrMatch = true; + } + } - if (!hasOrMatch && orMask.removed != null) { - const entityRemovedMask = registryRemovedMasks[generationId]?.[eid] ?? 0; - if ((entityRemovedMask & orMask.removed) !== 0) hasOrMatch = true; - } + if (!hasOrMatch && orMask.removed != null) { + const entityRemovedMask = registryRemovedMasks[generationId]?.[eid] ?? 0; + if ((entityRemovedMask & orMask.removed) !== 0) { + hasOrMatch = true; + } + } - if (!hasOrMatch) { - return false; + if (!hasOrMatch) { + return false; + } } - } - // ADDED check: entity must have ALL added components - const addedMask = addedMasks?.[generationId]; - if (addedMask != null) { - const entityAddedMask = registryAddedMasks[generationId]?.[eid] ?? 0; - if ((entityAddedMask & addedMask) !== addedMask) { - return false; + // ADDED check: entity must have ALL added components + const addedMask = addedMasks?.[generationId]; + if (addedMask != null) { + const entityAddedMask = registryAddedMasks[generationId]?.[eid] ?? 0; + if ((entityAddedMask & addedMask) !== addedMask) { + return false; + } } - } - // CHANGED check: entity must have ALL changed components - const changedMask = changedMasks?.[generationId]; - if (changedMask != null) { - const entityChangedMask = registryChangedMasks[generationId]?.[eid] ?? 0; - if ((entityChangedMask & changedMask) !== changedMask) { - return false; + // CHANGED check: entity must have ALL changed components + const changedMask = changedMasks?.[generationId]; + if (changedMask != null) { + const entityChangedMask = registryChangedMasks[generationId]?.[eid] ?? 0; + if ((entityChangedMask & changedMask) !== changedMask) { + return false; + } } - } - // REMOVED check: entity must have ALL removed components - const removedMask = removedMasks?.[generationId]; - if (removedMask != null) { - const entityRemovedMask = registryRemovedMasks[generationId]?.[eid] ?? 0; - if ((entityRemovedMask & removedMask) !== removedMask) { - return false; + // REMOVED check: entity must have ALL removed components + const removedMask = removedMasks?.[generationId]; + if (removedMask != null) { + const entityRemovedMask = registryRemovedMasks[generationId]?.[eid] ?? 0; + if ((entityRemovedMask & removedMask) !== removedMask) { + return false; + } } } + + return true; } - return true; + // Individual filter evaluation for more complex queries + case 'individual': + return filters.every((filter) => filter.evaluate(world, eid, queryData)); } - - // Individual filter evaluation for more complex queries - return filters.every((filter) => filter.evaluate(world, eid, queryData)); }, register(world: TWorld, queryData: TQueryData): void { @@ -440,68 +446,71 @@ export function Or(...filters: TQueryFilter[]): TQueryFilter { filters, evaluate(world: TWorld, eid: TEntityId, queryData: TQueryData): boolean { - if (queryData.evaluationStrategy === 'bitmask') { - const { orMasks, generations } = queryData; - - if (orMasks == null) { - return false; - } - - const entityMasks = world._componentRegistry._entityMasks; - const registryAddedMasks = world._componentRegistry._addedMasks; - const registryChangedMasks = world._componentRegistry._changedMasks; - const registryRemovedMasks = world._componentRegistry._removedMasks; + switch (queryData.evaluationStrategy) { + case 'bitmask': { + const { orMasks, generations } = queryData; - // Check each generation for any OR match - for (let i = 0; i < generations.length; i++) { - const generationId = generations[i] as number; - const entityMask = entityMasks[generationId]?.[eid] ?? 0; - const orMask = orMasks[generationId]; - - if (orMask == null) { - continue; + if (orMasks == null) { + return false; } - // OR WITH: entity has AT LEAST ONE of the OR components - if (orMask.with != null && (entityMask & orMask.with) !== 0) { - return true; - } + const entityMasks = world._componentRegistry._entityMasks; + const registryAddedMasks = world._componentRegistry._addedMasks; + const registryChangedMasks = world._componentRegistry._changedMasks; + const registryRemovedMasks = world._componentRegistry._removedMasks; - // OR WITHOUT: entity lacks AT LEAST ONE of the OR-forbidden components - if (orMask.without != null && (entityMask & orMask.without) !== orMask.without) { - return true; - } + // Check each generation for any OR match + for (let i = 0; i < generations.length; i++) { + const generationId = generations[i] as number; + const entityMask = entityMasks[generationId]?.[eid] ?? 0; + const orMask = orMasks[generationId]; - // OR ADDED: entity has AT LEAST ONE OR-added component - if (orMask.added != null) { - const entityAddedMask = registryAddedMasks[generationId]?.[eid] ?? 0; - if ((entityAddedMask & orMask.added) !== 0) { - return true; + if (orMask == null) { + continue; } - } - // OR CHANGED: entity has AT LEAST ONE OR-changed component - if (orMask.changed != null) { - const entityChangedMask = registryChangedMasks[generationId]?.[eid] ?? 0; - if ((entityChangedMask & orMask.changed) !== 0) { + // OR WITH: entity has AT LEAST ONE of the OR components + if (orMask.with != null && (entityMask & orMask.with) !== 0) { return true; } - } - // OR REMOVED: entity has AT LEAST ONE OR-removed component - if (orMask.removed != null) { - const entityRemovedMask = registryRemovedMasks[generationId]?.[eid] ?? 0; - if ((entityRemovedMask & orMask.removed) !== 0) { + // OR WITHOUT: entity lacks AT LEAST ONE of the OR-forbidden components + if (orMask.without != null && (entityMask & orMask.without) !== orMask.without) { return true; } + + // OR ADDED: entity has AT LEAST ONE OR-added component + if (orMask.added != null) { + const entityAddedMask = registryAddedMasks[generationId]?.[eid] ?? 0; + if ((entityAddedMask & orMask.added) !== 0) { + return true; + } + } + + // OR CHANGED: entity has AT LEAST ONE OR-changed component + if (orMask.changed != null) { + const entityChangedMask = registryChangedMasks[generationId]?.[eid] ?? 0; + if ((entityChangedMask & orMask.changed) !== 0) { + return true; + } + } + + // OR REMOVED: entity has AT LEAST ONE OR-removed component + if (orMask.removed != null) { + const entityRemovedMask = registryRemovedMasks[generationId]?.[eid] ?? 0; + if ((entityRemovedMask & orMask.removed) !== 0) { + return true; + } + } } + + return false; } - return false; + // Individual filter evaluation for more complex queries + case 'individual': + return filters.some((filter) => filter.evaluate(world, eid, queryData)); } - - // Individual filter evaluation for more complex queries - return filters.some((filter) => filter.evaluate(world, eid, queryData)); }, register(world: TWorld, queryData: TQueryData): void { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d8d19f9..4c5e0378 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -402,6 +402,9 @@ importers: '@types/node': specifier: ^22.15.21 version: 22.15.21 + bitecs: + specifier: github:NateTheGreatt/bitECS#rc-0-4-0 + version: https://codeload.github.com/NateTheGreatt/bitECS/tar.gz/caa1f58be2ccc304c1f0a085de34ca5904b3b80f rollup-presets: specifier: workspace:* version: link:../rollup-presets @@ -2012,6 +2015,10 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + bitecs@https://codeload.github.com/NateTheGreatt/bitECS/tar.gz/caa1f58be2ccc304c1f0a085de34ca5904b3b80f: + resolution: {tarball: https://codeload.github.com/NateTheGreatt/bitECS/tar.gz/caa1f58be2ccc304c1f0a085de34ca5904b3b80f} + version: 0.4.0 + body-parser@1.20.3: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -5612,6 +5619,8 @@ snapshots: binary-extensions@2.3.0: {} + bitecs@https://codeload.github.com/NateTheGreatt/bitECS/tar.gz/caa1f58be2ccc304c1f0a085de34ca5904b3b80f: {} + body-parser@1.20.3: dependencies: bytes: 3.1.2 From f6eb8ed5d4e9d5f33728aef1822f81fc3e4fd1f4 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Fri, 30 May 2025 10:12:14 +0200 Subject: [PATCH 18/39] #105 fixed typos --- .../create-component-registry.test.ts} | 74 +-------- .../create-component-registry.ts} | 71 +-------- packages/feature-ecs/src/component/index.ts | 3 + packages/feature-ecs/src/component/types.ts | 21 +++ .../validate-component-registry.test.ts | 84 ++++++++++ .../component/validate-component-registry.ts | 47 ++++++ .../create-entity-index.test.ts} | 88 +---------- .../create-entity-index.ts} | 72 +-------- .../src/entity/debug-entity-index.test.ts | 39 +++++ .../src/entity/debug-entity-index.ts | 32 ++++ packages/feature-ecs/src/entity/index.ts | 4 + packages/feature-ecs/src/entity/types.ts | 1 + .../src/entity/validate-entity-index.test.ts | 52 +++++++ .../src/entity/validate-entity-index.ts | 36 +++++ packages/feature-ecs/src/index.ts | 7 +- .../query/categorize-evaluation-strategy.ts | 49 ++++++ packages/feature-ecs/src/query/index.ts | 4 + .../src/{ => query}/query-filter.test.ts | 2 +- .../src/{ => query}/query-filter.ts | 144 +----------------- .../src/{ => query}/query-registry.test.ts | 27 +--- .../src/{ => query}/query-registry.ts | 16 +- packages/feature-ecs/src/query/types.ts | 65 ++++++++ packages/feature-ecs/src/world.ts | 8 +- 23 files changed, 463 insertions(+), 483 deletions(-) rename packages/feature-ecs/src/{component-registry.test.ts => component/create-component-registry.test.ts} (92%) rename packages/feature-ecs/src/{component-registry.ts => component/create-component-registry.ts} (89%) create mode 100644 packages/feature-ecs/src/component/index.ts create mode 100644 packages/feature-ecs/src/component/types.ts create mode 100644 packages/feature-ecs/src/component/validate-component-registry.test.ts create mode 100644 packages/feature-ecs/src/component/validate-component-registry.ts rename packages/feature-ecs/src/{entity-index.test.ts => entity/create-entity-index.test.ts} (81%) rename packages/feature-ecs/src/{entity-index.ts => entity/create-entity-index.ts} (81%) create mode 100644 packages/feature-ecs/src/entity/debug-entity-index.test.ts create mode 100644 packages/feature-ecs/src/entity/debug-entity-index.ts create mode 100644 packages/feature-ecs/src/entity/index.ts create mode 100644 packages/feature-ecs/src/entity/types.ts create mode 100644 packages/feature-ecs/src/entity/validate-entity-index.test.ts create mode 100644 packages/feature-ecs/src/entity/validate-entity-index.ts create mode 100644 packages/feature-ecs/src/query/categorize-evaluation-strategy.ts create mode 100644 packages/feature-ecs/src/query/index.ts rename packages/feature-ecs/src/{ => query}/query-filter.test.ts (99%) rename packages/feature-ecs/src/{ => query}/query-filter.ts (80%) rename packages/feature-ecs/src/{ => query}/query-registry.test.ts (93%) rename packages/feature-ecs/src/{ => query}/query-registry.ts (91%) create mode 100644 packages/feature-ecs/src/query/types.ts diff --git a/packages/feature-ecs/src/component-registry.test.ts b/packages/feature-ecs/src/component/create-component-registry.test.ts similarity index 92% rename from packages/feature-ecs/src/component-registry.test.ts rename to packages/feature-ecs/src/component/create-component-registry.test.ts index c656c73e..1fbd4c1f 100644 --- a/packages/feature-ecs/src/component-registry.test.ts +++ b/packages/feature-ecs/src/component/create-component-registry.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { createComponentRegistry, TComponentRegistry } from './component-registry'; -import { createEntityIndex, TEntityIndex } from './entity-index'; +import { createEntityIndex, TEntityIndex } from '../entity/create-entity-index'; +import { createComponentRegistry, TComponentRegistry } from './create-component-registry'; describe('createComponentRegistry', () => { let registry: TComponentRegistry; @@ -478,76 +478,6 @@ describe('createComponentRegistry', () => { // Registry should be empty expect(registry.hasComponent(eid, Position)).toBe(false); - expect(registry.validate()).toBe(true); - }); - }); - - describe('validate', () => { - it('should return true for valid empty registry', () => { - expect(registry.validate()).toBe(true); - }); - - it('should return true for valid registry with components', () => { - const Position: TPosition = { x: [], y: [] }; - const Health: THealth = []; - - registry.registerComponent(Position); - registry.registerComponent(Health); - - expect(registry.validate()).toBe(true); - }); - - it('should return true after component operations', () => { - const Position: TPosition = { x: [], y: [] }; - const Health: THealth = []; - - registry.registerComponent(Position); - registry.registerComponent(Health); - - const eid1 = entityIndex.addEntity(); - const eid2 = entityIndex.addEntity(); - - registry.addComponent(eid1, Position); - registry.addComponent(eid1, Health); - registry.addComponent(eid2, Position); - - expect(registry.validate()).toBe(true); - - registry.removeComponent(eid1, Health); - expect(registry.validate()).toBe(true); - - registry.removeAllComponents(eid2); - expect(registry.validate()).toBe(true); - }); - - it('should return true with generation system', () => { - // Register 35 components to test generation overflow - const components = []; - for (let i = 0; i < 35; i++) { - const component = {}; - components.push(component); - registry.registerComponent(component); - } - - expect(registry.validate()).toBe(true); - - const eid = entityIndex.addEntity(); - registry.addComponent(eid, components[0]!); // Gen 0 - registry.addComponent(eid, components[31]!); // Gen 1 - - expect(registry.validate()).toBe(true); - }); - - it('should return true after reset', () => { - const Position: TPosition = { x: [], y: [] }; - registry.registerComponent(Position); - - const eid = entityIndex.addEntity(); - registry.addComponent(eid, Position); - - registry.reset(); - - expect(registry.validate()).toBe(true); }); }); diff --git a/packages/feature-ecs/src/component-registry.ts b/packages/feature-ecs/src/component/create-component-registry.ts similarity index 89% rename from packages/feature-ecs/src/component-registry.ts rename to packages/feature-ecs/src/component/create-component-registry.ts index e09b3db8..77ab6958 100644 --- a/packages/feature-ecs/src/component-registry.ts +++ b/packages/feature-ecs/src/component/create-component-registry.ts @@ -13,7 +13,8 @@ * - Flexible component structure - supports multiple patterns */ -import { TEntityId } from './entity-index'; +import { TEntityId } from '../entity'; +import { TComponentCallbacks, TComponentData, TComponentRef } from './types'; /** * Creates a new component registry. @@ -415,48 +416,6 @@ export function createComponentRegistry(): TComponentRegistry { this._currentBitflag = 1; this._callbacks.clear(); this._componentsToFlush.clear(); - }, - - validate() { - // Validate generation structure - if (this._entityMasks.length === 0) { - return false; - } - - // Validate change tracking arrays match entity masks - if ( - this._addedMasks.length !== this._entityMasks.length || - this._changedMasks.length !== this._entityMasks.length || - this._removedMasks.length !== this._entityMasks.length - ) { - return false; - } - - // Validate bitflag consistency within generations - const generationCounts = new Array(this._entityMasks.length).fill(0); - - for (const componentData of this._componentMap.values()) { - const { generationId, bitflag } = componentData; - - // Check generation ID is valid - if (generationId >= this._entityMasks.length || generationId < 0) return false; - - // Check bitflag is a power of 2 and within valid range - if (bitflag <= 0 || bitflag >= 2 ** 31 || (bitflag & (bitflag - 1)) !== 0) return false; - - generationCounts[generationId]++; - } - - // Validate current bitflag matches expected value for current generation - const currentGeneration = this._entityMasks.length - 1; - const componentsInCurrentGen = generationCounts[currentGeneration] || 0; - const expectedBitflag = componentsInCurrentGen === 0 ? 1 : 2 ** (componentsInCurrentGen % 31); - - if (this._currentBitflag !== expectedBitflag) { - return false; - } - - return true; } }; } @@ -590,30 +549,4 @@ export interface TComponentRegistry { * Resets the registry to its initial empty state. */ reset(): void; - - /** - * Validates the internal data structure integrity. - * @returns True if the data structure is valid, false otherwise - */ - validate(): boolean; -} - -export interface TComponentData { - /** Unique component ID */ - id: number; - /** Generation ID (which mask array this component uses) */ - generationId: number; - /** Bitflag for this component (power of 2) */ - bitflag: number; - /** Reference to the component object */ - ref: TComponentRef; -} - -export type TComponentRef = any; // Can be array or object with arrays - -export interface TComponentCallbacks { - onAdd?: ((eid: TEntityId) => void)[]; - onChange?: ((eid: TEntityId) => void)[]; - onRemove?: ((eid: TEntityId) => void)[]; - onFlush?: (() => void)[]; } diff --git a/packages/feature-ecs/src/component/index.ts b/packages/feature-ecs/src/component/index.ts new file mode 100644 index 00000000..0f091edd --- /dev/null +++ b/packages/feature-ecs/src/component/index.ts @@ -0,0 +1,3 @@ +export * from './create-component-registry'; +export * from './types'; +export * from './validate-component-registry'; diff --git a/packages/feature-ecs/src/component/types.ts b/packages/feature-ecs/src/component/types.ts new file mode 100644 index 00000000..fe824d02 --- /dev/null +++ b/packages/feature-ecs/src/component/types.ts @@ -0,0 +1,21 @@ +import { TEntityId } from '../entity'; + +export interface TComponentData { + /** Unique component ID */ + id: number; + /** Generation ID (which mask array this component uses) */ + generationId: number; + /** Bitflag for this component (power of 2) */ + bitflag: number; + /** Reference to the component object */ + ref: TComponentRef; +} + +export type TComponentRef = any; // Can be array or object with arrays + +export interface TComponentCallbacks { + onAdd?: ((eid: TEntityId) => void)[]; + onChange?: ((eid: TEntityId) => void)[]; + onRemove?: ((eid: TEntityId) => void)[]; + onFlush?: (() => void)[]; +} diff --git a/packages/feature-ecs/src/component/validate-component-registry.test.ts b/packages/feature-ecs/src/component/validate-component-registry.test.ts new file mode 100644 index 00000000..135af541 --- /dev/null +++ b/packages/feature-ecs/src/component/validate-component-registry.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createEntityIndex, TEntityIndex } from '../entity'; +import { createComponentRegistry, TComponentRegistry } from './create-component-registry'; +import { validateComponentRegistry } from './validate-component-registry'; + +describe('validateComponentRegistry', () => { + let registry: TComponentRegistry; + let entityIndex: TEntityIndex; + + beforeEach(() => { + registry = createComponentRegistry(); + entityIndex = createEntityIndex(); + }); + + it('should return true for valid empty registry', () => { + expect(validateComponentRegistry(registry)).toBe(true); + }); + + it('should return true for valid registry with components', () => { + const Position: TPosition = { x: [], y: [] }; + const Health: THealth = []; + + registry.registerComponent(Position); + registry.registerComponent(Health); + + expect(validateComponentRegistry(registry)).toBe(true); + }); + + it('should return true after component operations', () => { + const Position: TPosition = { x: [], y: [] }; + const Health: THealth = []; + + registry.registerComponent(Position); + registry.registerComponent(Health); + + const eid1 = entityIndex.addEntity(); + const eid2 = entityIndex.addEntity(); + + registry.addComponent(eid1, Position); + registry.addComponent(eid1, Health); + registry.addComponent(eid2, Position); + + expect(validateComponentRegistry(registry)).toBe(true); + + registry.removeComponent(eid1, Health); + expect(validateComponentRegistry(registry)).toBe(true); + + registry.removeAllComponents(eid2); + expect(validateComponentRegistry(registry)).toBe(true); + }); + + it('should return true with generation system', () => { + // Register 35 components to test generation overflow + const components = []; + for (let i = 0; i < 35; i++) { + const component = {}; + components.push(component); + registry.registerComponent(component); + } + + expect(validateComponentRegistry(registry)).toBe(true); + + const eid = entityIndex.addEntity(); + registry.addComponent(eid, components[0]!); // Gen 0 + registry.addComponent(eid, components[31]!); // Gen 1 + + expect(validateComponentRegistry(registry)).toBe(true); + }); + + it('should return true after reset', () => { + const Position: TPosition = { x: [], y: [] }; + registry.registerComponent(Position); + + const eid = entityIndex.addEntity(); + registry.addComponent(eid, Position); + + registry.reset(); + + expect(validateComponentRegistry(registry)).toBe(true); + }); +}); + +type TPosition = { x: number[]; y: number[] }; +type THealth = number[]; diff --git a/packages/feature-ecs/src/component/validate-component-registry.ts b/packages/feature-ecs/src/component/validate-component-registry.ts new file mode 100644 index 00000000..929857fe --- /dev/null +++ b/packages/feature-ecs/src/component/validate-component-registry.ts @@ -0,0 +1,47 @@ +import { TComponentRegistry } from './create-component-registry'; + +/** + * Validates the internal data structure integrity. + * @returns True if the data structure is valid, false otherwise + */ +export function validateComponentRegistry(registry: TComponentRegistry) { + // Validate generation structure + if (registry._entityMasks.length === 0) { + return false; + } + + // Validate change tracking arrays match entity masks + if ( + registry._addedMasks.length !== registry._entityMasks.length || + registry._changedMasks.length !== registry._entityMasks.length || + registry._removedMasks.length !== registry._entityMasks.length + ) { + return false; + } + + // Validate bitflag consistency within generations + const generationCounts = new Array(registry._entityMasks.length).fill(0); + + for (const componentData of registry._componentMap.values()) { + const { generationId, bitflag } = componentData; + + // Check generation ID is valid + if (generationId >= registry._entityMasks.length || generationId < 0) return false; + + // Check bitflag is a power of 2 and within valid range + if (bitflag <= 0 || bitflag >= 2 ** 31 || (bitflag & (bitflag - 1)) !== 0) return false; + + generationCounts[generationId]++; + } + + // Validate current bitflag matches expected value for current generation + const currentGeneration = registry._entityMasks.length - 1; + const componentsInCurrentGen = generationCounts[currentGeneration] || 0; + const expectedBitflag = componentsInCurrentGen === 0 ? 1 : 2 ** (componentsInCurrentGen % 31); + + if (registry._currentBitflag !== expectedBitflag) { + return false; + } + + return true; +} diff --git a/packages/feature-ecs/src/entity-index.test.ts b/packages/feature-ecs/src/entity/create-entity-index.test.ts similarity index 81% rename from packages/feature-ecs/src/entity-index.test.ts rename to packages/feature-ecs/src/entity/create-entity-index.test.ts index e42c271c..943e3f2b 100644 --- a/packages/feature-ecs/src/entity-index.test.ts +++ b/packages/feature-ecs/src/entity/create-entity-index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createEntityIndex } from './entity-index'; +import { createEntityIndex } from './create-entity-index'; describe('createEntityIndex', () => { describe('initialization', () => { @@ -312,42 +312,6 @@ describe('createEntityIndex', () => { }); }); - describe('debugState', () => { - it('should show empty state for new index', () => { - const index = createEntityIndex({ versioning: true }); - const state = index.debugState(); - - expect(state).toContain('Alive (0): []'); - expect(state).toContain('Dead (0): []'); - expect(state).toContain('Sparse: {}'); - expect(state).toContain('NextBaseEid: 1'); - expect(state).toContain('Versioning: enabled'); - }); - - it('should show alive entities', () => { - const index = createEntityIndex({ versioning: true }); - index.addEntity(); - index.addEntity(); - const state = index.debugState(); - - expect(state).toContain('Alive (2): [1v0, 2v0]'); - expect(state).toContain('Dead (0): []'); - expect(state).toContain('Sparse: {1→0, 2→1}'); - }); - - it('should show dead entities after removal', () => { - const index = createEntityIndex({ versioning: true }); - const id1 = index.addEntity(); - const id2 = index.addEntity(); - index.removeEntity(id1); - const state = index.debugState(); - - expect(state).toContain('Alive (1): [2v0]'); - expect(state).toContain('Dead (1): [1v1]'); - expect(state).toContain('Sparse: {2→0}'); - }); - }); - describe('reset', () => { it('should reset to initial state and allow reuse', () => { const index = createEntityIndex({ versioning: true }); @@ -365,7 +329,6 @@ describe('createEntityIndex', () => { expect(index._dense).toEqual([]); expect(index._sparse).toEqual([]); expect(index._nextBaseEid).toBe(1); - expect(index.validate()).toBe(true); const newId = index.addEntity(); expect(newId).toBe(1); @@ -373,55 +336,6 @@ describe('createEntityIndex', () => { }); }); - describe('validate', () => { - it('should return true for valid empty index', () => { - const index = createEntityIndex(); - - expect(index.validate()).toBe(true); - }); - - it('should return true for valid index with entities', () => { - const index = createEntityIndex(); - index.addEntity(); - index.addEntity(); - index.addEntity(); - - expect(index.validate()).toBe(true); - }); - - it('should return true after remove operations', () => { - const index = createEntityIndex(); - const id1 = index.addEntity(); - const id2 = index.addEntity(); - const id3 = index.addEntity(); - - index.removeEntity(id2); - - expect(index.validate()).toBe(true); - }); - - it('should return true after recycling', () => { - const index = createEntityIndex({ versioning: true }); - const id1 = index.addEntity(); - - index.removeEntity(id1); - index.addEntity(); // Recycle - - expect(index.validate()).toBe(true); - }); - - it('should return true after reset', () => { - const index = createEntityIndex(); - index.addEntity(); - index.addEntity(); - index.removeEntity(index.addEntity()); - - index.reset(); - - expect(index.validate()).toBe(true); - }); - }); - describe('_createVersionedId', () => { it('should return base ID when versioning disabled', () => { const index = createEntityIndex({ versioning: false }); diff --git a/packages/feature-ecs/src/entity-index.ts b/packages/feature-ecs/src/entity/create-entity-index.ts similarity index 81% rename from packages/feature-ecs/src/entity-index.ts rename to packages/feature-ecs/src/entity/create-entity-index.ts index f98e3ff7..ed1ca0bb 100644 --- a/packages/feature-ecs/src/entity-index.ts +++ b/packages/feature-ecs/src/entity/create-entity-index.ts @@ -12,6 +12,8 @@ * - Dense array for cache-friendly iteration */ +import { TEntityId } from './types'; + /** * Creates a new entity index with the specified configuration. * @@ -191,30 +193,6 @@ export function createEntityIndex(options: TCreateEntityIndexOptions = {}): TEnt return this._config.versioning ? `${baseEid}v${version}` : `${baseEid}`; }, - debugState() { - const aliveEntities = this._dense - .slice(0, this._aliveCount) - .map((eid) => this.formatEid(eid)); - const deadEntities = this._dense.slice(this._aliveCount).map((eid) => this.formatEid(eid)); - - const sparseEntries = []; - for (let baseEid = 1; baseEid < this._nextBaseEid; baseEid++) { - const denseIndex = this._sparse[baseEid]; - if (denseIndex != null) { - sparseEntries.push(`${baseEid}→${denseIndex}`); - } - } - - return [ - `EntityIndex State:`, - ` Alive (${this._aliveCount}): [${aliveEntities.join(', ')}]`, - ` Dead (${this._dense.length - this._aliveCount}): [${deadEntities.join(', ')}]`, - ` Sparse: {${sparseEntries.join(', ')}}`, - ` NextBaseEid: ${this._nextBaseEid}`, - ` Versioning: ${this._config.versioning ? 'enabled' : 'disabled'}` - ].join('\n'); - }, - reset() { this._sparse.length = 0; this._dense.length = 0; @@ -222,36 +200,6 @@ export function createEntityIndex(options: TCreateEntityIndexOptions = {}): TEnt this._nextBaseEid = 1; }, - validate() { - // Check that all alive entities have correct sparse mappings (Dense -> Sparse) - for (let i = 0; i < this._aliveCount; i++) { - const eid = this._dense[i] as number; - const baseEid = this.getBaseEid(eid); - if (this._sparse[baseEid] !== i) { - return false; - } - } - - // Check that all entities in sparse array point to valid positions (Sparse -> Dense) - for (let baseEid = 1; baseEid < this._nextBaseEid; baseEid++) { - const denseIndex = this._sparse[baseEid]; - if (denseIndex != null) { - // Check bounds - if (denseIndex >= this._dense.length || denseIndex < 0) { - return false; - } - - // Check that the entity at this position has the correct base ID - const storedEid = this._dense[denseIndex] as number; - if (this.getBaseEid(storedEid) !== baseEid) { - return false; - } - } - } - - return true; - }, - _createVersionedEid(baseEid, version) { return this._config.versioning ? baseEid | (version << this._entityBits) : baseEid; } @@ -339,25 +287,11 @@ export interface TEntityIndex { */ formatEid(eid: TEntityId): string; - /** - * Returns a human-readable debug representation of the entity index state. - * Shows alive entities, dead entities, sparse mappings, and configuration. - * @returns Multi-line string with formatted state information - */ - debugState(): string; - /** * Resets the entity index to its initial empty state. */ reset(): void; - /** - * Validates the internal data structure integrity. - * Useful for debugging and testing. - * @returns True if the data structure is valid, false otherwise - */ - validate(): boolean; - /** * Creates a versioned entity ID by combining base ID and version. * @param baseEid - The base entity ID @@ -366,5 +300,3 @@ export interface TEntityIndex { */ _createVersionedEid(baseEid: number, version: number): number; } - -export type TEntityId = number; diff --git a/packages/feature-ecs/src/entity/debug-entity-index.test.ts b/packages/feature-ecs/src/entity/debug-entity-index.test.ts new file mode 100644 index 00000000..8d265036 --- /dev/null +++ b/packages/feature-ecs/src/entity/debug-entity-index.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { createEntityIndex } from './create-entity-index'; +import { debugEntityIndex } from './debug-entity-index'; + +describe('debugEntityIndex', () => { + it('should show empty state for new index', () => { + const index = createEntityIndex({ versioning: true }); + const state = debugEntityIndex(index); + + expect(state).toContain('Alive (0): []'); + expect(state).toContain('Dead (0): []'); + expect(state).toContain('Sparse: {}'); + expect(state).toContain('NextBaseEid: 1'); + expect(state).toContain('Versioning: enabled'); + }); + + it('should show alive entities', () => { + const index = createEntityIndex({ versioning: true }); + index.addEntity(); + index.addEntity(); + const state = debugEntityIndex(index); + + expect(state).toContain('Alive (2): [1v0, 2v0]'); + expect(state).toContain('Dead (0): []'); + expect(state).toContain('Sparse: {1→0, 2→1}'); + }); + + it('should show dead entities after removal', () => { + const index = createEntityIndex({ versioning: true }); + const id1 = index.addEntity(); + const id2 = index.addEntity(); + index.removeEntity(id1); + const state = debugEntityIndex(index); + + expect(state).toContain('Alive (1): [2v0]'); + expect(state).toContain('Dead (1): [1v1]'); + expect(state).toContain('Sparse: {2→0}'); + }); +}); diff --git a/packages/feature-ecs/src/entity/debug-entity-index.ts b/packages/feature-ecs/src/entity/debug-entity-index.ts new file mode 100644 index 00000000..f48c6317 --- /dev/null +++ b/packages/feature-ecs/src/entity/debug-entity-index.ts @@ -0,0 +1,32 @@ +import { TEntityIndex } from './create-entity-index'; + +/** + * Returns a human-readable debug representation of the entity index state. + * Shows alive entities, dead entities, sparse mappings, and configuration. + * @returns Multi-line string with formatted state information + */ +export function debugEntityIndex(entityIndex: TEntityIndex) { + const aliveEntities = entityIndex._dense + .slice(0, entityIndex._aliveCount) + .map((eid) => entityIndex.formatEid(eid)); + const deadEntities = entityIndex._dense + .slice(entityIndex._aliveCount) + .map((eid) => entityIndex.formatEid(eid)); + + const sparseEntries = []; + for (let baseEid = 1; baseEid < entityIndex._nextBaseEid; baseEid++) { + const denseIndex = entityIndex._sparse[baseEid]; + if (denseIndex != null) { + sparseEntries.push(`${baseEid}→${denseIndex}`); + } + } + + return [ + `EntityIndex State:`, + ` Alive (${entityIndex._aliveCount}): [${aliveEntities.join(', ')}]`, + ` Dead (${entityIndex._dense.length - entityIndex._aliveCount}): [${deadEntities.join(', ')}]`, + ` Sparse: {${sparseEntries.join(', ')}}`, + ` NextBaseEid: ${entityIndex._nextBaseEid}`, + ` Versioning: ${entityIndex._config.versioning ? 'enabled' : 'disabled'}` + ].join('\n'); +} diff --git a/packages/feature-ecs/src/entity/index.ts b/packages/feature-ecs/src/entity/index.ts new file mode 100644 index 00000000..a35f8b9c --- /dev/null +++ b/packages/feature-ecs/src/entity/index.ts @@ -0,0 +1,4 @@ +export * from './create-entity-index'; +export * from './debug-entity-index'; +export * from './types'; +export * from './validate-entity-index'; diff --git a/packages/feature-ecs/src/entity/types.ts b/packages/feature-ecs/src/entity/types.ts new file mode 100644 index 00000000..defd4050 --- /dev/null +++ b/packages/feature-ecs/src/entity/types.ts @@ -0,0 +1 @@ +export type TEntityId = number; diff --git a/packages/feature-ecs/src/entity/validate-entity-index.test.ts b/packages/feature-ecs/src/entity/validate-entity-index.test.ts new file mode 100644 index 00000000..c8340aa3 --- /dev/null +++ b/packages/feature-ecs/src/entity/validate-entity-index.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { createEntityIndex } from './create-entity-index'; +import { validateEntityIndex } from './validate-entity-index'; + +describe('validateEntityIndex', () => { + it('should return true for valid empty index', () => { + const index = createEntityIndex(); + + expect(validateEntityIndex(index)).toBe(true); + }); + + it('should return true for valid index with entities', () => { + const index = createEntityIndex(); + index.addEntity(); + index.addEntity(); + index.addEntity(); + + expect(validateEntityIndex(index)).toBe(true); + }); + + it('should return true after remove operations', () => { + const index = createEntityIndex(); + const id1 = index.addEntity(); + const id2 = index.addEntity(); + const id3 = index.addEntity(); + + index.removeEntity(id2); + + expect(validateEntityIndex(index)).toBe(true); + }); + + it('should return true after recycling', () => { + const index = createEntityIndex({ versioning: true }); + const id1 = index.addEntity(); + + index.removeEntity(id1); + index.addEntity(); // Recycle + + expect(validateEntityIndex(index)).toBe(true); + }); + + it('should return true after reset', () => { + const index = createEntityIndex(); + index.addEntity(); + index.addEntity(); + index.removeEntity(index.addEntity()); + + index.reset(); + + expect(validateEntityIndex(index)).toBe(true); + }); +}); diff --git a/packages/feature-ecs/src/entity/validate-entity-index.ts b/packages/feature-ecs/src/entity/validate-entity-index.ts new file mode 100644 index 00000000..6044deac --- /dev/null +++ b/packages/feature-ecs/src/entity/validate-entity-index.ts @@ -0,0 +1,36 @@ +import { TEntityIndex } from './create-entity-index'; + +/** + * Validates the internal data structure integrity. + * Useful for debugging and testing. + * @returns True if the data structure is valid, false otherwise + */ +export function validateEntityIndex(entityIndex: TEntityIndex) { + // Check that all alive entities have correct sparse mappings (Dense -> Sparse) + for (let i = 0; i < entityIndex._aliveCount; i++) { + const eid = entityIndex._dense[i] as number; + const baseEid = entityIndex.getBaseEid(eid); + if (entityIndex._sparse[baseEid] !== i) { + return false; + } + } + + // Check that all entities in sparse array point to valid positions (Sparse -> Dense) + for (let baseEid = 1; baseEid < entityIndex._nextBaseEid; baseEid++) { + const denseIndex = entityIndex._sparse[baseEid]; + if (denseIndex != null) { + // Check bounds + if (denseIndex >= entityIndex._dense.length || denseIndex < 0) { + return false; + } + + // Check that the entity at this position has the correct base ID + const storedEid = entityIndex._dense[denseIndex] as number; + if (entityIndex.getBaseEid(storedEid) !== baseEid) { + return false; + } + } + } + + return true; +} diff --git a/packages/feature-ecs/src/index.ts b/packages/feature-ecs/src/index.ts index aa16db95..644bfb7f 100644 --- a/packages/feature-ecs/src/index.ts +++ b/packages/feature-ecs/src/index.ts @@ -1,5 +1,4 @@ -export * from './component-registry'; -export * from './entity-index'; -export * from './query-filter'; -export * from './query-registry'; +export * from './component'; +export * from './entity'; +export * from './query'; export * from './world'; diff --git a/packages/feature-ecs/src/query/categorize-evaluation-strategy.ts b/packages/feature-ecs/src/query/categorize-evaluation-strategy.ts new file mode 100644 index 00000000..ce124c46 --- /dev/null +++ b/packages/feature-ecs/src/query/categorize-evaluation-strategy.ts @@ -0,0 +1,49 @@ +import { TQueryFilter } from './types'; + +/** + * Pre-categorizes a query's evaluation strategy. + * + * Strategies: + * - 'bitmask': All filters can use bitwise operations (With/Without/Added/Changed/Removed) + * - 'individual': Contains complex nested filters requiring individual evaluation + */ +export function categorizeEvaluationStrategy(filter: TQueryFilter): 'bitmask' | 'individual' { + switch (filter.type) { + case 'With': + case 'Without': + case 'Added': + case 'Changed': + case 'Removed': + // Simple component and change detection filters are bitmask-compatible + return 'bitmask'; + + case 'And': + // And is bitmask-compatible if ALL children are bitmask-compatible + // Nested And filters work because And(And(A,B),C) === And(A,B,C) logically + return filter.filters.every((f) => categorizeEvaluationStrategy(f) === 'bitmask') + ? 'bitmask' + : 'individual'; + + case 'Or': + // Or is bitmask-compatible ONLY for simple component/change filters + // + // Why Or doesn't support nested And/Or: + // - Or(And(A,B), C) cannot be flattened to simple bitmasks + // - Would require complex mask structures: { andGroups: [..], .. } + // - The performance benefit diminishes while code complexity explodes + return filter.filters.every( + (f) => + f.type === 'With' || + f.type === 'Without' || + f.type === 'Added' || + f.type === 'Changed' || + f.type === 'Removed' + ) + ? 'bitmask' + : 'individual'; + + default: + // Unknown filter types default to individual evaluation + return 'individual'; + } +} diff --git a/packages/feature-ecs/src/query/index.ts b/packages/feature-ecs/src/query/index.ts new file mode 100644 index 00000000..c3765b35 --- /dev/null +++ b/packages/feature-ecs/src/query/index.ts @@ -0,0 +1,4 @@ +export * from './categorize-evaluation-strategy'; +export * from './query-filter'; +export * from './query-registry'; +export * from './types'; diff --git a/packages/feature-ecs/src/query-filter.test.ts b/packages/feature-ecs/src/query/query-filter.test.ts similarity index 99% rename from packages/feature-ecs/src/query-filter.test.ts rename to packages/feature-ecs/src/query/query-filter.test.ts index bcea2397..7e4476d3 100644 --- a/packages/feature-ecs/src/query-filter.test.ts +++ b/packages/feature-ecs/src/query/query-filter.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest'; +import { createWorld, TWorld } from '../world'; import { Added, And, Changed, Or, Removed, With, Without } from './query-filter'; -import { createWorld, TWorld } from './world'; describe('Query Filters', () => { let world: TWorld; diff --git a/packages/feature-ecs/src/query-filter.ts b/packages/feature-ecs/src/query/query-filter.ts similarity index 80% rename from packages/feature-ecs/src/query-filter.ts rename to packages/feature-ecs/src/query/query-filter.ts index 0ca8a558..42556ddc 100644 --- a/packages/feature-ecs/src/query-filter.ts +++ b/packages/feature-ecs/src/query/query-filter.ts @@ -1,6 +1,7 @@ -import { TComponentRef } from './component-registry'; -import { TEntityId } from './entity-index'; -import { TWorld } from './world'; +import { TComponentRef } from '../component'; +import { TEntityId } from '../entity'; +import { TWorld } from '../world'; +import { TQueryData, TQueryFilter } from './types'; /** * Requires entity to have component @@ -615,68 +616,6 @@ export function Or(...filters: TQueryFilter[]): TQueryFilter { export const All = And; export const Any = Or; -export interface TQueryData { - /** Unique hash identifying this query filter combination */ - hash: string; - /** The original query filter that was compiled into this data */ - filter: TQueryFilter; - /** - * Pre-computed evaluation strategy for optimal performance: - * - 'bitmask': Fast bitwise operations for component/change filters - * - 'individual': Filter-by-filter evaluation for complex queries - */ - evaluationStrategy: 'bitmask' | 'individual'; - - /** Cached array of entity IDs that match this query */ - cachedResult: TEntityId[]; - /** True when cached results are stale and need re-evaluation */ - isDirty: boolean; - - /** Pre-computed generations array for optimal bitmask iteration */ - generations: number[]; - - /** Bitmasks for required components (AND logic: entity must have ALL) */ - withMasks?: Record; - /** Bitmasks for forbidden components (AND logic: entity must have NONE) */ - withoutMasks?: Record; - - /** Combined OR masks for all filter types (OR logic: entity must satisfy AT LEAST ONE per type) */ - orMasks?: Record< - number, - { - with?: number; // Components entity must HAVE (any) - without?: number; // Components entity must LACK (any) - added?: number; // Components entity ADDED this frame (any) - changed?: number; // Components entity CHANGED this frame (any) - removed?: number; // Components entity REMOVED this frame (any) - } - >; - - /** Bitmasks for change detection (AND logic: entity must have ALL changed) */ - addedMasks?: Record; - changedMasks?: Record; - removedMasks?: Record; - - /** Components that can affect this query - enables O(1) invalidation checks */ - affectedMasks?: Record; -} - -export interface TBaseQueryFilter { - type: string; - evaluate(world: TWorld, eid: TEntityId, queryData: TQueryData): boolean; - register?(world: TWorld, queryData: TQueryData): void; - getHash(world: TWorld): string; -} - -export type TQueryFilter = - | (TBaseQueryFilter & { type: 'With'; component: TComponentRef }) - | (TBaseQueryFilter & { type: 'Without'; component: TComponentRef }) - | (TBaseQueryFilter & { type: 'Added'; component: TComponentRef }) - | (TBaseQueryFilter & { type: 'Changed'; component: TComponentRef }) - | (TBaseQueryFilter & { type: 'Removed'; component: TComponentRef }) - | (TBaseQueryFilter & { type: 'And'; filters: TQueryFilter[] }) - | (TBaseQueryFilter & { type: 'Or'; filters: TQueryFilter[] }); - /** * Helper to get component ID, registering if needed */ @@ -687,78 +626,3 @@ function getComponentId(world: TWorld, component: TComponentRef): number { } return registry._componentMap.get(component)?.id as number; } - -/** - * Pre-categorizes a query's evaluation strategy. - * - * Strategies: - * - 'bitmask': All filters can use bitwise operations (With/Without/Added/Changed/Removed) - * - 'individual': Contains complex nested filters requiring individual evaluation - */ -export function categorizeEvaluationStrategy(filter: TQueryFilter): 'bitmask' | 'individual' { - switch (filter.type) { - case 'With': - case 'Without': - case 'Added': - case 'Changed': - case 'Removed': - // Simple component and change detection filters are bitmask-compatible - return 'bitmask'; - - case 'And': - // And is bitmask-compatible if ALL children are bitmask-compatible - // Nested And filters work because And(And(A,B),C) === And(A,B,C) logically - return filter.filters.every((f) => categorizeEvaluationStrategy(f) === 'bitmask') - ? 'bitmask' - : 'individual'; - - case 'Or': - // Or is bitmask-compatible ONLY for simple component/change filters - // - // Why Or doesn't support nested And/Or: - // - Or(And(A,B), C) cannot be flattened to simple bitmasks - // - Would require complex mask structures: { andGroups: [..], .. } - // - The performance benefit diminishes while code complexity explodes - return filter.filters.every( - (f) => - f.type === 'With' || - f.type === 'Without' || - f.type === 'Added' || - f.type === 'Changed' || - f.type === 'Removed' - ) - ? 'bitmask' - : 'individual'; - - default: - // Unknown filter types default to individual evaluation - return 'individual'; - } -} - -/** - * Can a component change affect this query? - * Returns false if query definitely doesn't care about this component. - */ -export function canComponentAffectQuery( - queryData: TQueryData, - component: TComponentRef, - world: TWorld -): boolean { - const registry = world._componentRegistry; - const componentData = registry._componentMap.get(component); - - if (componentData == null) { - return false; - } - - const { generationId, bitflag } = componentData; - - // Check if affectedMasks exists and has this generation - if (queryData.affectedMasks == null) { - return false; - } - - const affectedMask = queryData.affectedMasks[generationId]; - return affectedMask != null && (affectedMask & bitflag) !== 0; -} diff --git a/packages/feature-ecs/src/query-registry.test.ts b/packages/feature-ecs/src/query/query-registry.test.ts similarity index 93% rename from packages/feature-ecs/src/query-registry.test.ts rename to packages/feature-ecs/src/query/query-registry.test.ts index 2f78a3dc..094419f5 100644 --- a/packages/feature-ecs/src/query-registry.test.ts +++ b/packages/feature-ecs/src/query/query-registry.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest'; +import { createWorld, TWorld } from '../world'; import { Added, And, Changed, Or, Removed, With, Without } from './query-filter'; -import { createWorld, TWorld } from './world'; describe('createQueryRegistry', () => { let world: TWorld; @@ -323,29 +323,4 @@ describe('createQueryRegistry', () => { expect(world._queryRegistry._queryCache.size).toBe(1); }); }); - - describe('validate', () => { - it('should return true for new registry', () => { - expect(world._queryRegistry.validate()).toBe(true); - }); - - it('should return true after query operations', () => { - const Position = { x: [] as number[], y: [] as number[] }; - - world._queryRegistry.getQuery(With(Position)); - world._queryRegistry.executeQuery(With(Position)); - world._queryRegistry.generateQueryHash(With(Position)); - - expect(world._queryRegistry.validate()).toBe(true); - }); - - it('should return true after reset', () => { - const Position = { x: [] as number[], y: [] as number[] }; - - world._queryRegistry.getQuery(With(Position)); - world._queryRegistry.reset(); - - expect(world._queryRegistry.validate()).toBe(true); - }); - }); }); diff --git a/packages/feature-ecs/src/query-registry.ts b/packages/feature-ecs/src/query/query-registry.ts similarity index 91% rename from packages/feature-ecs/src/query-registry.ts rename to packages/feature-ecs/src/query/query-registry.ts index 9e52a924..0d23afd8 100644 --- a/packages/feature-ecs/src/query-registry.ts +++ b/packages/feature-ecs/src/query/query-registry.ts @@ -4,9 +4,10 @@ * Simple and fast query registry with bitmask optimizations. */ -import { TEntityId } from './entity-index'; -import { categorizeEvaluationStrategy, TQueryData, TQueryFilter } from './query-filter'; -import { TWorld } from './world'; +import { TEntityId } from '../entity'; +import { TWorld } from '../world'; +import { categorizeEvaluationStrategy } from './categorize-evaluation-strategy'; +import { TQueryData, TQueryFilter } from './types'; /** * Creates a new query registry @@ -93,10 +94,6 @@ export function createQueryRegistry(world: TWorld): TQueryRegistry { reset() { this._queryCache.clear(); - }, - - validate() { - return this._queryCache.size >= 0; } }; } @@ -136,11 +133,6 @@ export interface TQueryRegistry { * Resets the query registry to its initial state */ reset(): void; - - /** - * Validates the query registry integrity - */ - validate(): boolean; } export interface TGetQueryOptions { diff --git a/packages/feature-ecs/src/query/types.ts b/packages/feature-ecs/src/query/types.ts new file mode 100644 index 00000000..cc8b48b2 --- /dev/null +++ b/packages/feature-ecs/src/query/types.ts @@ -0,0 +1,65 @@ +import { TComponentRef } from '../component'; +import { TEntityId } from '../entity'; +import { TWorld } from '../world'; + +export interface TQueryData { + /** Unique hash identifying this query filter combination */ + hash: string; + /** The original query filter that was compiled into this data */ + filter: TQueryFilter; + /** + * Pre-computed evaluation strategy for optimal performance: + * - 'bitmask': Fast bitwise operations for component/change filters + * - 'individual': Filter-by-filter evaluation for complex queries + */ + evaluationStrategy: 'bitmask' | 'individual'; + + /** Cached array of entity IDs that match this query */ + cachedResult: TEntityId[]; + /** True when cached results are stale and need re-evaluation */ + isDirty: boolean; + + /** Pre-computed generations array for optimal bitmask iteration */ + generations: number[]; + + /** Bitmasks for required components (AND logic: entity must have ALL) */ + withMasks?: Record; + /** Bitmasks for forbidden components (AND logic: entity must have NONE) */ + withoutMasks?: Record; + + /** Combined OR masks for all filter types (OR logic: entity must satisfy AT LEAST ONE per type) */ + orMasks?: Record< + number, + { + with?: number; // Components entity must HAVE (any) + without?: number; // Components entity must LACK (any) + added?: number; // Components entity ADDED this frame (any) + changed?: number; // Components entity CHANGED this frame (any) + removed?: number; // Components entity REMOVED this frame (any) + } + >; + + /** Bitmasks for change detection (AND logic: entity must have ALL changed) */ + addedMasks?: Record; + changedMasks?: Record; + removedMasks?: Record; + + /** Components that can affect this query - enables O(1) invalidation checks */ + affectedMasks?: Record; +} + +export interface TBaseQueryFilter { + type: string; + evaluate(world: TWorld, eid: TEntityId, queryData: TQueryData): boolean; + register?(world: TWorld, queryData: TQueryData): void; + getHash(world: TWorld): string; +} + +export type TQueryFilter = + | (TBaseQueryFilter & { type: 'With'; component: TComponentRef }) + | (TBaseQueryFilter & { type: 'Without'; component: TComponentRef }) + | (TBaseQueryFilter & { type: 'Added'; component: TComponentRef }) + | (TBaseQueryFilter & { type: 'Changed'; component: TComponentRef }) + | (TBaseQueryFilter & { type: 'Removed'; component: TComponentRef }) + | (TBaseQueryFilter & { type: 'And'; filters: TQueryFilter[] }) + | (TBaseQueryFilter & { type: 'Or'; filters: TQueryFilter[] }); diff --git a/packages/feature-ecs/src/world.ts b/packages/feature-ecs/src/world.ts index 51e6a6b7..ee9932ca 100644 --- a/packages/feature-ecs/src/world.ts +++ b/packages/feature-ecs/src/world.ts @@ -1,5 +1,9 @@ -import { createComponentRegistry, TComponentRef, TComponentRegistry } from './component-registry'; -import { createEntityIndex, TEntityId, TEntityIndex } from './entity-index'; +import { + createComponentRegistry, + TComponentRef, + TComponentRegistry +} from './component/create-component-registry'; +import { createEntityIndex, TEntityId, TEntityIndex } from './entity/create-entity-index'; import { TQueryFilter } from './query-filter'; import { createQueryRegistry, TExecuteQueryOptions, TQueryRegistry } from './query-registry'; From 604284a03d1e912f5342f8f2791e2aa6a9b624b4 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Fri, 30 May 2025 10:14:05 +0200 Subject: [PATCH 19/39] #105 fixed typos --- packages/feature-ecs/src/world.ts | 32 +++++-------------------------- 1 file changed, 5 insertions(+), 27 deletions(-) diff --git a/packages/feature-ecs/src/world.ts b/packages/feature-ecs/src/world.ts index ee9932ca..fcd6965c 100644 --- a/packages/feature-ecs/src/world.ts +++ b/packages/feature-ecs/src/world.ts @@ -1,11 +1,6 @@ -import { - createComponentRegistry, - TComponentRef, - TComponentRegistry -} from './component/create-component-registry'; -import { createEntityIndex, TEntityId, TEntityIndex } from './entity/create-entity-index'; -import { TQueryFilter } from './query-filter'; -import { createQueryRegistry, TExecuteQueryOptions, TQueryRegistry } from './query-registry'; +import { createComponentRegistry, TComponentRef, TComponentRegistry } from './component'; +import { createEntityIndex, TEntityId, TEntityIndex } from './entity'; +import { createQueryRegistry, TExecuteQueryOptions, TQueryFilter, TQueryRegistry } from './query'; /** * Creates a new ECS world. @@ -26,12 +21,9 @@ import { createQueryRegistry, TExecuteQueryOptions, TQueryRegistry } from './que * ``` */ export function createWorld(): TWorld { - const componentRegistry = createComponentRegistry(); - const entityIndex = createEntityIndex(); - const world: TWorld = { - _componentRegistry: componentRegistry, - _entityIndex: entityIndex, + _componentRegistry: createComponentRegistry(), + _entityIndex: createEntityIndex(), _queryRegistry: null as any, // Will be set below createEntity() { @@ -69,14 +61,6 @@ export function createWorld(): TWorld { this._componentRegistry.reset(); this._entityIndex.reset(); this._queryRegistry.reset(); - }, - - validate() { - return ( - this._componentRegistry.validate() && - this._entityIndex.validate() && - this._queryRegistry.validate() - ); } }; @@ -147,10 +131,4 @@ export interface TWorld { * Resets the world to its initial state. */ reset(): void; - - /** - * Validates the world integrity. - * @returns True if the world is valid - */ - validate(): boolean; } From a2fca03827c1df2133ed4bbbc9756ca3b22c4307 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Fri, 30 May 2025 11:17:55 +0200 Subject: [PATCH 20/39] #105 fixed typos --- packages/feature-ecs/README.md | 207 ++++++++++++------ ....test.ts => create-query-registry.test.ts} | 2 +- ...y-registry.ts => create-query-registry.ts} | 0 packages/feature-ecs/src/query/index.ts | 4 +- ...y-filter.test.ts => query-filters.test.ts} | 2 +- .../{query-filter.ts => query-filters.ts} | 0 packages/feature-ecs/src/world.ts | 23 +- 7 files changed, 153 insertions(+), 85 deletions(-) rename packages/feature-ecs/src/query/{query-registry.test.ts => create-query-registry.test.ts} (99%) rename packages/feature-ecs/src/query/{query-registry.ts => create-query-registry.ts} (100%) rename packages/feature-ecs/src/query/{query-filter.test.ts => query-filters.test.ts} (99%) rename packages/feature-ecs/src/query/{query-filter.ts => query-filters.ts} (100%) diff --git a/packages/feature-ecs/README.md b/packages/feature-ecs/README.md index d7ca03bc..bc1de02c 100644 --- a/packages/feature-ecs/README.md +++ b/packages/feature-ecs/README.md @@ -2,13 +2,13 @@ TODO -## Entity Index +## Architecture -The entity index provides efficient entity ID management with optional versioning support using a sparse-dense array pattern. This component handles O(1) entity operations while maintaining cache-friendly iteration. +### Entity Index -### Architecture Overview +Efficient entity ID management using sparse-dense array pattern with optional versioning. Provides O(1) operations while maintaining cache-friendly iteration. -The entity index uses a **sparse-dense array pattern** that provides O(1) operations while maintaining cache-friendly iteration: +#### Sparse-Dense Pattern ``` Sparse Array: [_, 0, _, 2, 1, _, _] ← Maps entity ID → dense index @@ -21,13 +21,12 @@ Dense Array: [2, 5, 4, 7, 3] ← Alive entities (cache-friendly) aliveCount: 3 ← First 3 elements are alive ``` -#### Core Data Structures +**Core Data:** +- **Sparse Array**: Maps base entity IDs to dense array positions +- **Dense Array**: Contiguous alive entities, with dead entities at end +- **Alive Count**: Boundary between alive/dead entities -1. **Sparse Array** (`_sparse`): Maps base entity IDs to their position in the dense array -2. **Dense Array** (`dense`): Contiguous array of entity IDs, split into alive and dead sections -3. **Alive Count** (`aliveCount`): Boundary between alive and dead entities in dense array - -#### Entity ID Format (with versioning) +#### Entity ID Format ``` 32-bit Entity ID = [Version Bits | Entity ID Bits] @@ -39,105 +38,169 @@ Example with 8 version bits: └─ Version 1 └─ Base Entity ID 1 ``` -### Why This Architecture? - -#### 1. **Performance Requirements** +#### Why This Design? -ECS systems need to handle thousands of entities efficiently in game loops that run 60+ times per second. +**Problem: Stale References** +```typescript +const entity = addEntity(); // Returns ID 5 +removeEntity(entity); // Removes ID 5 +const newEntity = addEntity(); // Might reuse ID 5! +// Bug: old reference to ID 5 now points to wrong entity +``` -**Our solution:** +**Solution: Versioning** +```typescript +const entity = addEntity(); // Returns 5v0 (ID 5, version 0) +removeEntity(entity); // Increments to 5v1 +const newEntity = addEntity(); // Reuses base ID 5 but as 5v1 +// Safe: old reference (5v0) won't match new entity (5v1) +``` -- **O(1) entity creation/removal**: No searching or shifting arrays -- **Cache-friendly iteration**: Dense array keeps alive entities contiguous -- **Minimal memory allocation**: Recycles entity IDs instead of growing indefinitely +**Swap-and-Pop for O(1) Removal** +```typescript +// Remove entity at index 1: +dense = [1, 2, 3, 4, 5]; +// 1. Swap with last: [1, 5, 3, 4, 2] +// 2. Decrease alive count +// Result: [1, 5, 3, 4 | 2] - only alive section matters +``` -#### 2. **Memory Safety with Versioning** +**Performance:** O(1) all operations, ~8 bytes per entity, cache-friendly iteration. -Without versioning, stale entity references can cause bugs: +### Query System -```typescript -const enemy = entityIndex.addEntity(); -const enemyRef = enemy; // Store reference +Entity filtering with two strategies: bitmask optimization for simple queries, individual evaluation for complex queries. -// Later... -entityIndex.removeEntity(enemy); -const newEntity = entityIndex.addEntity(); // Might reuse same ID! +#### Query Filters -// BUG: enemyRef might accidentally refer to newEntity -if (entityIndex.isEntityAlive(enemyRef)) { - // This could be true for the wrong entity! -} +```typescript +// Component filters +With(Position) // Entity must have component +Without(Dead) // Entity must not have component + +// Change detection +Added(Position) // Component added this frame +Changed(Health) // Component modified this frame +Removed(Velocity) // Component removed this frame + +// Logical operators +And(With(Position), With(Velocity)) // All must match +Or(With(Player), With(Enemy)) // Any must match ``` -**Our solution with versioning:** +#### Evaluation Strategies +**Bitmask Strategy** - Fast bitwise operations: ```typescript -const enemy = entityIndex.addEntity(); // Returns ID with version 0 -entityIndex.removeEntity(enemy); // Increments version to 1 -const newEntity = entityIndex.addEntity(); // Reuses base ID but with version 1 +// Components get bit positions +Position: bitflag=0b001, Velocity: bitflag=0b010, Health: bitflag=0b100 + +// Entity masks show what components each entity has +entity1: 0b011 // Has Position + Velocity +entity2: 0b101 // Has Position + Health + +// Query: And(With(Position), With(Velocity)) → withMask = 0b011 +// Check: (entityMask & 0b011) === 0b011 +entity1: (0b011 & 0b011) === 0b011 ✓ true +entity2: (0b101 & 0b011) === 0b011 ✗ false +``` -// Safe: old reference (version 0) won't match new entity (version 1) -entityIndex.isEntityAlive(enemy); // false - version mismatch +**Individual Strategy** - Per-filter evaluation for complex queries: +```typescript +// Complex queries like Or(With(Position), Changed(Health)) +// Fall back to: filters.some(filter => filter.evaluate(world, eid)) ``` -#### 3. **Swap-and-Pop for Efficient Removal** +#### Performance (10,000 entities) + +| Query Type | Bitmask + Cache | Individual + Cache | Notes | +| --------------------------------------- | --------------- | ------------------ | ------------------------- | +| `And(With(Position), With(Velocity))` | 224,388 Hz | 219,211 Hz | Minimal difference (~2%) | + +**Key Insight:** Caching matters most (13-14x faster than no cache). Bitmask vs individual evaluation shows minimal difference. -Traditional array removal requires shifting elements (O(n)): +### Component Registry + +Component management with direct array access, unlimited components via generations, and flexible storage patterns. + +#### Component Patterns ```typescript -// Traditional approach - O(n) -array = [1, 2, 3, 4, 5]; -array.splice(1, 1); // Remove element at index 1 -// Result: [1, 3, 4, 5] - had to shift 3 elements +// Structure of Arrays (SoA) - cache-friendly for bulk operations +const Position = { x: [], y: [] }; +Position.x[eid] = 10; +Position.y[eid] = 20; + +// Array of Structures (AoS) - good for complete entity data +const Transform = []; +Transform[eid] = { x: 10, y: 20 }; + +// Single arrays and tag components +const Health = []; // Health[eid] = 100 +const Player = {}; // Just presence/absence ``` -Our swap-and-pop approach achieves O(1) removal: +#### Generation System + +Unlimited components beyond 31-bit limit: + +**Why Generations?** Bitmasks need one bit per component for fast O(1) checks. JavaScript integers are 32-bit, giving us only 31 usable bits (0 - 30, bit 31 is sign). So we can only track 31 components per bitmask. ```typescript -// Our approach - O(1) -dense = [1, 2, 3, 4, 5]; -// Remove element at index 1: -// 1. Swap with last element: [1, 5, 3, 4, 2] -// 2. Decrease alive count: aliveCount = 4 -// Result: [1, 5, 3, 4 | 2] - only alive section matters +// Problem: Only 31 components fit in one integer bitmask +// Bits: 31 30 29 28 ... 3 2 1 0 +// Components: ❌ ✓ ✓ ✓ ... ✓ ✓ ✓ ✓ (31 components max) + +// Solution: Multiple generations, each with 31 components +// Generation 0: Components 0-30 (bitflags 1, 2, 4, ..., 2^30) +Position: { generationId: 0, bitflag: 0b001 } +Velocity: { generationId: 0, bitflag: 0b010 } + +// Generation 1: Components 31+ (bitflags restart) +Armor: { generationId: 1, bitflag: 0b001 } +Weapon: { generationId: 1, bitflag: 0b010 } + +// Entity masks stored per generation +_entityMasks[0][eid] = 0b011; // Has Position + Velocity +_entityMasks[1][eid] = 0b001; // Has Armor ``` -#### 4. **Configurable Bit Allocation** - -Different applications have different needs: +#### Bitmask Operations ```typescript -// Game with many short-lived entities (bullets, particles) -versionBits: 12; // 4096 versions, ~1M entities max +// Adding component: OR with bitflag +entityMask |= 0b010; // Add Velocity -// Simulation with fewer, long-lived entities -versionBits: 4; // 16 versions, ~256M entities max +// Removing component: AND with inverted bitflag +entityMask &= ~0b010; // Remove Velocity + +// Checking component: AND with bitflag +const hasVelocity = (entityMask & 0b010) !== 0; ``` -### Performance Characteristics +#### Change Tracking -| Operation | Time Complexity | Space Complexity | -| ------------- | -------------------------- | ---------------- | -| Add Entity | O(1) | O(1) | -| Remove Entity | O(1) | O(1) | -| Check Alive | O(1) | O(1) | -| Iterate Alive | O(n) where n = alive count | O(1) | +```typescript +// Separate masks track changes per frame +_addedMasks[0][eid] |= bitflag; // Component added +_changedMasks[0][eid] |= bitflag; // Component changed +_removedMasks[0][eid] |= bitflag; // Component removed -**Memory Usage:** +// Clear at frame end +flush() { /* clear all change masks */ } +``` -- Sparse array: 4 bytes × max entities ever created -- Dense array: 4 bytes × max entities ever created -- Total: ~8 bytes per entity slot +#### Why These Decisions? -**Cache Performance:** +**Sparse Arrays:** Memory-efficient with large entity IDs - only allocated indices use memory. -- Iteration over alive entities is cache-friendly (contiguous memory) -- Sparse lookups may cause cache misses but are O(1) +**Direct Array Access:** No function call overhead - `Health[eid] = 100` is fastest possible. +**Flexible Patterns:** Physics systems benefit from SoA cache locality, UI systems need complete AoS objects. -## Component Registry +**Generations:** JavaScript 32-bit integers limit us to 31 components - generations provide unlimited components. -https://en.wikipedia.org/wiki/AoS_and_SoA +**Performance:** O(1) operations, 4 bytes per entity per generation, direct memory access. ## 📚 Good to Know diff --git a/packages/feature-ecs/src/query/query-registry.test.ts b/packages/feature-ecs/src/query/create-query-registry.test.ts similarity index 99% rename from packages/feature-ecs/src/query/query-registry.test.ts rename to packages/feature-ecs/src/query/create-query-registry.test.ts index 094419f5..ea8aa525 100644 --- a/packages/feature-ecs/src/query/query-registry.test.ts +++ b/packages/feature-ecs/src/query/create-query-registry.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { createWorld, TWorld } from '../world'; -import { Added, And, Changed, Or, Removed, With, Without } from './query-filter'; +import { Added, And, Changed, Or, Removed, With, Without } from './query-filters'; describe('createQueryRegistry', () => { let world: TWorld; diff --git a/packages/feature-ecs/src/query/query-registry.ts b/packages/feature-ecs/src/query/create-query-registry.ts similarity index 100% rename from packages/feature-ecs/src/query/query-registry.ts rename to packages/feature-ecs/src/query/create-query-registry.ts diff --git a/packages/feature-ecs/src/query/index.ts b/packages/feature-ecs/src/query/index.ts index c3765b35..9874510e 100644 --- a/packages/feature-ecs/src/query/index.ts +++ b/packages/feature-ecs/src/query/index.ts @@ -1,4 +1,4 @@ export * from './categorize-evaluation-strategy'; -export * from './query-filter'; -export * from './query-registry'; +export * from './create-query-registry'; +export * from './query-filters'; export * from './types'; diff --git a/packages/feature-ecs/src/query/query-filter.test.ts b/packages/feature-ecs/src/query/query-filters.test.ts similarity index 99% rename from packages/feature-ecs/src/query/query-filter.test.ts rename to packages/feature-ecs/src/query/query-filters.test.ts index 7e4476d3..0aab9252 100644 --- a/packages/feature-ecs/src/query/query-filter.test.ts +++ b/packages/feature-ecs/src/query/query-filters.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { createWorld, TWorld } from '../world'; -import { Added, And, Changed, Or, Removed, With, Without } from './query-filter'; +import { Added, And, Changed, Or, Removed, With, Without } from './query-filters'; describe('Query Filters', () => { let world: TWorld; diff --git a/packages/feature-ecs/src/query/query-filter.ts b/packages/feature-ecs/src/query/query-filters.ts similarity index 100% rename from packages/feature-ecs/src/query/query-filter.ts rename to packages/feature-ecs/src/query/query-filters.ts diff --git a/packages/feature-ecs/src/world.ts b/packages/feature-ecs/src/world.ts index fcd6965c..a610c26e 100644 --- a/packages/feature-ecs/src/world.ts +++ b/packages/feature-ecs/src/world.ts @@ -1,7 +1,13 @@ +import { withNew } from '@blgc/utils'; import { createComponentRegistry, TComponentRef, TComponentRegistry } from './component'; import { createEntityIndex, TEntityId, TEntityIndex } from './entity'; import { createQueryRegistry, TExecuteQueryOptions, TQueryFilter, TQueryRegistry } from './query'; +// TODO: +// Events +// Systems +// Resources + /** * Creates a new ECS world. * @@ -21,10 +27,15 @@ import { createQueryRegistry, TExecuteQueryOptions, TQueryFilter, TQueryRegistry * ``` */ export function createWorld(): TWorld { - const world: TWorld = { + return withNew({ _componentRegistry: createComponentRegistry(), _entityIndex: createEntityIndex(), - _queryRegistry: null as any, // Will be set below + _queryRegistry: null as any, // Will be set in _new + + _new() { + const queryRegistry = createQueryRegistry(this); + this._queryRegistry = queryRegistry; + }, createEntity() { const eid = this._entityIndex.addEntity(); @@ -62,13 +73,7 @@ export function createWorld(): TWorld { this._entityIndex.reset(); this._queryRegistry.reset(); } - }; - - // Create query registry with the world reference - const queryRegistry = createQueryRegistry(world); - world._queryRegistry = queryRegistry; - - return world; + }); } export interface TWorld { From 46c1848e28208b5ab9f25e93095dca9a66e3241b Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Fri, 30 May 2025 12:11:17 +0200 Subject: [PATCH 21/39] #105 fixed typos --- .../feature-ecs/src/query/query-filters.ts | 601 +++++++----------- packages/feature-ecs/src/query/types.ts | 24 +- 2 files changed, 237 insertions(+), 388 deletions(-) diff --git a/packages/feature-ecs/src/query/query-filters.ts b/packages/feature-ecs/src/query/query-filters.ts index 42556ddc..5fbd4e88 100644 --- a/packages/feature-ecs/src/query/query-filters.ts +++ b/packages/feature-ecs/src/query/query-filters.ts @@ -1,7 +1,7 @@ import { TComponentRef } from '../component'; import { TEntityId } from '../entity'; import { TWorld } from '../world'; -import { TQueryData, TQueryFilter } from './types'; +import { TQueryData, TQueryFilter, TQueryParentType } from './types'; /** * Requires entity to have component @@ -11,7 +11,7 @@ export function With(component: T): TQueryFilter { type: 'With', component, - evaluate(world: TWorld, eid: TEntityId): boolean { + evaluate(world, eid): boolean { const registry = world._componentRegistry; const componentData = registry._componentMap.get(component); @@ -24,7 +24,7 @@ export function With(component: T): TQueryFilter { return (entityMask & bitflag) !== 0; }, - register(world: TWorld, queryData: TQueryData): void { + register(world, queryData, parentType): void { // Register callbacks to invalidate this query when components are added/removed world._componentRegistry.onComponentAdd(component, () => { queryData.isDirty = true; @@ -33,32 +33,11 @@ export function With(component: T): TQueryFilter { queryData.isDirty = true; }); - const registry = world._componentRegistry; - const componentData = registry._componentMap.get(component); - - if (componentData != null) { - const { generationId, bitflag } = componentData; - - // Lazy allocation: only create objects when needed - if (queryData.withMasks == null) { - queryData.withMasks = {}; - } - if (queryData.affectedMasks == null) { - queryData.affectedMasks = {}; - } - - queryData.withMasks[generationId] = (queryData.withMasks[generationId] ?? 0) | bitflag; - queryData.affectedMasks[generationId] = - (queryData.affectedMasks[generationId] ?? 0) | bitflag; - - // Add to generations array if not already present - if (!queryData.generations.includes(generationId)) { - queryData.generations.push(generationId); - } - } + // Register the component mask in the appropriate structure + registerComponentMask(world, queryData, component, 'with', parentType); }, - getHash(world: TWorld): string { + getHash(world): string { const componentId = getComponentId(world, component); return `with(${componentId})`; } @@ -73,7 +52,7 @@ export function Without(component: T): TQueryFilter { type: 'Without', component, - evaluate(world: TWorld, eid: TEntityId): boolean { + evaluate(world, eid): boolean { const registry = world._componentRegistry; const componentData = registry._componentMap.get(component); @@ -86,7 +65,7 @@ export function Without(component: T): TQueryFilter { return (entityMask & bitflag) === 0; }, - register(world: TWorld, queryData: TQueryData): void { + register(world, queryData, parentType): void { // Register callbacks to invalidate this query when components are added/removed world._componentRegistry.onComponentAdd(component, () => { queryData.isDirty = true; @@ -95,33 +74,11 @@ export function Without(component: T): TQueryFilter { queryData.isDirty = true; }); - const registry = world._componentRegistry; - const componentData = registry._componentMap.get(component); - - if (componentData != null) { - const { generationId, bitflag } = componentData; - - // Lazy allocation: only create objects when needed - if (queryData.withoutMasks == null) { - queryData.withoutMasks = {}; - } - if (queryData.affectedMasks == null) { - queryData.affectedMasks = {}; - } - - queryData.withoutMasks[generationId] = - (queryData.withoutMasks[generationId] ?? 0) | bitflag; - queryData.affectedMasks[generationId] = - (queryData.affectedMasks[generationId] ?? 0) | bitflag; - - // Add to generations array if not already present - if (!queryData.generations.includes(generationId)) { - queryData.generations.push(generationId); - } - } + // Register the component mask in the appropriate structure + registerComponentMask(world, queryData, component, 'without', parentType); }, - getHash(world: TWorld): string { + getHash(world): string { const componentId = getComponentId(world, component); return `without(${componentId})`; } @@ -136,11 +93,11 @@ export function Added(component: T): TQueryFilter { type: 'Added', component, - evaluate(world: TWorld, eid: TEntityId): boolean { + evaluate(world, eid): boolean { return world._componentRegistry.wasAdded(eid, component); }, - register(world: TWorld, queryData: TQueryData): void { + register(world, queryData, parentType): void { // Register callback to invalidate this query when components are added world._componentRegistry.onComponentAdd(component, () => { queryData.isDirty = true; @@ -151,32 +108,11 @@ export function Added(component: T): TQueryFilter { queryData.isDirty = true; }); - // Register in change detection masks for potential bitmask evaluation - const registry = world._componentRegistry; - const componentData = registry._componentMap.get(component); - if (componentData != null) { - const { generationId, bitflag } = componentData; - - // Lazy allocation: only create objects when needed - if (queryData.addedMasks == null) { - queryData.addedMasks = {}; - } - if (queryData.affectedMasks == null) { - queryData.affectedMasks = {}; - } - - queryData.addedMasks[generationId] = (queryData.addedMasks[generationId] ?? 0) | bitflag; - queryData.affectedMasks[generationId] = - (queryData.affectedMasks[generationId] ?? 0) | bitflag; - - // Add to generations array if not already present - if (!queryData.generations.includes(generationId)) { - queryData.generations.push(generationId); - } - } + // Register the component mask in the appropriate structure + registerComponentMask(world, queryData, component, 'added', parentType); }, - getHash(world: TWorld): string { + getHash(world): string { const componentId = getComponentId(world, component); return `added(${componentId})`; } @@ -191,11 +127,11 @@ export function Changed(component: T): TQueryFilter { type: 'Changed', component, - evaluate(world: TWorld, eid: TEntityId): boolean { + evaluate(world, eid): boolean { return world._componentRegistry.wasChanged(eid, component); }, - register(world: TWorld, queryData: TQueryData): void { + register(world, queryData, parentType): void { // Register callback to invalidate this query when components are changed world._componentRegistry.onComponentChange(component, () => { queryData.isDirty = true; @@ -206,33 +142,11 @@ export function Changed(component: T): TQueryFilter { queryData.isDirty = true; }); - // Register in change detection masks for potential bitmask evaluation - const registry = world._componentRegistry; - const componentData = registry._componentMap.get(component); - if (componentData != null) { - const { generationId, bitflag } = componentData; - - // Lazy allocation: only create objects when needed - if (queryData.changedMasks == null) { - queryData.changedMasks = {}; - } - if (queryData.affectedMasks == null) { - queryData.affectedMasks = {}; - } - - queryData.changedMasks[generationId] = - (queryData.changedMasks[generationId] ?? 0) | bitflag; - queryData.affectedMasks[generationId] = - (queryData.affectedMasks[generationId] ?? 0) | bitflag; - - // Add to generations array if not already present - if (!queryData.generations.includes(generationId)) { - queryData.generations.push(generationId); - } - } + // Register the component mask in the appropriate structure + registerComponentMask(world, queryData, component, 'changed', parentType); }, - getHash(world: TWorld): string { + getHash(world): string { const componentId = getComponentId(world, component); return `changed(${componentId})`; } @@ -247,11 +161,11 @@ export function Removed(component: T): TQueryFilter { type: 'Removed', component, - evaluate(world: TWorld, eid: TEntityId): boolean { + evaluate(world, eid): boolean { return world._componentRegistry.wasRemoved(eid, component); }, - register(world: TWorld, queryData: TQueryData): void { + register(world, queryData, parentType): void { // Register callback to invalidate this query when components are removed world._componentRegistry.onComponentRemove(component, () => { queryData.isDirty = true; @@ -262,33 +176,11 @@ export function Removed(component: T): TQueryFilter { queryData.isDirty = true; }); - // Register in change detection masks for potential bitmask evaluation - const registry = world._componentRegistry; - const componentData = registry._componentMap.get(component); - if (componentData != null) { - const { generationId, bitflag } = componentData; - - // Lazy allocation: only create objects when needed - if (queryData.removedMasks == null) { - queryData.removedMasks = {}; - } - if (queryData.affectedMasks == null) { - queryData.affectedMasks = {}; - } - - queryData.removedMasks[generationId] = - (queryData.removedMasks[generationId] ?? 0) | bitflag; - queryData.affectedMasks[generationId] = - (queryData.affectedMasks[generationId] ?? 0) | bitflag; - - // Add to generations array if not already present - if (!queryData.generations.includes(generationId)) { - queryData.generations.push(generationId); - } - } + // Register the component mask in the appropriate structure + registerComponentMask(world, queryData, component, 'removed', parentType); }, - getHash(world: TWorld): string { + getHash(world): string { const componentId = getComponentId(world, component); return `removed(${componentId})`; } @@ -303,132 +195,42 @@ export function And(...filters: TQueryFilter[]): TQueryFilter { type: 'And', filters, - evaluate(world: TWorld, eid: TEntityId, queryData: TQueryData): boolean { + evaluate(world, eid, queryData): boolean { switch (queryData.evaluationStrategy) { case 'bitmask': { - const { - withMasks, - withoutMasks, - orMasks, - addedMasks, - changedMasks, - removedMasks, - generations - } = queryData; - const entityMasks = world._componentRegistry._entityMasks; - const registryAddedMasks = world._componentRegistry._addedMasks; - const registryChangedMasks = world._componentRegistry._changedMasks; - const registryRemovedMasks = world._componentRegistry._removedMasks; - - // Check each generation for any AND match - for (let i = 0; i < generations.length; i++) { - const generationId = generations[i] as number; - const entityMask = entityMasks[generationId]?.[eid] ?? 0; - - // WITH check: entity must have ALL required components - const withMask = withMasks?.[generationId]; - if (withMask != null && (entityMask & withMask) !== withMask) { - return false; - } + const { orMasks, andMasks, generations } = queryData; - // WITHOUT check: entity must have NONE of the forbidden components - const withoutMask = withoutMasks?.[generationId]; - if (withoutMask != null && (entityMask & withoutMask) !== 0) { + // Check AND requirements + if (andMasks != null) { + if (!evaluateAndMasks(world, eid, andMasks, generations)) { return false; } + } - // OR check: entity must satisfy AT LEAST ONE OR requirement - const orMask = orMasks?.[generationId]; - if (orMask != null) { - let hasOrMatch = false; - - // OR WITH: entity has AT LEAST ONE of the OR components - if (orMask.with != null && (entityMask & orMask.with) !== 0) { - hasOrMatch = true; - } - - // OR WITHOUT: entity lacks AT LEAST ONE of the OR-forbidden components - if ( - !hasOrMatch && - orMask.without != null && - (entityMask & orMask.without) !== orMask.without - ) { - hasOrMatch = true; - } - - // OR change detection checks - if (!hasOrMatch && orMask.added != null) { - const entityAddedMask = registryAddedMasks[generationId]?.[eid] ?? 0; - if ((entityAddedMask & orMask.added) !== 0) { - hasOrMatch = true; - } - } - - if (!hasOrMatch && orMask.changed != null) { - const entityChangedMask = registryChangedMasks[generationId]?.[eid] ?? 0; - if ((entityChangedMask & orMask.changed) !== 0) { - hasOrMatch = true; - } - } - - if (!hasOrMatch && orMask.removed != null) { - const entityRemovedMask = registryRemovedMasks[generationId]?.[eid] ?? 0; - if ((entityRemovedMask & orMask.removed) !== 0) { - hasOrMatch = true; - } - } - - if (!hasOrMatch) { - return false; - } - } - - // ADDED check: entity must have ALL added components - const addedMask = addedMasks?.[generationId]; - if (addedMask != null) { - const entityAddedMask = registryAddedMasks[generationId]?.[eid] ?? 0; - if ((entityAddedMask & addedMask) !== addedMask) { - return false; - } - } - - // CHANGED check: entity must have ALL changed components - const changedMask = changedMasks?.[generationId]; - if (changedMask != null) { - const entityChangedMask = registryChangedMasks[generationId]?.[eid] ?? 0; - if ((entityChangedMask & changedMask) !== changedMask) { - return false; - } - } - - // REMOVED check: entity must have ALL removed components - const removedMask = removedMasks?.[generationId]; - if (removedMask != null) { - const entityRemovedMask = registryRemovedMasks[generationId]?.[eid] ?? 0; - if ((entityRemovedMask & removedMask) !== removedMask) { - return false; - } + // Check OR requirements + if (orMasks != null) { + if (!evaluateOrMasks(world, eid, orMasks, generations)) { + return false; } } return true; } - // Individual filter evaluation for more complex queries case 'individual': return filters.every((filter) => filter.evaluate(world, eid, queryData)); } }, - register(world: TWorld, queryData: TQueryData): void { + register(world, queryData): void { for (const filter of filters) { if (filter.register != null) { - filter.register(world, queryData); + filter.register(world, queryData, 'And'); } } }, - getHash(world: TWorld): string { + getHash(world): string { const childHashes = filters .map((f) => f.getHash(world)) .sort() @@ -446,163 +248,27 @@ export function Or(...filters: TQueryFilter[]): TQueryFilter { type: 'Or', filters, - evaluate(world: TWorld, eid: TEntityId, queryData: TQueryData): boolean { + evaluate(world, eid, queryData): boolean { switch (queryData.evaluationStrategy) { case 'bitmask': { const { orMasks, generations } = queryData; - - if (orMasks == null) { - return false; - } - - const entityMasks = world._componentRegistry._entityMasks; - const registryAddedMasks = world._componentRegistry._addedMasks; - const registryChangedMasks = world._componentRegistry._changedMasks; - const registryRemovedMasks = world._componentRegistry._removedMasks; - - // Check each generation for any OR match - for (let i = 0; i < generations.length; i++) { - const generationId = generations[i] as number; - const entityMask = entityMasks[generationId]?.[eid] ?? 0; - const orMask = orMasks[generationId]; - - if (orMask == null) { - continue; - } - - // OR WITH: entity has AT LEAST ONE of the OR components - if (orMask.with != null && (entityMask & orMask.with) !== 0) { - return true; - } - - // OR WITHOUT: entity lacks AT LEAST ONE of the OR-forbidden components - if (orMask.without != null && (entityMask & orMask.without) !== orMask.without) { - return true; - } - - // OR ADDED: entity has AT LEAST ONE OR-added component - if (orMask.added != null) { - const entityAddedMask = registryAddedMasks[generationId]?.[eid] ?? 0; - if ((entityAddedMask & orMask.added) !== 0) { - return true; - } - } - - // OR CHANGED: entity has AT LEAST ONE OR-changed component - if (orMask.changed != null) { - const entityChangedMask = registryChangedMasks[generationId]?.[eid] ?? 0; - if ((entityChangedMask & orMask.changed) !== 0) { - return true; - } - } - - // OR REMOVED: entity has AT LEAST ONE OR-removed component - if (orMask.removed != null) { - const entityRemovedMask = registryRemovedMasks[generationId]?.[eid] ?? 0; - if ((entityRemovedMask & orMask.removed) !== 0) { - return true; - } - } - } - - return false; + return orMasks != null ? evaluateOrMasks(world, eid, orMasks, generations) : false; } - // Individual filter evaluation for more complex queries case 'individual': return filters.some((filter) => filter.evaluate(world, eid, queryData)); } }, - register(world: TWorld, queryData: TQueryData): void { + register(world, queryData): void { for (const filter of filters) { if (filter.register != null) { - filter.register(world, queryData); - } - } - - // For bitmask evaluation, move individual masks to OR masks for all supported types - if (queryData.evaluationStrategy === 'bitmask') { - for (const filter of filters) { - // Only handle component filters that have a component property - if ( - filter.type !== 'With' && - filter.type !== 'Without' && - filter.type !== 'Added' && - filter.type !== 'Changed' && - filter.type !== 'Removed' - ) { - continue; - } - - const registry = world._componentRegistry; - const componentData = registry._componentMap.get(filter.component); - if (componentData == null) { - continue; - } - - const { generationId, bitflag } = componentData; - - // Lazy allocation for orMasks - if (queryData.orMasks == null) { - queryData.orMasks = {}; - } - if (queryData.orMasks[generationId] == null) { - queryData.orMasks[generationId] = {}; - } - - switch (filter.type) { - case 'With': - // Move from withMasks to orMasks.with - queryData.orMasks[generationId].with = - (queryData.orMasks[generationId].with ?? 0) | bitflag; - if (queryData.withMasks?.[generationId] != null) { - queryData.withMasks[generationId] = - (queryData.withMasks[generationId] ?? 0) & ~bitflag; - } - break; - case 'Without': - // Move from withoutMasks to orMasks.without - queryData.orMasks[generationId].without = - (queryData.orMasks[generationId].without ?? 0) | bitflag; - if (queryData.withoutMasks?.[generationId] != null) { - queryData.withoutMasks[generationId] = - (queryData.withoutMasks[generationId] ?? 0) & ~bitflag; - } - break; - case 'Added': - // Move from addedMasks to orMasks.added - queryData.orMasks[generationId].added = - (queryData.orMasks[generationId].added ?? 0) | bitflag; - if (queryData.addedMasks?.[generationId] != null) { - queryData.addedMasks[generationId] = - (queryData.addedMasks[generationId] ?? 0) & ~bitflag; - } - break; - case 'Changed': - // Move from changedMasks to orMasks.changed - queryData.orMasks[generationId].changed = - (queryData.orMasks[generationId].changed ?? 0) | bitflag; - if (queryData.changedMasks?.[generationId] != null) { - queryData.changedMasks[generationId] = - (queryData.changedMasks[generationId] ?? 0) & ~bitflag; - } - break; - case 'Removed': - // Move from removedMasks to orMasks.removed - queryData.orMasks[generationId].removed = - (queryData.orMasks[generationId].removed ?? 0) | bitflag; - if (queryData.removedMasks?.[generationId] != null) { - queryData.removedMasks[generationId] = - (queryData.removedMasks[generationId] ?? 0) & ~bitflag; - } - break; - } + filter.register(world, queryData, 'Or'); } } }, - getHash(world: TWorld): string { + getHash(world): string { const childHashes = filters .map((f) => f.getHash(world)) .sort() @@ -626,3 +292,182 @@ function getComponentId(world: TWorld, component: TComponentRef): number { } return registry._componentMap.get(component)?.id as number; } + +/** + * Helper function to evaluate AND bitmask logic + */ +function evaluateAndMasks( + world: TWorld, + eid: TEntityId, + andMasks: Record< + number, + { with?: number; without?: number; added?: number; changed?: number; removed?: number } + >, + generations: number[] +): boolean { + const entityMasks = world._componentRegistry._entityMasks; + const registryAddedMasks = world._componentRegistry._addedMasks; + const registryChangedMasks = world._componentRegistry._changedMasks; + const registryRemovedMasks = world._componentRegistry._removedMasks; + + for (let i = 0; i < generations.length; i++) { + const generationId = generations[i] as number; + const entityMask = entityMasks[generationId]?.[eid] ?? 0; + const andMask = andMasks[generationId]; + + if (andMask == null) { + continue; + } + + // WITH check: entity must have ALL required components + if (andMask.with != null && (entityMask & andMask.with) !== andMask.with) { + return false; + } + + // WITHOUT check: entity must have NONE of the forbidden components + if (andMask.without != null && (entityMask & andMask.without) !== 0) { + return false; + } + + // ADDED check: entity must have ALL added components + if (andMask.added != null) { + const entityAddedMask = registryAddedMasks[generationId]?.[eid] ?? 0; + if ((entityAddedMask & andMask.added) !== andMask.added) { + return false; + } + } + + // CHANGED check: entity must have ALL changed components + if (andMask.changed != null) { + const entityChangedMask = registryChangedMasks[generationId]?.[eid] ?? 0; + if ((entityChangedMask & andMask.changed) !== andMask.changed) { + return false; + } + } + + // REMOVED check: entity must have ALL removed components + if (andMask.removed != null) { + const entityRemovedMask = registryRemovedMasks[generationId]?.[eid] ?? 0; + if ((entityRemovedMask & andMask.removed) !== andMask.removed) { + return false; + } + } + } + + return true; +} + +/** + * Helper function to evaluate OR bitmask logic + */ +function evaluateOrMasks( + world: TWorld, + eid: TEntityId, + orMasks: Record< + number, + { with?: number; without?: number; added?: number; changed?: number; removed?: number } + >, + generations: number[] +): boolean { + const entityMasks = world._componentRegistry._entityMasks; + const registryAddedMasks = world._componentRegistry._addedMasks; + const registryChangedMasks = world._componentRegistry._changedMasks; + const registryRemovedMasks = world._componentRegistry._removedMasks; + + for (let i = 0; i < generations.length; i++) { + const generationId = generations[i] as number; + const entityMask = entityMasks[generationId]?.[eid] ?? 0; + const orMask = orMasks[generationId]; + + if (orMask == null) { + continue; + } + + // OR WITH: entity has AT LEAST ONE of the OR components + if (orMask.with != null && (entityMask & orMask.with) !== 0) { + return true; + } + + // OR WITHOUT: entity lacks AT LEAST ONE of the OR-forbidden components + if (orMask.without != null && (entityMask & orMask.without) !== orMask.without) { + return true; + } + + // OR ADDED: entity has AT LEAST ONE OR-added component + if (orMask.added != null) { + const entityAddedMask = registryAddedMasks[generationId]?.[eid] ?? 0; + if ((entityAddedMask & orMask.added) !== 0) { + return true; + } + } + + // OR CHANGED: entity has AT LEAST ONE OR-changed component + if (orMask.changed != null) { + const entityChangedMask = registryChangedMasks[generationId]?.[eid] ?? 0; + if ((entityChangedMask & orMask.changed) !== 0) { + return true; + } + } + + // OR REMOVED: entity has AT LEAST ONE OR-removed component + if (orMask.removed != null) { + const entityRemovedMask = registryRemovedMasks[generationId]?.[eid] ?? 0; + if ((entityRemovedMask & orMask.removed) !== 0) { + return true; + } + } + } + + return false; +} + +/** + * Helper function to register component masks with proper parent type + */ +function registerComponentMask( + world: TWorld, + queryData: TQueryData, + component: TComponentRef, + maskType: 'with' | 'without' | 'added' | 'changed' | 'removed', + parentType: TQueryParentType = 'And' +): void { + const registry = world._componentRegistry; + const componentData = registry._componentMap.get(component); + if (componentData == null) { + return; + } + + const { generationId, bitflag } = componentData; + + // Determine which mask structure to use based on parent type + let targetMasks: 'andMasks' | 'orMasks'; + switch (parentType) { + case 'And': + targetMasks = 'andMasks'; + break; + case 'Or': + targetMasks = 'orMasks'; + break; + } + + // Lazy allocation: only create objects when needed + if (queryData[targetMasks] == null) { + queryData[targetMasks] = {}; + } + if (queryData[targetMasks]![generationId] == null) { + queryData[targetMasks]![generationId] = {}; + } + if (queryData.affectedMasks == null) { + queryData.affectedMasks = {}; + } + + // Add to appropriate mask + queryData[targetMasks]![generationId]![maskType] = + (queryData[targetMasks]![generationId]![maskType] ?? 0) | bitflag; + queryData.affectedMasks[generationId] = (queryData.affectedMasks[generationId] ?? 0) | bitflag; + + // Add to generations array if not already present + if (!queryData.generations.includes(generationId)) { + queryData.generations.push(generationId); + } +} diff --git a/packages/feature-ecs/src/query/types.ts b/packages/feature-ecs/src/query/types.ts index cc8b48b2..7f6be491 100644 --- a/packages/feature-ecs/src/query/types.ts +++ b/packages/feature-ecs/src/query/types.ts @@ -22,10 +22,17 @@ export interface TQueryData { /** Pre-computed generations array for optimal bitmask iteration */ generations: number[]; - /** Bitmasks for required components (AND logic: entity must have ALL) */ - withMasks?: Record; - /** Bitmasks for forbidden components (AND logic: entity must have NONE) */ - withoutMasks?: Record; + /** Combined AND masks for all filter types (AND logic: entity must satisfy ALL requirements) */ + andMasks?: Record< + number, + { + with?: number; // Components entity must HAVE (all) + without?: number; // Components entity must LACK (all) + added?: number; // Components entity ADDED this frame (all) + changed?: number; // Components entity CHANGED this frame (all) + removed?: number; // Components entity REMOVED this frame (all) + } + >; /** Combined OR masks for all filter types (OR logic: entity must satisfy AT LEAST ONE per type) */ orMasks?: Record< @@ -39,11 +46,6 @@ export interface TQueryData { } >; - /** Bitmasks for change detection (AND logic: entity must have ALL changed) */ - addedMasks?: Record; - changedMasks?: Record; - removedMasks?: Record; - /** Components that can affect this query - enables O(1) invalidation checks */ affectedMasks?: Record; } @@ -51,10 +53,12 @@ export interface TQueryData { export interface TBaseQueryFilter { type: string; evaluate(world: TWorld, eid: TEntityId, queryData: TQueryData): boolean; - register?(world: TWorld, queryData: TQueryData): void; + register?(world: TWorld, queryData: TQueryData, parentType?: TQueryParentType): void; getHash(world: TWorld): string; } +export type TQueryParentType = Extract; + export type TQueryFilter = | (TBaseQueryFilter & { type: 'With'; component: TComponentRef }) | (TBaseQueryFilter & { type: 'Without'; component: TComponentRef }) From b1079ebb9c63eed985a45f95e0b92c80614318a0 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Fri, 30 May 2025 12:18:53 +0200 Subject: [PATCH 22/39] #105 extended comment for more clarity --- packages/feature-ecs/src/query/query-filters.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/feature-ecs/src/query/query-filters.ts b/packages/feature-ecs/src/query/query-filters.ts index 5fbd4e88..0dc19dbc 100644 --- a/packages/feature-ecs/src/query/query-filters.ts +++ b/packages/feature-ecs/src/query/query-filters.ts @@ -200,14 +200,21 @@ export function And(...filters: TQueryFilter[]): TQueryFilter { case 'bitmask': { const { orMasks, andMasks, generations } = queryData; - // Check AND requirements + // An And filter requires ALL children to be true, which means: + // 1. All AND requirements must be satisfied (component filters in And contexts) + // 2. All OR requirements must be satisfied (component filters in Or contexts) + // Example: And(With(Position), Or(With(Player), With(Enemy))) + // → andMasks: Position must be true + // → orMasks: (Player OR Enemy) must be true + + // Check AND requirements (from component filters in And contexts) if (andMasks != null) { if (!evaluateAndMasks(world, eid, andMasks, generations)) { return false; } } - // Check OR requirements + // Check OR requirements (from component filters in Or contexts) if (orMasks != null) { if (!evaluateOrMasks(world, eid, orMasks, generations)) { return false; From baaf19164a2bf9665237eb487e59ac71a3d9bb00 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Fri, 30 May 2025 12:25:20 +0200 Subject: [PATCH 23/39] #105 inlined bitmask logic for better performance --- .../feature-ecs/src/query/query-filters.ts | 257 ++++++++---------- 1 file changed, 109 insertions(+), 148 deletions(-) diff --git a/packages/feature-ecs/src/query/query-filters.ts b/packages/feature-ecs/src/query/query-filters.ts index 0dc19dbc..04eb796d 100644 --- a/packages/feature-ecs/src/query/query-filters.ts +++ b/packages/feature-ecs/src/query/query-filters.ts @@ -1,5 +1,4 @@ import { TComponentRef } from '../component'; -import { TEntityId } from '../entity'; import { TWorld } from '../world'; import { TQueryData, TQueryFilter, TQueryParentType } from './types'; @@ -198,26 +197,78 @@ export function And(...filters: TQueryFilter[]): TQueryFilter { evaluate(world, eid, queryData): boolean { switch (queryData.evaluationStrategy) { case 'bitmask': { - const { orMasks, andMasks, generations } = queryData; - - // An And filter requires ALL children to be true, which means: - // 1. All AND requirements must be satisfied (component filters in And contexts) - // 2. All OR requirements must be satisfied (component filters in Or contexts) - // Example: And(With(Position), Or(With(Player), With(Enemy))) - // → andMasks: Position must be true - // → orMasks: (Player OR Enemy) must be true - - // Check AND requirements (from component filters in And contexts) - if (andMasks != null) { - if (!evaluateAndMasks(world, eid, andMasks, generations)) { - return false; + const { andMasks, orMasks, generations } = queryData; + const entityMasks = world._componentRegistry._entityMasks; + const addedMasks = world._componentRegistry._addedMasks; + const changedMasks = world._componentRegistry._changedMasks; + const removedMasks = world._componentRegistry._removedMasks; + + for (let i = 0; i < generations.length; i++) { + const gen = generations[i] as number; + const entityMask = entityMasks[gen]?.[eid] ?? 0; + + // Check AND requirements (must have ALL) + const andMask = andMasks?.[gen]; + if (andMask != null) { + if (andMask.with != null && (entityMask & andMask.with) !== andMask.with) { + return false; + } + if (andMask.without != null && (entityMask & andMask.without) !== 0) { + return false; + } + if (andMask.added != null) { + const entityAddedMask = addedMasks[gen]?.[eid] ?? 0; + if ((entityAddedMask & andMask.added) !== andMask.added) { + return false; + } + } + if (andMask.changed != null) { + const entityChangedMask = changedMasks[gen]?.[eid] ?? 0; + if ((entityChangedMask & andMask.changed) !== andMask.changed) { + return false; + } + } + if (andMask.removed != null) { + const entityRemovedMask = removedMasks[gen]?.[eid] ?? 0; + if ((entityRemovedMask & andMask.removed) !== andMask.removed) { + return false; + } + } } - } - // Check OR requirements (from component filters in Or contexts) - if (orMasks != null) { - if (!evaluateOrMasks(world, eid, orMasks, generations)) { - return false; + // Check OR requirements (must have ANY within each type) + const orMask = orMasks?.[gen]; + if (orMask != null) { + let hasAnyOR = false; + + if (orMask.with != null && (entityMask & orMask.with) !== 0) { + hasAnyOR = true; + } + if (orMask.without != null && (entityMask & orMask.without) !== orMask.without) { + hasAnyOR = true; + } + if (orMask.added != null) { + const entityAddedMask = addedMasks[gen]?.[eid] ?? 0; + if ((entityAddedMask & orMask.added) !== 0) { + hasAnyOR = true; + } + } + if (orMask.changed != null) { + const entityChangedMask = changedMasks[gen]?.[eid] ?? 0; + if ((entityChangedMask & orMask.changed) !== 0) { + hasAnyOR = true; + } + } + if (orMask.removed != null) { + const entityRemovedMask = removedMasks[gen]?.[eid] ?? 0; + if ((entityRemovedMask & orMask.removed) !== 0) { + hasAnyOR = true; + } + } + + if (!hasAnyOR) { + return false; + } } } @@ -259,7 +310,45 @@ export function Or(...filters: TQueryFilter[]): TQueryFilter { switch (queryData.evaluationStrategy) { case 'bitmask': { const { orMasks, generations } = queryData; - return orMasks != null ? evaluateOrMasks(world, eid, orMasks, generations) : false; + const entityMasks = world._componentRegistry._entityMasks; + const addedMasks = world._componentRegistry._addedMasks; + const changedMasks = world._componentRegistry._changedMasks; + const removedMasks = world._componentRegistry._removedMasks; + + for (let i = 0; i < generations.length; i++) { + const gen = generations[i] as number; + const entityMask = entityMasks[gen]?.[eid] ?? 0; + const orMask = orMasks?.[gen]; + + if (orMask != null) { + if (orMask.with != null && (entityMask & orMask.with) !== 0) { + return true; + } + if (orMask.without != null && (entityMask & orMask.without) !== orMask.without) { + return true; + } + if (orMask.added != null) { + const entityAddedMask = addedMasks[gen]?.[eid] ?? 0; + if ((entityAddedMask & orMask.added) !== 0) { + return true; + } + } + if (orMask.changed != null) { + const entityChangedMask = changedMasks[gen]?.[eid] ?? 0; + if ((entityChangedMask & orMask.changed) !== 0) { + return true; + } + } + if (orMask.removed != null) { + const entityRemovedMask = removedMasks[gen]?.[eid] ?? 0; + if ((entityRemovedMask & orMask.removed) !== 0) { + return true; + } + } + } + } + + return false; } case 'individual': @@ -300,134 +389,6 @@ function getComponentId(world: TWorld, component: TComponentRef): number { return registry._componentMap.get(component)?.id as number; } -/** - * Helper function to evaluate AND bitmask logic - */ -function evaluateAndMasks( - world: TWorld, - eid: TEntityId, - andMasks: Record< - number, - { with?: number; without?: number; added?: number; changed?: number; removed?: number } - >, - generations: number[] -): boolean { - const entityMasks = world._componentRegistry._entityMasks; - const registryAddedMasks = world._componentRegistry._addedMasks; - const registryChangedMasks = world._componentRegistry._changedMasks; - const registryRemovedMasks = world._componentRegistry._removedMasks; - - for (let i = 0; i < generations.length; i++) { - const generationId = generations[i] as number; - const entityMask = entityMasks[generationId]?.[eid] ?? 0; - const andMask = andMasks[generationId]; - - if (andMask == null) { - continue; - } - - // WITH check: entity must have ALL required components - if (andMask.with != null && (entityMask & andMask.with) !== andMask.with) { - return false; - } - - // WITHOUT check: entity must have NONE of the forbidden components - if (andMask.without != null && (entityMask & andMask.without) !== 0) { - return false; - } - - // ADDED check: entity must have ALL added components - if (andMask.added != null) { - const entityAddedMask = registryAddedMasks[generationId]?.[eid] ?? 0; - if ((entityAddedMask & andMask.added) !== andMask.added) { - return false; - } - } - - // CHANGED check: entity must have ALL changed components - if (andMask.changed != null) { - const entityChangedMask = registryChangedMasks[generationId]?.[eid] ?? 0; - if ((entityChangedMask & andMask.changed) !== andMask.changed) { - return false; - } - } - - // REMOVED check: entity must have ALL removed components - if (andMask.removed != null) { - const entityRemovedMask = registryRemovedMasks[generationId]?.[eid] ?? 0; - if ((entityRemovedMask & andMask.removed) !== andMask.removed) { - return false; - } - } - } - - return true; -} - -/** - * Helper function to evaluate OR bitmask logic - */ -function evaluateOrMasks( - world: TWorld, - eid: TEntityId, - orMasks: Record< - number, - { with?: number; without?: number; added?: number; changed?: number; removed?: number } - >, - generations: number[] -): boolean { - const entityMasks = world._componentRegistry._entityMasks; - const registryAddedMasks = world._componentRegistry._addedMasks; - const registryChangedMasks = world._componentRegistry._changedMasks; - const registryRemovedMasks = world._componentRegistry._removedMasks; - - for (let i = 0; i < generations.length; i++) { - const generationId = generations[i] as number; - const entityMask = entityMasks[generationId]?.[eid] ?? 0; - const orMask = orMasks[generationId]; - - if (orMask == null) { - continue; - } - - // OR WITH: entity has AT LEAST ONE of the OR components - if (orMask.with != null && (entityMask & orMask.with) !== 0) { - return true; - } - - // OR WITHOUT: entity lacks AT LEAST ONE of the OR-forbidden components - if (orMask.without != null && (entityMask & orMask.without) !== orMask.without) { - return true; - } - - // OR ADDED: entity has AT LEAST ONE OR-added component - if (orMask.added != null) { - const entityAddedMask = registryAddedMasks[generationId]?.[eid] ?? 0; - if ((entityAddedMask & orMask.added) !== 0) { - return true; - } - } - - // OR CHANGED: entity has AT LEAST ONE OR-changed component - if (orMask.changed != null) { - const entityChangedMask = registryChangedMasks[generationId]?.[eid] ?? 0; - if ((entityChangedMask & orMask.changed) !== 0) { - return true; - } - } - - // OR REMOVED: entity has AT LEAST ONE OR-removed component - if (orMask.removed != null) { - const entityRemovedMask = registryRemovedMasks[generationId]?.[eid] ?? 0; - if ((entityRemovedMask & orMask.removed) !== 0) { - return true; - } - } - } - - return false; -} - /** * Helper function to register component masks with proper parent type */ From 25428c8a591d78459b5a231455a28b575f1a9a66 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Fri, 30 May 2025 12:32:04 +0200 Subject: [PATCH 24/39] #105 updated readme --- packages/feature-ecs/README.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/feature-ecs/README.md b/packages/feature-ecs/README.md index bc1de02c..c914d63e 100644 --- a/packages/feature-ecs/README.md +++ b/packages/feature-ecs/README.md @@ -113,11 +113,19 @@ entity2: (0b101 & 0b011) === 0b011 ✗ false #### Performance (10,000 entities) -| Query Type | Bitmask + Cache | Individual + Cache | Notes | -| --------------------------------------- | --------------- | ------------------ | ------------------------- | -| `And(With(Position), With(Velocity))` | 224,388 Hz | 219,211 Hz | Minimal difference (~2%) | +``` + individual + cached - __tests__/query.bench.ts > Query Performance > With(Position) + 1.04x faster than bitmask + cached + 7.50x faster than bitmask + no cache + 7.83x faster than individual + no cache + + bitmask + cached - __tests__/query.bench.ts > Query Performance > And(With(Position), With(Velocity)) + 1.01x faster than individual + cached + 13.58x faster than bitmask + no cache + 13.72x faster than individual + no cache +``` -**Key Insight:** Caching matters most (13-14x faster than no cache). Bitmask vs individual evaluation shows minimal difference. +**Key Insight:** Caching matters most (7-14x faster than no cache). Bitmask vs individual evaluation shows minimal difference. ### Component Registry From d09b52117be16f7dc6e4ba6add81baf27335ca64 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Fri, 30 May 2025 17:12:36 +0200 Subject: [PATCH 25/39] #105 query components function --- .../__tests__/component-variant.bench.ts | 8 +- .../__tests__/ecs-comparison.bench.ts | 6 +- .../feature-ecs/__tests__/playground.test.ts | 2 +- packages/feature-ecs/__tests__/query.bench.ts | 24 +-- .../src/query/create-query-registry.test.ts | 167 ++++++++++++++++-- .../src/query/create-query-registry.ts | 120 ++++++++++++- .../src/query/query-filters.test.ts | 120 +++++++------ packages/feature-ecs/src/query/types.ts | 18 ++ packages/feature-ecs/src/world.ts | 59 ++++++- 9 files changed, 425 insertions(+), 99 deletions(-) diff --git a/packages/feature-ecs/__tests__/component-variant.bench.ts b/packages/feature-ecs/__tests__/component-variant.bench.ts index 94370481..1cfbf6b9 100644 --- a/packages/feature-ecs/__tests__/component-variant.bench.ts +++ b/packages/feature-ecs/__tests__/component-variant.bench.ts @@ -139,22 +139,22 @@ describe('Component Variants Performance', () => { } bench('AoS - Position', () => { - const entities = worldAoS.query(With(Position)); + const entities = worldAoS.queryEntities(With(Position)); expect(entities.length).toBeGreaterThan(0); }); bench('SoA - Transform', () => { - const entities = worldSoA.query(With(Transform)); + const entities = worldSoA.queryEntities(With(Transform)); expect(entities.length).toBeGreaterThan(0); }); bench('Single Array - Health', () => { - const entities = worldSingle.query(With(Health)); + const entities = worldSingle.queryEntities(With(Health)); expect(entities.length).toBeGreaterThan(0); }); bench('Tag - Player', () => { - const entities = worldTag.query(With(Player)); + const entities = worldTag.queryEntities(With(Player)); expect(entities.length).toBeGreaterThan(0); }); }); diff --git a/packages/feature-ecs/__tests__/ecs-comparison.bench.ts b/packages/feature-ecs/__tests__/ecs-comparison.bench.ts index 79a5f396..de5e2a12 100644 --- a/packages/feature-ecs/__tests__/ecs-comparison.bench.ts +++ b/packages/feature-ecs/__tests__/ecs-comparison.bench.ts @@ -91,7 +91,7 @@ describe('ECS Performance Comparison', () => { } bench('FeatureEcs - Query Position components', () => { - const entities = featureEcsWorld.query(With(FeatureEcsPosition)); + const entities = featureEcsWorld.queryEntities(With(FeatureEcsPosition)); expect(entities.length).toBeGreaterThan(0); }); @@ -101,7 +101,7 @@ describe('ECS Performance Comparison', () => { }); bench('FeatureEcs - Query Position + Velocity', () => { - const entities = featureEcsWorld.query( + const entities = featureEcsWorld.queryEntities( And(With(FeatureEcsPosition), With(FeatureEcsVelocity)) ); expect(entities.length).toBeGreaterThanOrEqual(0); @@ -142,7 +142,7 @@ describe('ECS Performance Comparison', () => { bench('FeatureEcs - Movement system iteration', () => { let updateCount = 0; - for (const eid of featureEcsWorld.query( + for (const eid of featureEcsWorld.queryEntities( And(With(FeatureEcsPosition), With(FeatureEcsVelocity)) )) { const velX = FeatureEcsVelocity.x[eid] ?? 0; diff --git a/packages/feature-ecs/__tests__/playground.test.ts b/packages/feature-ecs/__tests__/playground.test.ts index 20e0e2b7..8aa4f120 100644 --- a/packages/feature-ecs/__tests__/playground.test.ts +++ b/packages/feature-ecs/__tests__/playground.test.ts @@ -39,7 +39,7 @@ describe('playground', () => { } } - const entities = world.query(With(Position), { + const entities = world.queryEntities(With(Position), { evaluationStrategy: 'bitmask', cache: true }); diff --git a/packages/feature-ecs/__tests__/query.bench.ts b/packages/feature-ecs/__tests__/query.bench.ts index bdf18d6a..7589e47c 100644 --- a/packages/feature-ecs/__tests__/query.bench.ts +++ b/packages/feature-ecs/__tests__/query.bench.ts @@ -36,7 +36,7 @@ describe('Query Performance', () => { describe('With(Position)', () => { bench('bitmask + cached', () => { - const entities = world.query(With(Position), { + const entities = world.queryEntities(With(Position), { evaluationStrategy: 'bitmask', cache: true }); @@ -44,7 +44,7 @@ describe('Query Performance', () => { }); bench('bitmask + no cache', () => { - const entities = world.query(With(Position), { + const entities = world.queryEntities(With(Position), { evaluationStrategy: 'bitmask', cache: false }); @@ -52,7 +52,7 @@ describe('Query Performance', () => { }); bench('individual + cached', () => { - const entities = world.query(With(Position), { + const entities = world.queryEntities(With(Position), { evaluationStrategy: 'individual', cache: true }); @@ -60,7 +60,7 @@ describe('Query Performance', () => { }); bench('individual + no cache', () => { - const entities = world.query(With(Position), { + const entities = world.queryEntities(With(Position), { evaluationStrategy: 'individual', cache: false }); @@ -70,7 +70,7 @@ describe('Query Performance', () => { describe('And(With(Position), With(Velocity))', () => { bench('bitmask + cached', () => { - const entities = world.query(And(With(Position), With(Velocity)), { + const entities = world.queryEntities(And(With(Position), With(Velocity)), { evaluationStrategy: 'bitmask', cache: true }); @@ -78,7 +78,7 @@ describe('Query Performance', () => { }); bench('bitmask + no cache', () => { - const entities = world.query(And(With(Position), With(Velocity)), { + const entities = world.queryEntities(And(With(Position), With(Velocity)), { evaluationStrategy: 'bitmask', cache: false }); @@ -86,7 +86,7 @@ describe('Query Performance', () => { }); bench('individual + cached', () => { - const entities = world.query(And(With(Position), With(Velocity)), { + const entities = world.queryEntities(And(With(Position), With(Velocity)), { evaluationStrategy: 'individual', cache: true }); @@ -94,7 +94,7 @@ describe('Query Performance', () => { }); bench('individual + no cache', () => { - const entities = world.query(And(With(Position), With(Velocity)), { + const entities = world.queryEntities(And(With(Position), With(Velocity)), { evaluationStrategy: 'individual', cache: false }); @@ -104,7 +104,7 @@ describe('Query Performance', () => { describe('And(With(Position), Without(Health))', () => { bench('bitmask + cached', () => { - const entities = world.query(And(With(Position), Without(Health)), { + const entities = world.queryEntities(And(With(Position), Without(Health)), { evaluationStrategy: 'bitmask', cache: true }); @@ -112,7 +112,7 @@ describe('Query Performance', () => { }); bench('bitmask + no cache', () => { - const entities = world.query(And(With(Position), Without(Health)), { + const entities = world.queryEntities(And(With(Position), Without(Health)), { evaluationStrategy: 'bitmask', cache: false }); @@ -120,7 +120,7 @@ describe('Query Performance', () => { }); bench('individual + cached', () => { - const entities = world.query(And(With(Position), Without(Health)), { + const entities = world.queryEntities(And(With(Position), Without(Health)), { evaluationStrategy: 'individual', cache: true }); @@ -128,7 +128,7 @@ describe('Query Performance', () => { }); bench('individual + no cache', () => { - const entities = world.query(And(With(Position), Without(Health)), { + const entities = world.queryEntities(And(With(Position), Without(Health)), { evaluationStrategy: 'individual', cache: false }); diff --git a/packages/feature-ecs/src/query/create-query-registry.test.ts b/packages/feature-ecs/src/query/create-query-registry.test.ts index ea8aa525..4e737b75 100644 --- a/packages/feature-ecs/src/query/create-query-registry.test.ts +++ b/packages/feature-ecs/src/query/create-query-registry.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { createWorld, TWorld } from '../world'; import { Added, And, Changed, Or, Removed, With, Without } from './query-filters'; +import { Entity } from './types'; describe('createQueryRegistry', () => { let world: TWorld; @@ -9,7 +10,7 @@ describe('createQueryRegistry', () => { world = createWorld(); }); - describe('executeQuery', () => { + describe('queryEntities', () => { it('should return matching entities', () => { const Position = { x: [] as number[], y: [] as number[] }; @@ -18,14 +19,14 @@ describe('createQueryRegistry', () => { world.addComponent(eid1, Position); - const result = world._queryRegistry.executeQuery(With(Position)); + const result = world._queryRegistry.queryEntities(With(Position)); expect(result).toEqual([eid1]); }); it('should return empty array when no entities match', () => { const Position = { x: [] as number[], y: [] as number[] }; - const result = world._queryRegistry.executeQuery(With(Position)); + const result = world._queryRegistry.queryEntities(With(Position)); expect(result).toEqual([]); }); @@ -40,7 +41,7 @@ describe('createQueryRegistry', () => { world.addComponent(eid1, Health); world.addComponent(eid2, Position); - const result = world._queryRegistry.executeQuery(And(With(Position), With(Health))); + const result = world._queryRegistry.queryEntities(And(With(Position), With(Health))); expect(result).toEqual([eid1]); }); @@ -51,11 +52,11 @@ describe('createQueryRegistry', () => { world.addComponent(eid, Position); // First execution should cache result - const result1 = world._queryRegistry.executeQuery(With(Position)); + const result1 = world._queryRegistry.queryEntities(With(Position)); expect(result1).toEqual([eid]); // Second execution should use cache - const result2 = world._queryRegistry.executeQuery(With(Position)); + const result2 = world._queryRegistry.queryEntities(With(Position)); expect(result2).toEqual([eid]); }); @@ -66,7 +67,7 @@ describe('createQueryRegistry', () => { world.addComponent(eid1, Position); // First execution - const result1 = world._queryRegistry.executeQuery(With(Position)); + const result1 = world._queryRegistry.queryEntities(With(Position)); expect(result1).toEqual([eid1]); // Add another entity (makes cache dirty) @@ -74,7 +75,7 @@ describe('createQueryRegistry', () => { world.addComponent(eid2, Position); // Should rebuild with new entity - const result2 = world._queryRegistry.executeQuery(With(Position)); + const result2 = world._queryRegistry.queryEntities(With(Position)); expect(result2.sort()).toEqual([eid1, eid2].sort()); }); @@ -85,7 +86,7 @@ describe('createQueryRegistry', () => { world.addComponent(eid, Position); // Query with cache enabled - const result1 = world._queryRegistry.executeQuery(With(Position)); + const result1 = world._queryRegistry.queryEntities(With(Position)); expect(result1).toEqual([eid]); // Add another entity @@ -93,7 +94,7 @@ describe('createQueryRegistry', () => { world.addComponent(eid2, Position); // Query with cache disabled - should always rebuild - const result2 = world._queryRegistry.executeQuery(With(Position), { cache: false }); + const result2 = world._queryRegistry.queryEntities(With(Position), { cache: false }); expect(result2.sort()).toEqual([eid, eid2].sort()); }); @@ -108,12 +109,12 @@ describe('createQueryRegistry', () => { const filter = And(With(Position), With(Health)); // Force individual evaluation - const individualResult = world._queryRegistry.executeQuery(filter, { + const individualResult = world._queryRegistry.queryEntities(filter, { evaluationStrategy: 'individual' }); // Force bitmask evaluation - const bitmaskResult = world._queryRegistry.executeQuery(filter, { + const bitmaskResult = world._queryRegistry.queryEntities(filter, { evaluationStrategy: 'bitmask' }); @@ -123,6 +124,148 @@ describe('createQueryRegistry', () => { }); }); + describe('queryComponents', () => { + it('should query components with Entity ID', () => { + // Create components with proper typing + const Position = { x: [] as number[], y: [] as number[] }; + const Velocity = [] as { x: number; y: number }[]; + const Health = [] as number[]; + const Player = {}; + + // Create entities + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); + + // Add components + world.addComponent(eid1, Position); + world.addComponent(eid1, Velocity); + world.addComponent(eid1, Health); + world.addComponent(eid1, Player); + Position.x[eid1] = 10; + Position.y[eid1] = 5; + Velocity[eid1] = { x: 0, y: 0 }; + Health[eid1] = 100; + + world.addComponent(eid2, Position); + world.addComponent(eid2, Velocity); + world.addComponent(eid2, Health); + Position.x[eid2] = 20; + Position.y[eid2] = 15; + Velocity[eid2] = { x: 10, y: 0 }; + Health[eid2] = 75; + + world.addComponent(eid3, Health); + Health[eid3] = 50; + + // Query with Entity ID + const results = world._queryRegistry.queryComponents([ + Entity, + Position, + Velocity, + Health + ] as const); + + expect(results).toHaveLength(2); + expect(results[0]).toEqual([eid1, { x: 10, y: 5 }, { x: 0, y: 0 }, 100]); + expect(results[1]).toEqual([eid2, { x: 20, y: 15 }, { x: 10, y: 0 }, 75]); + }); + + it('should query with filters', () => { + const Health = [] as number[]; + const Player = {}; + const Enemy = {}; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); + + world.addComponent(eid1, Health); + world.addComponent(eid1, Player); + Health[eid1] = 100; + + world.addComponent(eid2, Health); + world.addComponent(eid2, Enemy); + Health[eid2] = 75; + + world.addComponent(eid3, Health); + Health[eid3] = 50; + + // Query only players + const playerResults = world._queryRegistry.queryComponents([Entity, Health], With(Player)); + expect(playerResults).toHaveLength(1); + expect(playerResults[0]).toEqual([eid1, 100]); + + // Query entities without Player tag + const nonPlayerResults = world._queryRegistry.queryComponents( + [Entity, Health], + Without(Player) + ); + expect(nonPlayerResults).toHaveLength(2); + expect(nonPlayerResults).toContainEqual([eid2, 75]); + expect(nonPlayerResults).toContainEqual([eid3, 50]); + }); + + it('should handle single array components', () => { + const Health = [] as number[]; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + + world.addComponent(eid1, Health); + world.addComponent(eid2, Health); + Health[eid1] = 100; + Health[eid2] = 75; + + const results = world._queryRegistry.queryComponents([Entity, Health]); + + expect(results).toHaveLength(2); + expect(results[0]).toEqual([eid1, 100]); + expect(results[1]).toEqual([eid2, 75]); + }); + + it('should handle tag components', () => { + const Player = {}; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + + world.addComponent(eid1, Player); + + const results = world._queryRegistry.queryComponents([Entity, Player]); + + expect(results).toHaveLength(1); + expect(results[0]).toEqual([eid1, true]); + }); + + it('should exclude entities without all components', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Velocity = { x: [] as number[], y: [] as number[] }; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + + // eid1 has both Position and Velocity + world.addComponent(eid1, Position); + world.addComponent(eid1, Velocity); + Position.x[eid1] = 10; + Position.y[eid1] = 5; + Velocity.x[eid1] = 2; + Velocity.y[eid1] = 1; + + // eid2 has only Position + world.addComponent(eid2, Position); + Position.x[eid2] = 20; + Position.y[eid2] = 15; + + const results = world._queryRegistry.queryComponents([Entity, Position, Velocity]); + + // Only eid1 should be included + expect(results).toHaveLength(1); + expect(results[0]).toEqual([eid1, { x: 10, y: 5 }, { x: 2, y: 1 }]); + }); + }); + describe('getQuery', () => { it('should create and cache query data', () => { const Position = { x: [] as number[], y: [] as number[] }; diff --git a/packages/feature-ecs/src/query/create-query-registry.ts b/packages/feature-ecs/src/query/create-query-registry.ts index 0d23afd8..18616ae0 100644 --- a/packages/feature-ecs/src/query/create-query-registry.ts +++ b/packages/feature-ecs/src/query/create-query-registry.ts @@ -4,10 +4,17 @@ * Simple and fast query registry with bitmask optimizations. */ +import { TComponentRef } from '../component'; import { TEntityId } from '../entity'; import { TWorld } from '../world'; import { categorizeEvaluationStrategy } from './categorize-evaluation-strategy'; -import { TQueryData, TQueryFilter } from './types'; +import { + Entity, + TEntity, + InferComponentType as TInferComponentType, + TQueryData, + TQueryFilter +} from './types'; /** * Creates a new query registry @@ -17,7 +24,7 @@ export function createQueryRegistry(world: TWorld): TQueryRegistry { _world: world, _queryCache: new Map(), - executeQuery(filter, options = {}) { + queryEntities(filter, options = {}) { const { cache = true, ...getQueryOptions } = options; const queryData = this.getQuery(filter, getQueryOptions); @@ -49,6 +56,70 @@ export function createQueryRegistry(world: TWorld): TQueryRegistry { return matchingEntities; }, + queryComponents( + components: T, + filter?: TQueryFilter + ): TComponentDataTuple[] { + // Get entities that match the filter (or all alive entities if no filter) + const matchingEntities = filter + ? this.queryEntities(filter) + : this._world._entityIndex.getAliveEntities(); + + // For each entity, check if it has all components and get their data + const results: TComponentDataTuple[] = []; + for (const eid of matchingEntities) { + const row: unknown[] = []; + let hasAllComponents = true; + + for (const comp of components) { + if (comp === Entity) { + row.push(eid); + } else { + // Check if entity has this component + if (!this._world._componentRegistry.hasComponent(eid, comp)) { + hasAllComponents = false; + break; + } + + // Get component data directly from the component array/object + let componentData; + if (Array.isArray(comp)) { + // Single array component: Health[eid] + componentData = comp[eid]; + } else if (typeof comp === 'object' && comp !== null) { + // Object with arrays (SoA): Position.x[eid], Position.y[eid] + componentData = {} as Record; + let hasArrayProperties = false; + for (const key in comp) { + if (Array.isArray((comp as Record)[key])) { + componentData[key] = (comp as Record)[key][eid]; + hasArrayProperties = true; + } + } + + // If no array properties found, it's a tag component + if (!hasArrayProperties) { + componentData = true; + } + } else { + // Unsupported component + hasAllComponents = false; + break; + } + + row.push(componentData); + } + } + + // Only include entities that have all requested components + if (hasAllComponents) { + results.push(row as TComponentDataTuple); + } + } + + return results; + }, + getQuery(filter, options = {}) { const { evaluationStrategy = categorizeEvaluationStrategy(filter) } = options; const hash = filter.getHash(this._world); @@ -105,9 +176,46 @@ export interface TQueryRegistry { _queryCache: Map; /** - * Executes a query and returns matching entities + * Queries entities that match the specified filter and returns only entity IDs. + * + * @param filter - The query filter to match entities against + * @param options - Query execution options + * @returns Array of entity IDs that match the filter + * + * @example + * ```typescript + * // Simple component query + * const entities = queryRegistry.queryEntities(With(Position)); + * + * // Complex query with multiple conditions + * const movingEntities = queryRegistry.queryEntities( + * And(With(Position), With(Velocity), Without(Dead)) + * ); + * ``` */ - executeQuery(filter: TQueryFilter, options?: TExecuteQueryOptions): TEntityId[]; + queryEntities(filter: TQueryFilter, options?: TExecuteQueryOptions): TEntityId[]; + + /** + * Queries components and returns matching entities with component data. + * + * @param components Components to retrieve data from (include Entity for entity ID) + * @param filter Optional filter to restrict results + * @returns Array of component data tuples. Entities without all requested components are excluded. + * @example + * ```ts + * // Query for entities with both Position and Velocity, include entity ID + * const results = queryRegistry.queryComponents([Entity, Position, Velocity]); + * // Returns: [[eid1, {x: 10, y: 5}, {x: 2, y: 1}], [eid2, {x: 20, y: 15}, {x: 1, y: -1}]] + * + * // Query with filter + * const playerResults = queryRegistry.queryComponents([Entity, Health], With(Player)); + * // Returns: [[eid1, 100], [eid3, 75]] + * ``` + */ + queryComponents( + components: T, + filter?: TQueryFilter + ): TComponentDataTuple[]; /** * Gets or creates a compiled query @@ -144,3 +252,7 @@ export interface TExecuteQueryOptions extends TGetQueryOptions { /** Whether to cache the query result */ cache?: boolean; } + +export type TComponentDataTuple = { + [K in keyof T]: TInferComponentType; +}; diff --git a/packages/feature-ecs/src/query/query-filters.test.ts b/packages/feature-ecs/src/query/query-filters.test.ts index 0aab9252..a9766f90 100644 --- a/packages/feature-ecs/src/query/query-filters.test.ts +++ b/packages/feature-ecs/src/query/query-filters.test.ts @@ -28,16 +28,16 @@ describe('Query Filters', () => { world.addComponent(eid3, Position); world.addComponent(eid3, Health); - const positionEntities = world.query(With(Position)); + const positionEntities = world.queryEntities(With(Position)); expect(positionEntities.sort()).toEqual([eid1, eid3].sort()); - const healthEntities = world.query(With(Health)); + const healthEntities = world.queryEntities(With(Health)); expect(healthEntities.sort()).toEqual([eid2, eid3].sort()); }); it('should return empty array when no entities have component', () => { const Position = { x: [] as number[], y: [] as number[] }; - const result = world.query(With(Position)); + const result = world.queryEntities(With(Position)); expect(result).toEqual([]); }); @@ -45,7 +45,7 @@ describe('Query Filters', () => { const Position = { x: [] as number[], y: [] as number[] }; expect(world._componentRegistry._componentMap.has(Position)).toBe(false); - world.query(With(Position)); + world.queryEntities(With(Position)); expect(world._componentRegistry._componentMap.has(Position)).toBe(true); }); }); @@ -67,10 +67,10 @@ describe('Query Filters', () => { // eid3: Both components - const withoutHealth = world.query(Without(Health)); + const withoutHealth = world.queryEntities(Without(Health)); expect(withoutHealth.sort()).toEqual([eid1, eid3].sort()); - const withoutPosition = world.query(Without(Position)); + const withoutPosition = world.queryEntities(Without(Position)); expect(withoutPosition.sort()).toEqual([eid2, eid3].sort()); }); @@ -83,7 +83,7 @@ describe('Query Filters', () => { world.addComponent(eid1, Position); - const withoutNonExistent = world.query(Without(NonExistent)); + const withoutNonExistent = world.queryEntities(Without(NonExistent)); expect(withoutNonExistent.sort()).toEqual([eid1, eid2].sort()); }); }); @@ -102,10 +102,10 @@ describe('Query Filters', () => { // Add Health to eid2 (should be tracked as added) world.addComponent(eid2, Health); - const addedPosition = world.query(Added(Position)); + const addedPosition = world.queryEntities(Added(Position)); expect(addedPosition).toEqual([eid1]); - const addedHealth = world.query(Added(Health)); + const addedHealth = world.queryEntities(Added(Health)); expect(addedHealth).toEqual([eid2]); }); @@ -116,12 +116,12 @@ describe('Query Filters', () => { world.addComponent(eid, Position); // Before flush: should find the entity - const beforeFlush = world.query(Added(Position)); + const beforeFlush = world.queryEntities(Added(Position)); expect(beforeFlush).toEqual([eid]); // After flush: should be empty world.flush(); - const afterFlush = world.query(Added(Position)); + const afterFlush = world.queryEntities(Added(Position)); expect(afterFlush).toEqual([]); }); @@ -136,7 +136,7 @@ describe('Query Filters', () => { world.addComponent(eid2, Position); world.addComponent(eid3, Position); - const addedPosition = world.query(Added(Position)); + const addedPosition = world.queryEntities(Added(Position)); expect(addedPosition.sort()).toEqual([eid1, eid2, eid3].sort()); }); }); @@ -159,10 +159,10 @@ describe('Query Filters', () => { // Mark Position as changed for eid1 world._componentRegistry.markChanged(eid1, Position); - const changedPosition = world.query(Changed(Position)); + const changedPosition = world.queryEntities(Changed(Position)); expect(changedPosition).toEqual([eid1]); - const changedHealth = world.query(Changed(Health)); + const changedHealth = world.queryEntities(Changed(Health)); expect(changedHealth).toEqual([]); }); @@ -176,12 +176,12 @@ describe('Query Filters', () => { world._componentRegistry.markChanged(eid, Position); // Before flush: should find the entity - const beforeFlush = world.query(Changed(Position)); + const beforeFlush = world.queryEntities(Changed(Position)); expect(beforeFlush).toEqual([eid]); // After flush: should be empty world.flush(); - const afterFlush = world.query(Changed(Position)); + const afterFlush = world.queryEntities(Changed(Position)); expect(afterFlush).toEqual([]); }); }); @@ -205,10 +205,10 @@ describe('Query Filters', () => { // Remove Health from eid1 world.removeComponent(eid1, Health); - const removedHealth = world.query(Removed(Health)); + const removedHealth = world.queryEntities(Removed(Health)); expect(removedHealth).toEqual([eid1]); - const removedPosition = world.query(Removed(Position)); + const removedPosition = world.queryEntities(Removed(Position)); expect(removedPosition).toEqual([]); }); @@ -222,12 +222,12 @@ describe('Query Filters', () => { world.removeComponent(eid, Position); // Before flush: should find the entity - const beforeFlush = world.query(Removed(Position)); + const beforeFlush = world.queryEntities(Removed(Position)); expect(beforeFlush).toEqual([eid]); // After flush: should be empty world.flush(); - const afterFlush = world.query(Removed(Position)); + const afterFlush = world.queryEntities(Removed(Position)); expect(afterFlush).toEqual([]); }); }); @@ -255,10 +255,10 @@ describe('Query Filters', () => { world.addComponent(eid3, Velocity); world.addComponent(eid3, Health); - const positionAndVelocity = world.query(And(With(Position), With(Velocity))); + const positionAndVelocity = world.queryEntities(And(With(Position), With(Velocity))); expect(positionAndVelocity.sort()).toEqual([eid1, eid3].sort()); - const allThree = world.query(And(With(Position), With(Velocity), With(Health))); + const allThree = world.queryEntities(And(With(Position), With(Velocity), With(Health))); expect(allThree).toEqual([eid3]); }); @@ -273,7 +273,7 @@ describe('Query Filters', () => { world.addComponent(eid, Health); // And(And(Position, Velocity), Health) should work - const nestedAnd = world.query(And(And(With(Position), With(Velocity)), With(Health))); + const nestedAnd = world.queryEntities(And(And(With(Position), With(Velocity)), With(Health))); expect(nestedAnd).toEqual([eid]); }); @@ -294,7 +294,9 @@ describe('Query Filters', () => { world.addComponent(eid2, Health); world.addComponent(eid2, Enemy); - const healthyNonEnemies = world.query(And(With(Position), With(Health), Without(Enemy))); + const healthyNonEnemies = world.queryEntities( + And(With(Position), With(Health), Without(Enemy)) + ); expect(healthyNonEnemies).toEqual([eid1]); }); @@ -316,7 +318,7 @@ describe('Query Filters', () => { // Mark Health as changed for eid1 world._componentRegistry.markChanged(eid1, Health); - const positionWithChangedHealth = world.query(And(With(Position), Changed(Health))); + const positionWithChangedHealth = world.queryEntities(And(With(Position), Changed(Health))); expect(positionWithChangedHealth).toEqual([eid1]); }); }); @@ -343,10 +345,10 @@ describe('Query Filters', () => { // eid4: No components - const positionOrHealth = world.query(Or(With(Position), With(Health))); + const positionOrHealth = world.queryEntities(Or(With(Position), With(Health))); expect(positionOrHealth.sort()).toEqual([eid1, eid2].sort()); - const anyOfThree = world.query(Or(With(Position), With(Health), With(Shield))); + const anyOfThree = world.queryEntities(Or(With(Position), With(Health), With(Shield))); expect(anyOfThree.sort()).toEqual([eid1, eid2, eid3].sort()); }); @@ -374,7 +376,7 @@ describe('Query Filters', () => { world.addComponent(eid3, Enemy); // Entities that lack Enemy OR lack Ally - const notEnemyOrNotAlly = world.query(Or(Without(Enemy), Without(Ally))); + const notEnemyOrNotAlly = world.queryEntities(Or(Without(Enemy), Without(Ally))); expect(notEnemyOrNotAlly.sort()).toEqual([eid1, eid2, eid3].sort()); // All match since each lacks at least one }); @@ -401,7 +403,9 @@ describe('Query Filters', () => { world.addComponent(eid1, Shield); world._componentRegistry.markChanged(eid2, Position); - const addedShieldOrChangedPosition = world.query(Or(Added(Shield), Changed(Position))); + const addedShieldOrChangedPosition = world.queryEntities( + Or(Added(Shield), Changed(Position)) + ); expect(addedShieldOrChangedPosition.sort()).toEqual([eid1, eid2].sort()); }); @@ -412,7 +416,7 @@ describe('Query Filters', () => { const eid = world.createEntity(); // Entity has no components - const result = world.query(Or(With(Position), With(Health))); + const result = world.queryEntities(Or(With(Position), With(Health))); expect(result).toEqual([]); }); }); @@ -448,7 +452,9 @@ describe('Query Filters', () => { world.addComponent(eid4, Alive); // Entities with Position AND (Health OR Shield) AND Alive - const complex = world.query(And(With(Position), Or(With(Health), With(Shield)), With(Alive))); + const complex = world.queryEntities( + And(With(Position), Or(With(Health), With(Shield)), With(Alive)) + ); expect(complex.sort()).toEqual([eid1, eid2].sort()); }); @@ -473,7 +479,7 @@ describe('Query Filters', () => { world.addComponent(eid3, Position); // Entities that have (Position AND Velocity) OR Health - const complex = world.query(Or(And(With(Position), With(Velocity)), With(Health))); + const complex = world.queryEntities(Or(And(With(Position), With(Velocity)), With(Health))); expect(complex.sort()).toEqual([eid1, eid2].sort()); }); @@ -490,7 +496,9 @@ describe('Query Filters', () => { world.addComponent(eid, C); // And(And(A, B), And(C, Without(D))) - const deeplyNested = world.query(And(And(With(A), With(B)), And(With(C), Without(D)))); + const deeplyNested = world.queryEntities( + And(And(With(A), With(B)), And(With(C), Without(D))) + ); expect(deeplyNested).toEqual([eid]); }); @@ -524,7 +532,7 @@ describe('Query Filters', () => { world._componentRegistry.markChanged(eid2, Health); // Entities with Position AND (Added Shield OR Changed Health) AND Without Enemy - const complex = world.query( + const complex = world.queryEntities( And(With(Position), Or(Added(Shield), Changed(Health)), Without(Enemy)) ); @@ -536,12 +544,12 @@ describe('Query Filters', () => { describe('Edge cases', () => { it('should handle empty And filter', () => { - const result = world.query(And()); + const result = world.queryEntities(And()); expect(result).toEqual([]); }); it('should handle empty Or filter', () => { - const result = world.query(Or()); + const result = world.queryEntities(Or()); expect(result).toEqual([]); }); @@ -551,7 +559,7 @@ describe('Query Filters', () => { const eid = world.createEntity(); world.addComponent(eid, Position); - const result = world.query(And(With(Position))); + const result = world.queryEntities(And(With(Position))); expect(result).toEqual([eid]); }); @@ -561,7 +569,7 @@ describe('Query Filters', () => { const eid = world.createEntity(); world.addComponent(eid, Position); - const result = world.query(Or(With(Position))); + const result = world.queryEntities(Or(With(Position))); expect(result).toEqual([eid]); }); @@ -573,11 +581,11 @@ describe('Query Filters', () => { world.addComponent(eid, Position); // With non-existent should return empty - const withNonExistent = world.query(With(NonExistent)); + const withNonExistent = world.queryEntities(With(NonExistent)); expect(withNonExistent).toEqual([]); // Without non-existent should return all entities - const withoutNonExistent = world.query(Without(NonExistent)); + const withoutNonExistent = world.queryEntities(Without(NonExistent)); expect(withoutNonExistent).toEqual([eid]); }); }); @@ -593,9 +601,9 @@ describe('Query Filters', () => { // Execute same query multiple times const filter = And(With(Position), With(Health)); - const result1 = world.query(filter); - const result2 = world.query(filter); - const result3 = world.query(filter); + const result1 = world.queryEntities(filter); + const result2 = world.queryEntities(filter); + const result3 = world.queryEntities(filter); // Should return consistent results expect(result1).toEqual([eid]); @@ -615,14 +623,14 @@ describe('Query Filters', () => { world.addComponent(eid1, Position); // Initial query - const result1 = world.query(With(Position)); + const result1 = world.queryEntities(With(Position)); expect(result1).toEqual([eid1]); // Add component to another entity world.addComponent(eid2, Position); // Query should reflect change - const result2 = world.query(With(Position)); + const result2 = world.queryEntities(With(Position)); expect(result2.sort()).toEqual([eid1, eid2].sort()); }); @@ -639,9 +647,9 @@ describe('Query Filters', () => { world.addComponent(eid1, Health); // Execute different queries to populate cache - const positionQuery = world.query(With(Position)); - const healthQuery = world.query(With(Health)); - const velocityQuery = world.query(With(Velocity)); + const positionQuery = world.queryEntities(With(Position)); + const healthQuery = world.queryEntities(With(Health)); + const velocityQuery = world.queryEntities(With(Velocity)); // Verify initial state expect(positionQuery).toEqual([eid1]); @@ -667,9 +675,9 @@ describe('Query Filters', () => { expect(velocityQueryData.isDirty).toBe(true); // Should be dirty // Execute queries to verify results - const newPositionQuery = world.query(With(Position)); - const newHealthQuery = world.query(With(Health)); - const newVelocityQuery = world.query(With(Velocity)); + const newPositionQuery = world.queryEntities(With(Position)); + const newHealthQuery = world.queryEntities(With(Health)); + const newVelocityQuery = world.queryEntities(With(Velocity)); expect(newPositionQuery).toEqual([eid1]); // No change expect(newHealthQuery).toEqual([eid1]); // No change @@ -684,9 +692,9 @@ describe('Query Filters', () => { world.addComponent(eid1, Health); // Create multiple queries that depend on Position - const positionOnlyQuery = world.query(With(Position)); - const positionAndHealthQuery = world.query(And(With(Position), With(Health))); - const positionOrHealthQuery = world.query(Or(With(Position), With(Health))); + const positionOnlyQuery = world.queryEntities(With(Position)); + const positionAndHealthQuery = world.queryEntities(And(With(Position), With(Health))); + const positionOrHealthQuery = world.queryEntities(Or(With(Position), With(Health))); // Get query data const positionOnlyData = world._queryRegistry.registerQuery(With(Position)); @@ -760,10 +768,10 @@ describe('Query Filters', () => { // eid3: Nothing // Bitmask-compatible query - const bitmaskResult = world.query(Or(With(Position), With(Shield))); + const bitmaskResult = world.queryEntities(Or(With(Position), With(Shield))); // Individual evaluation query (same logic) - const individualResult = world.query( + const individualResult = world.queryEntities( Or(And(With(Position)), With(Shield)) // Forces individual evaluation ); diff --git a/packages/feature-ecs/src/query/types.ts b/packages/feature-ecs/src/query/types.ts index 7f6be491..d772932a 100644 --- a/packages/feature-ecs/src/query/types.ts +++ b/packages/feature-ecs/src/query/types.ts @@ -2,6 +2,12 @@ import { TComponentRef } from '../component'; import { TEntityId } from '../entity'; import { TWorld } from '../world'; +/** + * Special entity symbol for component queries + */ +export const Entity = Symbol('Entity'); +export type TEntity = typeof Entity; + export interface TQueryData { /** Unique hash identifying this query filter combination */ hash: string; @@ -67,3 +73,15 @@ export type TQueryFilter = | (TBaseQueryFilter & { type: 'Removed'; component: TComponentRef }) | (TBaseQueryFilter & { type: 'And'; filters: TQueryFilter[] }) | (TBaseQueryFilter & { type: 'Or'; filters: TQueryFilter[] }); + +export type InferComponentType = T extends TEntity + ? TEntityId + : T extends readonly (infer U)[] // Array of components (AoS) + ? U + : T extends Record // Object with arrays (SoA) + ? { + [K in keyof T]: T[K] extends Array ? V : never; + } + : T extends {} // Empty object (tag component) + ? true + : never; diff --git a/packages/feature-ecs/src/world.ts b/packages/feature-ecs/src/world.ts index a610c26e..7431ef45 100644 --- a/packages/feature-ecs/src/world.ts +++ b/packages/feature-ecs/src/world.ts @@ -1,7 +1,14 @@ import { withNew } from '@blgc/utils'; import { createComponentRegistry, TComponentRef, TComponentRegistry } from './component'; import { createEntityIndex, TEntityId, TEntityIndex } from './entity'; -import { createQueryRegistry, TExecuteQueryOptions, TQueryFilter, TQueryRegistry } from './query'; +import { + createQueryRegistry, + TComponentDataTuple, + TExecuteQueryOptions, + TQueryFilter, + TQueryRegistry +} from './query'; +import { TEntity } from './query/types'; // TODO: // Events @@ -60,8 +67,12 @@ export function createWorld(): TWorld { return this._componentRegistry.hasComponent(eid, component); }, - query(filter, options) { - return this._queryRegistry.executeQuery(filter, options); + queryEntities(filter, options) { + return this._queryRegistry.queryEntities(filter, options); + }, + + queryComponents(components, filter) { + return this._queryRegistry.queryComponents(components, filter); }, flush() { @@ -120,12 +131,46 @@ export interface TWorld { hasComponent(eid: TEntityId, component: TComponentRef): boolean; /** - * Executes a query and returns matching entities. - * @param filter - The query filter + * Queries entities that match the specified filter and returns only entity IDs. + * + * @param filter - The query filter to match entities against * @param options - Query execution options - * @returns Array of matching entity IDs + * @returns Array of entity IDs that match the filter + * + * @example + * ```typescript + * // Simple component query + * const entities = world.queryEntities(With(Position)); + * + * // Complex query with multiple conditions + * const movingEntities = world.queryEntities( + * And(With(Position), With(Velocity), Without(Dead)) + * ); + * ``` + */ + queryEntities(filter: TQueryFilter, options?: TExecuteQueryOptions): TEntityId[]; + + /** + * Queries components and returns matching entities with component data. + * + * @param components Components to retrieve data from (include Entity for entity ID) + * @param filter Optional filter to restrict results + * @returns Array of component data tuples. Entities without all requested components are excluded. + * @example + * ```ts + * // Query for entities with both Position and Velocity, include entity ID + * const results = world.queryComponents([Entity, Position, Velocity]); + * // Returns: [[eid1, {x: 10, y: 5}, {x: 2, y: 1}], [eid2, {x: 20, y: 15}, {x: 1, y: -1}]] + * + * // Query with filter + * const playerResults = world.queryComponents([Entity, Health], With(Player)); + * // Returns: [[eid1, 100], [eid3, 75]] + * ``` */ - query(filter: TQueryFilter, options?: TExecuteQueryOptions): TEntityId[]; + queryComponents( + components: T, + filter?: TQueryFilter + ): TComponentDataTuple[]; /** * Clears the world. From 1e2601cfdd9183f229f024068aefd72d556f5501 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Fri, 30 May 2025 17:14:42 +0200 Subject: [PATCH 26/39] #15 fixed typos --- packages/feature-ecs/src/world.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/feature-ecs/src/world.ts b/packages/feature-ecs/src/world.ts index 7431ef45..cfed20dd 100644 --- a/packages/feature-ecs/src/world.ts +++ b/packages/feature-ecs/src/world.ts @@ -45,8 +45,7 @@ export function createWorld(): TWorld { }, createEntity() { - const eid = this._entityIndex.addEntity(); - return eid; + return this._entityIndex.addEntity(); }, destroyEntity(eid) { @@ -59,8 +58,7 @@ export function createWorld(): TWorld { }, removeComponent(eid, component) { - const result = this._componentRegistry.removeComponent(eid, component); - return result; + return this._componentRegistry.removeComponent(eid, component); }, hasComponent(eid, component) { From 415b16052d50db39e571b77b421094721ae1716f Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Sat, 31 May 2025 11:40:42 +0200 Subject: [PATCH 27/39] #105 fixed typos --- .../src/query/create-query-registry.ts | 29 ------------------- packages/feature-ecs/src/world.ts | 4 +-- 2 files changed, 2 insertions(+), 31 deletions(-) diff --git a/packages/feature-ecs/src/query/create-query-registry.ts b/packages/feature-ecs/src/query/create-query-registry.ts index 18616ae0..78f6748d 100644 --- a/packages/feature-ecs/src/query/create-query-registry.ts +++ b/packages/feature-ecs/src/query/create-query-registry.ts @@ -177,40 +177,11 @@ export interface TQueryRegistry { /** * Queries entities that match the specified filter and returns only entity IDs. - * - * @param filter - The query filter to match entities against - * @param options - Query execution options - * @returns Array of entity IDs that match the filter - * - * @example - * ```typescript - * // Simple component query - * const entities = queryRegistry.queryEntities(With(Position)); - * - * // Complex query with multiple conditions - * const movingEntities = queryRegistry.queryEntities( - * And(With(Position), With(Velocity), Without(Dead)) - * ); - * ``` */ queryEntities(filter: TQueryFilter, options?: TExecuteQueryOptions): TEntityId[]; /** * Queries components and returns matching entities with component data. - * - * @param components Components to retrieve data from (include Entity for entity ID) - * @param filter Optional filter to restrict results - * @returns Array of component data tuples. Entities without all requested components are excluded. - * @example - * ```ts - * // Query for entities with both Position and Velocity, include entity ID - * const results = queryRegistry.queryComponents([Entity, Position, Velocity]); - * // Returns: [[eid1, {x: 10, y: 5}, {x: 2, y: 1}], [eid2, {x: 20, y: 15}, {x: 1, y: -1}]] - * - * // Query with filter - * const playerResults = queryRegistry.queryComponents([Entity, Health], With(Player)); - * // Returns: [[eid1, 100], [eid3, 75]] - * ``` */ queryComponents( components: T, diff --git a/packages/feature-ecs/src/world.ts b/packages/feature-ecs/src/world.ts index cfed20dd..f4b6c1dc 100644 --- a/packages/feature-ecs/src/world.ts +++ b/packages/feature-ecs/src/world.ts @@ -11,6 +11,7 @@ import { import { TEntity } from './query/types'; // TODO: +// Pass component data directly into addComponent (optional) // Events // Systems // Resources @@ -40,8 +41,7 @@ export function createWorld(): TWorld { _queryRegistry: null as any, // Will be set in _new _new() { - const queryRegistry = createQueryRegistry(this); - this._queryRegistry = queryRegistry; + this._queryRegistry = createQueryRegistry(this); }, createEntity() { From d047ed5eda3c442013e40651de4c10df007ac364 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Sat, 31 May 2025 11:53:33 +0200 Subject: [PATCH 28/39] #105 wip --- packages/head-metadata/README.md | 3 + packages/head-metadata/eslint.config.js | 5 + packages/head-metadata/package.json | 49 + packages/head-metadata/rollup.config.js | 6 + .../src/__tests__/playground.test.ts | 20 + .../__tests__/resources/e2e/bento-com.html | 1038 + .../__tests__/resources/e2e/bento-com.json | 22 + .../src/__tests__/resources/e2e/bsky.html | 196 + .../src/__tests__/resources/e2e/bsky.json | 29 + .../resources/e2e/google-unformatted.html | 14 + .../resources/e2e/google-unformatted.json | 15 + .../src/__tests__/resources/e2e/google.html | 920 + .../src/__tests__/resources/e2e/google.json | 15 + .../src/__tests__/resources/e2e/paddle.html | 1229 ++ .../src/__tests__/resources/e2e/paddle.json | 20 + .../__tests__/resources/e2e/starterstory.html | 2998 +++ .../__tests__/resources/e2e/starterstory.json | 26 + .../src/__tests__/resources/e2e/youtube.html | 15804 ++++++++++++++++ .../src/__tests__/resources/e2e/youtube.json | 51 + .../src/extract-head-metadata.test.ts | 31 + .../src/extract-head-metadata.ts | 115 + .../head-metadata/src/extractors/index.ts | 3 + .../src/extractors/link-extractor.ts | 15 + .../src/extractors/meta-extractor.ts | 20 + .../src/extractors/title-extractor.ts | 10 + packages/head-metadata/src/index.ts | 3 + packages/head-metadata/src/types.ts | 34 + packages/head-metadata/tsconfig.json | 10 + packages/head-metadata/tsconfig.prod.json | 6 + packages/head-metadata/vitest.config.mjs | 4 + packages/kleinanzeigen-client/README.md | 115 + .../kleinanzeigen-client/eslint.config.js | 5 + packages/kleinanzeigen-client/package.json | 52 + .../kleinanzeigen-client/rollup.config.js | 6 + .../src/__tests__/playground.test.ts | 39 + .../src/__tests__/resources/e2e/s-laptop.html | 7700 ++++++++ .../kleinanzeigen-client/src/extract-ads.ts | 279 + .../kleinanzeigen-client/src/fetch-ads.ts | 80 + packages/kleinanzeigen-client/src/index.ts | 2 + packages/kleinanzeigen-client/tsconfig.json | 10 + .../kleinanzeigen-client/tsconfig.prod.json | 6 + .../kleinanzeigen-client/vitest.config.mjs | 4 + .../src/__tests__/playground.test.ts | 9 +- .../__tests__/resources/kleinanzeigen.html | 7694 ++++++++ packages/xml-tokenizer/src/extract.test.ts | 42 + packages/xml-tokenizer/src/extract.ts | 68 + packages/xml-tokenizer/src/index.ts | 1 + .../xml-tokenizer/src/tokenizer/tokenize.ts | 2 +- pnpm-lock.yaml | 34 + 49 files changed, 38852 insertions(+), 7 deletions(-) create mode 100644 packages/head-metadata/README.md create mode 100644 packages/head-metadata/eslint.config.js create mode 100644 packages/head-metadata/package.json create mode 100644 packages/head-metadata/rollup.config.js create mode 100644 packages/head-metadata/src/__tests__/playground.test.ts create mode 100644 packages/head-metadata/src/__tests__/resources/e2e/bento-com.html create mode 100644 packages/head-metadata/src/__tests__/resources/e2e/bento-com.json create mode 100644 packages/head-metadata/src/__tests__/resources/e2e/bsky.html create mode 100644 packages/head-metadata/src/__tests__/resources/e2e/bsky.json create mode 100644 packages/head-metadata/src/__tests__/resources/e2e/google-unformatted.html create mode 100644 packages/head-metadata/src/__tests__/resources/e2e/google-unformatted.json create mode 100644 packages/head-metadata/src/__tests__/resources/e2e/google.html create mode 100644 packages/head-metadata/src/__tests__/resources/e2e/google.json create mode 100644 packages/head-metadata/src/__tests__/resources/e2e/paddle.html create mode 100644 packages/head-metadata/src/__tests__/resources/e2e/paddle.json create mode 100644 packages/head-metadata/src/__tests__/resources/e2e/starterstory.html create mode 100644 packages/head-metadata/src/__tests__/resources/e2e/starterstory.json create mode 100644 packages/head-metadata/src/__tests__/resources/e2e/youtube.html create mode 100644 packages/head-metadata/src/__tests__/resources/e2e/youtube.json create mode 100644 packages/head-metadata/src/extract-head-metadata.test.ts create mode 100644 packages/head-metadata/src/extract-head-metadata.ts create mode 100644 packages/head-metadata/src/extractors/index.ts create mode 100644 packages/head-metadata/src/extractors/link-extractor.ts create mode 100644 packages/head-metadata/src/extractors/meta-extractor.ts create mode 100644 packages/head-metadata/src/extractors/title-extractor.ts create mode 100644 packages/head-metadata/src/index.ts create mode 100644 packages/head-metadata/src/types.ts create mode 100644 packages/head-metadata/tsconfig.json create mode 100644 packages/head-metadata/tsconfig.prod.json create mode 100644 packages/head-metadata/vitest.config.mjs create mode 100644 packages/kleinanzeigen-client/README.md create mode 100644 packages/kleinanzeigen-client/eslint.config.js create mode 100644 packages/kleinanzeigen-client/package.json create mode 100644 packages/kleinanzeigen-client/rollup.config.js create mode 100644 packages/kleinanzeigen-client/src/__tests__/playground.test.ts create mode 100644 packages/kleinanzeigen-client/src/__tests__/resources/e2e/s-laptop.html create mode 100644 packages/kleinanzeigen-client/src/extract-ads.ts create mode 100644 packages/kleinanzeigen-client/src/fetch-ads.ts create mode 100644 packages/kleinanzeigen-client/src/index.ts create mode 100644 packages/kleinanzeigen-client/tsconfig.json create mode 100644 packages/kleinanzeigen-client/tsconfig.prod.json create mode 100644 packages/kleinanzeigen-client/vitest.config.mjs create mode 100644 packages/xml-tokenizer/src/__tests__/resources/kleinanzeigen.html create mode 100644 packages/xml-tokenizer/src/extract.test.ts create mode 100644 packages/xml-tokenizer/src/extract.ts diff --git a/packages/head-metadata/README.md b/packages/head-metadata/README.md new file mode 100644 index 00000000..ab52d184 --- /dev/null +++ b/packages/head-metadata/README.md @@ -0,0 +1,3 @@ +# `@repo/head-metadata` + +todo diff --git a/packages/head-metadata/eslint.config.js b/packages/head-metadata/eslint.config.js new file mode 100644 index 00000000..275e54fa --- /dev/null +++ b/packages/head-metadata/eslint.config.js @@ -0,0 +1,5 @@ +/** + * @see https://eslint.org/docs/latest/use/configure/configuration-files + * @type {import("eslint").Linter.Config} + */ +module.exports = [...require('@blgc/config/eslint/library')]; diff --git a/packages/head-metadata/package.json b/packages/head-metadata/package.json new file mode 100644 index 00000000..d7749f37 --- /dev/null +++ b/packages/head-metadata/package.json @@ -0,0 +1,49 @@ +{ + "name": "head-metadata", + "version": "0.0.1", + "private": false, + "description": "Extracts metadata from the head of a HTML document", + "keywords": [], + "homepage": "https://builder.group/?source=package-json", + "bugs": { + "url": "https://github.com/builder-group/community/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/builder-group/community.git" + }, + "license": "MIT", + "author": "@bennobuilder", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "source": "./src/index.ts", + "types": "./dist/types/index.d.ts", + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "shx rm -rf dist && rollup -c rollup.config.js", + "build:prod": "export NODE_ENV=production && pnpm build", + "clean": "shx rm -rf dist && shx rm -rf .turbo && shx rm -rf node_modules", + "install:clean": "pnpm run clean && pnpm install", + "lint": "eslint . --fix", + "publish:patch": "pnpm build:prod && pnpm version patch && pnpm publish --no-git-checks --access=public", + "size": "size-limit --why", + "start:dev": "tsc -w", + "test": "vitest run", + "update:latest": "pnpm update --latest" + }, + "dependencies": { + "xml-tokenizer": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.15.21", + "rollup-presets": "workspace:*" + }, + "size-limit": [ + { + "path": "dist/esm/index.js" + } + ] +} diff --git a/packages/head-metadata/rollup.config.js b/packages/head-metadata/rollup.config.js new file mode 100644 index 00000000..d09fd346 --- /dev/null +++ b/packages/head-metadata/rollup.config.js @@ -0,0 +1,6 @@ +const { libraryPreset } = require('rollup-presets'); + +/** + * @type {import('rollup').RollupOptions[]} + */ +module.exports = libraryPreset(); diff --git a/packages/head-metadata/src/__tests__/playground.test.ts b/packages/head-metadata/src/__tests__/playground.test.ts new file mode 100644 index 00000000..2d9d4d6f --- /dev/null +++ b/packages/head-metadata/src/__tests__/playground.test.ts @@ -0,0 +1,20 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { extractHeadMetadata } from '../extract-head-metadata'; +import { linkExtractor, metaExtractor, titleExtractor } from '../extractors'; + +describe('playground', () => { + it('should work', async () => { + const filePath = join(__dirname, './resources/e2e/bsky.html'); + const html = await readFile(filePath, 'utf-8'); + + const metadata = await extractHeadMetadata(html, { + meta: metaExtractor, + title: titleExtractor, + link: linkExtractor + }); + + expect(metadata).toBeDefined(); + }); +}); diff --git a/packages/head-metadata/src/__tests__/resources/e2e/bento-com.html b/packages/head-metadata/src/__tests__/resources/e2e/bento-com.html new file mode 100644 index 00000000..ea4238fc --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/bento-com.html @@ -0,0 +1,1038 @@ + + + + + + + + + + + + + + + + + + + + + + +Bento.com Japanese cuisine and restaurant guide + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + +
+ + + + + +
+
+
+
+
+
+Bento.com +
+
+"Always fresh, never fishy" +
+ + +
+ +
+
+
+
+ + +
+ +
+
+ + +
+ +
+
Share:
+ +
+ + +
+
Follow:
+ +
+ +
+ +
+

Latest reviews

+
+ + + + + +
+ +
+ + +
+
Ageha isn't super-fancy, but they turn out reliable, good-quality kushiage skewers at reasonable prices. There are four prix-fixe menus at both lunch and dinner, with somewhat more premium ingredients as the price level rises. Most full meals comprise eight or ten skewers of vegetables, seafood and meats, plus soup, rice, pickles and dessert. + Ingredients are lightly coated before.... + [Continue reading] +
+
+
+ + + +
+ +
+ + +
+
We'd love this place even if it weren't for the gorgeous bayside view. They serve great food and inspired original cocktails, and they run their own craft brewery, coffee roastery and gin distillery on premises. When the weather is nice you can enjoy the fresh ocean breezes out on their waterfront terrace. + The food menu focuses on casual American classics, among them some of the.... + [Continue reading] +
+
+
+ + +
+ +
+ + +
+
Fish is known for their three-curry combo plates, which come with your choice of a main curry (pork, chicken, extra-spicy chicken, and various weekly specials) paired with a mixed-bean curry and a keema. The recommended pork curry is particularly fiery, with a good mix of spices, while the three-bean curry is mild and gently spiced and the keema is rich and meaty. + Curry combos come.... + [Continue reading] +
+
+
+ + +
+ +
+ + +
+
Tomato ramen is the unusual specialty here, and a bowl of it incorporates juice and pulp from five and a half tomatoes as well as pork, onions, greens and other ingredients. Tomako's soup is richer and more meaty than other tomato-based ramen bowls we've had, and the thin, firm noodles do a good job soaking up the flavors. + Tomato ramen topped with oven-grilled cheese is the most.... + [Continue reading] +
+
+
+ + + +
+ +
+ + +
+
This standing bar inside a regional antenna shop showcases sake from Toyama, Ishikawa and Fukui Prefectures - collectively known as the Hokuriku region. Between the selection behind the bar and the self-service sake dispensers, they boast the largest collection of Hokuriku sake in Kansai. + When you order at the bar, sake comes in 60ml servings, or three-part tasting flights of 45ml each. .... + [Continue reading] +
+
+
+ + +
+ +
+ + +
+
This lively dining bar specializes in smoked foods and smoky cocktails, and stocks a good selection of whiskies. Homemade smoked items like bacon, cheese, chicken wings and quail eggs are skillfully prepared in the bar's custom smoker, while zucchini fritters, fries and similar dishes are served with the bar's own smoked mayonnaise and smoked ketchup. + One standout, though it's not.... + [Continue reading] +
+
+
+ + + + + +
+ +
+ + +
+

City guides

+
+
+
+
+
+
+ Tokyo +
+
+
+ +
+
+
+
+ Fukuoka +
+
+
+ +
+
+
+
+ Nagoya +
+
+
+ +
+
+
+
+ Yokohama +
+
+
+ +
+
+
+ +
+
+ +
+
+
+ +
+
+ + +
+

Travel tools

+
+ + + +
+
+
+ + + +
+ +
+
+ + +
+
+
+ + + +
+ +
+
+ +
+
+
+ + + +
+ +
+
+ +
+
+
+ + + +
+
+ Search tips +
+
+ + +
+
+
+
+
+ + +
+
+ + +
+

Exploring Japanese cuisine

+
+ + +
+
+ + +
+ All about ramen, tonkatsu, tempura and grilled chicken on sticks, with menu-reading guides +
+
+
+
+ +
+
+ +
+ Recipes +
+
+ Making Japanese dishes at home +
+
+
+
+ +
+
+ + +
+ Holiday meals, kitchen tools, sake snacks +
+
+
+
+ + + +
+ +
+
+ + +
+ Food-related travels around Japan +
+
+
+
+ +
+
+ + +
+ Kitchen tools, sake snacks, holiday meals +
+
+
+
+ + +
+
+ + + +
+

Articles and special features

+
+ + +
+
+ +
+ +
+ An introduction to different types of sake, plus a glossary of sake terms +
+
+
+
+
+ + +
+
+ + +
+ Menu-reading help for izakaya and sushi shops +
+
+
+
+ + + + + +
+ + +
+
+ + +
+ A combination museum, restaurant complex and mini-theme park, with eight ramen shops to try +
+
+
+
+ + +
+
+ + +
+ Sample regional delicacies and local sake without leaving the capital +
+
+
+
+ + + + +
+
+
+ + +
+
+ +
+ +
+ Learn the secrets of Osaka's favorite octopus snack +
+
+
+
+
+ +
+
+ +
+ +
+ What are style-savvy brewers wearing at festivals and tastings around the country? +
+
+
+
+
+ + + +
+ +
+
+ +
+ +
+ A bustling warren of tiny shops catering to both restaurants and consumers +
+
+
+
+
+ +
+
+ +
+ +
+ Tour of one of Kyoto's luxurious department-store food halls +
+
+
+
+
+ + + +
+
+
+ + + +
+
+ + +
+ Explore Kobe's biggest sake museum, built in an old brewery building +
+
+
+
+ +
+
+ + +
+ Different styles of ramen explained, with a noodle-term glossary +
+
+
+
+ + + +
+ + +
+
+ + +
+ Find the best craft-beer bars in Osaka, Kobe and Kyoto +
+
+
+
+ + +
+
+ + +
+ Finding great pork is easy, but where do you go for great turnips? Here are some suggestions. +
+
+
+
+ + + + +
+

Travel tools

+
+ +
+
+
+ + + +
+ +
+
+ + +
+
+
+ + + +
+ +
+
+ +
+
+
+ + + +
+ +
+
+ + +
+
+
+ + + +
+
+ Search tips +
+
+ + +
+
+
+
+
+ + + +
+
+ + + + +
+ + + + +
+ +


+ + + +
+ +
Share:
+ +
+ + +
+ +
Follow:
+ +
+ + +
+
Sister sites:
+
+
+
+
+
Craft Beer Bars Japan
+
+
+
Bars, retailers and festivals
+
+
+
+
+
+
+
Animal Cafes
+
+
+
Cat, rabbit and bird cafe guide
+
+
+
+
+
+
+
Where in Tokyo
+
+
+
Fun things to do in the big city
+
+
+
+
+
+
+
tokyopicks.com
+
+
+
Neighborhood guides and top-five lists from Tokyo experts
+
+
+
+
+
+
+
Barking Inu
+
+
+
Sushi dictionary and Japan Android apps
+
+
+
+
+ +
+
+ + +
+ +
+ + + + + + +
 
+
+
+
+ +
+
+ +
+ + +
+ +
+ + +
+
+ + + + + + + + + + diff --git a/packages/head-metadata/src/__tests__/resources/e2e/bento-com.json b/packages/head-metadata/src/__tests__/resources/e2e/bento-com.json new file mode 100644 index 00000000..ba5d5bf3 --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/bento-com.json @@ -0,0 +1,22 @@ +{ + "link": { + "alternate": "http://bento.com/bentonews.xml", + "apple-touch-icon-precomposed": "/apple-touch-icon-120x120.png", + "icon": "/favicon-16x16.png", + "search": "bentotokyosearch.xml", + "stylesheet": "css/bento-front-grid.css" + }, + "meta": { + "bitly-verification": "0bcee6f100be", + "charset": "sjis", + "description": "The Tokyo Food Page is a complete guide to Japanese food and restaurants in Tokyo, featuring recipes, articles on Japanese cooking, restaurant listings, culinary travel tips and more.", + "google-site-verification": "eOJ2O8Jr70PfyLxj7o-KkvXxl8oJKTB-YIFYyOPcgMQ", + "og:image": "http://bento.com/pix/icon-1000-400-tokyo2.jpg", + "og:site_name": "Bento.com", + "og:title": "Bento.com Japanese cuisine and restaurant guide", + "og:url": "http://bento.com/tokyofood.html", + "theme-color": "#000000", + "viewport": "width=device-width, initial-scale=1.0" + }, + "title": "Bento.com Japanese cuisine and restaurant guide" +} diff --git a/packages/head-metadata/src/__tests__/resources/e2e/bsky.html b/packages/head-metadata/src/__tests__/resources/e2e/bsky.html new file mode 100644 index 00000000..5396d915 --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/bsky.html @@ -0,0 +1,196 @@ + + + + + + + + + @bennobuilder.bsky.social on Bluesky + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + +
+
+ + + + diff --git a/packages/head-metadata/src/__tests__/resources/e2e/bsky.json b/packages/head-metadata/src/__tests__/resources/e2e/bsky.json new file mode 100644 index 00000000..69ee8538 --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/bsky.json @@ -0,0 +1,29 @@ +{ + "meta": { + "charset": "UTF-8", + "viewport": "width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover", + "referrer": "origin-when-cross-origin", + "application-name": "Bluesky", + "generator": "bskyweb", + "og:site_name": "Bluesky Social", + "og:type": "profile", + "profile:username": "bennobuilder.bsky.social", + "og:url": "https://bsky.app/profile/bennobuilder.bsky.social", + "og:title": "Benno Builder (@bennobuilder.bsky.social)", + "description": "passionate builder :)", + "og:description": "passionate builder :)", + "twitter:card": "summary", + "twitter:label1": "Account DID", + "twitter:value1": "did:plc:hs2ktvfcphpglp6dami7cpxg" + }, + "link": { + "preconnect": "https://bsky.social", + "preload": "https://web-cdn.bsky.app/static/media/InterVariable.c504db5c06caaf7cdfba.woff2", + "stylesheet": "https://web-cdn.bsky.app/static/css/main.b583bf74.css", + "apple-touch-icon": "https://web-cdn.bsky.app/static/apple-touch-icon.png", + "icon": "https://web-cdn.bsky.app/static/favicon-16x16.png", + "mask-icon": "https://web-cdn.bsky.app/static/safari-pinned-tab.svg", + "alternate": "at://did:plc:hs2ktvfcphpglp6dami7cpxg/app.bsky.actor.profile/self" + }, + "title": "@bennobuilder.bsky.social on Bluesky" +} diff --git a/packages/head-metadata/src/__tests__/resources/e2e/google-unformatted.html b/packages/head-metadata/src/__tests__/resources/e2e/google-unformatted.html new file mode 100644 index 00000000..a07f6846 --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/google-unformatted.html @@ -0,0 +1,14 @@ + Google



 

Erweiterte Suche

© 2025 - Datenschutzerkl�rung - Nutzungsbedingungen

\ No newline at end of file diff --git a/packages/head-metadata/src/__tests__/resources/e2e/google-unformatted.json b/packages/head-metadata/src/__tests__/resources/e2e/google-unformatted.json new file mode 100644 index 00000000..ee386090 --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/google-unformatted.json @@ -0,0 +1,15 @@ +{ + "link": {}, + "meta": { + "og:description": "Bundestagswahl 2025! #GoogleDoodle", + "og:image": "https://www.google.com/logos/doodles/2025/german-federal-election-2025-6753651837110659-2x.png", + "og:image:height": "460", + "og:image:width": "1150", + "twitter:card": "summary_large_image", + "twitter:description": "Bundestagswahl 2025! #GoogleDoodle", + "twitter:image": "https://www.google.com/logos/doodles/2025/german-federal-election-2025-6753651837110659-2x.png", + "twitter:site": "@GoogleDoodles", + "twitter:title": "Bundestagswahl 2025" + }, + "title": "Google" +} diff --git a/packages/head-metadata/src/__tests__/resources/e2e/google.html b/packages/head-metadata/src/__tests__/resources/e2e/google.html new file mode 100644 index 00000000..7638a236 --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/google.html @@ -0,0 +1,920 @@ + + + + + + + + + + + + + + + Google + + + + + + + +
+
+ Suche + Bilder + Maps + Play + YouTube + News + Gmail + Drive + Mehr » +
+ +
+
+
+
+
+
+

+
+
+ + + + + + +
  + +
+ +
+
+ + +
+ Erweiterte Suche +
+ + +
+

+ +

+ © 2025 - Datenschutzerkl�rung - + Nutzungsbedingungen +

+
+ + + + + diff --git a/packages/head-metadata/src/__tests__/resources/e2e/google.json b/packages/head-metadata/src/__tests__/resources/e2e/google.json new file mode 100644 index 00000000..ee386090 --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/google.json @@ -0,0 +1,15 @@ +{ + "link": {}, + "meta": { + "og:description": "Bundestagswahl 2025! #GoogleDoodle", + "og:image": "https://www.google.com/logos/doodles/2025/german-federal-election-2025-6753651837110659-2x.png", + "og:image:height": "460", + "og:image:width": "1150", + "twitter:card": "summary_large_image", + "twitter:description": "Bundestagswahl 2025! #GoogleDoodle", + "twitter:image": "https://www.google.com/logos/doodles/2025/german-federal-election-2025-6753651837110659-2x.png", + "twitter:site": "@GoogleDoodles", + "twitter:title": "Bundestagswahl 2025" + }, + "title": "Google" +} diff --git a/packages/head-metadata/src/__tests__/resources/e2e/paddle.html b/packages/head-metadata/src/__tests__/resources/e2e/paddle.html new file mode 100644 index 00000000..ab84c14b --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/paddle.html @@ -0,0 +1,1229 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Paddle - Payments, tax and subscription management for SaaS and digital products + + + + + + + + + + + + + +
+

Affected by Digital River winding down?

+ We’ve got you covered in 48 hours +
+ + + + +
+
+ +
+
+

Put your billing operations on autopilot

+
+

As a merchant of record, we manage your payments, tax and compliance needs, so you can focus on growth.

+
+ HubX Logo + MacPaw Logo + Runna Logo + Geoguessr Logo +
+ +
+
+
+ +
+
+
+ +
+ Platform Collage +
+
+
+
+
+ + +
+
+
+ Products +

Your all-in-one payments +infrastructure

+
+
+
+
+ + +
+
+
+
+
+

Billing

+

The only complete billing solution for digital products. Payments, tax, subscription management and more, all handled for you.

+ Discover Billing +
+ SaaS billing dashboard +
+
+
+

ProfitWell Metrics

+

Keep a finger on the pulse of your business with accurate, accessible revenue reporting for subscription and SaaS companies - completely free.

+ Discover ProfitWell Metrics +
+ Metrics SaaS analytics dashboard +
+
+
+

Retain

+

A smarter way to recover failed payments, Retain automatically recovers failed card payments and increases customer retention. Set it up once and we’ll do the rest.

+ Discover Retain +
+ Retain automatic customer retention software +
+
+
+
+ + +
+
+
+ + +
+
+
+ Results +

Over 5,000+ software businesses use Paddle to scale their commercial operations

+
+ +
+
+ + + + + +
+
+
+
+

+ 122 million + transactions processed +

+
+
+

+ $89 million + in sales taxes remitted last year +

+
+
+

+ 5,000+ + customers using Paddle +

+
+
+
+
+ + +
+
+
+ Fortinet + MacPaw + Laravel + Adaptavist + GeoGuessr + n8n.io + tailwind labs + removebg + BeyondCode + Daylite + GETBLOCK +
+
+ Fortinet + MacPaw + Laravel + Adaptavist + GeoGuessr + n8n.io + tailwind labs + removebg + BeyondCode + Daylite + GETBLOCK +
+
+
+ + +
+
+
+ + +
+
+
+ Our model +

How is Paddle different?

+

Paddle provides more than just the plumbing for your revenue. As a merchant of record, we do it for you.

+ + What is a merchant of record? + +
+
+
+ +

Build and maintain relationships with payment providers

+
+
+ +

Take on liability for charging and remitting sales taxes, globally

+
+
+ +

Take on liability for all fraud that takes place on our platform

+
+
+ +

Reconcile your revenue data across billing and payment methods

+
+
+ +

Handle all billing-related support queries for you

+
+
+ +

Reduce churn by recovering failed payments

+
+
+
+
+ + +
+
+
+ +
+

Join 5,000+ businesses already growing with Paddle

+

We built the complete payment stack, so you don‘t have to

+ +
+ +
+
+
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/head-metadata/src/__tests__/resources/e2e/paddle.json b/packages/head-metadata/src/__tests__/resources/e2e/paddle.json new file mode 100644 index 00000000..889e0d52 --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/paddle.json @@ -0,0 +1,20 @@ +{ + "link": { + "canonical": "https://www.paddle.com", + "preload": "https://js.hsforms.net/forms/v2.js", + "stylesheet": "/_next/static/css/9200dd3d7924dc8e.css" + }, + "meta": { + "description": "We are the merchant of record for B2B & B2C software businesses. Helping increase global conversions, reducing churn, staying compliant, and scaling up fast.", + "og:description": "We are the merchant of record for B2B & B2C software businesses. Helping increase global conversions, reducing churn, staying compliant, and scaling up fast.", + "og:image": "https://images.prismic.io/paddle/a01787c4-75fa-408c-8b63-16a85b255826_paddle-share-image.png?auto=compress,format&rect=0,19,1200,591&w=1280&h=630", + "og:title": "Paddle - Payments, tax and subscription management for SaaS and digital products", + "robots": "index", + "twitter:card": "summary_large_image", + "twitter:description": "We are the merchant of record for B2B & B2C software businesses. Helping increase global conversions, reducing churn, staying compliant, and scaling up fast.", + "twitter:image": "https://images.prismic.io/paddle/a01787c4-75fa-408c-8b63-16a85b255826_paddle-share-image.png?auto=compress,format&rect=0,14,1200,600&w=1024&h=512", + "twitter:title": "Paddle - Payments, tax and subscription management for SaaS and digital products", + "viewport": "width=device-width, initial-scale=1, maximum-scale=5" + }, + "title": "Paddle - Payments, tax and subscription management for SaaS and digital products" +} diff --git a/packages/head-metadata/src/__tests__/resources/e2e/starterstory.html b/packages/head-metadata/src/__tests__/resources/e2e/starterstory.html new file mode 100644 index 00000000..a91973f4 --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/starterstory.html @@ -0,0 +1,2998 @@ + + + + Starter Story: Learn How People Are Starting Successful Businesses + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+
+ +
+ + + + +
+
+ + + + + +
+
+
+
+
+ +
Starter Story
+
+
+ Unlock the secrets to 7-figure online businesses +
+
+ Dive into our database of 4,413 case studies & join our community of thousands of + successful founders. +
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + Join thousands of founders +
+
+ +
+
+ + + + + +
+ + + + + + + + + + + + + + + + +
+ + + + diff --git a/packages/head-metadata/src/__tests__/resources/e2e/starterstory.json b/packages/head-metadata/src/__tests__/resources/e2e/starterstory.json new file mode 100644 index 00000000..ade8b483 --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/starterstory.json @@ -0,0 +1,26 @@ +{ + "title": "Starter Story: Learn How People Are Starting Successful Businesses", + "meta": { + "description": "Starter Story interviews successful entrepreneurs and shares the stories behind their businesses. In each interview, we ask how they got started, how they grew, and how they run their business today.", + "og:title": "Starter Story: Learn How People Are Starting Successful Businesses", + "og:description": "Starter Story interviews successful entrepreneurs and shares the stories behind their businesses. In each interview, we ask how they got started, how they grew, and how they run their business today.", + "og:url": "https://www.starterstory.com", + "og:image": "https://d1coqmn8qm80r4.cloudfront.net/production/images/6fd0cbcde3a17eb5", + "og:image:width": "1024", + "og:image:height": "512", + "twitter:card": "summary_large_image", + "twitter:site": "@starter_story", + "twitter:title": "Starter Story: Learn How People Are Starting Successful Businesses", + "twitter:description": "Starter Story interviews successful entrepreneurs and shares the stories behind their businesses. In each interview, we ask how they got started, how they grew, and how they run their business today.", + "twitter:creator": "@thepatwalls", + "twitter:image": "https://d1coqmn8qm80r4.cloudfront.net/production/images/6fd0cbcde3a17eb5", + "viewport": "width=device-width, initial-scale=1.0", + "csrf-param": "authenticity_token", + "csrf-token": "Y/r2blv1BSrDAQ+KLrVUMUqVgc5W0UQXdS5chzqCA3BGvpgvK4LKV6rZteu/Ef4ApzMHW5k04EXQvR4wmg4EQg==" + }, + "link": { + "stylesheet": "https://d1kpq1xlswihti.cloudfront.net/assets/non_essential-5983ca74615a995e158d7aefdbad7fb8f78ee5eb023bc4852a51b13135b36d7b.css", + "icon": "https://d1kpq1xlswihti.cloudfront.net/assets/starterstory_favicon-1d56fe8e0cb50101dd68673cc80986ee5e7b409621e7da39a5154ac25d36bfb2.ico", + "alternate": "https://www.starterstory.com/feed?format=rss" + } +} diff --git a/packages/head-metadata/src/__tests__/resources/e2e/youtube.html b/packages/head-metadata/src/__tests__/resources/e2e/youtube.html new file mode 100644 index 00000000..dc47c913 --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/youtube.html @@ -0,0 +1,15804 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Jai Howitt - YouTube + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + + + + + +
+
+
+
+
+
+ About + Press + Copyright + Contact us + Creators + Advertise + Developers + Impressum + Cancel Memberships + Terms + Privacy + Policy &Safety + How YouTube works + Test new features + +
+ + + + + + + + + + + + + + + + + + diff --git a/packages/head-metadata/src/__tests__/resources/e2e/youtube.json b/packages/head-metadata/src/__tests__/resources/e2e/youtube.json new file mode 100644 index 00000000..ac96a766 --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/youtube.json @@ -0,0 +1,51 @@ +{ + "meta": { + "theme-color": "rgba(255, 255, 255, 0.98)", + "description": "Building in public. Weekly raw episodes on Mondays & some other stuff sprinkled in.Much love,Jaihttps://www.instagram.com/jai.journeys", + "keywords": "Just Jai howitt artofmondays art of mondays", + "og:title": "Jai Howitt", + "og:site_name": "YouTube", + "og:url": "https://www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA", + "og:image": "https://yt3.googleusercontent.com/ytc/AIdro_n4YnbRzb7802She3I2Tq7lPWbXsDhHItRK8SX6ImO3wtg=s900-c-k-c0x00ffffff-no-rj", + "og:image:width": "900", + "og:image:height": "900", + "og:description": "Building in public. Weekly raw episodes on Mondays & some other stuff sprinkled in.\n\nMuch love,\nJai\n\nhttps://www.instagram.com/jai.journeys\n", + "al:ios:app_store_id": "544007664", + "al:ios:app_name": "YouTube", + "al:ios:url": "vnd.youtube://www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA", + "al:android:url": "https://www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA?feature=applinks", + "al:android:app_name": "YouTube", + "al:android:package": "com.google.android.youtube", + "al:web:url": "https://www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA?feature=applinks", + "og:type": "profile", + "og:video:tag": "mondays", + "fb:app_id": "87741124305", + "twitter:card": "summary", + "twitter:site": "@youtube", + "twitter:url": "https://www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA", + "twitter:title": "Jai Howitt", + "twitter:description": "Building in public. Weekly raw episodes on Mondays & some other stuff sprinkled in.\n\nMuch love,\nJai\n\nhttps://www.instagram.com/jai.journeys\n", + "twitter:image": "https://yt3.googleusercontent.com/ytc/AIdro_n4YnbRzb7802She3I2Tq7lPWbXsDhHItRK8SX6ImO3wtg=s900-c-k-c0x00ffffff-no-rj", + "twitter:app:name:iphone": "YouTube", + "twitter:app:id:iphone": "544007664", + "twitter:app:name:ipad": "YouTube", + "twitter:app:id:ipad": "544007664", + "twitter:app:url:iphone": "vnd.youtube://www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA", + "twitter:app:url:ipad": "vnd.youtube://www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA", + "twitter:app:name:googleplay": "YouTube", + "twitter:app:id:googleplay": "com.google.android.youtube", + "twitter:app:url:googleplay": "https://www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA" + }, + "title": "Jai Howitt - YouTube", + "link": { + "shortcut icon": "https://www.youtube.com/s/desktop/8df21d66/img/logos/favicon.ico", + "icon": "https://www.youtube.com/s/desktop/8df21d66/img/logos/favicon_144x144.png", + "preload": "https://www.youtube.com/s/_/ytmainappweb/_/js/k=ytmainappweb.kevlar_base.en_US.QVf4ASTs3oE.es5.O/d=0/br=1/rs=AGKMywFzBElaMTZqv0bMqJHrSdSpi4kx7A", + "stylesheet": "https://www.youtube.com/s/_/ytmainappweb/_/ss/k=ytmainappweb.kevlar_base.HrTonLT-ODE.L.B1.O/am=AAAECQ/d=0/br=1/rs=AGKMywEP101ZRKVcNYsT1M6YX5N_tcp9IA", + "search": "https://www.youtube.com/opensearch?locale=en_US", + "manifest": "/manifest.webmanifest", + "canonical": "https://www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA", + "alternate": "ios-app://544007664/vnd.youtube/www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA", + "image_src": "https://yt3.googleusercontent.com/ytc/AIdro_n4YnbRzb7802She3I2Tq7lPWbXsDhHItRK8SX6ImO3wtg=s900-c-k-c0x00ffffff-no-rj" + } +} diff --git a/packages/head-metadata/src/extract-head-metadata.test.ts b/packages/head-metadata/src/extract-head-metadata.test.ts new file mode 100644 index 00000000..f339e781 --- /dev/null +++ b/packages/head-metadata/src/extract-head-metadata.test.ts @@ -0,0 +1,31 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { extractHeadMetadata } from './extract-head-metadata'; +import { linkExtractor, metaExtractor, titleExtractor } from './extractors'; + +describe('extractHeadMetadata', () => { + it('should extract metadata from all test files', async () => { + // Get all HTML files from the e2e directory + const testFiles = await readdir(join(__dirname, './__tests__/resources/e2e')); + const htmlFiles = testFiles.filter((file) => file.endsWith('.html')); + + // Test each HTML file + for (const htmlFile of htmlFiles) { + const filePath = join(__dirname, './__tests__/resources/e2e', htmlFile); + const html = await readFile(filePath, 'utf-8'); + + // Get corresponding JSON file + const jsonPath = filePath.replace('.html', '.json'); + const expectedResults = JSON.parse(await readFile(jsonPath, 'utf-8')); + + // Extract metadata and assert + const metadata = await extractHeadMetadata(html, { + meta: metaExtractor, + title: titleExtractor, + link: linkExtractor + }); + expect(metadata).toEqual(expectedResults); + } + }); +}); diff --git a/packages/head-metadata/src/extract-head-metadata.ts b/packages/head-metadata/src/extract-head-metadata.ts new file mode 100644 index 00000000..02ea69a8 --- /dev/null +++ b/packages/head-metadata/src/extract-head-metadata.ts @@ -0,0 +1,115 @@ +import { htmlConfig, select, TXmlNode } from 'xml-tokenizer'; +import { TExtractCollectionKeys, TExtractors, TExtractSingleKeys } from './types'; + +export function extractHeadMetadata( + html: string, + extractors: GExtractors +): TExtractMetadata { + const metadata: TExtractMetadata = Object.keys(extractors).reduce((acc, key) => { + // @ts-expect-error -- We know that the key is a valid key since it comes from the extractors object + acc[key] = {}; + return acc; + }, {} as TExtractMetadata); + const stack: TXmlNode[] = []; + + select( + html, + [[{ axis: 'self-or-descendant', local: 'head' }]], + (token, stream) => { + switch (token.type) { + // Since HTML only has one head element, we can just go to the end of the file + // after we've processed the first head element + case 'SelectionEnd': { + stream.goToEnd(); + break; + } + case 'ElementStart': { + if (token.local in extractors) { + const newNode: TXmlNode = { + local: token.local, + prefix: token.prefix.length > 0 ? token.prefix : undefined, + attributes: [], + content: [] + }; + + const currentNode = stack[stack.length - 1]; + if (currentNode != null) { + currentNode.content.push(newNode); + } + + stack.push(newNode); + } + break; + } + case 'ElementEnd': { + if (token.end.type === 'Close' || token.end.type === 'Empty') { + const node = stack[stack.length - 1]; + if (node == null) { + break; + } + + const extractor = extractors[node.local]; + if (extractor != null) { + switch (extractor.type) { + case 'collection': { + const result = extractor.callback(node); + if (result != null) { + (metadata as any)[extractor.parent][result.key] = result.value; + } + break; + } + case 'single': { + const value = extractor.callback(node); + if (value != null) { + (metadata as any)[extractor.key] = value; + } + break; + } + } + } + + stack.pop(); + } + break; + } + case 'Attribute': { + const currentNode = stack[stack.length - 1]; + if (currentNode != null) { + currentNode.attributes.push({ + local: token.local, + prefix: token.prefix.length > 0 ? token.prefix : undefined, + value: token.value + }); + } + break; + } + case 'Text': + case 'Cdata': { + const currentNode = stack[stack.length - 1]; + if (currentNode != null) { + const trimmedText = token.text.trim(); + if (trimmedText.length > 0) { + currentNode.content.push(token.text); + } + } + break; + } + case 'Comment': + case 'ProcessingInstruction': + case 'EntityDeclaration': + case 'SelectionStart': + } + }, + htmlConfig + ); + + return metadata; +} + +export type TExtractMetadata = { + // Single value fields + [K in TExtractSingleKeys]: string; +} & { + // Collection fields + [K in TExtractCollectionKeys]: Record; +}; diff --git a/packages/head-metadata/src/extractors/index.ts b/packages/head-metadata/src/extractors/index.ts new file mode 100644 index 00000000..7961638a --- /dev/null +++ b/packages/head-metadata/src/extractors/index.ts @@ -0,0 +1,3 @@ +export * from './link-extractor'; +export * from './meta-extractor'; +export * from './title-extractor'; diff --git a/packages/head-metadata/src/extractors/link-extractor.ts b/packages/head-metadata/src/extractors/link-extractor.ts new file mode 100644 index 00000000..c8674ef9 --- /dev/null +++ b/packages/head-metadata/src/extractors/link-extractor.ts @@ -0,0 +1,15 @@ +import { TCollectionExtractor } from '../types'; + +export const linkExtractor = { + type: 'collection' as const, + parent: 'link' as const, + callback: (node) => { + const rel = node.attributes.find((a) => a.local === 'rel'); + const href = node.attributes.find((a) => a.local === 'href'); + if (rel != null && href != null) { + return { key: rel.value, value: href.value }; + } + + return null; + } +} satisfies TCollectionExtractor; diff --git a/packages/head-metadata/src/extractors/meta-extractor.ts b/packages/head-metadata/src/extractors/meta-extractor.ts new file mode 100644 index 00000000..78515906 --- /dev/null +++ b/packages/head-metadata/src/extractors/meta-extractor.ts @@ -0,0 +1,20 @@ +import { TCollectionExtractor } from '../types'; + +export const metaExtractor = { + type: 'collection' as const, + parent: 'meta' as const, + callback: (node) => { + const charset = node.attributes.find((a) => a.local === 'charset'); + if (charset != null) { + return { key: 'charset', value: charset.value }; + } + + const name = node.attributes.find((a) => a.local === 'name' || a.local === 'property'); + const content = node.attributes.find((a) => a.local === 'content'); + if (name != null && content != null) { + return { key: name.value, value: content.value }; + } + + return null; + } +} satisfies TCollectionExtractor; diff --git a/packages/head-metadata/src/extractors/title-extractor.ts b/packages/head-metadata/src/extractors/title-extractor.ts new file mode 100644 index 00000000..31876088 --- /dev/null +++ b/packages/head-metadata/src/extractors/title-extractor.ts @@ -0,0 +1,10 @@ +import { TSingleExtractor } from '../types'; + +export const titleExtractor = { + type: 'single' as const, + key: 'title' as const, + callback: (node) => { + const text = node.content[0]; + return typeof text === 'string' ? text.trim() : null; + } +} satisfies TSingleExtractor; diff --git a/packages/head-metadata/src/index.ts b/packages/head-metadata/src/index.ts new file mode 100644 index 00000000..671c6847 --- /dev/null +++ b/packages/head-metadata/src/index.ts @@ -0,0 +1,3 @@ +export * from './extract-head-metadata'; +export * from './extractors'; +export * from './types'; diff --git a/packages/head-metadata/src/types.ts b/packages/head-metadata/src/types.ts new file mode 100644 index 00000000..ead54603 --- /dev/null +++ b/packages/head-metadata/src/types.ts @@ -0,0 +1,34 @@ +export interface TXmlNode { + local: string; + prefix?: string; + attributes: { local: string; prefix?: string; value: string }[]; + content: (TXmlNode | string)[]; +} + +export type TCollectionExtractor = { + type: 'collection'; + parent: string; + callback: (node: TXmlNode) => { key: string; value: string } | null; +}; + +export type TSingleExtractor = { + type: 'single'; + key: string; + callback: (node: TXmlNode) => string | null; +}; + +export type TExtractor = TCollectionExtractor | TSingleExtractor; + +export type TExtractors = { + [K: string]: TExtractor; +}; + +export type TExtractCollectionKeys = { + [K in keyof GExtractors]: GExtractors[K] extends TCollectionExtractor + ? GExtractors[K]['parent'] + : never; +}[keyof GExtractors]; + +export type TExtractSingleKeys = { + [K in keyof GExtractors]: GExtractors[K] extends TSingleExtractor ? GExtractors[K]['key'] : never; +}[keyof GExtractors]; diff --git a/packages/head-metadata/tsconfig.json b/packages/head-metadata/tsconfig.json new file mode 100644 index 00000000..bf70a3c1 --- /dev/null +++ b/packages/head-metadata/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@blgc/config/typescript/library", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declarationDir": "./dist/types" + }, + "include": ["src"], + "exclude": ["**/__tests__/*", "**/*.test.ts"] +} diff --git a/packages/head-metadata/tsconfig.prod.json b/packages/head-metadata/tsconfig.prod.json new file mode 100644 index 00000000..01151c39 --- /dev/null +++ b/packages/head-metadata/tsconfig.prod.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declarationMap": false + } +} diff --git a/packages/head-metadata/vitest.config.mjs b/packages/head-metadata/vitest.config.mjs new file mode 100644 index 00000000..8482b939 --- /dev/null +++ b/packages/head-metadata/vitest.config.mjs @@ -0,0 +1,4 @@ +import { nodeConfig } from '@blgc/config/vite/node'; +import { defineConfig, mergeConfig } from 'vitest/config'; + +export default mergeConfig(nodeConfig, defineConfig({})); diff --git a/packages/kleinanzeigen-client/README.md b/packages/kleinanzeigen-client/README.md new file mode 100644 index 00000000..4519b73c --- /dev/null +++ b/packages/kleinanzeigen-client/README.md @@ -0,0 +1,115 @@ +

+ google-webfonts-client banner +

+ +

+ + GitHub License + + + NPM bundle minzipped size + + + NPM total downloads + + + Join Discord + +

+ +> Status: Experimental + +`google-webfonts-client` is a typesafe and straightforward fetch client for interacting with the Google Web Fonts API using [`feature-fetch`](https://github.com/builder-group/community/tree/develop/packages/feature-fetch). This client provides typesafe methods for fetching and downloading Google Fonts. + +- [Google Fonts Developer API Docs](https://developers.google.com/fonts/docs/developer_api) + +## 📖 Usage + +### Create a Google Web Fonts Client + +Use `createGoogleWebfontsClient()` to create a client with your API key. + +```ts +import { createGoogleWebfontsClient } from 'google-webfonts-client'; + +const client = createGoogleWebfontsClient({ + apiKey: 'YOUR_API_KEY' +}); +``` + +### Fetch Available Web Fonts + +Fetches the available web fonts from the Google Fonts API. + +```ts +const webFontsResult = await client.getWebFonts(); +const webFonts = webFontsResult.unwrap(); +``` + +### Fetch Font File URL + +Fetches the URL of a specific font file based on the provided family, weight, and style. + +```ts +const fontUrlResult = await client.getFontFileUrl('Roboto Serif', { + fontWeight: 400, + fontStyle: 'regular' +}); +const fontUrl = fontUrlResult.unwrap(); +``` + +### Download a Font File + +Use the client to download a font file, specifying the font family, weight, and style. + +```ts +const fontFileResult = await client.downloadFontFile('Roboto Serif', { + fontWeight: 100, + fontStyle: 'italic' +}); +const fontFile = fontFileResult.unwrap(); +``` + +### Error Handling + +Errors can occur during API requests, and the client will return detailed error information. Possible error types include: + +- **`NetworkError`**: Indicates a failure in network communication, such as loss of connectivity +- **`RequestError`**: Occurs when the server returns a response with a status code indicating an error (e.g., 4xx or 5xx) +- **`FetchError`**: A general exception type that can encompass other error scenarios not covered by `NetworkError` or `RequestError`, for example when the response couldn't be parsed, .. + +```ts +const fontUrlResult = await client.getFontFileUrl('Roboto Serif', { + fontWeight: 400, + fontStyle: 'regular' +}); + +// First Approach: Handle error using `isErr()` +if (fontUrlResult.isErr()) { + const { error } = fontUrlResult; + if (error instanceof NetworkError) { + console.error('Network error:', error.message); + } else if (error instanceof RequestError) { + console.error('Request error:', error.message, 'Status:', error.status); + } else if (error instanceof FetchError) { + console.error('Service error:', error.message, 'Code:', error.code); + } else { + console.error('Unexpected error:', error); + } +} + +// Second Approach: Unwrap response with `try-catch` +try { + const fontUrl = fontUrlResult.unwrap(); +} catch (error) { + if (error instanceof NetworkError) { + console.error('Network error:', error.message); + } else if (error instanceof RequestError) { + console.error('Request error:', error.message, 'Status:', error.status); + } else if (error instanceof FetchError) { + console.error('Service error:', error.message, 'Code:', error.code); + } else { + console.error('Unexpected error:', error); + } +} +``` diff --git a/packages/kleinanzeigen-client/eslint.config.js b/packages/kleinanzeigen-client/eslint.config.js new file mode 100644 index 00000000..275e54fa --- /dev/null +++ b/packages/kleinanzeigen-client/eslint.config.js @@ -0,0 +1,5 @@ +/** + * @see https://eslint.org/docs/latest/use/configure/configuration-files + * @type {import("eslint").Linter.Config} + */ +module.exports = [...require('@blgc/config/eslint/library')]; diff --git a/packages/kleinanzeigen-client/package.json b/packages/kleinanzeigen-client/package.json new file mode 100644 index 00000000..74da270d --- /dev/null +++ b/packages/kleinanzeigen-client/package.json @@ -0,0 +1,52 @@ +{ + "name": "kleinanzeigen-client", + "version": "0.0.1", + "private": false, + "description": "Typesafe and straightforward fetch client for interacting with the Kleinanzeigen API using feature-fetch", + "keywords": [], + "homepage": "https://builder.group/?source=package-json", + "bugs": { + "url": "https://github.com/builder-group/community/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/builder-group/community.git" + }, + "license": "MIT", + "author": "@bennobuilder", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "source": "./src/index.ts", + "types": "./dist/types/index.d.ts", + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "shx rm -rf dist && rollup -c rollup.config.js", + "build:prod": "export NODE_ENV=production && pnpm build", + "clean": "shx rm -rf dist && shx rm -rf .turbo && shx rm -rf node_modules", + "install:clean": "pnpm run clean && pnpm install", + "lint": "eslint . --fix", + "openapi:generate": "npx openapi-typescript ./resources/openapi-v1.yaml -o ./src/gen/v1.ts", + "publish:patch": "pnpm build:prod && pnpm version patch && pnpm publish --no-git-checks --access=public", + "size": "size-limit --why", + "start:dev": "tsc -w", + "test": "vitest run", + "update:latest": "pnpm update --latest" + }, + "dependencies": { + "feature-fetch": "workspace:*", + "xml-tokenizer": "workspace:*" + }, + "devDependencies": { + "@blgc/config": "workspace:*", + "@types/node": "^22.15.21", + "rollup-presets": "workspace:*" + }, + "size-limit": [ + { + "path": "dist/esm/index.js" + } + ] +} diff --git a/packages/kleinanzeigen-client/rollup.config.js b/packages/kleinanzeigen-client/rollup.config.js new file mode 100644 index 00000000..d09fd346 --- /dev/null +++ b/packages/kleinanzeigen-client/rollup.config.js @@ -0,0 +1,6 @@ +const { libraryPreset } = require('rollup-presets'); + +/** + * @type {import('rollup').RollupOptions[]} + */ +module.exports = libraryPreset(); diff --git a/packages/kleinanzeigen-client/src/__tests__/playground.test.ts b/packages/kleinanzeigen-client/src/__tests__/playground.test.ts new file mode 100644 index 00000000..1292e494 --- /dev/null +++ b/packages/kleinanzeigen-client/src/__tests__/playground.test.ts @@ -0,0 +1,39 @@ +import fs from 'fs'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; +import { extractAdsData } from '../extract-ads'; +import { fetchAds } from '../fetch-ads'; + +describe('playground', () => { + it('should pass', () => { + expect(true).toBe(true); + }); + + describe('should work', () => { + it('should fetch ads with basic search', async () => { + const result = await fetchAds({ + query: 'laptop' + }); + + expect(result).toBeDefined(); + expect(result.html).toBeDefined(); + + fs.writeFileSync( + path.resolve(__dirname, './resources/e2e/s-laptop.html'), + result.html, + 'utf-8' + ); + }); + + it('should extract complete listing data', () => { + const html = fs.readFileSync( + path.resolve(__dirname, './resources/e2e/s-laptop.html'), + 'utf-8' + ); + + const listings = extractAdsData(html); + + console.log('Extracted listings:', JSON.stringify(listings, null, 2)); + }); + }); +}); diff --git a/packages/kleinanzeigen-client/src/__tests__/resources/e2e/s-laptop.html b/packages/kleinanzeigen-client/src/__tests__/resources/e2e/s-laptop.html new file mode 100644 index 00000000..7dc75889 --- /dev/null +++ b/packages/kleinanzeigen-client/src/__tests__/resources/e2e/s-laptop.html @@ -0,0 +1,7700 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Laptop kleinanzeigen.de + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+
+ +
+ + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + +
+
+ +

1 - 25 von 122.854 Ergebnissen für „laptop“ in Deutschland +

+
+ +
+ + + + Sortieren nach: + + + + + + + + + + + +
+
Neueste
+ +
    + + + + + + + +
  • + Neueste +
  • + + + + + + + +
  • + Niedrigster Preis +
  • + + + + + + + +
  • + Höchster Preis +
  • + +
+
+
+
+ +
+
+
+ + + + + + + + + + +
+ + + + +
+ +
+
+

Kategorien

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Elektronik + + + +  (94.673) + + + + + + +
      + +
    • + + + + + + + + + + + + + + + + + + + Notebooks + + + +  (79.564) + + + + + +
    • + +
    • + + + + + + + + + + + + + + + + + + + PC-Zubehör & Software + + + +  (12.093) + + + + + +
    • + + +
    • mehr
    • + +
    + +
  • + +
  • + + + + + + + + + + + + + + + + + + Mode & Beauty + + + +  (20.251) + + + + + + + + +
  • + + +
  • Alle Kategorien
  • + +
+
+
+
+
+

Zustand in Notebooks

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Neu + + + +  (6.358) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Sehr Gut + + + +  (36.942) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Gut + + + +  (18.972) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + In Ordnung + + + +  (4.437) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Defekt + + + +  (3.745) + + + + + +
  • + + +
+
+
+
+
+

Versand in Notebooks

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Versand möglich + + + +  (63.696) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Nur Abholung + + + +  (15.480) + + + + + +
  • + + +
+
+
+
+
+

Preis

+
+
+
+
+ -
+ + +
+
+
+
+
+

Angebotstyp

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Angebote + + + +  (122.024) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Gesuche + + + +  (830) + + + + + +
  • + + +
+
+
+
+
+

Anbieter

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Privat + + + +  (116.652) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Gewerblich + + + +  (6.202) + + + + + +
  • + + +
+
+
+
+
+

Direkt kaufen

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Aktiv + + + +  (39.293) + + + + + +
  • + + +
+
+
+
+
+

Versand

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Versand möglich + + + +  (98.041) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Nur Abholung + + + +  (23.426) + + + + + +
  • + + +
+
+
+
+
+

Paketdienst

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + DHL + + + +  (77.039) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Hermes + + + +  (68.438) + + + + + +
  • + + +
+
+
+
+
+

Ort

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Baden-Württemberg + + + +  (16.188) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Bayern + + + +  (21.134) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Berlin + + + +  (7.918) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Brandenburg + + + +  (2.565) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Bremen + + + +  (1.097) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Hamburg + + + +  (4.042) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Hessen + + + +  (9.927) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Mecklenburg-Vorpommern + + + +  (1.350) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Niedersachsen + + + +  (11.495) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Nordrhein-Westfalen + + + +  (26.802) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Rheinland-Pfalz + + + +  (5.259) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Saarland + + + +  (1.200) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Sachsen + + + +  (5.158) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Sachsen-Anhalt + + + +  (1.902) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Schleswig-Holstein + + + +  (4.792) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Thüringen + + + +  (2.025) + + + + + +
  • + + +
+
+
+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ + + +
+ + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Apple MacBook Pro (14" 2021) mit Apple M1 Pro Chip Nordrhein-Westfalen - Velbert Vorschau + + + +
    + 2 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 42553 Velbert + + + +
    +
    + +
    +
    +
    +

    + + + + Apple MacBook Pro (14" 2021) mit Apple M1 Pro Chip + + +

    +

    Macbook Pro M1 Pro Chip in gutem Zustand . Es hat 32 GB Arbeitsspeicher und eine Festplatte der...

    + +
    +

    + 1.300 € + +

    +
    + +
    +
    +

    + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Lenovo ThinkPad P14s Gen 2 – 32GB RAM | 1TB SSD | Top Zustand Friedrichshain-Kreuzberg - Friedrichshain Vorschau + + + +
    + 5 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 10249 Friedrichshain + + + +
    +
    + +
    +
    +
    +

    + + + + Lenovo ThinkPad P14s Gen 2 – 32GB RAM | 1TB SSD | Top Zustand + + +

    +

    Deutsch + +Ich verkaufe mein Lenovo ThinkPad P14s Gen 2 – in ausgezeichnetem Zustand. Ideal für...

    + +
    +

    + 740 € + +

    +
    + +
    +
    +

    + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + +
    + + + + + +
    +
    + + + + +
    +
    +
    +
    + + 85077 Manching + + + +
    +
    + + + Heute, 10:19 + +
    +
    +
    +

    + + + Laptop für kleine Kinder + + + +

    +

    Laptop mit verschiedenen Spielen

    + +
    +

    + 25 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  • + +
    + +
  • + + + + + + + + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Asus Notebook 16 Zoll mit Laufwerk und Ziffernblock Nordrhein-Westfalen - Hagen Vorschau + + + +
    + 6 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 58119 Hagen + + + +
    +
    + + + Heute, 10:19 + +
    +
    +
    +

    + + + + Asus Notebook 16 Zoll mit Laufwerk und Ziffernblock + + +

    +

    Angeboten wird hier ein sehr alter, aber zuverlässiger Laptop. Er hat deutliche Gebrauchsspuren,...

    + +
    +

    + 1 € VB + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + 2 Dell Laptops Latitude E7440 Mecklenburg-Vorpommern - Ducherow Vorschau + + + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 17398 Ducherow + + + +
    +
    + + + Heute, 10:19 + +
    +
    +
    +

    + + + + 2 Dell Laptops Latitude E7440 + + +

    +

    120 GB , i5-4300u, 8 GB, er iner hat win 11, einer win 10, die Oberfläche bei der Handauflage des...

    + +
    +

    + 130 € VB + +

    +
    + +
    +
    +

    + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Laptoptasche Friedrichshain-Kreuzberg - Friedrichshain Vorschau + + + +
    + 5 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 10243 Friedrichshain + + + +
    +
    + + + Heute, 10:18 + +
    +
    +
    +

    + + + + Laptoptasche + + +

    +

    Laptoptasche +Höhe 35cm, Länge 40cm +Passend bis zu 19 Zoll + +Der Verkauf erfolgt unter Ausschluss...

    + +
    +

    + 14 € + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + HP RTL8723BE Laptop teildefekt Sachsen - Görlitz Vorschau + + + +
    + 8 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 02826 Görlitz + + + +
    +
    + + + Heute, 10:17 + +
    +
    +
    +

    + + + + HP RTL8723BE Laptop teildefekt + + +

    +

    Hallo + +verkaufe diesen Laptop teildefekt . Wie man sieht auf den Bildern sind , Na ja wie zwei...

    + +
    +

    + 60 € VB + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  • + +
    + +
  • + + + + + + + + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Tablet Laptop 2in1 Microsoft Surface Pro 5 128 GB Set Tastatur Nordrhein-Westfalen - Düren Vorschau + + + +
    + 3 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 52353 Düren + + + +
    +
    + + + Heute, 10:17 + +
    +
    +
    +

    + + + + Tablet Laptop 2in1 Microsoft Surface Pro 5 128 GB Set Tastatur + + +

    +

    guter, gebrauchter Zustand, keine Kratzer auf Display, voll funktionsfähig + +Surface Pro 1796 (5....

    + +
    +

    + 189 € VB + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + MacBook Air 13” (2019) – Top Zustand Nordrhein-Westfalen - Remscheid Vorschau + + + +
    + 6 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 42853 Remscheid + + + +
    +
    + + + Heute, 10:17 + +
    +
    +
    +

    + + + + MacBook Air 13” (2019) – Top Zustand + + +

    +

    Apple MacBook Air 13” (2019) – Top Zustand – 128 GB SSD, i5, 8 GB RAM + +Ich biete hier ein Apple...

    + +
    +

    + 340 € VB + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + tpplet und Boxen Nürnberg (Mittelfr) - Südstadt Vorschau + + + +
    + 3 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 90441 Südstadt + + + +
    +
    + + + Heute, 10:17 + +
    +
    +
    +

    + + + + tpplet und Boxen + + +

    +

    tepplet mit Boxen, guter Zustand , privat verkauf vom Umtausch ausgeschlossen,

    + +
    +

    + 100 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + HP ProBook 650 G5, 15,6" FHD, intel Core i5-8265U, 8GB DDR4, 256G Berlin - Tempelhof Vorschau + + + +
    + 6 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 12305 Tempelhof + + + +
    +
    + + + Heute, 10:16 + +
    +
    +
    +

    + + + + HP ProBook 650 G5, 15,6" FHD, intel Core i5-8265U, 8GB DDR4, 256G + + +

    +

    30525C + + +Hersteller HP + + +Model ProBook 650...

    + +
    +

    + 140 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  • + +
    + +
  • + + + + + + + + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Laptop Schoßablage München - Schwabing-West Vorschau + + + +
    + 3 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 80803 Schwabing-​West + + + +
    +
    + + + Heute, 10:15 + +
    +
    +
    +

    + + + + Laptop Schoßablage + + +

    +

    Laptop Schoßablage. Super, um den Laptop im Schoß abzustellen. + +— +Privatverkauf. Keine...

    + +
    +

    + 5 € VB + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + 2x 8GB Kingston DDR3L 1600MHz Laptop RAM (16GB) München - Sendling-Westpark Vorschau + + + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 80686 Sendling-​Westpark + + + +
    +
    + + + Heute, 10:15 + +
    +
    +
    +

    + + + + 2x 8GB Kingston DDR3L 1600MHz Laptop RAM (16GB) + + +

    +

    Verkaufe zwei gebrauchte, voll funktionsfähige Kingston 8GB DDR3L (PC3L-12800S) RAM-Module

    + +
    +

    + 30 € VB + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Lenovo Flex 15 Sachsen - Taucha Vorschau + + + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 04425 Taucha + + + +
    +
    + + + Heute, 10:15 + +
    +
    +
    +

    + + + + Lenovo Flex 15 + + +

    +

    zum verkauf steht ein funktionsfähiges 15 zoll Lenovo Flex +das Gerät stammt aus einer Auflösung...

    + +
    +

    + 79 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Apple MacBook 12” 2017 – Rose Gold  8GB RAM – 256GB Essen - Essen-Stadtmitte Vorschau + + + +
    + 6 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 45127 Essen-​Stadtmitte + + + +
    +
    + + + Heute, 10:15 + +
    +
    +
    +

    + + + + Apple MacBook 12” 2017 – Rose Gold 8GB RAM – 256GB + + +

    +

    Ich verkaufe mein gut erhaltenes Apple MacBook 12” aus dem Jahr 2017 in der Farbe Rose...

    + +
    +

    + 180 € + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  • + +
    + +
  • + + + + + + + + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Laptoptasche Schleswig-Holstein - Schuby Vorschau + + + +
    + 3 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 24850 Schuby + + + +
    +
    + + + Heute, 10:15 + +
    +
    +
    +

    + + + + Laptoptasche + + +

    +

    Verschenke diese Laptoptasche der Marke Samsonite + +Standort Lürschau

    + +
    +

    + Zu verschenken + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Wortmann Terra Mobile 1460P 14" FHD, intel Core i5-8200Y, 8GB DDR Berlin - Tempelhof Vorschau + + + +
    + 5 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 12305 Tempelhof + + + +
    +
    + + + Heute, 10:14 + +
    +
    +
    +

    + + + + Wortmann Terra Mobile 1460P 14" FHD, intel Core i5-8200Y, 8GB DDR + + +

    +

    Hersteller Wortmann Terra + +Model Mobile 1460P + + +Zustand gebraucht + +Optisch 2...

    + +
    +

    + 129 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Notebook HP 250 G5 Nürnberg (Mittelfr) - Oststadt Vorschau + + + +
    + 8 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 90489 Oststadt + + + +
    +
    + + + Heute, 10:14 + +
    +
    +
    +

    + + + + Notebook HP 250 G5 + + +

    +

    Notebook HP 250 G5 +- Intel Pentium N3710 (4x 1,6 GHz) +- 39,6cm 15,6" TFT Display +- 1366 x 768...

    + +
    +

    + 250 € + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Neue Laptoptasche von Targus Bochum - Bochum-Nord Vorschau + + + +
    + 6 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 44807 Bochum-​Nord + + + +
    +
    + + + Heute, 10:14 + +
    +
    +
    +

    + + + + Neue Laptoptasche von Targus + + +

    +

    Verkaufe neue Laptoptasche von Targus für max. 15 Zoll Laptop. Die Tasche hat viele Fächer und ist...

    + +
    +

    + 20 € VB + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  • + +
    + +
  • + + + + + + + + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Chromebook von ASUS CX1400CN Thüringen - Saalfeld (Saale) Vorschau + + + +
    + 4 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 07318 Saalfeld (Saale) + + + +
    +
    + + + Heute, 10:14 + +
    +
    +
    +

    + + + + Chromebook von ASUS CX1400CN + + +

    +

    verkaufe mein chromebook von Asus. Dieser Artikel wahr kaum in Benutzung. Wird aber nicht mehr...

    + +
    +

    + 65 € VB + +

    +
    + +
    +
    +

    + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Acer 13,3“ Notebook Niedersachsen - Schapen Vorschau + + + +
    + 9 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 48480 Schapen + + + +
    +
    + + + Heute, 10:13 + +
    +
    +
    +

    + + + + Acer 13,3“ Notebook + + +

    +

    Verkaufe einen voll funktionstüchtigen Acer ES1-311 Notebook. Der hat eine 128GB SSD Festplatte und...

    + +
    +

    + 100 € VB + +

    +
    + +
    +
    +

    + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + laptop  NOTEBOOOK  COMPUTER Essen - Rüttenscheid Vorschau + + + +
    + 5 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 45130 Rüttenscheid + + + +
    +
    + + + Heute, 10:12 + +
    +
    +
    +

    + + + + laptop NOTEBOOOK COMPUTER + + +

    +

    Ich verkaufe diesen Laptop mit Ladegerät, keine Festplatte

    + +
    +

    + 150 € VB + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + MacBook Pro 13 mit Touchbar Neuwertig 8GB/128GB Dortmund - Körne Vorschau + + + +
    + 6 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 44141 Körne + + + +
    +
    + + + Heute, 10:12 + +
    +
    +
    +

    + + + + MacBook Pro 13 mit Touchbar Neuwertig 8GB/128GB + + +

    +

    Biete hier mein MacBook Pro mit Touchbar von Ende 2019 zum Verkauf an. + +Das MacBook gehört noch zu...

    + +
    +

    + 450 € VB + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Mac Book Pro 2019 I7 1 TB 16 GB RAM Tausch gegen IPad Pro Niedersachsen - Lehrte Vorschau + + + +
    + 8 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 31275 Lehrte + + + +
    +
    + + + Heute, 10:11 + +
    +
    +
    +

    + + + + Mac Book Pro 2019 I7 1 TB 16 GB RAM Tausch gegen IPad Pro + + +

    +

    Moin, + +Würde hier mein MacBook Pro verkaufen / Tauschen gegen iPad Pro. + +Mac Book ist ein 2019...

    + +
    +

    + VB + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + MSI Alpha 17 Niedersachsen - Nienburg (Weser) Vorschau + + + +
    + 5 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 31582 Nienburg (Weser) + + + +
    +
    + + + Heute, 10:11 + +
    +
    +
    +

    + + + + MSI Alpha 17 + + +

    +

    Verkaufe mein Gaming Notebook von MSI bei Interesse einfach melden.

    + +
    +

    + 900 € VB + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Fujitsu Lifebook A3511 (FPC04951BS) - neu und unbenutzt Berlin - Schöneberg Vorschau + + + +
    + 7 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 10823 Schöneberg + + + +
    +
    + + + Heute, 10:11 + +
    +
    +
    +

    + + + + Fujitsu Lifebook A3511 (FPC04951BS) - neu und unbenutzt + + +

    +

    Daten: +Prozessor: Intel Core i3 +Arbeitsspeicher: DDR 4 - 8 GB +Displaygröße: 39,62 cm (15,6...

    + +
    +

    + 350 € + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + gaming laptop Asus ROG strix G13IC Berlin - Tempelhof Vorschau + + + +
    + 3 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 12103 Tempelhof + + + +
    +
    + + + Heute, 10:10 + +
    +
    +
    +

    + + + + gaming laptop Asus ROG strix G13IC + + +

    +

    Rtx 3050 +ryzen 7 400 +Bildschirm 1920.1080 17zoll +500gb +funktioniert sehr gut

    + +
    +

    + 700 € + +

    +
    + +
    +
    +

    + + +

    + +
    +
    +
    +
  • + + + +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + + + +
+

Ähnliche Suchanfragen

+ +
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+
+ +
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + diff --git a/packages/kleinanzeigen-client/src/extract-ads.ts b/packages/kleinanzeigen-client/src/extract-ads.ts new file mode 100644 index 00000000..433d18fa --- /dev/null +++ b/packages/kleinanzeigen-client/src/extract-ads.ts @@ -0,0 +1,279 @@ +import { extract, type TExtractor } from 'xml-tokenizer'; + +export type TListingData = { + id: string; + title?: string; + description?: string; + location?: string; + date?: string; + price?: number; + currency?: string; + priceText?: string; + imageUrl?: string; + tags?: string[]; + jsonLd?: any; +}; + +const elementTracker = { + context: { + elementPath: [] as string[], + currentElement: '', + currentClasses: [] as string[] + }, + extract: (token, cx) => { + if (token.type === 'ElementStart') { + cx.elementPath.push(token.local); + cx.currentElement = token.local; + cx.currentClasses = []; + } else if (token.type === 'Attribute' && token.local === 'class') { + cx.currentClasses = token.value.split(' ').filter((cls) => cls.trim()); + } else if (token.type === 'ElementEnd' && token.end.type === 'Close') { + cx.elementPath.pop(); + cx.currentElement = cx.elementPath[cx.elementPath.length - 1] || ''; + } + } +} satisfies TExtractor; + +const listingTracker = { + context: { + listings: [] as TListingData[], + currentListing: null as Partial | null + }, + deps: [elementTracker], + extract: (token, cx) => { + if (token.type === 'ElementStart' && token.local === 'article') { + cx.currentListing = {}; + } else if (token.type === 'Attribute' && token.local === 'data-adid' && cx.currentListing) { + cx.currentListing.id = token.value; + } else if ( + token.type === 'ElementEnd' && + token.end.type === 'Close' && + token.end.local === 'article' && + cx.currentListing?.id + ) { + cx.listings.push(cx.currentListing as TListingData); + cx.currentListing = null; + } + } +} satisfies TExtractor; + +const titleExtractor = { + context: {}, + deps: [elementTracker, listingTracker], + extract: (token, cx) => { + if (token.type === 'Text' && cx.currentListing && token.text.trim()) { + if (cx.currentElement === 'a' && cx.currentClasses.includes('ellipsis')) { + cx.currentListing.title = token.text.trim(); + } + } + } +} satisfies TExtractor; + +const descriptionExtractor = { + context: {}, + deps: [elementTracker, listingTracker], + extract: (token, cx) => { + if (token.type === 'Text' && cx.currentListing && token.text.trim()) { + if (cx.currentClasses.some((cls) => cls.includes('aditem-main--middle--description'))) { + cx.currentListing.description = token.text.trim(); + } + } + } +} satisfies TExtractor; + +const locationDateExtractor = { + context: { insideIcon: false }, + deps: [elementTracker, listingTracker], + extract: (token, cx) => { + if (token.type === 'ElementStart' && token.local === 'i') { + cx.insideIcon = true; + } else if ( + token.type === 'ElementEnd' && + token.end.type === 'Close' && + token.end.local === 'i' + ) { + cx.insideIcon = false; + } else if (token.type === 'Text' && cx.currentListing && token.text.trim() && !cx.insideIcon) { + const text = token.text.trim(); + + if ( + cx.currentClasses.some((cls) => cls.includes('aditem-main--top--left')) && + /\d{5}/.test(text) + ) { + cx.currentListing.location = text.replace(/^[^a-zA-Z0-9]*/, '').trim(); + } else if ( + cx.currentClasses.some((cls) => cls.includes('aditem-main--top--right')) && + (text.includes('Heute') || /\d{2}:\d{2}/.test(text) || /\d{2}\.\d{2}\.\d{4}/.test(text)) + ) { + cx.currentListing.date = text; + } + } + } +} satisfies TExtractor; + +const priceExtractor = { + context: { insideIcon: false }, + deps: [elementTracker, listingTracker], + extract: (token, cx) => { + if (token.type === 'ElementStart' && token.local === 'i') { + cx.insideIcon = true; + } else if ( + token.type === 'ElementEnd' && + token.end.type === 'Close' && + token.end.local === 'i' + ) { + cx.insideIcon = false; + } else if (token.type === 'Text' && cx.currentListing && token.text.trim() && !cx.insideIcon) { + const text = token.text.trim(); + + if ( + cx.currentClasses.some((cls) => cls.includes('aditem-main--middle--price-shipping--price')) + ) { + cx.currentListing.priceText = text; + const priceInfo = parsePrice(text); + cx.currentListing.price = priceInfo.amount; + cx.currentListing.currency = priceInfo.currency; + } + } + } +} satisfies TExtractor; + +const imageExtractor = { + context: {}, + deps: [elementTracker, listingTracker], + extract: (token, cx) => { + if ( + token.type === 'Attribute' && + token.local === 'src' && + cx.currentElement === 'img' && + cx.currentListing && + !cx.currentListing.imageUrl + ) { + if (token.value.includes('img.kleinanzeigen.de')) { + cx.currentListing.imageUrl = token.value; + } + } + } +} satisfies TExtractor; + +const tagExtractor = { + context: { insideIcon: false, inTagWithIcon: false }, + deps: [elementTracker, listingTracker], + extract: (token, cx) => { + if (token.type === 'ElementStart' && token.local === 'i') { + cx.insideIcon = true; + } else if ( + token.type === 'ElementEnd' && + token.end.type === 'Close' && + token.end.local === 'i' + ) { + cx.insideIcon = false; + } else if ( + token.type === 'ElementEnd' && + token.end.type === 'Close' && + token.end.local === 'span' + ) { + cx.inTagWithIcon = false; + } else if ( + token.type === 'Attribute' && + token.local === 'class' && + cx.currentElement === 'span' + ) { + const classes = token.value.split(' '); + if (classes.includes('tag-with-icon')) { + cx.inTagWithIcon = true; + } + } else if (token.type === 'Text' && cx.currentListing && token.text.trim()) { + const text = token.text.trim(); + + if ( + cx.currentElement === 'span' && + cx.currentClasses.includes('simpletag') && + text.length > 2 && + /[a-zA-Z]/.test(text) + ) { + if ((cx.inTagWithIcon && !cx.insideIcon) || !cx.inTagWithIcon) { + if (!cx.currentListing.tags) cx.currentListing.tags = []; + cx.currentListing.tags.push(text); + } + } + } + } +} satisfies TExtractor; + +const jsonLdExtractor = { + context: { insideScript: false, scriptContent: '' }, + deps: [elementTracker, listingTracker], + extract: (token, cx) => { + if (token.type === 'ElementStart' && token.local === 'script') { + cx.insideScript = false; + cx.scriptContent = ''; + } else if ( + token.type === 'Attribute' && + token.local === 'type' && + cx.currentElement === 'script' && + token.value === 'application/ld+json' + ) { + cx.insideScript = true; + } else if (token.type === 'Text' && cx.insideScript) { + cx.scriptContent += token.text; + } else if ( + token.type === 'ElementEnd' && + token.end.type === 'Close' && + token.end.local === 'script' && + cx.insideScript && + cx.scriptContent.trim() && + cx.currentListing + ) { + try { + const jsonData = JSON.parse(cx.scriptContent.trim()); + if (jsonData['@type'] === 'ImageObject') { + cx.currentListing.jsonLd = jsonData; + if (!cx.currentListing.title && jsonData.title) { + cx.currentListing.title = jsonData.title; + } + if (!cx.currentListing.description && jsonData.description) { + cx.currentListing.description = jsonData.description; + } + if (!cx.currentListing.imageUrl && jsonData.contentUrl) { + cx.currentListing.imageUrl = jsonData.contentUrl; + } + } + } catch (e) { + // Ignore JSON parse errors + } + cx.insideScript = false; + cx.scriptContent = ''; + } + } +} satisfies TExtractor; + +export function extractAdsData(html: string): TListingData[] { + const result = extract(html, [ + elementTracker, + listingTracker, + titleExtractor, + descriptionExtractor, + locationDateExtractor, + priceExtractor, + imageExtractor, + tagExtractor, + jsonLdExtractor + ]); + + return result.listings; +} + +function parsePrice(priceText: string): { amount?: number; currency: string } { + const cleanText = priceText.replace(/\s*(VB|Verhandlungsbasis)\s*$/i, '').trim(); + const currency = cleanText.includes('€') ? 'EUR' : 'USD'; + + const numberMatch = cleanText.match(/[\d.]+/); + if (numberMatch) { + const numberStr = numberMatch[0]; + const amount = parseFloat(numberStr.replace(/\./g, '')); + return { amount, currency }; + } + + return { currency }; +} diff --git a/packages/kleinanzeigen-client/src/fetch-ads.ts b/packages/kleinanzeigen-client/src/fetch-ads.ts new file mode 100644 index 00000000..ae053bf7 --- /dev/null +++ b/packages/kleinanzeigen-client/src/fetch-ads.ts @@ -0,0 +1,80 @@ +import { createApiFetchClient } from 'feature-fetch'; + +export async function fetchAds(options: TFetchAds = {}): Promise { + const { + url = 'https://www.kleinanzeigen.de', + query, + location, + radius, + minPrice, + maxPrice, + page = 1 + } = options; + + const fetchClient = createApiFetchClient({ + prefixUrl: url + }); + + // Build path template and path params + let pathTemplate = ''; + const pathParams: Record = {}; + + // Add price filter path + if (minPrice != null || maxPrice != null) { + pathTemplate += '/preis:{minPrice}:{maxPrice}'; + pathParams['minPrice'] = minPrice != null ? minPrice : ''; + pathParams['maxPrice'] = maxPrice != null ? maxPrice : ''; + } + + // Add page path + if (page != null) { + pathTemplate += '/s-seite:{page}'; + pathParams['page'] = page; + } + + // Build query parameters + const queryParams: Record = {}; + if (query) { + queryParams['keywords'] = query; + } + if (location) { + queryParams['locationStr'] = location; + } + if (radius != null) { + queryParams['radius'] = radius; + } + + // Fetch the HTML + const response = await fetchClient.get(pathTemplate, { + pathParams, + queryParams, + parseAs: 'text' + }); + + if (response.isErr()) { + throw new Error(`Failed to fetch kleinanzeigen search: ${response.error.message}`); + } + + // Build the final URL for reference + const finalUrl = `${url}${pathTemplate}${Object.keys(queryParams).length > 0 ? '?' + new URLSearchParams(queryParams as Record).toString() : ''}`; + + return { + html: response.value.data, + url: finalUrl + }; +} + +export type TFetchAds = { + url?: string; + query?: string; + location?: string; + radius?: number; + minPrice?: number; + maxPrice?: number; + page?: number; +}; + +export type TFetchAdsResult = { + html: string; + url: string; +}; diff --git a/packages/kleinanzeigen-client/src/index.ts b/packages/kleinanzeigen-client/src/index.ts new file mode 100644 index 00000000..d1775404 --- /dev/null +++ b/packages/kleinanzeigen-client/src/index.ts @@ -0,0 +1,2 @@ +export * from './extract-ads'; +export * from './fetch-ads'; diff --git a/packages/kleinanzeigen-client/tsconfig.json b/packages/kleinanzeigen-client/tsconfig.json new file mode 100644 index 00000000..bf70a3c1 --- /dev/null +++ b/packages/kleinanzeigen-client/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@blgc/config/typescript/library", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declarationDir": "./dist/types" + }, + "include": ["src"], + "exclude": ["**/__tests__/*", "**/*.test.ts"] +} diff --git a/packages/kleinanzeigen-client/tsconfig.prod.json b/packages/kleinanzeigen-client/tsconfig.prod.json new file mode 100644 index 00000000..01151c39 --- /dev/null +++ b/packages/kleinanzeigen-client/tsconfig.prod.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declarationMap": false + } +} diff --git a/packages/kleinanzeigen-client/vitest.config.mjs b/packages/kleinanzeigen-client/vitest.config.mjs new file mode 100644 index 00000000..8482b939 --- /dev/null +++ b/packages/kleinanzeigen-client/vitest.config.mjs @@ -0,0 +1,4 @@ +import { nodeConfig } from '@blgc/config/vite/node'; +import { defineConfig, mergeConfig } from 'vitest/config'; + +export default mergeConfig(nodeConfig, defineConfig({})); diff --git a/packages/xml-tokenizer/src/__tests__/playground.test.ts b/packages/xml-tokenizer/src/__tests__/playground.test.ts index 5406ae0b..20263a58 100644 --- a/packages/xml-tokenizer/src/__tests__/playground.test.ts +++ b/packages/xml-tokenizer/src/__tests__/playground.test.ts @@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises'; import { describe } from 'node:test'; import * as camaro from 'camaro'; import { beforeAll, expect, it } from 'vitest'; +import { htmlConfig } from '../config'; import { select } from '../selector'; import { tokenToXml } from '../token-to-xml'; import { xmlToSimplifiedObject } from '../xml-to-simplified-object'; @@ -15,15 +16,11 @@ describe('playground', () => { let html = ''; beforeAll(async () => { - html = await readFile(`${__dirname}/resources/google.html`, 'utf-8'); + html = await readFile(`${__dirname}/resources/kleinanzeigen.html`, 'utf-8'); }); it('[xml-tokenizer] shoud work', async () => { - const result = await xmlToSimplifiedObject(html, { - allowDtd: true, - rawTextElements: ['script', 'style'], - strictDocument: false - }); + const result = await xmlToSimplifiedObject(html, htmlConfig); console.log(result); }); diff --git a/packages/xml-tokenizer/src/__tests__/resources/kleinanzeigen.html b/packages/xml-tokenizer/src/__tests__/resources/kleinanzeigen.html new file mode 100644 index 00000000..7f972bd1 --- /dev/null +++ b/packages/xml-tokenizer/src/__tests__/resources/kleinanzeigen.html @@ -0,0 +1,7694 @@ + + + + + Laptop kleinanzeigen.de + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+
+ +
+ + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + +
+
+ +

1 - 25 von 122.854 Ergebnissen für „laptop“ in Deutschland +

+
+ +
+ + + + Sortieren nach: + + + + + + + + + + + +
+
Neueste
+ +
    + + + + + + + +
  • + Neueste +
  • + + + + + + + +
  • + Niedrigster Preis +
  • + + + + + + + +
  • + Höchster Preis +
  • + +
+
+
+
+ +
+
+
+ + + + + + + + + + +
+ + + + +
+ +
+
+

Kategorien

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Elektronik + + + +  (94.671) + + + + + + +
      + +
    • + + + + + + + + + + + + + + + + + + + Notebooks + + + +  (79.558) + + + + + +
    • + +
    • + + + + + + + + + + + + + + + + + + + PC-Zubehör & Software + + + +  (12.095) + + + + + +
    • + + +
    • mehr
    • + +
    + +
  • + +
  • + + + + + + + + + + + + + + + + + + Mode & Beauty + + + +  (20.255) + + + + + + + + +
  • + + +
  • Alle Kategorien
  • + +
+
+
+
+
+

Zustand in Notebooks

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Neu + + + +  (6.360) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Sehr Gut + + + +  (36.934) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Gut + + + +  (18.974) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + In Ordnung + + + +  (4.444) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Defekt + + + +  (3.743) + + + + + +
  • + + +
+
+
+
+
+

Versand in Notebooks

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Versand möglich + + + +  (63.704) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Nur Abholung + + + +  (15.468) + + + + + +
  • + + +
+
+
+
+
+

Preis

+
+
+
+
+ -
+ + +
+
+
+
+
+

Angebotstyp

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Angebote + + + +  (122.026) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Gesuche + + + +  (828) + + + + + +
  • + + +
+
+
+
+
+

Anbieter

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Privat + + + +  (116.648) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Gewerblich + + + +  (6.206) + + + + + +
  • + + +
+
+
+
+
+

Direkt kaufen

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Aktiv + + + +  (39.289) + + + + + +
  • + + +
+
+
+
+
+

Versand

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Versand möglich + + + +  (98.057) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Nur Abholung + + + +  (23.412) + + + + + +
  • + + +
+
+
+
+
+

Paketdienst

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + DHL + + + +  (77.046) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Hermes + + + +  (68.437) + + + + + +
  • + + +
+
+
+
+
+

Ort

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Baden-Württemberg + + + +  (16.172) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Bayern + + + +  (21.133) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Berlin + + + +  (7.923) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Brandenburg + + + +  (2.567) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Bremen + + + +  (1.098) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Hamburg + + + +  (4.041) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Hessen + + + +  (9.930) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Mecklenburg-Vorpommern + + + +  (1.353) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Niedersachsen + + + +  (11.485) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Nordrhein-Westfalen + + + +  (26.815) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Rheinland-Pfalz + + + +  (5.254) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Saarland + + + +  (1.199) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Sachsen + + + +  (5.159) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Sachsen-Anhalt + + + +  (1.905) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Schleswig-Holstein + + + +  (4.789) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Thüringen + + + +  (2.031) + + + + + +
  • + + +
+
+
+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ + + +
+ + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + MacBook Pro 13” M2 (2022) – 16GB / 512GB – Neu & OVP -US-Tastatur Berlin - Charlottenburg Vorschau + + + +
    + 3 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 10585 Charlottenburg + + + +
    +
    + +
    +
    +
    +

    + + + + MacBook Pro 13” M2 (2022) – 16GB / 512GB – Neu & OVP -US-Tastatur + + +

    +

    Ich verkaufe ein originalverpacktes, unbenutztes Apple MacBook Pro 13 Zoll mit M2 Chip (Modell...

    + +
    +

    + 1.099 € + +

    1.300 €

    + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Lenovo ThinkPad T470 acculess i5-6300U/2,4GHz/16GB/256GB Win10pro Brandenburg - Cottbus Vorschau + + + +
    + 3 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 03044 Cottbus + + + +
    +
    + +
    +
    +
    +

    + + + + Lenovo ThinkPad T470 acculess i5-6300U/2,4GHz/16GB/256GB Win10pro + + +

    +

    Das ThinkPad T470 mit Intel Core i5-6300U 2.40 GHz, 16GB RAM und 256GB NVMe von Lenovo ist ein...

    + +
    +

    + 99 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + + + + + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Fujits Lifebook U758 defekt Nordrhein-Westfalen - Langenfeld Vorschau + + + +
    + 8 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 40764 Langenfeld + + + +
    +
    + + + Heute, 08:24 + +
    +
    +
    +

    + + + + Fujits Lifebook U758 defekt + + +

    +

    Zum Verkauf steht ein defekter Fujits Lifebook U758 +Das Teil hat irgendwann aufgehört was zu...

    + +
    +

    + 15 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  • + +
    + +
  • + + + + + + + + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + MacBook Pro A1502 16 GB Aachen - Aachen-Mitte Vorschau + + + +
    + 6 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 52066 Aachen-​Mitte + + + +
    +
    + + + Heute, 08:24 + +
    +
    +
    +

    + + + + MacBook Pro A1502 16 GB + + +

    +

    16GB RAM // Intel i5 // 128 GB Flashspeicher // 13“ + +Das MacBook...

    + +
    +

    + 350 € VB + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Lenovo Thinkpad T480s i7-8550u 16 GB RAM 512 GB SSD M.2 SSD Wandsbek - Hamburg Rahlstedt Vorschau + + + +
    + 14 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 22147 Hamburg Rahlstedt + + + +
    +
    + + + Heute, 08:23 + +
    +
    +
    +

    + + + + Lenovo Thinkpad T480s i7-8550u 16 GB RAM 512 GB SSD M.2 SSD + + +

    +

    Lenovo Thinkpad T480s i7-8550u 16 GB RAM 512 GB SSD M.2 SSD (Display IPS) + + +⚙️ BESONDERHEITEN ⚙️ + +►...

    + +
    +

    + 360 € + +

    +
    + +
    +
    +

    + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Acer Chromebook Sachsen - Hoyerswerda Vorschau + + + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 02977 Hoyerswerda + + + +
    +
    + + + Heute, 08:22 + +
    +
    +
    +

    + + + + Acer Chromebook + + +

    +

    Laptop von Acer+ Bloototh Maus +sehr guter Zustand + +Bezahlung bei Abholung oder per PayPal möglich

    + +
    +

    + 200 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  • + +
    + +
  • + + + + + + + + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Laptop Tasche von George Gina Lucy Brandenburg - Großbeeren Vorschau + + + +
    + 3 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 14979 Großbeeren + + + +
    +
    + + + Heute, 08:18 + +
    +
    +
    +

    + + + + Laptop Tasche von George Gina Lucy + + +

    +

    Laptop Tasche zu verkaufen. Kann auch versandt werden. Etwas eingestaubt Aber Käufer trägt die...

    + +
    +

    + 10 € + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Vaude:Collegetasche, Laptoptasche, Umhängetasche Nordrhein-Westfalen - Siegburg Vorschau + + + +
    + 6 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 53721 Siegburg + + + +
    +
    + + + Heute, 08:16 + +
    +
    +
    +

    + + + + Vaude:Collegetasche, Laptoptasche, Umhängetasche + + +

    +

    Maße: 35 x 30 cm. + +Ideal für die Schule oder die Uni. Guter, benutzter Zustand. Keine Löcher oder...

    + +
    +

    + 25 € VB + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Latitude 7320 Detachable Kr. München - Planegg Vorschau + + + +
    + 4 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 82152 Planegg + + + +
    +
    + + + Heute, 08:16 + +
    +
    +
    +

    + + + + Latitude 7320 Detachable + + +

    +

    Ich verkaufe mein zuverlässiges Notebook&Tablett da ich kürzlich auf ein neues Gerät umgestiegen...

    + +
    +

    + 450 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Apple MacBook Air 13,3 4BG RAM 251GB SSD Niedersachsen - Bötersen Vorschau + + + +
    + 5 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 27367 Bötersen + + + +
    +
    + + + Heute, 08:16 + +
    +
    +
    +

    + + + + Apple MacBook Air 13,3 4BG RAM 251GB SSD + + +

    +

    Abzugeben ist ein gebrauchtes Apple MacBook Air 13,3 Zoll inkl. Netzteil. Das Book wurden...

    + +
    +

    + 149 € VB + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  • + +
    + +
  • + + + + + + + + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + macbook , 13", 1466, emc 2632, mitte 2013, qwerty Rheinland-Pfalz - Gau-Algesheim Vorschau + + + +
    + 5 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 55435 Gau-​Algesheim + + + +
    +
    + + + Heute, 08:16 + +
    +
    +
    +

    + + + + macbook , 13", 1466, emc 2632, mitte 2013, qwerty + + +

    +

    macbook , 13", 1466, emc 2632, mitte 2013, qwerty, +ich habe zur probe ventura installiert und...

    + +
    +

    + 65 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Acer aspire 5 Baden-Württemberg - Illingen Vorschau + + + +
    + 3 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 75428 Illingen + + + +
    +
    + + + Heute, 08:15 + +
    +
    +
    +

    + + + + Acer aspire 5 + + +

    +

    Fast neu , Schreiben Sie alle Fragen in die Nachricht

    + +
    +

    + 1 € + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Apple MacBook Air 13,6" 2025 M4/16GB/256GB SSD Silver Baden-Württemberg - Heidelberg Vorschau + + + +
    + 6 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 69115 Heidelberg + + + +
    +
    + + + Heute, 08:14 + +
    +
    +
    +

    + + + + Apple MacBook Air 13,6" 2025 M4/16GB/256GB SSD Silver + + +

    +

    Schönen guten Tag, + +Verkaufe hier mein MacBook, da ich es geschäftlich überraschend nicht mehr...

    + +
    +

    + 800 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + MSI Katana 17,3 Zoll, Core i7 12700H,16 GB, 512 TB SSD,RTX 3060ti Nordrhein-Westfalen - Menden Vorschau + + + +
    + 4 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 58706 Menden + + + +
    +
    + + + Heute, 08:13 + +
    +
    +
    +

    + + + + MSI Katana 17,3 Zoll, Core i7 12700H,16 GB, 512 TB SSD,RTX 3060ti + + +

    +

    CPU: Core i7-12700H + +RAM: 16GB DDR4/3200MHz + +GPU: Nvidia GeForce RTX 3060Ti + +Display:...

    + +
    +

    + 550 € + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  • + +
    + +
  • + + + + + + + + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Lenovo Thinkpad T500 Laptop Niedersachsen - Reppenstedt Vorschau + + + +
    + 5 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 21391 Reppenstedt + + + +
    +
    + + + Heute, 08:12 + +
    +
    +
    +

    + + + + Lenovo Thinkpad T500 Laptop + + +

    +

    Ich verkaufe hier einen zuverlässigen Lenovo ThinkPad T500 + +Technische Daten: + • Prozessor: Intel...

    + +
    +

    + 222 € VB + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Notebook ASUS R512M, N1312 Baden-Württemberg - Karlsruhe Vorschau + + + +
    + 4 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 76185 Karlsruhe + + + +
    +
    + + + Heute, 08:12 + +
    +
    +
    +

    + + + + Notebook ASUS R512M, N1312 + + +

    +

    Notebook ASUS R512M, N1312 in weiß mit Laufwerk aber ohne Netzteil und Festplatte zu...

    + +
    +

    + 30 € + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Compaq Presario B1011 Vintage Retro Notebook Laptop Brandenburg - Potsdam Vorschau + + + +
    + 5 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 14480 Potsdam + + + +
    +
    + + + Heute, 08:11 + +
    +
    +
    +

    + + + + Compaq Presario B1011 Vintage Retro Notebook Laptop + + +

    +

    Compaq Presario B1011 Notebook Laptop. Das Notebook wurde nicht getestet und wird daher als...

    + +
    +

    + 20 € VB + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Asus Rog strix G18 (OVP u. Garantie) Thüringen - Erfurt Vorschau + + + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 99086 Erfurt + + + +
    +
    + + + Heute, 08:11 + +
    +
    +
    +

    + + + + Asus Rog strix G18 (OVP u. Garantie) + + +

    +

    Hallo der Laptop ist knapp 1 Jahr alt und hat mir immer Spaß gemacht doch möchte ich ihn jetzt...

    + +
    +

    + 1.600 € VB + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  • + +
    + +
  • + + + + + + + + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + MacBook Pro 15 Zoll, Mitte 2010 Bayern - Erlangen Vorschau + + + +
    + 8 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 91058 Erlangen + + + +
    +
    + + + Heute, 08:10 + +
    +
    +
    +

    + + + + MacBook Pro 15 Zoll, Mitte 2010 + + +

    +

    MacBook Pro 15 Zoll, Mitte 2010 +CPU: Intel i5 2,53 GHz +RAM: 8 Gb +SSD: 250 Gb +Grafikkarte: NVidia...

    + +
    +

    + 100 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Razer Blade 18(2024)QHD+ Core i9-14900HX 32GBRAM 1TB SSD RTX 4080 Feldmoching-Hasenbergl - Feldmoching Vorschau + + + +
    + 9 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 80995 Feldmoching + + + +
    +
    + + + Heute, 08:10 + +
    +
    +
    +

    + + + + Razer Blade 18(2024)QHD+ Core i9-14900HX 32GBRAM 1TB SSD RTX 4080 + + +

    +

    Hallo zusammen, + +ich verkaufe privat diesen Laptop für einen Freund. + +- Nichtraucherhaushalt +- nur...

    + +
    +

    + 3.400 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Laptop omen gamer Lübeck - Buntekuh Vorschau + + + +
    + 7 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 23556 Buntekuh + + + +
    +
    + + + Heute, 08:10 + +
    +
    +
    +

    + + + + Laptop omen gamer + + +

    +

    Leistung siehe Bilder +top Zustand auch der Akku +13 Monate alt +neu Preis 1500 euro

    + +
    +

    + 750 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Acer Swift 3 Ryzen 5 Prozessor 8 GB RAM 500 GB SSD Pankow - Prenzlauer Berg Vorschau + + + +
    + 13 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 10249 Prenzlauer Berg + + + +
    +
    + + + Heute, 08:10 + +
    +
    +
    +

    + + + + Acer Swift 3 Ryzen 5 Prozessor 8 GB RAM 500 GB SSD + + +

    +

    Acer Swift 3 +Ryzen 5 Prozessor +8 GB RAM +Windows 10 +Netzteil +Original Verpackung +Akku hält ca 5...

    + +
    +

    + 170 € + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + MacBook Air 2018 – 13" Retina – 8 GB RAM Rheinland-Pfalz - Mainz Vorschau + + + +
    + 3 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 55131 Mainz + + + +
    +
    + + + Heute, 08:10 + +
    +
    +
    +

    + + + + MacBook Air 2018 – 13" Retina – 8 GB RAM + + +

    +

    Ich verkaufe mein MacBook Air (Modell 2018) mit 13-Zoll-Retina-Display und 8 GB RAM. Das Gerät ist...

    + +
    +

    + 250 € VB + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Notebook 17 " Dithmarschen - Heide Vorschau + + + +
    + 8 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 25746 Heide + + + +
    +
    + + + Heute, 08:09 + +
    +
    +
    +

    + + + + Notebook 17 " + + +

    +

    Verkaufe ein 17 " Notebook. +Techn.Daten folgen. +Neupreis war 600 € +Mit vielen Programmen +Die...

    + +
    +

    + 150 € VB + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Laptop 12gb ram Nordrhein-Westfalen - Hagen Vorschau + + + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 58097 Hagen + + + +
    +
    + + + Heute, 08:07 + +
    +
    +
    +

    + + + + Laptop 12gb ram + + +

    +

    Guten abend ich verkaufe mein laptop funktioniert einwandfrei ohne probleme + +Cpu: intel i5...

    + +
    +

    + 100 € VB + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Samsung Galaxy Book Go LTE Hessen - Habichtswald Vorschau + + + +
    + 2 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 34317 Habichtswald + + + +
    +
    + + + Heute, 08:06 + +
    +
    +
    +

    + + + + Samsung Galaxy Book Go LTE + + +

    +

    Das Samsung Galaxy Book Go LTE (345XLA-KB3) ist ein leistungsstarkes und vielseitiges Notebook, das...

    + +
    +

    + 140 € + +

    +
    + +
    +
    +

    + + +

    + +
    +
    +
    +
  • + + + +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + + + +
+

Ähnliche Suchanfragen

+ +
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+
+ +
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + diff --git a/packages/xml-tokenizer/src/extract.test.ts b/packages/xml-tokenizer/src/extract.test.ts new file mode 100644 index 00000000..ecff228a --- /dev/null +++ b/packages/xml-tokenizer/src/extract.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { htmlConfig } from './config'; +import { extract, type TExtractor } from './extract'; + +describe('extract function', () => { + const sampleHtml = ` +
+ +
+ `; + + it('should handle empty extractors', () => { + const result = extract(sampleHtml, [], htmlConfig); + expect(result).toEqual({}); + }); + + it('should merge extractor contexts', () => { + const counter = { + context: { count: 0 }, + extract: (token, cx) => { + if (token.type === 'ElementStart') cx.count++; + } + } satisfies TExtractor; + + const collector = { + context: { elements: [] as string[] }, + extract: (token, cx) => { + if (token.type === 'ElementStart') { + cx.elements.push(token.local); + } + } + } satisfies TExtractor; + + const result = extract(sampleHtml, [counter, collector], htmlConfig); + expect(result.count).toBe(5); + expect(result.elements).toEqual(['div', 'article', 'h2', 'p', 'span']); + }); +}); diff --git a/packages/xml-tokenizer/src/extract.ts b/packages/xml-tokenizer/src/extract.ts new file mode 100644 index 00000000..a1f646c5 --- /dev/null +++ b/packages/xml-tokenizer/src/extract.ts @@ -0,0 +1,68 @@ +import { tokenize, type TXmlStreamOptions, type TXmlToken } from './tokenizer'; + +export function extract( + xml: string, + extractors: [...GExtractors], + options?: TXmlStreamOptions +): TMergeExtractorContexts { + validateExtractorOrder(extractors); + + // Build shared context + const context: TMergeExtractorContexts = {} as TMergeExtractorContexts; + for (const extractor of extractors) { + Object.assign(context, extractor.context); + } + + // Process tokens + tokenize( + xml, + (token) => { + for (const extractor of extractors) { + extractor.extract(token, context); + } + }, + options + ); + + return context; +} + +function validateExtractorOrder(extractors: TExtractor[]) { + const seen = new Set(); + + for (const ext of extractors) { + if (ext.deps == null) { + continue; + } + + for (const dep of ext.deps) { + if (!seen.has(dep)) { + throw new Error( + `Extractor dependency order invalid: one extractor depends on another that hasn't run yet.` + ); + } + } + + seen.add(ext); + } +} + +export interface TExtractor { + context: GContext; + deps?: GDeps; + extract: TTokenCallback>; +} + +type TTokenCallback = (token: TXmlToken, context: GContext) => void; + +type TExtractorContext = + GExtractor extends TExtractor ? C : never; + +type TMergeExtractorContexts = GExtractors extends [ + infer H, + ...infer R +] + ? TExtractorContext & TMergeExtractorContexts> + : {}; + +type TCastToExtractors = T extends TExtractor[] ? T : []; diff --git a/packages/xml-tokenizer/src/index.ts b/packages/xml-tokenizer/src/index.ts index dddf3e44..54e55295 100644 --- a/packages/xml-tokenizer/src/index.ts +++ b/packages/xml-tokenizer/src/index.ts @@ -1,4 +1,5 @@ export * from './config'; +export * from './extract'; export * from './get-q-name'; export * from './selector'; export * from './token-to-xml'; diff --git a/packages/xml-tokenizer/src/tokenizer/tokenize.ts b/packages/xml-tokenizer/src/tokenizer/tokenize.ts index 9d777efc..f0a62679 100644 --- a/packages/xml-tokenizer/src/tokenizer/tokenize.ts +++ b/packages/xml-tokenizer/src/tokenizer/tokenize.ts @@ -641,7 +641,7 @@ function parseText( // According to the spec, `]]>` must not appear inside a Text node. // https://www.w3.org/TR/xml/#syntax - if (text.includes(CDATA_END)) { + if (s.config.strictDocument && text.includes(CDATA_END)) { throw new XmlError({ type: 'InvalidCharacterData' }, s.genTextPos()); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c5e0378..9bfe4231 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -578,6 +578,35 @@ importers: specifier: workspace:* version: link:../rollup-presets + packages/head-metadata: + dependencies: + xml-tokenizer: + specifier: ^0.0.32 + version: 0.0.32 + devDependencies: + '@types/node': + specifier: ^22.15.21 + version: 22.15.21 + + packages/kleinanzeigen-client: + dependencies: + feature-fetch: + specifier: workspace:* + version: link:../feature-fetch + xml-tokenizer: + specifier: workspace:* + version: link:../xml-tokenizer + devDependencies: + '@blgc/config': + specifier: workspace:* + version: link:../config + '@types/node': + specifier: ^22.15.21 + version: 22.15.21 + rollup-presets: + specifier: workspace:* + version: link:../rollup-presets + packages/openapi-ts-router: dependencies: '@blgc/types': @@ -4366,6 +4395,9 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xml-tokenizer@0.0.32: + resolution: {integrity: sha512-0f86fWVZUrxLrFxs3ZIgmtmPKVHm0Wc0J2TBZbivBE+cSPjgg2p4bAb246xKEfNbvjsGg37cFJuXsVRoE3OlHQ==} + xml2js@0.6.2: resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} engines: {node: '>=4.0.0'} @@ -8161,6 +8193,8 @@ snapshots: wrappy@1.0.2: {} + xml-tokenizer@0.0.32: {} + xml2js@0.6.2: dependencies: sax: 1.4.1 From ef9d1aa900195ff6452391ad6e80006e45238c68 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Sat, 31 May 2025 15:22:49 +0200 Subject: [PATCH 29/39] #105 fixed typo --- packages/head-metadata/README.md | 78 ++++++++++++++++++++++++++++- packages/head-metadata/package.json | 2 +- pnpm-lock.yaml | 12 ++--- 3 files changed, 82 insertions(+), 10 deletions(-) diff --git a/packages/head-metadata/README.md b/packages/head-metadata/README.md index ab52d184..e70c9915 100644 --- a/packages/head-metadata/README.md +++ b/packages/head-metadata/README.md @@ -1,3 +1,77 @@ -# `@repo/head-metadata` +

+ head-metadata banner +

-todo +

+ + GitHub License + + + NPM bundle minzipped size + + + NPM total downloads + +

+ +> Status: Experimental + +`head-metadata` is a utility for extracting structured metadata (like ``, ``, and `<link>`) from the `<head>` of an HTML document. + +## 📖 Usage + +### Extract Metadata from `<head>` + +```ts +import { extractHeadMetadata } from 'head-metadata'; +import { metaExtractor, titleExtractor, linkExtractor } from 'head-metadata/extractors'; + +const html = ` + <html> + <head> + <title>Example + + + + +`; + +const metadata = extractHeadMetadata(html, { + meta: metaExtractor, + title: titleExtractor, + link: linkExtractor +}); + +console.log(metadata); +/* +{ + title: 'Example', + meta: { + description: 'An example page' + }, + link: { + canonical: 'https://example.com' + } +} +*/ +``` + +### Create Custom Extractors + +You can write your own extractors to handle any `` child element: + +```ts +export const customLinkExtractor = { + type: 'collection' as const, + parent: 'link' as const, + callback: (node) => { + const rel = node.attributes.find((a) => a.local === 'rel'); + const href = node.attributes.find((a) => a.local === 'href'); + if (rel != null && href != null) { + return { key: rel.value, value: href.value }; + } + + return null; + } +} satisfies TCollectionExtractor; +``` diff --git a/packages/head-metadata/package.json b/packages/head-metadata/package.json index d7749f37..4786ac45 100644 --- a/packages/head-metadata/package.json +++ b/packages/head-metadata/package.json @@ -1,6 +1,6 @@ { "name": "head-metadata", - "version": "0.0.1", + "version": "0.0.3", "private": false, "description": "Extracts metadata from the head of a HTML document", "keywords": [], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9bfe4231..482533b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -581,12 +581,15 @@ importers: packages/head-metadata: dependencies: xml-tokenizer: - specifier: ^0.0.32 - version: 0.0.32 + specifier: workspace:* + version: link:../xml-tokenizer devDependencies: '@types/node': specifier: ^22.15.21 version: 22.15.21 + rollup-presets: + specifier: workspace:* + version: link:../rollup-presets packages/kleinanzeigen-client: dependencies: @@ -4395,9 +4398,6 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - xml-tokenizer@0.0.32: - resolution: {integrity: sha512-0f86fWVZUrxLrFxs3ZIgmtmPKVHm0Wc0J2TBZbivBE+cSPjgg2p4bAb246xKEfNbvjsGg37cFJuXsVRoE3OlHQ==} - xml2js@0.6.2: resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} engines: {node: '>=4.0.0'} @@ -8193,8 +8193,6 @@ snapshots: wrappy@1.0.2: {} - xml-tokenizer@0.0.32: {} - xml2js@0.6.2: dependencies: sax: 1.4.1 From c3270849459858cbc39bdfde4b535cd794a2d08b Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Sat, 31 May 2025 15:30:33 +0200 Subject: [PATCH 30/39] #105 fixed typos --- README.md | 1 + packages/head-metadata/.github/banner.svg | 6 +++++ packages/head-metadata/README.md | 28 ++++++++++++----------- packages/head-metadata/package.json | 4 ++-- 4 files changed, 24 insertions(+), 15 deletions(-) create mode 100644 packages/head-metadata/.github/banner.svg diff --git a/README.md b/README.md index dab2ebea..b3c08893 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ A collection of open source libraries maintained by [builder.group](https://buil | [feature-state](https://github.com/builder-group/community/blob/develop/packages/feature-state) | Straightforward, typesafe, and feature-based state management library for ReactJs | [`feature-state`](https://www.npmjs.com/package/feature-state) | | [figma-connect](https://github.com/builder-group/community/blob/develop/packages/figma-connect) | Straightforward and typesafe wrapper around the communication between the app/ui (iframe) and plugin (sandbox) part of a Figma Plugin | [`figma-connect`](https://www.npmjs.com/package/figma-connect) | | [google-webfonts-client](https://github.com/builder-group/community/blob/develop/packages/google-webfonts-client) | Typesafe and straightforward fetch client for interacting with the Google Web Fonts API using feature-fetch | [`google-webfonts-client`](https://www.npmjs.com/package/google-webfonts-client) | +| [head-metadata](https://github.com/builder-group/community/blob/develop/packages/head-metadata) | Typesafe and straightforward utility for extracting structured metadata (like ``, ``, and `<link>`) from the `<head>` of an HTML document. | [`head-metadata`](https://www.npmjs.com/package/head-metadata) | | [openapi-ts-router](https://github.com/builder-group/community/blob/develop/packages/openapi-ts-router) | Thin wrapper around the router of web frameworks like Express and Hono, offering OpenAPI typesafety and seamless integration with validation libraries such as Valibot and Zod | [`openapi-ts-router`](https://www.npmjs.com/package/openapi-ts-router) | | [rollup-presets](https://github.com/builder-group/community/blob/develop/packages/rollup-presets) | A collection of opinionated, production-ready Rollup presets | [`rollup-presets`](https://www.npmjs.com/package/rollup-presets) | | [types](https://github.com/builder-group/community/blob/develop/packages/types) | Shared TypeScript type definitions used across builder.group community packages | [`@blgc/types`](https://www.npmjs.com/package/@blgc/types) | diff --git a/packages/head-metadata/.github/banner.svg b/packages/head-metadata/.github/banner.svg new file mode 100644 index 00000000..c3335a2f --- /dev/null +++ b/packages/head-metadata/.github/banner.svg @@ -0,0 +1,6 @@ +<svg width="2000" height="250" viewBox="0 0 2000 250" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="2000" height="250" rx="48" fill="#FDE200"/> +<path d="M148.121 63.9112C151.892 65.4726 155.384 67.6248 158.466 70.2816C159.177 70.895 159.867 71.5353 160.534 72.2014C164.086 75.7527 166.905 79.9687 168.827 84.6086C170.75 89.2486 171.74 94.2217 171.74 99.2439C171.74 103.776 170.934 108.269 169.363 112.512H171.74C176.764 112.512 181.739 113.501 186.381 115.423C190.153 116.985 193.644 119.137 196.726 121.794C197.438 122.407 198.127 123.047 198.794 123.714C202.347 127.265 205.165 131.481 207.088 136.121C209.01 140.761 210 145.734 210 150.756C210 155.778 209.01 160.751 207.088 165.391C205.165 170.031 202.347 174.247 198.794 177.799C198.127 178.465 197.438 179.105 196.726 179.718C193.644 182.375 190.152 184.527 186.381 186.089C181.739 188.011 176.764 189 171.74 189H146.753C139.854 189 134.26 183.409 134.26 176.512C134.26 169.615 139.854 164.024 146.753 164.024H171.74L171.74 164.028C173.483 164.028 175.21 163.684 176.821 163.017C178.431 162.35 179.895 161.373 181.128 160.14C182.361 158.908 183.339 157.445 184.006 155.835C184.673 154.225 185.017 152.499 185.017 150.756C185.017 149.013 184.673 147.288 184.006 145.677C183.339 144.067 182.361 142.604 181.128 141.372C179.895 140.139 178.432 139.162 176.821 138.495C175.21 137.828 173.483 137.485 171.74 137.485V137.488H108.493C101.593 137.488 96 131.897 96 125C96 118.103 101.593 112.512 108.493 112.512H133.767C135.413 112.477 137.038 112.135 138.56 111.505C140.171 110.838 141.635 109.861 142.868 108.628C144.101 107.396 145.079 105.933 145.746 104.323C146.413 102.713 146.757 100.987 146.757 99.2439C146.757 97.5011 146.413 95.7753 145.746 94.1652C145.079 92.555 144.101 91.092 142.868 89.8596C141.635 88.6272 140.171 87.6497 138.56 86.9827C136.95 86.3158 135.223 85.9725 133.479 85.9725V85.9756H108.493C101.593 85.9756 96 80.3846 96 73.4878C96 66.591 101.593 61 108.493 61H133.479C138.504 61 143.479 61.9892 148.121 63.9112Z" fill="black"/> +<path d="M96 176.512C96 169.615 101.593 164.024 108.493 164.024H115.521C122.42 164.024 128.014 169.615 128.014 176.512C128.014 183.409 122.42 189 115.521 189H108.493C101.593 189 96 183.409 96 176.512Z" fill="black"/> +<path d="M278.318 129.727V160H263.795V90.1818H277.909V116.875H278.523C279.705 113.784 281.614 111.364 284.25 109.614C286.886 107.841 290.193 106.955 294.17 106.955C297.807 106.955 300.977 107.75 303.682 109.341C306.409 110.909 308.523 113.17 310.023 116.125C311.545 119.057 312.295 122.568 312.273 126.659V160H297.75V129.25C297.773 126.023 296.955 123.511 295.295 121.716C293.659 119.92 291.364 119.023 288.409 119.023C286.432 119.023 284.682 119.443 283.159 120.284C281.659 121.125 280.477 122.352 279.614 123.966C278.773 125.557 278.341 127.477 278.318 129.727ZM347.565 161.023C342.179 161.023 337.543 159.932 333.656 157.75C329.793 155.545 326.815 152.432 324.724 148.409C322.634 144.364 321.588 139.58 321.588 134.057C321.588 128.67 322.634 123.943 324.724 119.875C326.815 115.807 329.759 112.636 333.554 110.364C337.372 108.091 341.849 106.955 346.986 106.955C350.44 106.955 353.656 107.511 356.634 108.625C359.634 109.716 362.247 111.364 364.474 113.568C366.724 115.773 368.474 118.545 369.724 121.886C370.974 125.205 371.599 129.091 371.599 133.545V137.534H327.384V128.534H357.929C357.929 126.443 357.474 124.591 356.565 122.977C355.656 121.364 354.395 120.102 352.781 119.193C351.19 118.261 349.338 117.795 347.224 117.795C345.02 117.795 343.065 118.307 341.361 119.33C339.679 120.33 338.361 121.682 337.406 123.386C336.452 125.068 335.963 126.943 335.94 129.011V137.568C335.94 140.159 336.418 142.398 337.372 144.284C338.349 146.17 339.724 147.625 341.497 148.648C343.27 149.67 345.372 150.182 347.804 150.182C349.418 150.182 350.895 149.955 352.236 149.5C353.577 149.045 354.724 148.364 355.679 147.455C356.634 146.545 357.361 145.432 357.861 144.114L371.293 145C370.611 148.227 369.213 151.045 367.099 153.455C365.009 155.841 362.304 157.705 358.986 159.045C355.69 160.364 351.884 161.023 347.565 161.023ZM396.009 160.989C392.668 160.989 389.69 160.409 387.077 159.25C384.463 158.068 382.395 156.33 380.872 154.034C379.372 151.716 378.622 148.83 378.622 145.375C378.622 142.466 379.156 140.023 380.224 138.045C381.293 136.068 382.747 134.477 384.588 133.273C386.429 132.068 388.52 131.159 390.861 130.545C393.224 129.932 395.702 129.5 398.293 129.25C401.338 128.932 403.793 128.636 405.656 128.364C407.52 128.068 408.872 127.636 409.713 127.068C410.554 126.5 410.974 125.659 410.974 124.545V124.341C410.974 122.182 410.293 120.511 408.929 119.33C407.588 118.148 405.679 117.557 403.202 117.557C400.588 117.557 398.509 118.136 396.963 119.295C395.418 120.432 394.395 121.864 393.895 123.591L380.463 122.5C381.145 119.318 382.486 116.568 384.486 114.25C386.486 111.909 389.065 110.114 392.224 108.864C395.406 107.591 399.088 106.955 403.27 106.955C406.179 106.955 408.963 107.295 411.622 107.977C414.304 108.659 416.679 109.716 418.747 111.148C420.838 112.58 422.486 114.42 423.69 116.67C424.895 118.898 425.497 121.568 425.497 124.682V160H411.724V152.739H411.315C410.474 154.375 409.349 155.818 407.94 157.068C406.531 158.295 404.838 159.261 402.861 159.966C400.884 160.648 398.599 160.989 396.009 160.989ZM400.168 150.966C402.304 150.966 404.19 150.545 405.827 149.705C407.463 148.841 408.747 147.682 409.679 146.227C410.611 144.773 411.077 143.125 411.077 141.284V135.727C410.622 136.023 409.997 136.295 409.202 136.545C408.429 136.773 407.554 136.989 406.577 137.193C405.599 137.375 404.622 137.545 403.645 137.705C402.668 137.841 401.781 137.966 400.986 138.08C399.281 138.33 397.793 138.727 396.52 139.273C395.247 139.818 394.259 140.557 393.554 141.489C392.849 142.398 392.497 143.534 392.497 144.898C392.497 146.875 393.213 148.386 394.645 149.432C396.099 150.455 397.94 150.966 400.168 150.966ZM456.06 160.852C452.082 160.852 448.48 159.83 445.253 157.784C442.048 155.716 439.503 152.682 437.616 148.682C435.753 144.659 434.821 139.727 434.821 133.886C434.821 127.886 435.787 122.898 437.719 118.92C439.651 114.92 442.219 111.932 445.423 109.955C448.651 107.955 452.185 106.955 456.026 106.955C458.957 106.955 461.401 107.455 463.355 108.455C465.332 109.432 466.923 110.659 468.128 112.136C469.355 113.591 470.287 115.023 470.923 116.432H471.366V90.1818H485.855V160H471.537V151.614H470.923C470.241 153.068 469.276 154.511 468.026 155.943C466.798 157.352 465.196 158.523 463.219 159.455C461.264 160.386 458.878 160.852 456.06 160.852ZM460.662 149.295C463.003 149.295 464.98 148.659 466.594 147.386C468.23 146.091 469.48 144.284 470.344 141.966C471.23 139.648 471.673 136.932 471.673 133.818C471.673 130.705 471.241 128 470.378 125.705C469.514 123.409 468.264 121.636 466.628 120.386C464.991 119.136 463.003 118.511 460.662 118.511C458.276 118.511 456.264 119.159 454.628 120.455C452.991 121.75 451.753 123.545 450.912 125.841C450.071 128.136 449.651 130.795 449.651 133.818C449.651 136.864 450.071 139.557 450.912 141.898C451.776 144.216 453.014 146.034 454.628 147.352C456.264 148.648 458.276 149.295 460.662 149.295ZM530.42 125.568V137.091H498.58V125.568H530.42ZM542.795 160V107.636H556.636V116.875H557.25C558.341 113.807 560.159 111.386 562.705 109.614C565.25 107.841 568.295 106.955 571.841 106.955C575.432 106.955 578.489 107.852 581.011 109.648C583.534 111.42 585.216 113.83 586.057 116.875H586.602C587.67 113.875 589.602 111.477 592.398 109.682C595.216 107.864 598.545 106.955 602.386 106.955C607.273 106.955 611.239 108.511 614.284 111.625C617.352 114.716 618.886 119.102 618.886 124.784V160H604.398V127.648C604.398 124.739 603.625 122.557 602.08 121.102C600.534 119.648 598.602 118.92 596.284 118.92C593.648 118.92 591.591 119.761 590.114 121.443C588.636 123.102 587.898 125.295 587.898 128.023V160H573.818V127.341C573.818 124.773 573.08 122.727 571.602 121.205C570.148 119.682 568.227 118.92 565.841 118.92C564.227 118.92 562.773 119.33 561.477 120.148C560.205 120.943 559.193 122.068 558.443 123.523C557.693 124.955 557.318 126.636 557.318 128.568V160H542.795ZM654.222 161.023C648.835 161.023 644.199 159.932 640.312 157.75C636.449 155.545 633.472 152.432 631.381 148.409C629.29 144.364 628.244 139.58 628.244 134.057C628.244 128.67 629.29 123.943 631.381 119.875C633.472 115.807 636.415 112.636 640.21 110.364C644.028 108.091 648.506 106.955 653.642 106.955C657.097 106.955 660.313 107.511 663.29 108.625C666.29 109.716 668.903 111.364 671.131 113.568C673.381 115.773 675.131 118.545 676.381 121.886C677.631 125.205 678.256 129.091 678.256 133.545V137.534H634.04V128.534H664.585C664.585 126.443 664.131 124.591 663.222 122.977C662.313 121.364 661.051 120.102 659.438 119.193C657.847 118.261 655.994 117.795 653.881 117.795C651.676 117.795 649.722 118.307 648.017 119.33C646.335 120.33 645.017 121.682 644.062 123.386C643.108 125.068 642.619 126.943 642.597 129.011V137.568C642.597 140.159 643.074 142.398 644.028 144.284C645.006 146.17 646.381 147.625 648.153 148.648C649.926 149.67 652.028 150.182 654.46 150.182C656.074 150.182 657.551 149.955 658.892 149.5C660.233 149.045 661.381 148.364 662.335 147.455C663.29 146.545 664.017 145.432 664.517 144.114L677.949 145C677.267 148.227 675.869 151.045 673.756 153.455C671.665 155.841 668.96 157.705 665.642 159.045C662.347 160.364 658.54 161.023 654.222 161.023ZM715.585 107.636V118.545H684.051V107.636H715.585ZM691.21 95.0909H705.733V143.909C705.733 145.25 705.938 146.295 706.347 147.045C706.756 147.773 707.324 148.284 708.051 148.58C708.801 148.875 709.665 149.023 710.642 149.023C711.324 149.023 712.006 148.966 712.688 148.852C713.369 148.716 713.892 148.614 714.256 148.545L716.54 159.352C715.813 159.58 714.79 159.841 713.472 160.136C712.153 160.455 710.551 160.648 708.665 160.716C705.165 160.852 702.097 160.386 699.46 159.318C696.847 158.25 694.813 156.591 693.358 154.341C691.903 152.091 691.188 149.25 691.21 145.818V95.0909ZM739.977 160.989C736.636 160.989 733.659 160.409 731.045 159.25C728.432 158.068 726.364 156.33 724.841 154.034C723.341 151.716 722.591 148.83 722.591 145.375C722.591 142.466 723.125 140.023 724.193 138.045C725.261 136.068 726.716 134.477 728.557 133.273C730.398 132.068 732.489 131.159 734.83 130.545C737.193 129.932 739.67 129.5 742.261 129.25C745.307 128.932 747.761 128.636 749.625 128.364C751.489 128.068 752.841 127.636 753.682 127.068C754.523 126.5 754.943 125.659 754.943 124.545V124.341C754.943 122.182 754.261 120.511 752.898 119.33C751.557 118.148 749.648 117.557 747.17 117.557C744.557 117.557 742.477 118.136 740.932 119.295C739.386 120.432 738.364 121.864 737.864 123.591L724.432 122.5C725.114 119.318 726.455 116.568 728.455 114.25C730.455 111.909 733.034 110.114 736.193 108.864C739.375 107.591 743.057 106.955 747.239 106.955C750.148 106.955 752.932 107.295 755.591 107.977C758.273 108.659 760.648 109.716 762.716 111.148C764.807 112.58 766.455 114.42 767.659 116.67C768.864 118.898 769.466 121.568 769.466 124.682V160H755.693V152.739H755.284C754.443 154.375 753.318 155.818 751.909 157.068C750.5 158.295 748.807 159.261 746.83 159.966C744.852 160.648 742.568 160.989 739.977 160.989ZM744.136 150.966C746.273 150.966 748.159 150.545 749.795 149.705C751.432 148.841 752.716 147.682 753.648 146.227C754.58 144.773 755.045 143.125 755.045 141.284V135.727C754.591 136.023 753.966 136.295 753.17 136.545C752.398 136.773 751.523 136.989 750.545 137.193C749.568 137.375 748.591 137.545 747.614 137.705C746.636 137.841 745.75 137.966 744.955 138.08C743.25 138.33 741.761 138.727 740.489 139.273C739.216 139.818 738.227 140.557 737.523 141.489C736.818 142.398 736.466 143.534 736.466 144.898C736.466 146.875 737.182 148.386 738.614 149.432C740.068 150.455 741.909 150.966 744.136 150.966ZM800.028 160.852C796.051 160.852 792.449 159.83 789.222 157.784C786.017 155.716 783.472 152.682 781.585 148.682C779.722 144.659 778.79 139.727 778.79 133.886C778.79 127.886 779.756 122.898 781.688 118.92C783.619 114.92 786.188 111.932 789.392 109.955C792.619 107.955 796.153 106.955 799.994 106.955C802.926 106.955 805.369 107.455 807.324 108.455C809.301 109.432 810.892 110.659 812.097 112.136C813.324 113.591 814.256 115.023 814.892 116.432H815.335V90.1818H829.824V160H815.506V151.614H814.892C814.21 153.068 813.244 154.511 811.994 155.943C810.767 157.352 809.165 158.523 807.188 159.455C805.233 160.386 802.847 160.852 800.028 160.852ZM804.631 149.295C806.972 149.295 808.949 148.659 810.562 147.386C812.199 146.091 813.449 144.284 814.312 141.966C815.199 139.648 815.642 136.932 815.642 133.818C815.642 130.705 815.21 128 814.347 125.705C813.483 123.409 812.233 121.636 810.597 120.386C808.96 119.136 806.972 118.511 804.631 118.511C802.244 118.511 800.233 119.159 798.597 120.455C796.96 121.75 795.722 123.545 794.881 125.841C794.04 128.136 793.619 130.795 793.619 133.818C793.619 136.864 794.04 139.557 794.881 141.898C795.744 144.216 796.983 146.034 798.597 147.352C800.233 148.648 802.244 149.295 804.631 149.295ZM856.696 160.989C853.355 160.989 850.378 160.409 847.764 159.25C845.151 158.068 843.082 156.33 841.56 154.034C840.06 151.716 839.31 148.83 839.31 145.375C839.31 142.466 839.844 140.023 840.912 138.045C841.98 136.068 843.435 134.477 845.276 133.273C847.116 132.068 849.207 131.159 851.548 130.545C853.912 129.932 856.389 129.5 858.98 129.25C862.026 128.932 864.48 128.636 866.344 128.364C868.207 128.068 869.56 127.636 870.401 127.068C871.241 126.5 871.662 125.659 871.662 124.545V124.341C871.662 122.182 870.98 120.511 869.616 119.33C868.276 118.148 866.366 117.557 863.889 117.557C861.276 117.557 859.196 118.136 857.651 119.295C856.105 120.432 855.082 121.864 854.582 123.591L841.151 122.5C841.832 119.318 843.173 116.568 845.173 114.25C847.173 111.909 849.753 110.114 852.912 108.864C856.094 107.591 859.776 106.955 863.957 106.955C866.866 106.955 869.651 107.295 872.31 107.977C874.991 108.659 877.366 109.716 879.435 111.148C881.526 112.58 883.173 114.42 884.378 116.67C885.582 118.898 886.185 121.568 886.185 124.682V160H872.412V152.739H872.003C871.162 154.375 870.037 155.818 868.628 157.068C867.219 158.295 865.526 159.261 863.548 159.966C861.571 160.648 859.287 160.989 856.696 160.989ZM860.855 150.966C862.991 150.966 864.878 150.545 866.514 149.705C868.151 148.841 869.435 147.682 870.366 146.227C871.298 144.773 871.764 143.125 871.764 141.284V135.727C871.31 136.023 870.685 136.295 869.889 136.545C869.116 136.773 868.241 136.989 867.264 137.193C866.287 137.375 865.31 137.545 864.332 137.705C863.355 137.841 862.469 137.966 861.673 138.08C859.969 138.33 858.48 138.727 857.207 139.273C855.935 139.818 854.946 140.557 854.241 141.489C853.537 142.398 853.185 143.534 853.185 144.898C853.185 146.875 853.901 148.386 855.332 149.432C856.787 150.455 858.628 150.966 860.855 150.966ZM925.304 107.636V118.545H893.77V107.636H925.304ZM900.929 95.0909H915.452V143.909C915.452 145.25 915.656 146.295 916.065 147.045C916.474 147.773 917.043 148.284 917.77 148.58C918.52 148.875 919.384 149.023 920.361 149.023C921.043 149.023 921.724 148.966 922.406 148.852C923.088 148.716 923.611 148.614 923.974 148.545L926.259 159.352C925.531 159.58 924.509 159.841 923.19 160.136C921.872 160.455 920.27 160.648 918.384 160.716C914.884 160.852 911.815 160.386 909.179 159.318C906.565 158.25 904.531 156.591 903.077 154.341C901.622 152.091 900.906 149.25 900.929 145.818V95.0909ZM949.696 160.989C946.355 160.989 943.378 160.409 940.764 159.25C938.151 158.068 936.082 156.33 934.56 154.034C933.06 151.716 932.31 148.83 932.31 145.375C932.31 142.466 932.844 140.023 933.912 138.045C934.98 136.068 936.435 134.477 938.276 133.273C940.116 132.068 942.207 131.159 944.548 130.545C946.912 129.932 949.389 129.5 951.98 129.25C955.026 128.932 957.48 128.636 959.344 128.364C961.207 128.068 962.56 127.636 963.401 127.068C964.241 126.5 964.662 125.659 964.662 124.545V124.341C964.662 122.182 963.98 120.511 962.616 119.33C961.276 118.148 959.366 117.557 956.889 117.557C954.276 117.557 952.196 118.136 950.651 119.295C949.105 120.432 948.082 121.864 947.582 123.591L934.151 122.5C934.832 119.318 936.173 116.568 938.173 114.25C940.173 111.909 942.753 110.114 945.912 108.864C949.094 107.591 952.776 106.955 956.957 106.955C959.866 106.955 962.651 107.295 965.31 107.977C967.991 108.659 970.366 109.716 972.435 111.148C974.526 112.58 976.173 114.42 977.378 116.67C978.582 118.898 979.185 121.568 979.185 124.682V160H965.412V152.739H965.003C964.162 154.375 963.037 155.818 961.628 157.068C960.219 158.295 958.526 159.261 956.548 159.966C954.571 160.648 952.287 160.989 949.696 160.989ZM953.855 150.966C955.991 150.966 957.878 150.545 959.514 149.705C961.151 148.841 962.435 147.682 963.366 146.227C964.298 144.773 964.764 143.125 964.764 141.284V135.727C964.31 136.023 963.685 136.295 962.889 136.545C962.116 136.773 961.241 136.989 960.264 137.193C959.287 137.375 958.31 137.545 957.332 137.705C956.355 137.841 955.469 137.966 954.673 138.08C952.969 138.33 951.48 138.727 950.207 139.273C948.935 139.818 947.946 140.557 947.241 141.489C946.537 142.398 946.185 143.534 946.185 144.898C946.185 146.875 946.901 148.386 948.332 149.432C949.787 150.455 951.628 150.966 953.855 150.966Z" fill="black"/> +</svg> diff --git a/packages/head-metadata/README.md b/packages/head-metadata/README.md index e70c9915..f926f4e5 100644 --- a/packages/head-metadata/README.md +++ b/packages/head-metadata/README.md @@ -1,30 +1,32 @@ <h1 align="center"> - <img src="https://raw.githubusercontent.com/your-org/head-metadata/main/.github/banner.svg" alt="head-metadata banner"> + <img src="https://raw.githubusercontent.com/builder-group/community/develop/packages/head-metadata/.github/banner.svg" alt="head-metadata banner"> </h1> <p align="left"> - <a href="https://github.com/your-org/head-metadata/blob/main/LICENSE"> - <img src="https://img.shields.io/github/license/your-org/head-metadata?label=license&style=flat&colorA=293140&colorB=00C896" alt="GitHub License"/> - </a> - <a href="https://www.npmjs.com/package/head-metadata"> - <img src="https://img.shields.io/bundlephobia/minzip/head-metadata?label=minzipped%20size&style=flat&colorA=293140&colorB=00C896" alt="NPM bundle minzipped size"/> - </a> - <a href="https://www.npmjs.com/package/head-metadata"> - <img src="https://img.shields.io/npm/dt/head-metadata.svg?label=downloads&style=flat&colorA=293140&colorB=00C896" alt="NPM total downloads"/> - </a> + <a href="https://github.com/builder-group/community/blob/develop/LICENSE"> + <img src="https://img.shields.io/github/license/builder-group/community.svg?label=license&style=flat&colorA=293140&colorB=FDE200" alt="GitHub License"/> + </a> + <a href="https://www.npmjs.com/package/head-metadata"> + <img src="https://img.shields.io/bundlephobia/minzip/head-metadata.svg?label=minzipped%20size&style=flat&colorA=293140&colorB=FDE200" alt="NPM bundle minzipped size"/> + </a> + <a href="https://www.npmjs.com/package/head-metadata"> + <img src="https://img.shields.io/npm/dt/featuer-state.svg?label=downloads&style=flat&colorA=293140&colorB=FDE200" alt="NPM total downloads"/> + </a> + <a href="https://discord.gg/w4xE3bSjhQ"> + <img src="https://img.shields.io/discord/795291052897992724.svg?label=&logo=discord&logoColor=000000&color=293140&labelColor=FDE200" alt="Join Discord"/> + </a> </p> > Status: Experimental -`head-metadata` is a utility for extracting structured metadata (like `<meta>`, `<title>`, and `<link>`) from the `<head>` of an HTML document. +`head-metadata` is a typesafe and straightforward utility for extracting structured metadata (like `<meta>`, `<title>`, and `<link>`) from the `<head>` of an HTML document. ## 📖 Usage ### Extract Metadata from `<head>` ```ts -import { extractHeadMetadata } from 'head-metadata'; -import { metaExtractor, titleExtractor, linkExtractor } from 'head-metadata/extractors'; +import { extractHeadMetadata, metaExtractor, titleExtractor, linkExtractor } from 'head-metadata'; const html = ` <html> diff --git a/packages/head-metadata/package.json b/packages/head-metadata/package.json index 4786ac45..51054cce 100644 --- a/packages/head-metadata/package.json +++ b/packages/head-metadata/package.json @@ -1,8 +1,8 @@ { "name": "head-metadata", - "version": "0.0.3", + "version": "0.0.4", "private": false, - "description": "Extracts metadata from the head of a HTML document", + "description": "Typesafe and straightforward utility for extracting structured metadata (like `<meta>`, `<title>`, and `<link>`) from the `<head>` of an HTML document", "keywords": [], "homepage": "https://builder.group/?source=package-json", "bugs": { From 0c88f138f43e1b327121065ee129b1ede9f029b6 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Sat, 31 May 2025 17:57:13 +0200 Subject: [PATCH 31/39] #105 fixed typos --- packages/xml-tokenizer/src/extract.test.ts | 42 ---- packages/xml-tokenizer/src/extract.ts | 68 ------ packages/xml-tokenizer/src/index.ts | 2 +- packages/xml-tokenizer/src/processor/index.ts | 4 + .../src/processor/process.test.ts | 209 ++++++++++++++++++ .../xml-tokenizer/src/processor/process.ts | 30 +++ .../src/processor/processors/index.ts | 1 + .../processor/processors/path-tracker.test.ts | 35 +++ .../src/processor/processors/path-tracker.ts | 25 +++ packages/xml-tokenizer/src/processor/types.ts | 24 ++ .../src/processor/validate-processor-order.ts | 46 ++++ 11 files changed, 375 insertions(+), 111 deletions(-) delete mode 100644 packages/xml-tokenizer/src/extract.test.ts delete mode 100644 packages/xml-tokenizer/src/extract.ts create mode 100644 packages/xml-tokenizer/src/processor/index.ts create mode 100644 packages/xml-tokenizer/src/processor/process.test.ts create mode 100644 packages/xml-tokenizer/src/processor/process.ts create mode 100644 packages/xml-tokenizer/src/processor/processors/index.ts create mode 100644 packages/xml-tokenizer/src/processor/processors/path-tracker.test.ts create mode 100644 packages/xml-tokenizer/src/processor/processors/path-tracker.ts create mode 100644 packages/xml-tokenizer/src/processor/types.ts create mode 100644 packages/xml-tokenizer/src/processor/validate-processor-order.ts diff --git a/packages/xml-tokenizer/src/extract.test.ts b/packages/xml-tokenizer/src/extract.test.ts deleted file mode 100644 index ecff228a..00000000 --- a/packages/xml-tokenizer/src/extract.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { htmlConfig } from './config'; -import { extract, type TExtractor } from './extract'; - -describe('extract function', () => { - const sampleHtml = ` - <div class="container main"> - <article class="item featured" data-id="123"> - <h2 class="title">Sample Title</h2> - <p class="description">Sample description</p> - <span class="price">$100</span> - </article> - </div> - `; - - it('should handle empty extractors', () => { - const result = extract(sampleHtml, [], htmlConfig); - expect(result).toEqual({}); - }); - - it('should merge extractor contexts', () => { - const counter = { - context: { count: 0 }, - extract: (token, cx) => { - if (token.type === 'ElementStart') cx.count++; - } - } satisfies TExtractor; - - const collector = { - context: { elements: [] as string[] }, - extract: (token, cx) => { - if (token.type === 'ElementStart') { - cx.elements.push(token.local); - } - } - } satisfies TExtractor; - - const result = extract(sampleHtml, [counter, collector], htmlConfig); - expect(result.count).toBe(5); - expect(result.elements).toEqual(['div', 'article', 'h2', 'p', 'span']); - }); -}); diff --git a/packages/xml-tokenizer/src/extract.ts b/packages/xml-tokenizer/src/extract.ts deleted file mode 100644 index a1f646c5..00000000 --- a/packages/xml-tokenizer/src/extract.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { tokenize, type TXmlStreamOptions, type TXmlToken } from './tokenizer'; - -export function extract<GExtractors extends TExtractor[]>( - xml: string, - extractors: [...GExtractors], - options?: TXmlStreamOptions -): TMergeExtractorContexts<GExtractors> { - validateExtractorOrder(extractors); - - // Build shared context - const context: TMergeExtractorContexts<GExtractors> = {} as TMergeExtractorContexts<GExtractors>; - for (const extractor of extractors) { - Object.assign(context, extractor.context); - } - - // Process tokens - tokenize( - xml, - (token) => { - for (const extractor of extractors) { - extractor.extract(token, context); - } - }, - options - ); - - return context; -} - -function validateExtractorOrder(extractors: TExtractor[]) { - const seen = new Set<TExtractor>(); - - for (const ext of extractors) { - if (ext.deps == null) { - continue; - } - - for (const dep of ext.deps) { - if (!seen.has(dep)) { - throw new Error( - `Extractor dependency order invalid: one extractor depends on another that hasn't run yet.` - ); - } - } - - seen.add(ext); - } -} - -export interface TExtractor<GContext = any, GDeps extends TExtractor[] = []> { - context: GContext; - deps?: GDeps; - extract: TTokenCallback<GContext & TMergeExtractorContexts<GDeps>>; -} - -type TTokenCallback<GContext = any> = (token: TXmlToken, context: GContext) => void; - -type TExtractorContext<GExtractor extends TExtractor> = - GExtractor extends TExtractor<infer C, any> ? C : never; - -type TMergeExtractorContexts<GExtractors extends TExtractor[]> = GExtractors extends [ - infer H, - ...infer R -] - ? TExtractorContext<H & TExtractor> & TMergeExtractorContexts<TCastToExtractors<R>> - : {}; - -type TCastToExtractors<T extends unknown[]> = T extends TExtractor[] ? T : []; diff --git a/packages/xml-tokenizer/src/index.ts b/packages/xml-tokenizer/src/index.ts index 54e55295..ef5813e5 100644 --- a/packages/xml-tokenizer/src/index.ts +++ b/packages/xml-tokenizer/src/index.ts @@ -1,6 +1,6 @@ export * from './config'; -export * from './extract'; export * from './get-q-name'; +export * from './processor'; export * from './selector'; export * from './token-to-xml'; export * from './tokenizer'; diff --git a/packages/xml-tokenizer/src/processor/index.ts b/packages/xml-tokenizer/src/processor/index.ts new file mode 100644 index 00000000..e650d636 --- /dev/null +++ b/packages/xml-tokenizer/src/processor/index.ts @@ -0,0 +1,4 @@ +export * from './process'; +export * from './processors'; +export * from './types'; +export * from './validate-processor-order'; diff --git a/packages/xml-tokenizer/src/processor/process.test.ts b/packages/xml-tokenizer/src/processor/process.test.ts new file mode 100644 index 00000000..ca0130a7 --- /dev/null +++ b/packages/xml-tokenizer/src/processor/process.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from 'vitest'; +import { htmlConfig } from '../config'; +import { process } from './process'; +import { TProcessor } from './types'; + +describe('process function', () => { + const simpleXml = '<root><item>text</item></root>'; + + it('should handle empty processors', () => { + const result = process(simpleXml, [], htmlConfig); + expect(result).toEqual({}); + }); + + it('should process with single processor', () => { + const counter: TProcessor<{ count: number }> = { + context: { count: 0 }, + process: (token, context) => { + if (token.type === 'ElementStart') { + context.count++; + } + } + }; + + const result = process(simpleXml, [counter], htmlConfig); + expect(result.count).toBe(2); // root + item + }); + + it('should process with multiple processors', () => { + const elementCounter: TProcessor<{ elements: number }> = { + context: { elements: 0 }, + process: (token, context) => { + if (token.type === 'ElementStart') { + context.elements++; + } + } + }; + + const textCounter: TProcessor<{ texts: number }> = { + context: { texts: 0 }, + process: (token, context) => { + if (token.type === 'Text' && token.text.trim().length > 0) { + context.texts++; + } + } + }; + + const result = process(simpleXml, [elementCounter, textCounter], htmlConfig); + expect(result.elements).toBe(2); + expect(result.texts).toBe(1); + }); + + it('should merge contexts from all processors', () => { + const processor1: TProcessor<{ prop1: string }> = { + context: { prop1: 'value1' }, + process: () => {} + }; + + const processor2: TProcessor<{ prop2: string }> = { + context: { prop2: 'value2' }, + process: () => {} + }; + + const result = process(simpleXml, [processor1, processor2], htmlConfig); + expect(result.prop1).toBe('value1'); + expect(result.prop2).toBe('value2'); + }); + + it('should allow processors to share data', () => { + const shared: TProcessor<{ sharedValue: number }> = { + context: { sharedValue: 0 } + }; + + const setter: TProcessor<{ setValue: number }, [typeof shared]> = { + context: { setValue: 0 }, + deps: [shared], + process: (token, context) => { + if (token.type === 'ElementStart') { + context.sharedValue = 42; + context.setValue = 42; + } + } + }; + + const reader: TProcessor<{ getValue: number }, [typeof shared]> = { + context: { getValue: 0 }, + deps: [shared], + process: (token, context) => { + if (token.type === 'Text') { + context.getValue = context.sharedValue; + } + } + }; + + const result = process(simpleXml, [shared, setter, reader], htmlConfig); + expect(result.setValue).toBe(42); + expect(result.getValue).toBe(42); + }); + + it('should work with correct dependency order', () => { + const base: TProcessor<{ count: number }> = { + context: { count: 0 }, + process: (token, context) => { + if (token.type === 'ElementStart') { + context.count++; + } + } + }; + + const dependent: TProcessor<{ doubled: number }, [typeof base]> = { + context: { doubled: 0 }, + deps: [base], + process: (token, context) => { + if (token.type === 'ElementEnd') { + context.doubled = context.count * 2; + } + } + }; + + const result = process(simpleXml, [base, dependent], htmlConfig); + expect(result.count).toBe(2); + expect(result.doubled).toBe(4); + }); + + it('should throw error with wrong dependency order', () => { + const base: TProcessor<{ count: number }> = { + name: 'BaseCounter', + context: { count: 0 }, + process: () => {} + }; + + const dependent: TProcessor<{ items: string[] }, [typeof base]> = { + name: 'DependentProcessor', + context: { items: [] }, + deps: [base], + process: () => {} + }; + + expect(() => { + process(simpleXml, [dependent, base], htmlConfig); + }).toThrow('Processor dependency order invalid'); + }); + + it('should handle multiple dependencies', () => { + const counter: TProcessor<{ count: number }> = { + context: { count: 0 }, + process: (token, context) => { + if (token.type === 'ElementStart') { + context.count++; + } + } + }; + + const collector: TProcessor<{ names: string[] }> = { + context: { names: [] }, + process: (token, context) => { + if (token.type === 'ElementStart') { + context.names.push(token.local); + } + } + }; + + const combiner: TProcessor<{ summary: string }, [typeof counter, typeof collector]> = { + context: { summary: '' }, + deps: [counter, collector], + process: (token, context) => { + if ( + token.type === 'ElementEnd' && + token.end.type === 'Close' && + token.end.local === 'root' + ) { + context.summary = `${context.count} elements: ${context.names.join(', ')}`; + } + } + }; + + const result = process(simpleXml, [counter, collector, combiner], htmlConfig); + expect(result.count).toBe(2); + expect(result.names).toEqual(['root', 'item']); + expect(result.summary).toBe('2 elements: root, item'); + }); + + it('should keep references in shared context', () => { + const sharedData = { list: ['start'] }; + + const writer: TProcessor<typeof sharedData> = { + context: sharedData, + process: (token, context) => { + if (token.type === 'ElementStart') { + context.list.push('written'); + } + } + }; + + const reader: TProcessor<{ result: string }> = { + context: { result: '' }, + process: (token, context) => { + if (token.type === 'Text') { + context.result = (context as any).list.join('-'); + } + } + }; + + const result = process(simpleXml, [writer, reader], htmlConfig); + + // Both should reference the same array + expect(result.list).toBe(sharedData.list); + expect(result.result).toBe('start-written-written'); // reader saw writer's changes + }); +}); diff --git a/packages/xml-tokenizer/src/processor/process.ts b/packages/xml-tokenizer/src/processor/process.ts new file mode 100644 index 00000000..2c3167aa --- /dev/null +++ b/packages/xml-tokenizer/src/processor/process.ts @@ -0,0 +1,30 @@ +import { tokenize, type TXmlStreamOptions } from '../tokenizer'; +import { TMergeProcessorContexts, TProcessorAny } from './types'; +import { validateProcessorOrder } from './validate-processor-order'; + +export function process<GProcessors extends readonly TProcessorAny[]>( + xml: string, + processors: [...GProcessors], + options?: TXmlStreamOptions +): TMergeProcessorContexts<GProcessors> { + validateProcessorOrder(processors); + + // Build shared context + const context: TMergeProcessorContexts<GProcessors> = {} as TMergeProcessorContexts<GProcessors>; + for (const processor of processors) { + Object.assign(context, processor.context); + } + + // Process tokens + tokenize( + xml, + (token) => { + for (const processor of processors) { + processor.process?.(token, context); + } + }, + options + ); + + return context; +} diff --git a/packages/xml-tokenizer/src/processor/processors/index.ts b/packages/xml-tokenizer/src/processor/processors/index.ts new file mode 100644 index 00000000..5c6c6a47 --- /dev/null +++ b/packages/xml-tokenizer/src/processor/processors/index.ts @@ -0,0 +1 @@ +export { pathTracker, type TPathTrackerContext } from './path-tracker'; diff --git a/packages/xml-tokenizer/src/processor/processors/path-tracker.test.ts b/packages/xml-tokenizer/src/processor/processors/path-tracker.test.ts new file mode 100644 index 00000000..40fe1de5 --- /dev/null +++ b/packages/xml-tokenizer/src/processor/processors/path-tracker.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { htmlConfig } from '../../config'; +import { process } from '../process'; +import { TProcessor } from '../types'; +import { pathTracker } from './path-tracker'; + +describe('pathTracker processor', () => { + const simpleXml = `<article><header><h1>Title</h1></header></article>`; + + it('should track path correctly', () => { + const result = process(simpleXml, [pathTracker], htmlConfig); + + // After processing, should be back to empty path + expect(result.currentPath).toBe(''); + expect(result.currentPathArray).toEqual([]); + }); + + it('should work with dependent processor that uses path', () => { + // Processor that depends on pathTracker and collects paths + const pathCollector: TProcessor<{ visitedPaths: string[] }, [typeof pathTracker]> = { + name: 'PathCollector', + context: { visitedPaths: [] }, + deps: [pathTracker], + process: (token, context) => { + if (token.type === 'ElementStart' && context.currentPath) { + context.visitedPaths.push(context.currentPath); + } + } + }; + + const result = process(simpleXml, [pathTracker, pathCollector], htmlConfig); + + expect(result.visitedPaths).toEqual(['article', 'article/header', 'article/header/h1']); + }); +}); diff --git a/packages/xml-tokenizer/src/processor/processors/path-tracker.ts b/packages/xml-tokenizer/src/processor/processors/path-tracker.ts new file mode 100644 index 00000000..e3275b22 --- /dev/null +++ b/packages/xml-tokenizer/src/processor/processors/path-tracker.ts @@ -0,0 +1,25 @@ +import type { TProcessor } from '../types'; + +export type TPathTrackerContext = { + currentPath: string; + currentPathArray: string[]; +}; + +export const pathTracker: TProcessor<TPathTrackerContext> = { + name: 'PathTracker', + context: { + currentPath: '', + currentPathArray: [] + }, + process: (token, context) => { + if (token.type === 'ElementStart') { + // Add element to path + const elementName = token.prefix ? `${token.prefix}:${token.local}` : token.local; + context.currentPathArray.push(elementName); + context.currentPath = context.currentPathArray.join('/'); + } else if (token.type === 'ElementEnd' && token.end.type === 'Close') { + context.currentPathArray.pop(); + context.currentPath = context.currentPathArray.join('/'); + } + } +}; diff --git a/packages/xml-tokenizer/src/processor/types.ts b/packages/xml-tokenizer/src/processor/types.ts new file mode 100644 index 00000000..ae95d34d --- /dev/null +++ b/packages/xml-tokenizer/src/processor/types.ts @@ -0,0 +1,24 @@ +import { TXmlToken } from '../tokenizer'; + +export interface TProcessor<GContext = any, GDeps extends readonly TProcessorAny[] = readonly []> { + name?: string; + context: GContext; + deps?: GDeps; + process?: TTokenCallback<GContext & TMergeProcessorContexts<GDeps>>; +} + +export type TProcessorAny = TProcessor<any, any>; + +export type TTokenCallback<GContext = any> = (token: TXmlToken, context: GContext) => void; + +export type TProcessorContext<GProcessor extends TProcessorAny> = + GProcessor extends TProcessor<infer C, any> ? C : never; + +export type TMergeProcessorContexts<GProcessors extends readonly TProcessorAny[]> = + GProcessors extends readonly [infer H, ...infer R] + ? TProcessorContext<H & TProcessorAny> & TMergeProcessorContexts<TCastToProcessors<R>> + : {}; + +export type TCastToProcessors<T extends readonly unknown[]> = T extends readonly TProcessorAny[] + ? T + : readonly []; diff --git a/packages/xml-tokenizer/src/processor/validate-processor-order.ts b/packages/xml-tokenizer/src/processor/validate-processor-order.ts new file mode 100644 index 00000000..bf2b1483 --- /dev/null +++ b/packages/xml-tokenizer/src/processor/validate-processor-order.ts @@ -0,0 +1,46 @@ +import { TProcessorAny } from './types'; + +export function validateProcessorOrder(processors: TProcessorAny[]) { + const seen = new Set<TProcessorAny>(); + + for (let i = 0; i < processors.length; i++) { + const processor = processors[i] as TProcessorAny; + + if (processor.deps == null) { + seen.add(processor); + continue; + } + + const missingDeps: TProcessorAny[] = []; + for (const dep of processor.deps) { + if (!seen.has(dep)) { + missingDeps.push(dep); + } + } + + if (missingDeps.length > 0) { + const processorName = processor.name || `Processor[${i}]`; + const missingNames = missingDeps + .map((dep, idx) => { + const depIndex = processors.indexOf(dep); + return dep.name || `Processor[${depIndex >= 0 ? depIndex : idx}]`; + }) + .join(', '); + + const currentOrder = processors + .filter((p) => p != null) + .map((p, idx) => p.name || `Processor[${idx}]`) + .join(' → '); + + throw new Error( + `Processor dependency order invalid:\n` + + ` ${processorName} depends on: ${missingNames}\n` + + ` But those dependencies haven't been processed yet.\n` + + ` Current order: ${currentOrder}\n` + + ` Solution: Move dependencies before ${processorName} in the processors array.` + ); + } + + seen.add(processor); + } +} From 8b4141f1dbe0e0b0166d9a9d3f7ecfb8f62505ba Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Sat, 31 May 2025 18:18:26 +0200 Subject: [PATCH 32/39] #105 fixed typos --- .../src/__tests__/playground.test.ts | 74 ++++++++++++++++++- .../processor/processors/path-tracker.test.ts | 5 +- .../src/processor/processors/path-tracker.ts | 12 +-- 3 files changed, 79 insertions(+), 12 deletions(-) diff --git a/packages/xml-tokenizer/src/__tests__/playground.test.ts b/packages/xml-tokenizer/src/__tests__/playground.test.ts index 20263a58..b3092bc9 100644 --- a/packages/xml-tokenizer/src/__tests__/playground.test.ts +++ b/packages/xml-tokenizer/src/__tests__/playground.test.ts @@ -3,6 +3,8 @@ import { describe } from 'node:test'; import * as camaro from 'camaro'; import { beforeAll, expect, it } from 'vitest'; import { htmlConfig } from '../config'; +import { process, type TProcessor } from '../processor'; +import { pathTracker } from '../processor/processors'; import { select } from '../selector'; import { tokenToXml } from '../token-to-xml'; import { xmlToSimplifiedObject } from '../xml-to-simplified-object'; @@ -12,7 +14,7 @@ describe('playground', () => { expect(true).toBe(true); }); - describe.skip('HTML should work', () => { + describe('HTML should work', () => { let html = ''; beforeAll(async () => { @@ -24,6 +26,76 @@ describe('playground', () => { console.log(result); }); + + it('[process] should work', () => { + // Article extractor that uses path tracking + const articleExtractor: TProcessor< + { + articles: Array<{ id: string; title: string }>; + currentArticle: { id?: string; title?: string } | null; + }, + [typeof pathTracker] + > = { + name: 'ArticleExtractor', + context: { + articles: [], + currentArticle: null + }, + deps: [pathTracker], + process: (token, context) => { + const path = context.currentPath; + + // Start tracking a new article when we enter an article element + if (token.type === 'ElementStart' && token.local === 'article') { + context.currentArticle = {}; + } + + // Extract ID from data-adid attribute on article element + if ( + token.type === 'Attribute' && + token.local === 'data-adid' && + path.endsWith('article') && + context.currentArticle + ) { + context.currentArticle.id = token.value; + } + + // Extract title from text in h2/a path within article + if ( + token.type === 'Text' && + path.includes('article') && + path.includes('h2') && + path.includes('a') && + context.currentArticle + ) { + const text = token.text.trim(); + if (text.length > 0) { + context.currentArticle.title = text; + } + } + + // Finish article when we close the article element + if ( + token.type === 'ElementEnd' && + token.end.type === 'Close' && + token.end.local === 'article' && + context.currentArticle && + context.currentArticle.id && + context.currentArticle.title + ) { + context.articles.push({ + id: context.currentArticle.id, + title: context.currentArticle.title + }); + context.currentArticle = null; + } + } + }; + + const result = process(html, [pathTracker, articleExtractor], htmlConfig); + + console.log('Extracted articles:', result.articles); + }); }); describe.skip('XML should work', () => { diff --git a/packages/xml-tokenizer/src/processor/processors/path-tracker.test.ts b/packages/xml-tokenizer/src/processor/processors/path-tracker.test.ts index 40fe1de5..266c5472 100644 --- a/packages/xml-tokenizer/src/processor/processors/path-tracker.test.ts +++ b/packages/xml-tokenizer/src/processor/processors/path-tracker.test.ts @@ -11,8 +11,7 @@ describe('pathTracker processor', () => { const result = process(simpleXml, [pathTracker], htmlConfig); // After processing, should be back to empty path - expect(result.currentPath).toBe(''); - expect(result.currentPathArray).toEqual([]); + expect(result.currentPath).toEqual([]); }); it('should work with dependent processor that uses path', () => { @@ -23,7 +22,7 @@ describe('pathTracker processor', () => { deps: [pathTracker], process: (token, context) => { if (token.type === 'ElementStart' && context.currentPath) { - context.visitedPaths.push(context.currentPath); + context.visitedPaths.push(context.currentPath.join('/')); } } }; diff --git a/packages/xml-tokenizer/src/processor/processors/path-tracker.ts b/packages/xml-tokenizer/src/processor/processors/path-tracker.ts index e3275b22..49dcc3f2 100644 --- a/packages/xml-tokenizer/src/processor/processors/path-tracker.ts +++ b/packages/xml-tokenizer/src/processor/processors/path-tracker.ts @@ -1,25 +1,21 @@ import type { TProcessor } from '../types'; export type TPathTrackerContext = { - currentPath: string; - currentPathArray: string[]; + currentPath: string[]; }; export const pathTracker: TProcessor<TPathTrackerContext> = { name: 'PathTracker', context: { - currentPath: '', - currentPathArray: [] + currentPath: [] }, process: (token, context) => { if (token.type === 'ElementStart') { // Add element to path const elementName = token.prefix ? `${token.prefix}:${token.local}` : token.local; - context.currentPathArray.push(elementName); - context.currentPath = context.currentPathArray.join('/'); + context.currentPath.push(elementName); } else if (token.type === 'ElementEnd' && token.end.type === 'Close') { - context.currentPathArray.pop(); - context.currentPath = context.currentPathArray.join('/'); + context.currentPath.pop(); } } }; From 720483af04d8148a28b4525220cc3bf58c122c56 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Sat, 31 May 2025 18:43:21 +0200 Subject: [PATCH 33/39] #105 fixed typos --- .../src/__tests__/playground.test.ts | 2 +- packages/xml-tokenizer/src/processor/types.ts | 19 ++++++++----------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/xml-tokenizer/src/__tests__/playground.test.ts b/packages/xml-tokenizer/src/__tests__/playground.test.ts index b3092bc9..cd34ee3c 100644 --- a/packages/xml-tokenizer/src/__tests__/playground.test.ts +++ b/packages/xml-tokenizer/src/__tests__/playground.test.ts @@ -54,7 +54,7 @@ describe('playground', () => { if ( token.type === 'Attribute' && token.local === 'data-adid' && - path.endsWith('article') && + path[path.length - 1] === 'article' && context.currentArticle ) { context.currentArticle.id = token.value; diff --git a/packages/xml-tokenizer/src/processor/types.ts b/packages/xml-tokenizer/src/processor/types.ts index ae95d34d..105e4a53 100644 --- a/packages/xml-tokenizer/src/processor/types.ts +++ b/packages/xml-tokenizer/src/processor/types.ts @@ -1,6 +1,6 @@ import { TXmlToken } from '../tokenizer'; -export interface TProcessor<GContext = any, GDeps extends readonly TProcessorAny[] = readonly []> { +export interface TProcessor<GContext = unknown, GDeps extends readonly TProcessorAny[] = []> { name?: string; context: GContext; deps?: GDeps; @@ -9,16 +9,13 @@ export interface TProcessor<GContext = any, GDeps extends readonly TProcessorAny export type TProcessorAny = TProcessor<any, any>; -export type TTokenCallback<GContext = any> = (token: TXmlToken, context: GContext) => void; - -export type TProcessorContext<GProcessor extends TProcessorAny> = - GProcessor extends TProcessor<infer C, any> ? C : never; +export type TTokenCallback<GContext = unknown> = (token: TXmlToken, context: GContext) => void; export type TMergeProcessorContexts<GProcessors extends readonly TProcessorAny[]> = - GProcessors extends readonly [infer H, ...infer R] - ? TProcessorContext<H & TProcessorAny> & TMergeProcessorContexts<TCastToProcessors<R>> + GProcessors extends [infer Head, ...infer Tail] + ? Head extends TProcessorAny + ? Tail extends readonly TProcessorAny[] + ? Head['context'] & TMergeProcessorContexts<Tail> + : Head['context'] + : {} : {}; - -export type TCastToProcessors<T extends readonly unknown[]> = T extends readonly TProcessorAny[] - ? T - : readonly []; From fc892549f15a359a48a246e51f55d3445d760f13 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Sat, 31 May 2025 19:09:41 +0200 Subject: [PATCH 34/39] #105 fixed typos --- .../kleinanzeigen-client/README.md | 1 + .../kleinanzeigen-client/eslint.config.js | 0 .../kleinanzeigen-client/package.json | 0 .../kleinanzeigen-client/rollup.config.js | 0 .../src/__tests__/playground.test.ts | 0 .../src/__tests__/resources/e2e/s-laptop.html | 0 .../kleinanzeigen-client/src/extract-ads.ts | 75 +++++ .../kleinanzeigen-client/src/fetch-ads.ts | 0 .../kleinanzeigen-client/src/index.ts | 0 .../kleinanzeigen-client/tsconfig.json | 0 .../kleinanzeigen-client/tsconfig.prod.json | 0 .../kleinanzeigen-client/vitest.config.mjs | 0 packages/kleinanzeigen-client/README.md | 115 -------- .../kleinanzeigen-client/src/extract-ads.ts | 279 ------------------ .../src/__tests__/playground.test.ts | 74 +---- packages/xml-tokenizer/src/processor/types.ts | 7 +- 16 files changed, 82 insertions(+), 469 deletions(-) create mode 100644 packages/_deprecated/kleinanzeigen-client/README.md rename packages/{ => _deprecated}/kleinanzeigen-client/eslint.config.js (100%) rename packages/{ => _deprecated}/kleinanzeigen-client/package.json (100%) rename packages/{ => _deprecated}/kleinanzeigen-client/rollup.config.js (100%) rename packages/{ => _deprecated}/kleinanzeigen-client/src/__tests__/playground.test.ts (100%) rename packages/{ => _deprecated}/kleinanzeigen-client/src/__tests__/resources/e2e/s-laptop.html (100%) create mode 100644 packages/_deprecated/kleinanzeigen-client/src/extract-ads.ts rename packages/{ => _deprecated}/kleinanzeigen-client/src/fetch-ads.ts (100%) rename packages/{ => _deprecated}/kleinanzeigen-client/src/index.ts (100%) rename packages/{ => _deprecated}/kleinanzeigen-client/tsconfig.json (100%) rename packages/{ => _deprecated}/kleinanzeigen-client/tsconfig.prod.json (100%) rename packages/{ => _deprecated}/kleinanzeigen-client/vitest.config.mjs (100%) delete mode 100644 packages/kleinanzeigen-client/README.md delete mode 100644 packages/kleinanzeigen-client/src/extract-ads.ts diff --git a/packages/_deprecated/kleinanzeigen-client/README.md b/packages/_deprecated/kleinanzeigen-client/README.md new file mode 100644 index 00000000..4d944060 --- /dev/null +++ b/packages/_deprecated/kleinanzeigen-client/README.md @@ -0,0 +1 @@ +todo \ No newline at end of file diff --git a/packages/kleinanzeigen-client/eslint.config.js b/packages/_deprecated/kleinanzeigen-client/eslint.config.js similarity index 100% rename from packages/kleinanzeigen-client/eslint.config.js rename to packages/_deprecated/kleinanzeigen-client/eslint.config.js diff --git a/packages/kleinanzeigen-client/package.json b/packages/_deprecated/kleinanzeigen-client/package.json similarity index 100% rename from packages/kleinanzeigen-client/package.json rename to packages/_deprecated/kleinanzeigen-client/package.json diff --git a/packages/kleinanzeigen-client/rollup.config.js b/packages/_deprecated/kleinanzeigen-client/rollup.config.js similarity index 100% rename from packages/kleinanzeigen-client/rollup.config.js rename to packages/_deprecated/kleinanzeigen-client/rollup.config.js diff --git a/packages/kleinanzeigen-client/src/__tests__/playground.test.ts b/packages/_deprecated/kleinanzeigen-client/src/__tests__/playground.test.ts similarity index 100% rename from packages/kleinanzeigen-client/src/__tests__/playground.test.ts rename to packages/_deprecated/kleinanzeigen-client/src/__tests__/playground.test.ts diff --git a/packages/kleinanzeigen-client/src/__tests__/resources/e2e/s-laptop.html b/packages/_deprecated/kleinanzeigen-client/src/__tests__/resources/e2e/s-laptop.html similarity index 100% rename from packages/kleinanzeigen-client/src/__tests__/resources/e2e/s-laptop.html rename to packages/_deprecated/kleinanzeigen-client/src/__tests__/resources/e2e/s-laptop.html diff --git a/packages/_deprecated/kleinanzeigen-client/src/extract-ads.ts b/packages/_deprecated/kleinanzeigen-client/src/extract-ads.ts new file mode 100644 index 00000000..5813a5d9 --- /dev/null +++ b/packages/_deprecated/kleinanzeigen-client/src/extract-ads.ts @@ -0,0 +1,75 @@ +import { htmlConfig, pathTracker, process, TProcessor } from 'xml-tokenizer'; + +const articleExtractor: TProcessor< + { + articles: Array<{ id: string; title: string }>; + currentArticle: { id?: string; title?: string } | null; + }, + [typeof pathTracker] +> = { + name: 'ArticleExtractor', + context: { + articles: [], + currentArticle: null + }, + deps: [pathTracker], + process: (token, context) => { + const path = context.currentPath; + + // Start tracking a new article when we enter an article element + if (token.type === 'ElementStart' && token.local === 'article') { + context.currentArticle = {}; + } + + // Extract ID from data-adid attribute on article element + if ( + token.type === 'Attribute' && + token.local === 'data-adid' && + path[path.length - 1] === 'article' && + context.currentArticle + ) { + context.currentArticle.id = token.value; + } + + // Extract title from text in h2/a path within article + if ( + token.type === 'Text' && + path.includes('article') && + path.includes('h2') && + path.includes('a') && + context.currentArticle + ) { + const text = token.text.trim(); + if (text.length > 0) { + context.currentArticle.title = text; + } + } + + // Finish article when we close the article element + if ( + token.type === 'ElementEnd' && + token.end.type === 'Close' && + token.end.local === 'article' && + context.currentArticle && + context.currentArticle.id && + context.currentArticle.title + ) { + context.articles.push({ + id: context.currentArticle.id, + title: context.currentArticle.title + }); + context.currentArticle = null; + } + } +}; + +export function extractAdsData(html: string): TListingData[] { + const result = process(html, [pathTracker, articleExtractor], htmlConfig); + + return result.articles; +} + +export type TListingData = { + id: string; + title?: string; +}; diff --git a/packages/kleinanzeigen-client/src/fetch-ads.ts b/packages/_deprecated/kleinanzeigen-client/src/fetch-ads.ts similarity index 100% rename from packages/kleinanzeigen-client/src/fetch-ads.ts rename to packages/_deprecated/kleinanzeigen-client/src/fetch-ads.ts diff --git a/packages/kleinanzeigen-client/src/index.ts b/packages/_deprecated/kleinanzeigen-client/src/index.ts similarity index 100% rename from packages/kleinanzeigen-client/src/index.ts rename to packages/_deprecated/kleinanzeigen-client/src/index.ts diff --git a/packages/kleinanzeigen-client/tsconfig.json b/packages/_deprecated/kleinanzeigen-client/tsconfig.json similarity index 100% rename from packages/kleinanzeigen-client/tsconfig.json rename to packages/_deprecated/kleinanzeigen-client/tsconfig.json diff --git a/packages/kleinanzeigen-client/tsconfig.prod.json b/packages/_deprecated/kleinanzeigen-client/tsconfig.prod.json similarity index 100% rename from packages/kleinanzeigen-client/tsconfig.prod.json rename to packages/_deprecated/kleinanzeigen-client/tsconfig.prod.json diff --git a/packages/kleinanzeigen-client/vitest.config.mjs b/packages/_deprecated/kleinanzeigen-client/vitest.config.mjs similarity index 100% rename from packages/kleinanzeigen-client/vitest.config.mjs rename to packages/_deprecated/kleinanzeigen-client/vitest.config.mjs diff --git a/packages/kleinanzeigen-client/README.md b/packages/kleinanzeigen-client/README.md deleted file mode 100644 index 4519b73c..00000000 --- a/packages/kleinanzeigen-client/README.md +++ /dev/null @@ -1,115 +0,0 @@ -<h1 align="center"> - <img src="https://raw.githubusercontent.com/builder-group/community/develop/packages/google-webfonts-client/.github/banner.svg" alt="google-webfonts-client banner"> -</h1> - -<p align="left"> - <a href="https://github.com/builder-group/community/blob/develop/LICENSE"> - <img src="https://img.shields.io/github/license/builder-group/community.svg?label=license&style=flat&colorA=293140&colorB=FDE200" alt="GitHub License"/> - </a> - <a href="https://www.npmjs.com/package/google-webfonts-client"> - <img src="https://img.shields.io/bundlephobia/minzip/google-webfonts-client.svg?label=minzipped%20size&style=flat&colorA=293140&colorB=FDE200" alt="NPM bundle minzipped size"/> - </a> - <a href="https://www.npmjs.com/package/google-webfonts-client"> - <img src="https://img.shields.io/npm/dt/google-webfonts-client.svg?label=downloads&style=flat&colorA=293140&colorB=FDE200" alt="NPM total downloads"/> - </a> - <a href="https://discord.gg/w4xE3bSjhQ"> - <img src="https://img.shields.io/discord/795291052897992724.svg?label=&logo=discord&logoColor=000000&color=293140&labelColor=FDE200" alt="Join Discord"/> - </a> -</p> - -> Status: Experimental - -`google-webfonts-client` is a typesafe and straightforward fetch client for interacting with the Google Web Fonts API using [`feature-fetch`](https://github.com/builder-group/community/tree/develop/packages/feature-fetch). This client provides typesafe methods for fetching and downloading Google Fonts. - -- [Google Fonts Developer API Docs](https://developers.google.com/fonts/docs/developer_api) - -## 📖 Usage - -### Create a Google Web Fonts Client - -Use `createGoogleWebfontsClient()` to create a client with your API key. - -```ts -import { createGoogleWebfontsClient } from 'google-webfonts-client'; - -const client = createGoogleWebfontsClient({ - apiKey: 'YOUR_API_KEY' -}); -``` - -### Fetch Available Web Fonts - -Fetches the available web fonts from the Google Fonts API. - -```ts -const webFontsResult = await client.getWebFonts(); -const webFonts = webFontsResult.unwrap(); -``` - -### Fetch Font File URL - -Fetches the URL of a specific font file based on the provided family, weight, and style. - -```ts -const fontUrlResult = await client.getFontFileUrl('Roboto Serif', { - fontWeight: 400, - fontStyle: 'regular' -}); -const fontUrl = fontUrlResult.unwrap(); -``` - -### Download a Font File - -Use the client to download a font file, specifying the font family, weight, and style. - -```ts -const fontFileResult = await client.downloadFontFile('Roboto Serif', { - fontWeight: 100, - fontStyle: 'italic' -}); -const fontFile = fontFileResult.unwrap(); -``` - -### Error Handling - -Errors can occur during API requests, and the client will return detailed error information. Possible error types include: - -- **`NetworkError`**: Indicates a failure in network communication, such as loss of connectivity -- **`RequestError`**: Occurs when the server returns a response with a status code indicating an error (e.g., 4xx or 5xx) -- **`FetchError`**: A general exception type that can encompass other error scenarios not covered by `NetworkError` or `RequestError`, for example when the response couldn't be parsed, .. - -```ts -const fontUrlResult = await client.getFontFileUrl('Roboto Serif', { - fontWeight: 400, - fontStyle: 'regular' -}); - -// First Approach: Handle error using `isErr()` -if (fontUrlResult.isErr()) { - const { error } = fontUrlResult; - if (error instanceof NetworkError) { - console.error('Network error:', error.message); - } else if (error instanceof RequestError) { - console.error('Request error:', error.message, 'Status:', error.status); - } else if (error instanceof FetchError) { - console.error('Service error:', error.message, 'Code:', error.code); - } else { - console.error('Unexpected error:', error); - } -} - -// Second Approach: Unwrap response with `try-catch` -try { - const fontUrl = fontUrlResult.unwrap(); -} catch (error) { - if (error instanceof NetworkError) { - console.error('Network error:', error.message); - } else if (error instanceof RequestError) { - console.error('Request error:', error.message, 'Status:', error.status); - } else if (error instanceof FetchError) { - console.error('Service error:', error.message, 'Code:', error.code); - } else { - console.error('Unexpected error:', error); - } -} -``` diff --git a/packages/kleinanzeigen-client/src/extract-ads.ts b/packages/kleinanzeigen-client/src/extract-ads.ts deleted file mode 100644 index 433d18fa..00000000 --- a/packages/kleinanzeigen-client/src/extract-ads.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { extract, type TExtractor } from 'xml-tokenizer'; - -export type TListingData = { - id: string; - title?: string; - description?: string; - location?: string; - date?: string; - price?: number; - currency?: string; - priceText?: string; - imageUrl?: string; - tags?: string[]; - jsonLd?: any; -}; - -const elementTracker = { - context: { - elementPath: [] as string[], - currentElement: '', - currentClasses: [] as string[] - }, - extract: (token, cx) => { - if (token.type === 'ElementStart') { - cx.elementPath.push(token.local); - cx.currentElement = token.local; - cx.currentClasses = []; - } else if (token.type === 'Attribute' && token.local === 'class') { - cx.currentClasses = token.value.split(' ').filter((cls) => cls.trim()); - } else if (token.type === 'ElementEnd' && token.end.type === 'Close') { - cx.elementPath.pop(); - cx.currentElement = cx.elementPath[cx.elementPath.length - 1] || ''; - } - } -} satisfies TExtractor; - -const listingTracker = { - context: { - listings: [] as TListingData[], - currentListing: null as Partial<TListingData> | null - }, - deps: [elementTracker], - extract: (token, cx) => { - if (token.type === 'ElementStart' && token.local === 'article') { - cx.currentListing = {}; - } else if (token.type === 'Attribute' && token.local === 'data-adid' && cx.currentListing) { - cx.currentListing.id = token.value; - } else if ( - token.type === 'ElementEnd' && - token.end.type === 'Close' && - token.end.local === 'article' && - cx.currentListing?.id - ) { - cx.listings.push(cx.currentListing as TListingData); - cx.currentListing = null; - } - } -} satisfies TExtractor; - -const titleExtractor = { - context: {}, - deps: [elementTracker, listingTracker], - extract: (token, cx) => { - if (token.type === 'Text' && cx.currentListing && token.text.trim()) { - if (cx.currentElement === 'a' && cx.currentClasses.includes('ellipsis')) { - cx.currentListing.title = token.text.trim(); - } - } - } -} satisfies TExtractor; - -const descriptionExtractor = { - context: {}, - deps: [elementTracker, listingTracker], - extract: (token, cx) => { - if (token.type === 'Text' && cx.currentListing && token.text.trim()) { - if (cx.currentClasses.some((cls) => cls.includes('aditem-main--middle--description'))) { - cx.currentListing.description = token.text.trim(); - } - } - } -} satisfies TExtractor; - -const locationDateExtractor = { - context: { insideIcon: false }, - deps: [elementTracker, listingTracker], - extract: (token, cx) => { - if (token.type === 'ElementStart' && token.local === 'i') { - cx.insideIcon = true; - } else if ( - token.type === 'ElementEnd' && - token.end.type === 'Close' && - token.end.local === 'i' - ) { - cx.insideIcon = false; - } else if (token.type === 'Text' && cx.currentListing && token.text.trim() && !cx.insideIcon) { - const text = token.text.trim(); - - if ( - cx.currentClasses.some((cls) => cls.includes('aditem-main--top--left')) && - /\d{5}/.test(text) - ) { - cx.currentListing.location = text.replace(/^[^a-zA-Z0-9]*/, '').trim(); - } else if ( - cx.currentClasses.some((cls) => cls.includes('aditem-main--top--right')) && - (text.includes('Heute') || /\d{2}:\d{2}/.test(text) || /\d{2}\.\d{2}\.\d{4}/.test(text)) - ) { - cx.currentListing.date = text; - } - } - } -} satisfies TExtractor; - -const priceExtractor = { - context: { insideIcon: false }, - deps: [elementTracker, listingTracker], - extract: (token, cx) => { - if (token.type === 'ElementStart' && token.local === 'i') { - cx.insideIcon = true; - } else if ( - token.type === 'ElementEnd' && - token.end.type === 'Close' && - token.end.local === 'i' - ) { - cx.insideIcon = false; - } else if (token.type === 'Text' && cx.currentListing && token.text.trim() && !cx.insideIcon) { - const text = token.text.trim(); - - if ( - cx.currentClasses.some((cls) => cls.includes('aditem-main--middle--price-shipping--price')) - ) { - cx.currentListing.priceText = text; - const priceInfo = parsePrice(text); - cx.currentListing.price = priceInfo.amount; - cx.currentListing.currency = priceInfo.currency; - } - } - } -} satisfies TExtractor; - -const imageExtractor = { - context: {}, - deps: [elementTracker, listingTracker], - extract: (token, cx) => { - if ( - token.type === 'Attribute' && - token.local === 'src' && - cx.currentElement === 'img' && - cx.currentListing && - !cx.currentListing.imageUrl - ) { - if (token.value.includes('img.kleinanzeigen.de')) { - cx.currentListing.imageUrl = token.value; - } - } - } -} satisfies TExtractor; - -const tagExtractor = { - context: { insideIcon: false, inTagWithIcon: false }, - deps: [elementTracker, listingTracker], - extract: (token, cx) => { - if (token.type === 'ElementStart' && token.local === 'i') { - cx.insideIcon = true; - } else if ( - token.type === 'ElementEnd' && - token.end.type === 'Close' && - token.end.local === 'i' - ) { - cx.insideIcon = false; - } else if ( - token.type === 'ElementEnd' && - token.end.type === 'Close' && - token.end.local === 'span' - ) { - cx.inTagWithIcon = false; - } else if ( - token.type === 'Attribute' && - token.local === 'class' && - cx.currentElement === 'span' - ) { - const classes = token.value.split(' '); - if (classes.includes('tag-with-icon')) { - cx.inTagWithIcon = true; - } - } else if (token.type === 'Text' && cx.currentListing && token.text.trim()) { - const text = token.text.trim(); - - if ( - cx.currentElement === 'span' && - cx.currentClasses.includes('simpletag') && - text.length > 2 && - /[a-zA-Z]/.test(text) - ) { - if ((cx.inTagWithIcon && !cx.insideIcon) || !cx.inTagWithIcon) { - if (!cx.currentListing.tags) cx.currentListing.tags = []; - cx.currentListing.tags.push(text); - } - } - } - } -} satisfies TExtractor; - -const jsonLdExtractor = { - context: { insideScript: false, scriptContent: '' }, - deps: [elementTracker, listingTracker], - extract: (token, cx) => { - if (token.type === 'ElementStart' && token.local === 'script') { - cx.insideScript = false; - cx.scriptContent = ''; - } else if ( - token.type === 'Attribute' && - token.local === 'type' && - cx.currentElement === 'script' && - token.value === 'application/ld+json' - ) { - cx.insideScript = true; - } else if (token.type === 'Text' && cx.insideScript) { - cx.scriptContent += token.text; - } else if ( - token.type === 'ElementEnd' && - token.end.type === 'Close' && - token.end.local === 'script' && - cx.insideScript && - cx.scriptContent.trim() && - cx.currentListing - ) { - try { - const jsonData = JSON.parse(cx.scriptContent.trim()); - if (jsonData['@type'] === 'ImageObject') { - cx.currentListing.jsonLd = jsonData; - if (!cx.currentListing.title && jsonData.title) { - cx.currentListing.title = jsonData.title; - } - if (!cx.currentListing.description && jsonData.description) { - cx.currentListing.description = jsonData.description; - } - if (!cx.currentListing.imageUrl && jsonData.contentUrl) { - cx.currentListing.imageUrl = jsonData.contentUrl; - } - } - } catch (e) { - // Ignore JSON parse errors - } - cx.insideScript = false; - cx.scriptContent = ''; - } - } -} satisfies TExtractor; - -export function extractAdsData(html: string): TListingData[] { - const result = extract(html, [ - elementTracker, - listingTracker, - titleExtractor, - descriptionExtractor, - locationDateExtractor, - priceExtractor, - imageExtractor, - tagExtractor, - jsonLdExtractor - ]); - - return result.listings; -} - -function parsePrice(priceText: string): { amount?: number; currency: string } { - const cleanText = priceText.replace(/\s*(VB|Verhandlungsbasis)\s*$/i, '').trim(); - const currency = cleanText.includes('€') ? 'EUR' : 'USD'; - - const numberMatch = cleanText.match(/[\d.]+/); - if (numberMatch) { - const numberStr = numberMatch[0]; - const amount = parseFloat(numberStr.replace(/\./g, '')); - return { amount, currency }; - } - - return { currency }; -} diff --git a/packages/xml-tokenizer/src/__tests__/playground.test.ts b/packages/xml-tokenizer/src/__tests__/playground.test.ts index cd34ee3c..20263a58 100644 --- a/packages/xml-tokenizer/src/__tests__/playground.test.ts +++ b/packages/xml-tokenizer/src/__tests__/playground.test.ts @@ -3,8 +3,6 @@ import { describe } from 'node:test'; import * as camaro from 'camaro'; import { beforeAll, expect, it } from 'vitest'; import { htmlConfig } from '../config'; -import { process, type TProcessor } from '../processor'; -import { pathTracker } from '../processor/processors'; import { select } from '../selector'; import { tokenToXml } from '../token-to-xml'; import { xmlToSimplifiedObject } from '../xml-to-simplified-object'; @@ -14,7 +12,7 @@ describe('playground', () => { expect(true).toBe(true); }); - describe('HTML should work', () => { + describe.skip('HTML should work', () => { let html = ''; beforeAll(async () => { @@ -26,76 +24,6 @@ describe('playground', () => { console.log(result); }); - - it('[process] should work', () => { - // Article extractor that uses path tracking - const articleExtractor: TProcessor< - { - articles: Array<{ id: string; title: string }>; - currentArticle: { id?: string; title?: string } | null; - }, - [typeof pathTracker] - > = { - name: 'ArticleExtractor', - context: { - articles: [], - currentArticle: null - }, - deps: [pathTracker], - process: (token, context) => { - const path = context.currentPath; - - // Start tracking a new article when we enter an article element - if (token.type === 'ElementStart' && token.local === 'article') { - context.currentArticle = {}; - } - - // Extract ID from data-adid attribute on article element - if ( - token.type === 'Attribute' && - token.local === 'data-adid' && - path[path.length - 1] === 'article' && - context.currentArticle - ) { - context.currentArticle.id = token.value; - } - - // Extract title from text in h2/a path within article - if ( - token.type === 'Text' && - path.includes('article') && - path.includes('h2') && - path.includes('a') && - context.currentArticle - ) { - const text = token.text.trim(); - if (text.length > 0) { - context.currentArticle.title = text; - } - } - - // Finish article when we close the article element - if ( - token.type === 'ElementEnd' && - token.end.type === 'Close' && - token.end.local === 'article' && - context.currentArticle && - context.currentArticle.id && - context.currentArticle.title - ) { - context.articles.push({ - id: context.currentArticle.id, - title: context.currentArticle.title - }); - context.currentArticle = null; - } - } - }; - - const result = process(html, [pathTracker, articleExtractor], htmlConfig); - - console.log('Extracted articles:', result.articles); - }); }); describe.skip('XML should work', () => { diff --git a/packages/xml-tokenizer/src/processor/types.ts b/packages/xml-tokenizer/src/processor/types.ts index 105e4a53..1ab37ae9 100644 --- a/packages/xml-tokenizer/src/processor/types.ts +++ b/packages/xml-tokenizer/src/processor/types.ts @@ -4,12 +4,15 @@ export interface TProcessor<GContext = unknown, GDeps extends readonly TProcesso name?: string; context: GContext; deps?: GDeps; - process?: TTokenCallback<GContext & TMergeProcessorContexts<GDeps>>; + process?: TProcessorTokenCallback<GContext & TMergeProcessorContexts<GDeps>>; } export type TProcessorAny = TProcessor<any, any>; -export type TTokenCallback<GContext = unknown> = (token: TXmlToken, context: GContext) => void; +export type TProcessorTokenCallback<GContext = unknown> = ( + token: TXmlToken, + context: GContext +) => void; export type TMergeProcessorContexts<GProcessors extends readonly TProcessorAny[]> = GProcessors extends [infer Head, ...infer Tail] From 597fd2159c1d2df9dbd0f524101f27fa31a8eed3 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Sun, 1 Jun 2025 15:43:26 +0200 Subject: [PATCH 35/39] #105 updated deps --- packages/config/package.json | 18 +- packages/elevenlabs-client/package.json | 2 +- packages/eprel-client/package.json | 2 +- packages/feature-ecs/package.json | 2 +- packages/feature-fetch/package.json | 4 +- packages/feature-form/package.json | 2 +- packages/feature-logger/package.json | 2 +- packages/feature-react/package.json | 4 +- packages/feature-state/package.json | 2 +- packages/figma-connect/package.json | 2 +- packages/google-webfonts-client/package.json | 2 +- packages/head-metadata/package.json | 2 +- packages/openapi-ts-router/package.json | 4 +- packages/rollup-presets/package.json | 4 +- packages/utils/package.json | 2 +- packages/validatenv/package.json | 2 +- packages/validation-adapter/package.json | 2 +- packages/validation-adapters/package.json | 4 +- packages/xml-tokenizer/package.json | 2 +- pnpm-lock.yaml | 1391 +++++++----------- 20 files changed, 587 insertions(+), 868 deletions(-) diff --git a/packages/config/package.json b/packages/config/package.json index 72c06f88..f1dbc43e 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -38,23 +38,23 @@ "update:latest": "pnpm update --latest" }, "dependencies": { - "@ianvs/prettier-plugin-sort-imports": "^4.4.1", - "@next/eslint-plugin-next": "^15.3.2", - "@typescript-eslint/eslint-plugin": "^8.32.1", - "@typescript-eslint/parser": "^8.32.1", + "@ianvs/prettier-plugin-sort-imports": "^4.4.2", + "@next/eslint-plugin-next": "^15.3.3", + "@typescript-eslint/eslint-plugin": "^8.33.0", + "@typescript-eslint/parser": "^8.33.0", "eslint-config-prettier": "^10.1.5", "eslint-plugin-only-warn": "^1.1.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-turbo": "^2.5.3", + "eslint-plugin-turbo": "^2.5.4", "prettier-plugin-css-order": "^2.1.2", - "prettier-plugin-packagejson": "^2.5.14", - "prettier-plugin-tailwindcss": "^0.6.11", - "typescript-eslint": "^8.32.1", + "prettier-plugin-packagejson": "^2.5.15", + "prettier-plugin-tailwindcss": "^0.6.12", + "typescript-eslint": "^8.33.0", "vite-tsconfig-paths": "^5.1.4" }, "devDependencies": { - "eslint": "^9.27.0", + "eslint": "^9.28.0", "prettier": "^3.5.3", "typescript": "^5.8.3", "vitest": "^3.1.4" diff --git a/packages/elevenlabs-client/package.json b/packages/elevenlabs-client/package.json index efc03b42..e349fc7b 100644 --- a/packages/elevenlabs-client/package.json +++ b/packages/elevenlabs-client/package.json @@ -42,7 +42,7 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "dotenv": "^16.5.0", "openapi-typescript": "^7.8.0", "rollup-presets": "workspace:*" diff --git a/packages/eprel-client/package.json b/packages/eprel-client/package.json index 105a9241..48c02352 100644 --- a/packages/eprel-client/package.json +++ b/packages/eprel-client/package.json @@ -42,7 +42,7 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "dotenv": "^16.5.0", "openapi-typescript": "^7.8.0", "rollup-presets": "workspace:*" diff --git a/packages/feature-ecs/package.json b/packages/feature-ecs/package.json index 4f6dedcf..2cd4d6b6 100644 --- a/packages/feature-ecs/package.json +++ b/packages/feature-ecs/package.json @@ -39,7 +39,7 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "bitecs": "github:NateTheGreatt/bitECS#rc-0-4-0", "rollup-presets": "workspace:*" }, diff --git a/packages/feature-fetch/package.json b/packages/feature-fetch/package.json index faff5506..65bb40d2 100644 --- a/packages/feature-fetch/package.json +++ b/packages/feature-fetch/package.json @@ -41,9 +41,9 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "@types/url-parse": "^1.4.11", - "msw": "^2.8.4", + "msw": "^2.8.7", "rollup-presets": "workspace:*" }, "size-limit": [ diff --git a/packages/feature-form/package.json b/packages/feature-form/package.json index db584aae..65671022 100644 --- a/packages/feature-form/package.json +++ b/packages/feature-form/package.json @@ -42,7 +42,7 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "rollup-presets": "workspace:*" }, "size-limit": [ diff --git a/packages/feature-logger/package.json b/packages/feature-logger/package.json index afe70d33..19c01aa3 100644 --- a/packages/feature-logger/package.json +++ b/packages/feature-logger/package.json @@ -40,7 +40,7 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "rollup-presets": "workspace:*" }, "size-limit": [ diff --git a/packages/feature-react/package.json b/packages/feature-react/package.json index e0eb497f..b75ef7cf 100644 --- a/packages/feature-react/package.json +++ b/packages/feature-react/package.json @@ -60,8 +60,8 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", - "@types/react": "^19.1.5", + "@types/node": "^22.15.29", + "@types/react": "^19.1.6", "feature-form": "workspace:*", "feature-state": "workspace:*", "react": "^19.1.0", diff --git a/packages/feature-state/package.json b/packages/feature-state/package.json index 7c97d17f..25c9d54f 100644 --- a/packages/feature-state/package.json +++ b/packages/feature-state/package.json @@ -40,7 +40,7 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "rollup-presets": "workspace:*" }, "size-limit": [ diff --git a/packages/figma-connect/package.json b/packages/figma-connect/package.json index a9701d54..6fca3973 100644 --- a/packages/figma-connect/package.json +++ b/packages/figma-connect/package.json @@ -60,7 +60,7 @@ "devDependencies": { "@blgc/config": "workspace:*", "@figma/plugin-typings": "^1.113.0", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "rollup-presets": "workspace:*" }, "size-limit": [ diff --git a/packages/google-webfonts-client/package.json b/packages/google-webfonts-client/package.json index f794d207..df1e4bc0 100644 --- a/packages/google-webfonts-client/package.json +++ b/packages/google-webfonts-client/package.json @@ -42,7 +42,7 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "dotenv": "^16.5.0", "openapi-typescript": "^7.8.0", "rollup-presets": "workspace:*" diff --git a/packages/head-metadata/package.json b/packages/head-metadata/package.json index 51054cce..2e9bc306 100644 --- a/packages/head-metadata/package.json +++ b/packages/head-metadata/package.json @@ -38,7 +38,7 @@ "xml-tokenizer": "workspace:*" }, "devDependencies": { - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "rollup-presets": "workspace:*" }, "size-limit": [ diff --git a/packages/openapi-ts-router/package.json b/packages/openapi-ts-router/package.json index d2042590..22646611 100644 --- a/packages/openapi-ts-router/package.json +++ b/packages/openapi-ts-router/package.json @@ -42,9 +42,9 @@ "@blgc/config": "workspace:*", "@types/express": "^5.0.2", "@types/express-serve-static-core": "^5.0.6", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "express": "^5.1.0", - "hono": "^4.7.10", + "hono": "^4.7.11", "rollup-presets": "workspace:*", "valibot": "1.1.0", "validation-adapters": "workspace:*" diff --git a/packages/rollup-presets/package.json b/packages/rollup-presets/package.json index f5ce4e18..9ff50492 100644 --- a/packages/rollup-presets/package.json +++ b/packages/rollup-presets/package.json @@ -33,7 +33,7 @@ }, "dependencies": { "@rollup/plugin-commonjs": "^28.0.3", - "execa": "9.5.3", + "execa": "9.6.0", "picocolors": "^1.1.1", "rollup-plugin-dts": "^6.2.1", "rollup-plugin-esbuild": "^6.2.1", @@ -41,7 +41,7 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "rollup": "^4.41.1", "type-fest": "^4.41.0" } diff --git a/packages/utils/package.json b/packages/utils/package.json index 9b166504..64b48e3a 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -36,7 +36,7 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "rollup-presets": "workspace:*" }, "size-limit": [ diff --git a/packages/validatenv/package.json b/packages/validatenv/package.json index 0bf8dcd7..23d19e95 100644 --- a/packages/validatenv/package.json +++ b/packages/validatenv/package.json @@ -40,7 +40,7 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "rollup-presets": "workspace:*" }, "size-limit": [ diff --git a/packages/validation-adapter/package.json b/packages/validation-adapter/package.json index b3ce5388..0c8d4014 100644 --- a/packages/validation-adapter/package.json +++ b/packages/validation-adapter/package.json @@ -39,7 +39,7 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "rollup-presets": "workspace:*" }, "size-limit": [ diff --git a/packages/validation-adapters/package.json b/packages/validation-adapters/package.json index 5fba0076..4925e7b9 100644 --- a/packages/validation-adapters/package.json +++ b/packages/validation-adapters/package.json @@ -78,11 +78,11 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "rollup-presets": "workspace:*", "valibot": "1.1.0", "yup": "^1.6.1", - "zod": "^3.25.28" + "zod": "^3.25.46" }, "size-limit": [ { diff --git a/packages/xml-tokenizer/package.json b/packages/xml-tokenizer/package.json index 5ef9222e..4b051f77 100644 --- a/packages/xml-tokenizer/package.json +++ b/packages/xml-tokenizer/package.json @@ -37,7 +37,7 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "@types/sax": "^1.2.7", "@types/xml2js": "^0.4.14", "camaro": "^6.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 482533b8..c5a7d0b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 2.29.4 '@ianvs/prettier-plugin-sort-imports': specifier: ^4.4.1 - version: 4.4.1(prettier@3.5.3) + version: 4.4.2(prettier@3.5.3) '@size-limit/esbuild': specifier: ^11.2.0 version: 11.2.0(size-limit@11.2.0) @@ -31,13 +31,13 @@ importers: version: 11.2.0(size-limit@11.2.0) eslint: specifier: ^9.27.0 - version: 9.27.0(jiti@2.4.2) + version: 9.28.0(jiti@2.4.2) prettier: specifier: ^3.5.3 version: 3.5.3 prettier-plugin-tailwindcss: specifier: ^0.6.11 - version: 0.6.11(@ianvs/prettier-plugin-sort-imports@4.4.1(prettier@3.5.3))(prettier-plugin-css-order@2.1.2(postcss@8.5.3)(prettier@3.5.3))(prettier@3.5.3) + version: 0.6.12(@ianvs/prettier-plugin-sort-imports@4.4.2(prettier@3.5.3))(prettier-plugin-css-order@2.1.2(postcss@8.5.4)(prettier@3.5.3))(prettier@3.5.3) rollup: specifier: ^4.41.1 version: 4.41.1 @@ -49,13 +49,13 @@ importers: version: 11.2.0 turbo: specifier: ^2.5.3 - version: 2.5.3 + version: 2.5.4 typescript: specifier: ^5.8.3 version: 5.8.3 vitest: specifier: ^3.1.4 - version: 3.1.4(@types/node@22.15.21)(jiti@2.4.2)(msw@2.8.4(@types/node@22.15.21)(typescript@5.8.3))(tsx@4.19.4) + version: 3.1.4(@types/node@22.15.29)(jiti@2.4.2)(msw@2.8.7(@types/node@22.15.29)(typescript@5.8.3))(tsx@4.19.4) examples/feature-fetch/vanilla/open-meteo: dependencies: @@ -71,7 +71,7 @@ importers: version: 5.8.3 vite: specifier: ^6.0.5 - version: 6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4) + version: 6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4) examples/feature-form/react/basic: dependencies: @@ -92,44 +92,44 @@ importers: version: 19.1.0(react@19.1.0) valibot: specifier: 1.0.0-beta.9 - version: 1.0.0-beta.9(typescript@5.8.3) + version: 1.1.0(typescript@5.8.3) validation-adapters: specifier: workspace:* version: link:../../../../packages/validation-adapters zod: specifier: ^3.24.1 - version: 3.25.28 + version: 3.25.46 devDependencies: '@types/react': specifier: ^19.0.2 - version: 19.1.5 + version: 19.1.6 '@types/react-dom': specifier: ^19.0.2 - version: 19.1.5(@types/react@19.1.5) + version: 19.1.5(@types/react@19.1.6) '@typescript-eslint/eslint-plugin': specifier: ^8.18.1 - version: 8.32.1(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) + version: 8.33.0(@typescript-eslint/parser@8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) '@typescript-eslint/parser': specifier: ^8.18.1 - version: 8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) + version: 8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.5.0(vite@6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4)) + version: 4.5.0(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4)) eslint: specifier: ^9.17.0 - version: 9.27.0(jiti@2.4.2) + version: 9.28.0(jiti@2.4.2) eslint-plugin-react-hooks: specifier: ^5.1.0 - version: 5.2.0(eslint@9.27.0(jiti@2.4.2)) + version: 5.2.0(eslint@9.28.0(jiti@2.4.2)) eslint-plugin-react-refresh: specifier: ^0.4.16 - version: 0.4.20(eslint@9.27.0(jiti@2.4.2)) + version: 0.4.20(eslint@9.28.0(jiti@2.4.2)) typescript: specifier: ^5.7.2 version: 5.8.3 vite: specifier: ^6.0.5 - version: 6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4) + version: 6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4) examples/feature-state/react/counter: dependencies: @@ -151,46 +151,46 @@ importers: devDependencies: '@types/react': specifier: ^19.0.2 - version: 19.1.5 + version: 19.1.6 '@types/react-dom': specifier: ^19.0.2 - version: 19.1.5(@types/react@19.1.5) + version: 19.1.5(@types/react@19.1.6) '@typescript-eslint/eslint-plugin': specifier: ^8.18.1 - version: 8.32.1(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) + version: 8.33.0(@typescript-eslint/parser@8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) '@typescript-eslint/parser': specifier: ^8.18.1 - version: 8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) + version: 8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.5.0(vite@6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4)) + version: 4.5.0(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4)) eslint: specifier: ^9.17.0 - version: 9.27.0(jiti@2.4.2) + version: 9.28.0(jiti@2.4.2) eslint-plugin-react-hooks: specifier: ^5.1.0 - version: 5.2.0(eslint@9.27.0(jiti@2.4.2)) + version: 5.2.0(eslint@9.28.0(jiti@2.4.2)) eslint-plugin-react-refresh: specifier: ^0.4.16 - version: 0.4.20(eslint@9.27.0(jiti@2.4.2)) + version: 0.4.20(eslint@9.28.0(jiti@2.4.2)) typescript: specifier: ^5.7.2 version: 5.8.3 vite: specifier: ^6.0.5 - version: 6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4) + version: 6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4) examples/openapi-ts-router/express/petstore: dependencies: express: specifier: ^4.21.2 - version: 4.21.2 + version: 5.1.0 openapi-ts-router: specifier: workspace:* version: link:../../../../packages/openapi-ts-router valibot: specifier: 1.0.0-beta.12 - version: 1.0.0-beta.12(typescript@5.8.3) + version: 1.1.0(typescript@5.8.3) validation-adapters: specifier: workspace:* version: link:../../../../packages/validation-adapters @@ -203,7 +203,7 @@ importers: version: 5.0.2 '@types/node': specifier: ^22.10.7 - version: 22.15.21 + version: 22.15.29 nodemon: specifier: ^3.1.9 version: 3.1.10 @@ -212,7 +212,7 @@ importers: version: 7.8.0(typescript@5.8.3) ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@22.15.21)(typescript@5.8.3) + version: 10.9.2(@types/node@22.15.29)(typescript@5.8.3) tsconfig-paths: specifier: ^4.2.0 version: 4.2.0 @@ -221,13 +221,13 @@ importers: dependencies: '@hono/node-server': specifier: ^1.13.7 - version: 1.14.2(hono@4.7.10) + version: 1.14.3(hono@4.7.11) '@hono/zod-validator': specifier: ^0.4.2 - version: 0.4.3(hono@4.7.10)(zod@3.25.28) + version: 0.7.0(hono@4.7.11)(zod@3.25.46) hono: specifier: ^4.6.15 - version: 4.7.10 + version: 4.7.11 openapi-ts-router: specifier: workspace:* version: link:../../../../packages/openapi-ts-router @@ -236,11 +236,11 @@ importers: version: link:../../../../packages/validation-adapters zod: specifier: ^3.24.1 - version: 3.25.28 + version: 3.25.46 devDependencies: '@types/node': specifier: ^22.10.3 - version: 22.15.21 + version: 22.15.29 tsx: specifier: ^4.19.2 version: 4.19.4 @@ -252,10 +252,10 @@ importers: version: 6.2.3 fast-xml-parser: specifier: ^4.4.1 - version: 4.5.3 + version: 5.2.3 tinybench: specifier: ^2.9.0 - version: 2.9.0 + version: 4.0.1 txml: specifier: ^5.1.1 version: 5.1.1 @@ -274,56 +274,56 @@ importers: version: 5.8.3 vite: specifier: ^5.3.4 - version: 5.4.19(@types/node@22.15.21) + version: 6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4) packages/config: dependencies: '@ianvs/prettier-plugin-sort-imports': specifier: ^4.4.1 - version: 4.4.1(prettier@3.5.3) + version: 4.4.2(prettier@3.5.3) '@next/eslint-plugin-next': specifier: ^15.3.2 - version: 15.3.2 + version: 15.3.3 '@typescript-eslint/eslint-plugin': specifier: ^8.32.1 - version: 8.32.1(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) + version: 8.33.0(@typescript-eslint/parser@8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) '@typescript-eslint/parser': specifier: ^8.32.1 - version: 8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) + version: 8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) eslint-config-prettier: specifier: ^10.1.5 - version: 10.1.5(eslint@9.27.0(jiti@2.4.2)) + version: 10.1.5(eslint@9.28.0(jiti@2.4.2)) eslint-plugin-only-warn: specifier: ^1.1.0 version: 1.1.0 eslint-plugin-react: specifier: ^7.37.5 - version: 7.37.5(eslint@9.27.0(jiti@2.4.2)) + version: 7.37.5(eslint@9.28.0(jiti@2.4.2)) eslint-plugin-react-hooks: specifier: ^5.2.0 - version: 5.2.0(eslint@9.27.0(jiti@2.4.2)) + version: 5.2.0(eslint@9.28.0(jiti@2.4.2)) eslint-plugin-turbo: specifier: ^2.5.3 - version: 2.5.3(eslint@9.27.0(jiti@2.4.2))(turbo@2.5.3) + version: 2.5.4(eslint@9.28.0(jiti@2.4.2))(turbo@2.5.4) prettier-plugin-css-order: specifier: ^2.1.2 - version: 2.1.2(postcss@8.5.3)(prettier@3.5.3) + version: 2.1.2(postcss@8.5.4)(prettier@3.5.3) prettier-plugin-packagejson: specifier: ^2.5.14 - version: 2.5.14(prettier@3.5.3) + version: 2.5.15(prettier@3.5.3) prettier-plugin-tailwindcss: specifier: ^0.6.11 - version: 0.6.11(@ianvs/prettier-plugin-sort-imports@4.4.1(prettier@3.5.3))(prettier-plugin-css-order@2.1.2(postcss@8.5.3)(prettier@3.5.3))(prettier@3.5.3) + version: 0.6.12(@ianvs/prettier-plugin-sort-imports@4.4.2(prettier@3.5.3))(prettier-plugin-css-order@2.1.2(postcss@8.5.4)(prettier@3.5.3))(prettier@3.5.3) typescript-eslint: specifier: ^8.32.1 - version: 8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) + version: 8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4)) + version: 5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4)) devDependencies: eslint: specifier: ^9.27.0 - version: 9.27.0(jiti@2.4.2) + version: 9.28.0(jiti@2.4.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -332,7 +332,7 @@ importers: version: 5.8.3 vitest: specifier: ^3.1.4 - version: 3.1.4(@types/node@22.15.21)(jiti@2.4.2)(msw@2.8.4(@types/node@22.15.21)(typescript@5.8.3))(tsx@4.19.4) + version: 3.1.4(@types/node@22.15.29)(jiti@2.4.2)(msw@2.8.7(@types/node@22.15.29)(typescript@5.8.3))(tsx@4.19.4) packages/elevenlabs-client: dependencies: @@ -351,7 +351,7 @@ importers: version: link:../config '@types/node': specifier: ^22.15.21 - version: 22.15.21 + version: 22.15.29 dotenv: specifier: ^16.5.0 version: 16.5.0 @@ -378,8 +378,8 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 - version: 22.15.21 + specifier: ^22.15.29 + version: 22.15.29 dotenv: specifier: ^16.5.0 version: 16.5.0 @@ -400,8 +400,8 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 - version: 22.15.21 + specifier: ^22.15.29 + version: 22.15.29 bitecs: specifier: github:NateTheGreatt/bitECS#rc-0-4-0 version: https://codeload.github.com/NateTheGreatt/bitECS/tar.gz/caa1f58be2ccc304c1f0a085de34ca5904b3b80f @@ -413,7 +413,7 @@ importers: dependencies: '@0no-co/graphql.web': specifier: ^1.1.2 - version: 1.1.2(graphql@16.10.0) + version: 1.1.2(graphql@16.11.0) '@blgc/types': specifier: workspace:* version: link:../types @@ -425,14 +425,14 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 - version: 22.15.21 + specifier: ^22.15.29 + version: 22.15.29 '@types/url-parse': specifier: ^1.4.11 version: 1.4.11 msw: - specifier: ^2.8.4 - version: 2.8.4(@types/node@22.15.21)(typescript@5.8.3) + specifier: ^2.8.7 + version: 2.8.7(@types/node@22.15.29)(typescript@5.8.3) rollup-presets: specifier: workspace:* version: link:../rollup-presets @@ -456,8 +456,8 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 - version: 22.15.21 + specifier: ^22.15.29 + version: 22.15.29 rollup-presets: specifier: workspace:* version: link:../rollup-presets @@ -475,8 +475,8 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 - version: 22.15.21 + specifier: ^22.15.29 + version: 22.15.29 rollup-presets: specifier: workspace:* version: link:../rollup-presets @@ -494,11 +494,11 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 - version: 22.15.21 + specifier: ^22.15.29 + version: 22.15.29 '@types/react': - specifier: ^19.1.5 - version: 19.1.5 + specifier: ^19.1.6 + version: 19.1.6 feature-form: specifier: workspace:* version: link:../feature-form @@ -526,7 +526,7 @@ importers: version: link:../config '@types/node': specifier: ^22.15.21 - version: 22.15.21 + version: 22.15.29 rollup-presets: specifier: workspace:* version: link:../rollup-presets @@ -545,7 +545,7 @@ importers: version: 1.113.0 '@types/node': specifier: ^22.15.21 - version: 22.15.21 + version: 22.15.29 rollup-presets: specifier: workspace:* version: link:../rollup-presets @@ -567,7 +567,7 @@ importers: version: link:../config '@types/node': specifier: ^22.15.21 - version: 22.15.21 + version: 22.15.29 dotenv: specifier: ^16.5.0 version: 16.5.0 @@ -586,26 +586,7 @@ importers: devDependencies: '@types/node': specifier: ^22.15.21 - version: 22.15.21 - rollup-presets: - specifier: workspace:* - version: link:../rollup-presets - - packages/kleinanzeigen-client: - dependencies: - feature-fetch: - specifier: workspace:* - version: link:../feature-fetch - xml-tokenizer: - specifier: workspace:* - version: link:../xml-tokenizer - devDependencies: - '@blgc/config': - specifier: workspace:* - version: link:../config - '@types/node': - specifier: ^22.15.21 - version: 22.15.21 + version: 22.15.29 rollup-presets: specifier: workspace:* version: link:../rollup-presets @@ -629,14 +610,14 @@ importers: specifier: ^5.0.6 version: 5.0.6 '@types/node': - specifier: ^22.15.21 - version: 22.15.21 + specifier: ^22.15.29 + version: 22.15.29 express: specifier: ^5.1.0 version: 5.1.0 hono: - specifier: ^4.7.10 - version: 4.7.10 + specifier: ^4.7.11 + version: 4.7.11 rollup-presets: specifier: workspace:* version: link:../rollup-presets @@ -654,7 +635,7 @@ importers: version: 28.0.3(rollup@4.41.1) execa: specifier: 9.5.3 - version: 9.5.3 + version: 9.6.0 picocolors: specifier: ^1.1.1 version: 1.1.1 @@ -663,7 +644,7 @@ importers: version: 6.2.1(rollup@4.41.1)(typescript@5.8.3) rollup-plugin-esbuild: specifier: ^6.2.1 - version: 6.2.1(esbuild@0.25.4)(rollup@4.41.1) + version: 6.2.1(esbuild@0.25.5)(rollup@4.41.1) rollup-plugin-node-externals: specifier: 8.0.0 version: 8.0.0(rollup@4.41.1) @@ -673,7 +654,7 @@ importers: version: link:../config '@types/node': specifier: ^22.15.21 - version: 22.15.21 + version: 22.15.29 rollup: specifier: ^4.41.1 version: 4.41.1 @@ -696,8 +677,8 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 - version: 22.15.21 + specifier: ^22.15.29 + version: 22.15.29 rollup-presets: specifier: workspace:* version: link:../rollup-presets @@ -716,7 +697,7 @@ importers: version: link:../config '@types/node': specifier: ^22.15.21 - version: 22.15.21 + version: 22.15.29 rollup-presets: specifier: workspace:* version: link:../rollup-presets @@ -732,7 +713,7 @@ importers: version: link:../config '@types/node': specifier: ^22.15.21 - version: 22.15.21 + version: 22.15.29 rollup-presets: specifier: workspace:* version: link:../rollup-presets @@ -747,8 +728,8 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 - version: 22.15.21 + specifier: ^22.15.29 + version: 22.15.29 rollup-presets: specifier: workspace:* version: link:../rollup-presets @@ -759,8 +740,8 @@ importers: specifier: ^1.6.1 version: 1.6.1 zod: - specifier: ^3.25.28 - version: 3.25.28 + specifier: ^3.25.46 + version: 3.25.46 packages/xml-tokenizer: devDependencies: @@ -769,7 +750,7 @@ importers: version: link:../config '@types/node': specifier: ^22.15.21 - version: 22.15.21 + version: 22.15.29 '@types/sax': specifier: ^1.2.7 version: 1.2.7 @@ -983,12 +964,6 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.25.0': resolution: {integrity: sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==} engines: {node: '>=18'} @@ -1001,11 +976,11 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] + '@esbuild/aix-ppc64@0.25.5': + resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] '@esbuild/android-arm64@0.25.0': resolution: {integrity: sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==} @@ -1019,10 +994,10 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} - cpu: [arm] + '@esbuild/android-arm64@0.25.5': + resolution: {integrity: sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==} + engines: {node: '>=18'} + cpu: [arm64] os: [android] '@esbuild/android-arm@0.25.0': @@ -1037,10 +1012,10 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} - cpu: [x64] + '@esbuild/android-arm@0.25.5': + resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==} + engines: {node: '>=18'} + cpu: [arm] os: [android] '@esbuild/android-x64@0.25.0': @@ -1055,11 +1030,11 @@ packages: cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] + '@esbuild/android-x64@0.25.5': + resolution: {integrity: sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] '@esbuild/darwin-arm64@0.25.0': resolution: {integrity: sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==} @@ -1073,10 +1048,10 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} - cpu: [x64] + '@esbuild/darwin-arm64@0.25.5': + resolution: {integrity: sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==} + engines: {node: '>=18'} + cpu: [arm64] os: [darwin] '@esbuild/darwin-x64@0.25.0': @@ -1091,11 +1066,11 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] + '@esbuild/darwin-x64@0.25.5': + resolution: {integrity: sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] '@esbuild/freebsd-arm64@0.25.0': resolution: {integrity: sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==} @@ -1109,10 +1084,10 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} - cpu: [x64] + '@esbuild/freebsd-arm64@0.25.5': + resolution: {integrity: sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==} + engines: {node: '>=18'} + cpu: [arm64] os: [freebsd] '@esbuild/freebsd-x64@0.25.0': @@ -1127,11 +1102,11 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] + '@esbuild/freebsd-x64@0.25.5': + resolution: {integrity: sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] '@esbuild/linux-arm64@0.25.0': resolution: {integrity: sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==} @@ -1145,10 +1120,10 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} - cpu: [arm] + '@esbuild/linux-arm64@0.25.5': + resolution: {integrity: sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==} + engines: {node: '>=18'} + cpu: [arm64] os: [linux] '@esbuild/linux-arm@0.25.0': @@ -1163,10 +1138,10 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} - cpu: [ia32] + '@esbuild/linux-arm@0.25.5': + resolution: {integrity: sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==} + engines: {node: '>=18'} + cpu: [arm] os: [linux] '@esbuild/linux-ia32@0.25.0': @@ -1181,10 +1156,10 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} - cpu: [loong64] + '@esbuild/linux-ia32@0.25.5': + resolution: {integrity: sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==} + engines: {node: '>=18'} + cpu: [ia32] os: [linux] '@esbuild/linux-loong64@0.25.0': @@ -1199,10 +1174,10 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} - cpu: [mips64el] + '@esbuild/linux-loong64@0.25.5': + resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==} + engines: {node: '>=18'} + cpu: [loong64] os: [linux] '@esbuild/linux-mips64el@0.25.0': @@ -1217,10 +1192,10 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} - cpu: [ppc64] + '@esbuild/linux-mips64el@0.25.5': + resolution: {integrity: sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==} + engines: {node: '>=18'} + cpu: [mips64el] os: [linux] '@esbuild/linux-ppc64@0.25.0': @@ -1235,10 +1210,10 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} - cpu: [riscv64] + '@esbuild/linux-ppc64@0.25.5': + resolution: {integrity: sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==} + engines: {node: '>=18'} + cpu: [ppc64] os: [linux] '@esbuild/linux-riscv64@0.25.0': @@ -1253,10 +1228,10 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} - cpu: [s390x] + '@esbuild/linux-riscv64@0.25.5': + resolution: {integrity: sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==} + engines: {node: '>=18'} + cpu: [riscv64] os: [linux] '@esbuild/linux-s390x@0.25.0': @@ -1271,10 +1246,10 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} - cpu: [x64] + '@esbuild/linux-s390x@0.25.5': + resolution: {integrity: sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==} + engines: {node: '>=18'} + cpu: [s390x] os: [linux] '@esbuild/linux-x64@0.25.0': @@ -1289,6 +1264,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.25.5': + resolution: {integrity: sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.0': resolution: {integrity: sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==} engines: {node: '>=18'} @@ -1301,10 +1282,10 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} - cpu: [x64] + '@esbuild/netbsd-arm64@0.25.5': + resolution: {integrity: sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==} + engines: {node: '>=18'} + cpu: [arm64] os: [netbsd] '@esbuild/netbsd-x64@0.25.0': @@ -1319,6 +1300,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.25.5': + resolution: {integrity: sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.0': resolution: {integrity: sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==} engines: {node: '>=18'} @@ -1331,10 +1318,10 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} - cpu: [x64] + '@esbuild/openbsd-arm64@0.25.5': + resolution: {integrity: sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==} + engines: {node: '>=18'} + cpu: [arm64] os: [openbsd] '@esbuild/openbsd-x64@0.25.0': @@ -1349,11 +1336,11 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} + '@esbuild/openbsd-x64@0.25.5': + resolution: {integrity: sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==} + engines: {node: '>=18'} cpu: [x64] - os: [sunos] + os: [openbsd] '@esbuild/sunos-x64@0.25.0': resolution: {integrity: sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==} @@ -1367,11 +1354,11 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] + '@esbuild/sunos-x64@0.25.5': + resolution: {integrity: sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] '@esbuild/win32-arm64@0.25.0': resolution: {integrity: sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==} @@ -1385,10 +1372,10 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} - cpu: [ia32] + '@esbuild/win32-arm64@0.25.5': + resolution: {integrity: sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==} + engines: {node: '>=18'} + cpu: [arm64] os: [win32] '@esbuild/win32-ia32@0.25.0': @@ -1403,10 +1390,10 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} - cpu: [x64] + '@esbuild/win32-ia32@0.25.5': + resolution: {integrity: sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==} + engines: {node: '>=18'} + cpu: [ia32] os: [win32] '@esbuild/win32-x64@0.25.0': @@ -1421,6 +1408,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.25.5': + resolution: {integrity: sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.7.0': resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1447,8 +1440,8 @@ packages: resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.27.0': - resolution: {integrity: sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA==} + '@eslint/js@9.28.0': + resolution: {integrity: sha512-fnqSjGWd/CoIp4EXIxWVK/sHA6DOHN4+8Ix2cX5ycOY7LG0UY8nHCU5pIp2eaE1Mc7Qd8kHspYNzYXT2ojPLzg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.6': @@ -1462,17 +1455,17 @@ packages: '@figma/plugin-typings@1.113.0': resolution: {integrity: sha512-gasgrtK6XsZmpsWCbE1g7KTLWGCc6teo4alNUDF06OtJ7E9hwOOTMdicueAgghvQJETI+dmWE3IjJfGIPSsdfA==} - '@hono/node-server@1.14.2': - resolution: {integrity: sha512-GHjpOeHYbr9d1vkID2sNUYkl5IxumyhDrUJB7wBp7jvqYwPFt+oNKsAPBRcdSbV7kIrXhouLE199ks1QcK4r7A==} + '@hono/node-server@1.14.3': + resolution: {integrity: sha512-KuDMwwghtFYSmIpr4WrKs1VpelTrptvJ+6x6mbUcZnFcc213cumTF5BdqfHyW93B19TNI4Vaev14vOI2a0Ie3w==} engines: {node: '>=18.14.1'} peerDependencies: hono: ^4 - '@hono/zod-validator@0.4.3': - resolution: {integrity: sha512-xIgMYXDyJ4Hj6ekm9T9Y27s080Nl9NXHcJkOvkXPhubOLj8hZkOL8pDnnXfvCf5xEE8Q4oMFenQUZZREUY2gqQ==} + '@hono/zod-validator@0.7.0': + resolution: {integrity: sha512-qe2ZE6sHFE98dcUrbYMtS3bAV8hqcCOflykvZga2S7XhmNSZzT+dIz4OuMILsjLHkJw9JMn912/dB7dQOmuPvg==} peerDependencies: hono: '>=3.9.0' - zod: ^3.19.1 + zod: ^3.25.0 '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} @@ -1494,17 +1487,17 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@ianvs/prettier-plugin-sort-imports@4.4.1': - resolution: {integrity: sha512-F0/Hrcfpy8WuxlQyAWJTEren/uxKhYonOGY4OyWmwRdeTvkh9mMSCxowZLjNkhwi/2ipqCgtXwwOk7tW0mWXkA==} + '@ianvs/prettier-plugin-sort-imports@4.4.2': + resolution: {integrity: sha512-KkVFy3TLh0OFzimbZglMmORi+vL/i2OFhEs5M07R9w0IwWAGpsNNyE4CY/2u0YoMF5bawKC2+8/fUH60nnNtjw==} peerDependencies: '@vue/compiler-sfc': 2.7.x || 3.x - prettier: 2 || 3 + prettier: 2 || 3 || ^4.0.0-0 peerDependenciesMeta: '@vue/compiler-sfc': optional: true - '@inquirer/confirm@5.1.7': - resolution: {integrity: sha512-Xrfbrw9eSiHb+GsesO8TQIeHSMTP0xyvTCeeYevgZ4sKW+iz9w/47bgfG9b0niQm+xaLY2EWPBINUPldLwvYiw==} + '@inquirer/confirm@5.1.12': + resolution: {integrity: sha512-dpq+ielV9/bqgXRUbNH//KsY6WEw9DrGPmipkpmgC1Y46cwuBTNx7PXFWTjc3MQ+urcc0QxoVHcMI0FW4Ok0hg==} engines: {node: '>=18'} peerDependencies: '@types/node': '>=18' @@ -1512,8 +1505,8 @@ packages: '@types/node': optional: true - '@inquirer/core@10.1.8': - resolution: {integrity: sha512-HpAqR8y715zPpM9e/9Q+N88bnGwqqL8ePgZ0SMv/s3673JLMv3bIkoivGmjPqXlEgisUksSXibweQccUwEx4qQ==} + '@inquirer/core@10.1.13': + resolution: {integrity: sha512-1viSxebkYN2nJULlzCxES6G9/stgHSepZ9LqqfdIGPHj5OHhiBUXVS0a6R0bEC2A+VL4D9w6QB66ebCr6HGllA==} engines: {node: '>=18'} peerDependencies: '@types/node': '>=18' @@ -1521,12 +1514,12 @@ packages: '@types/node': optional: true - '@inquirer/figures@1.0.11': - resolution: {integrity: sha512-eOg92lvrn/aRUqbxRyvpEWnrvRuTYRifixHkYVpJiygTgVSBIHDqLh0SrMQXkafvULg3ck11V7xvR+zcgvpHFw==} + '@inquirer/figures@1.0.12': + resolution: {integrity: sha512-MJttijd8rMFcKJC8NYmprWr6hD3r9Gd9qUC0XwPNwoEPWSMVJwA2MlXxF+nhZZNMY+HXsWa+o7KY2emWYIn0jQ==} engines: {node: '>=18'} - '@inquirer/type@3.0.5': - resolution: {integrity: sha512-ZJpeIYYueOz/i/ONzrfof8g89kNdO2hjGuvULROo3O8rlB2CRtSseE5KeirnyE4t/thAn/EwvS/vuQeJCn+NZg==} + '@inquirer/type@3.0.7': + resolution: {integrity: sha512-PfunHQcjwnju84L+ycmcMKB/pTPIngjUJvfnRhKY6FKPuYXlM4aQCb/nIdTFR6BEhMjFvngzvng/vBAJMZpLSA==} engines: {node: '>=18'} peerDependencies: '@types/node': '>=18' @@ -1561,12 +1554,12 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} - '@mswjs/interceptors@0.37.6': - resolution: {integrity: sha512-wK+5pLK5XFmgtH3aQ2YVvA3HohS3xqV/OxuVOdNx9Wpnz7VE/fnC+e1A7ln6LFYeck7gOJ/dsZV6OLplOtAJ2w==} + '@mswjs/interceptors@0.38.7': + resolution: {integrity: sha512-Jkb27iSn7JPdkqlTqKfhncFfnEZsIJVYxsFbUSWEkxdIPdsyngrhoDBk0/BGD2FQcRH99vlRrkHpNTyKqI+0/w==} engines: {node: '>=18'} - '@next/eslint-plugin-next@15.3.2': - resolution: {integrity: sha512-ijVRTXBgnHT33aWnDtmlG+LJD+5vhc9AKTJPquGG5NKXjpKNjc62woIhFtrAcWdBobt8kqjCoaJ0q6sDQoX7aQ==} + '@next/eslint-plugin-next@15.3.3': + resolution: {integrity: sha512-VKZJEiEdpKkfBmcokGjHu0vGDG+8CehGs90tBEy/IDoDDKGngeyIStt2MmE5FYNyU9BhgR7tybNWTAJY/30u+Q==} '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -1589,8 +1582,8 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@pkgr/core@0.2.4': - resolution: {integrity: sha512-ROFF39F6ZrnzSUEmQQZUar0Jt4xVoP9WnDRdWwF4NNcXs3xBTLgBUDoOwW141y1jP+S8nahIbdxbFC7IShw9Iw==} + '@pkgr/core@0.2.5': + resolution: {integrity: sha512-YRx7tFgLkrpFkDAzVSV5sUJydmf2ZDrW+O3IbQ1JyeMW7B0FiWroFJTnR4/fD9CsusnAn4qRUcbb5jFnZSd6uw==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} '@redocly/ajv@8.11.2': @@ -1808,8 +1801,8 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} - '@types/node@22.15.21': - resolution: {integrity: sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==} + '@types/node@22.15.29': + resolution: {integrity: sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ==} '@types/qs@6.9.18': resolution: {integrity: sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==} @@ -1822,8 +1815,8 @@ packages: peerDependencies: '@types/react': ^19.0.0 - '@types/react@19.1.5': - resolution: {integrity: sha512-piErsCVVbpMMT2r7wbawdZsq4xMvIAhQuac2gedQHysu1TZYEigE6pnFfgZT+/jQnrRuF5r+SHzuehFjfRjr4g==} + '@types/react@19.1.6': + resolution: {integrity: sha512-JeG0rEWak0N6Itr6QUx+X60uQmN+5t3j9r/OVDtWzFXKaj6kD1BwJzOksD0FF6iWxZlbE1kB0q9vtnU2ekqa1Q==} '@types/sax@1.2.7': resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} @@ -1846,51 +1839,61 @@ packages: '@types/xml2js@0.4.14': resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==} - '@typescript-eslint/eslint-plugin@8.32.1': - resolution: {integrity: sha512-6u6Plg9nP/J1GRpe/vcjjabo6Uc5YQPAMxsgQyGC/I0RuukiG1wIe3+Vtg3IrSCVJDmqK3j8adrtzXSENRtFgg==} + '@typescript-eslint/eslint-plugin@8.33.0': + resolution: {integrity: sha512-CACyQuqSHt7ma3Ns601xykeBK/rDeZa3w6IS6UtMQbixO5DWy+8TilKkviGDH6jtWCo8FGRKEK5cLLkPvEammQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0 + '@typescript-eslint/parser': ^8.33.0 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/parser@8.32.1': - resolution: {integrity: sha512-LKMrmwCPoLhM45Z00O1ulb6jwyVr2kr3XJp+G+tSEZcbauNnScewcQwtJqXDhXeYPDEjZ8C1SjXm015CirEmGg==} + '@typescript-eslint/parser@8.33.0': + resolution: {integrity: sha512-JaehZvf6m0yqYp34+RVnihBAChkqeH+tqqhS0GuX1qgPpwLvmTPheKEs6OeCK6hVJgXZHJ2vbjnC9j119auStQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/scope-manager@8.32.1': - resolution: {integrity: sha512-7IsIaIDeZn7kffk7qXC3o6Z4UblZJKV3UBpkvRNpr5NSyLji7tvTcvmnMNYuYLyh26mN8W723xpo3i4MlD33vA==} + '@typescript-eslint/project-service@8.33.0': + resolution: {integrity: sha512-d1hz0u9l6N+u/gcrk6s6gYdl7/+pp8yHheRTqP6X5hVDKALEaTn8WfGiit7G511yueBEL3OpOEpD+3/MBdoN+A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/scope-manager@8.33.0': + resolution: {integrity: sha512-LMi/oqrzpqxyO72ltP+dBSP6V0xiUb4saY7WLtxSfiNEBI8m321LLVFU9/QDJxjDQG9/tjSqKz/E3380TEqSTw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/type-utils@8.32.1': - resolution: {integrity: sha512-mv9YpQGA8iIsl5KyUPi+FGLm7+bA4fgXaeRcFKRDRwDMu4iwrSHeDPipwueNXhdIIZltwCJv+NkxftECbIZWfA==} + '@typescript-eslint/tsconfig-utils@8.33.0': + resolution: {integrity: sha512-sTkETlbqhEoiFmGr1gsdq5HyVbSOF0145SYDJ/EQmXHtKViCaGvnyLqWFFHtEXoS0J1yU8Wyou2UGmgW88fEug==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <5.9.0' + + '@typescript-eslint/type-utils@8.33.0': + resolution: {integrity: sha512-lScnHNCBqL1QayuSrWeqAL5GmqNdVUQAAMTaCwdYEdWfIrSrOGzyLGRCHXcCixa5NK6i5l0AfSO2oBSjCjf4XQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/types@8.32.1': - resolution: {integrity: sha512-YmybwXUJcgGqgAp6bEsgpPXEg6dcCyPyCSr0CAAueacR/CCBi25G3V8gGQ2kRzQRBNol7VQknxMs9HvVa9Rvfg==} + '@typescript-eslint/types@8.33.0': + resolution: {integrity: sha512-DKuXOKpM5IDT1FA2g9x9x1Ug81YuKrzf4mYX8FAVSNu5Wo/LELHWQyM1pQaDkI42bX15PWl0vNPt1uGiIFUOpg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.32.1': - resolution: {integrity: sha512-Y3AP9EIfYwBb4kWGb+simvPaqQoT5oJuzzj9m0i6FCY6SPvlomY2Ei4UEMm7+FXtlNJbor80ximyslzaQF6xhg==} + '@typescript-eslint/typescript-estree@8.33.0': + resolution: {integrity: sha512-vegY4FQoB6jL97Tu/lWRsAiUUp8qJTqzAmENH2k59SJhw0Th1oszb9Idq/FyyONLuNqT1OADJPXfyUNOR8SzAQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/utils@8.32.1': - resolution: {integrity: sha512-DsSFNIgLSrc89gpq1LJB7Hm1YpuhK086DRDJSNrewcGvYloWW1vZLHBTIvarKZDcAORIy/uWNx8Gad+4oMpkSA==} + '@typescript-eslint/utils@8.33.0': + resolution: {integrity: sha512-lPFuQaLA9aSNa7D5u2EpRiqdAUhzShwGg/nhpBlc4GR6kcTABttCuyjFs8BcEZ8VWrjCBof/bePhP3Q3fS+Yrw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/visitor-keys@8.32.1': - resolution: {integrity: sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==} + '@typescript-eslint/visitor-keys@8.33.0': + resolution: {integrity: sha512-7RW7CMYoskiz5OOGAWjJFxgb7c5UNjTG292gYhWeOAcFmYCtVCSqjqSBj5zMhxbXo2JOW95YYrUWJfU0zrpaGQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@vitejs/plugin-react@4.5.0': @@ -1928,10 +1931,6 @@ packages: '@vitest/utils@3.1.4': resolution: {integrity: sha512-yriMuO1cfFhmiGc8ataN51+9ooHRuURdfAZfwFd3usWynjzpLslZdYnRegTv32qdgtJTsj15FoeZe2g15fY1gg==} - accepts@1.3.8: - resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} - engines: {node: '>= 0.6'} - accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -1990,9 +1989,6 @@ packages: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} - array-flatten@1.1.1: - resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} - array-includes@3.1.8: resolution: {integrity: sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==} engines: {node: '>= 0.4'} @@ -2051,10 +2047,6 @@ packages: resolution: {tarball: https://codeload.github.com/NateTheGreatt/bitECS/tar.gz/caa1f58be2ccc304c1f0a085de34ca5904b3b80f} version: 0.4.0 - body-parser@1.20.3: - resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - body-parser@2.2.0: resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} engines: {node: '>=18'} @@ -2167,10 +2159,6 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - content-disposition@0.5.4: - resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} - engines: {node: '>= 0.6'} - content-disposition@1.0.0: resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} engines: {node: '>= 0.6'} @@ -2182,9 +2170,6 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - cookie-signature@1.0.6: - resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} - cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -2232,14 +2217,6 @@ packages: dataloader@1.4.0: resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} - debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.0: resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} engines: {node: '>=6.0'} @@ -2293,10 +2270,6 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} - destroy@1.2.0: - resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -2346,10 +2319,6 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - encodeurl@1.0.2: - resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} - engines: {node: '>= 0.8'} - encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -2404,11 +2373,6 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} - hasBin: true - esbuild@0.25.0: resolution: {integrity: sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==} engines: {node: '>=18'} @@ -2419,6 +2383,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.25.5: + resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -2457,8 +2426,8 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 - eslint-plugin-turbo@2.5.3: - resolution: {integrity: sha512-DlXZd+LgpDlxH/6IsiAXLhy82x0jeJDm0XBEqP6Le08uy0HBQkjCUt7SmXNp8esAtX9RYe6oDClbNbmI1jtK5g==} + eslint-plugin-turbo@2.5.4: + resolution: {integrity: sha512-IZsW61DFj5mLMMaCJxhh1VE4HvNhfdnHnAaXajgne+LUzdyHk2NvYT0ECSa/1SssArcqgTvV74MrLL68hWLLFw==} peerDependencies: eslint: '>6.6.0' turbo: '>2.0.0' @@ -2475,8 +2444,8 @@ packages: resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint@9.27.0: - resolution: {integrity: sha512-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q==} + eslint@9.28.0: + resolution: {integrity: sha512-ocgh41VhRlf9+fVpe7QKzwLj9c92fDiqOj8Y3Sd4/ZmVA4Btx4PlUYPq4pp9JDyupkf1upbEXecxL2mwNV7jPQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -2527,18 +2496,14 @@ packages: resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==} engines: {node: '>=6'} - execa@9.5.3: - resolution: {integrity: sha512-QFNnTvU3UjgWFy8Ef9iDHvIdcgZ344ebkwYx4/KLbR+CKQA4xBaHzv+iRpp86QfMHP8faFQLh8iOc57215y4Rg==} + execa@9.6.0: + resolution: {integrity: sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==} engines: {node: ^18.19.0 || >=20.5.0} expect-type@1.2.1: resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==} engines: {node: '>=12.0.0'} - express@4.21.2: - resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} - engines: {node: '>= 0.10.0'} - express@5.1.0: resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} engines: {node: '>= 18'} @@ -2567,10 +2532,6 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-xml-parser@4.5.3: - resolution: {integrity: sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==} - hasBin: true - fast-xml-parser@5.2.3: resolution: {integrity: sha512-OdCYfRqfpuLUFonTNjvd30rCBZUneHpSQkCqfaeWQ9qrKcl6XlWeDBNVwGb+INAIxRshuN2jF+BE0L6gbBO2mw==} hasBin: true @@ -2594,6 +2555,14 @@ packages: picomatch: optional: true + fdir@6.4.5: + resolution: {integrity: sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw==} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -2606,10 +2575,6 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - finalhandler@1.3.1: - resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} - engines: {node: '>= 0.8'} - finalhandler@2.1.0: resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} engines: {node: '>= 0.8'} @@ -2637,10 +2602,6 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} - fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} - engines: {node: '>= 0.6'} - fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -2739,8 +2700,8 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - graphql@16.10.0: - resolution: {integrity: sha512-AjqGKbDGUFRKIRCP9tCKiIGHyriz2oHEbPIbEtcSLSs4YjReZOIPQQWek4+6hjw62H9QShXHyaGivGiYVLeYFQ==} + graphql@16.11.0: + resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} has-bigints@1.1.0: @@ -2783,8 +2744,8 @@ packages: headers-polyfill@4.0.3: resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} - hono@4.7.10: - resolution: {integrity: sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ==} + hono@4.7.11: + resolution: {integrity: sha512-rv0JMwC0KALbbmwJDEnxvQCeJh+xbS3KEWW5PC9cMJ08Ur9xgatI0HmtgYZfOdOSOeYsp5LO2cOhdI8cLEbDEQ==} engines: {node: '>=16.9.0'} http-errors@2.0.0: @@ -2799,8 +2760,8 @@ packages: resolution: {integrity: sha512-3gKm/gCSUipeLsRYZbbdA1BD83lBoWUkZ7G9VFrhWPAU76KwYo5KR8V28bpoPm/ygy0x5/GCbpRQdY7VLYCoIg==} hasBin: true - human-signals@8.0.0: - resolution: {integrity: sha512-/1/GPCpDUCCYwlERiYjxoczfP0zfvZMU/OWgQPMya9AbAE24vseigFdhAMObpc8Q4lc/kjutPfUddDYyAmejnA==} + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} iconv-lite@0.4.24: @@ -3112,17 +3073,10 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} - engines: {node: '>= 0.6'} - media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} - merge-descriptors@1.0.3: - resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} - merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} @@ -3131,35 +3085,18 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - methods@1.1.2: - resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} - engines: {node: '>= 0.6'} - micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - mime-types@3.0.1: resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} engines: {node: '>= 0.6'} - mime@1.6.0: - resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} - engines: {node: '>=4'} - hasBin: true - minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -3178,14 +3115,11 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} - ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msw@2.8.4: - resolution: {integrity: sha512-GLU8gx0o7RBG/3x/eTnnLd5S5ZInxXRRRMN8GJwaPZ4jpJTxzQfWGvwr90e8L5dkKJnz+gT4gQYCprLy/c4kVw==} + msw@2.8.7: + resolution: {integrity: sha512-0TGfV4oQiKpa3pDsQBDf0xvFP+sRrqEOnh2n1JWpHVKHJHLv6ZmY1HCZpCi7uDiJTeIHJMBpmBiRmBJN+ETPSQ==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -3214,10 +3148,6 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - negotiator@0.6.3: - resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} - engines: {node: '>= 0.6'} - negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -3405,9 +3335,6 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-to-regexp@0.1.12: - resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} - path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -3464,8 +3391,8 @@ packages: peerDependencies: postcss: ^8.4.29 - postcss@8.5.3: - resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} + postcss@8.5.4: + resolution: {integrity: sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -3478,16 +3405,16 @@ packages: peerDependencies: prettier: 3.x - prettier-plugin-packagejson@2.5.14: - resolution: {integrity: sha512-h+3tSpr2nVpp+YOK1MDIYtYhHVXr8/0V59UUbJpIJFaqi3w4fvUokJo6eV8W+vELrUXIZzJ+DKm5G7lYzrMcKQ==} + prettier-plugin-packagejson@2.5.15: + resolution: {integrity: sha512-2QSx6y4IT6LTwXtCvXAopENW5IP/aujC8fobEM2pDbs0IGkiVjW/ipPuYAHuXigbNe64aGWF7vIetukuzM3CBw==} peerDependencies: prettier: '>= 1.16.0' peerDependenciesMeta: prettier: optional: true - prettier-plugin-tailwindcss@0.6.11: - resolution: {integrity: sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==} + prettier-plugin-tailwindcss@0.6.12: + resolution: {integrity: sha512-OuTQKoqNwV7RnxTPwXWzOFXy6Jc4z8oeRZYGuMpRyG3WbuR3jjXdQFK8qFBMBx8UHWdHrddARz2fgUenild6aw==} engines: {node: '>=14.21.3'} peerDependencies: '@ianvs/prettier-plugin-sort-imports': '*' @@ -3578,10 +3505,6 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.13.0: - resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} - engines: {node: '>=0.6'} - qs@6.14.0: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} @@ -3599,10 +3522,6 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} - raw-body@2.5.2: - resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} - engines: {node: '>= 0.8'} - raw-body@3.0.0: resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==} engines: {node: '>= 0.8'} @@ -3768,18 +3687,10 @@ packages: engines: {node: '>=10'} hasBin: true - send@0.19.0: - resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} - engines: {node: '>= 0.8.0'} - send@1.2.0: resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} engines: {node: '>= 18'} - serve-static@1.16.2: - resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} - engines: {node: '>= 0.8.0'} - serve-static@2.2.0: resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} engines: {node: '>= 18'} @@ -3940,9 +3851,6 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - strnum@1.1.2: - resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} - strnum@2.1.0: resolution: {integrity: sha512-w0S//9BqZZGw0L0Y8uLSelFGnDJgTyyNQLmSlPnVz43zPAiqu3w4t8J8sDqqANOGeZIZ/9jWuPguYcEnsoHv4A==} @@ -3962,8 +3870,8 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - synckit@0.11.6: - resolution: {integrity: sha512-2pR2ubZSV64f/vqm9eLPz/KOvR9Dm+Co/5ChLgeHl0yEDRc6h5hXHoxEQH8Y5Ljycozd3p1k5TTSVdzYGkPvLw==} + synckit@0.11.8: + resolution: {integrity: sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A==} engines: {node: ^14.18.0 || >=16.0.0} term-size@2.2.1: @@ -3979,6 +3887,10 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinybench@4.0.1: + resolution: {integrity: sha512-Nb1srn7dvzkVx0J5h1vq8f48e3TIcbrS7e/UfAI/cDSef/n8yLh4zsAEsFkfpw6auTY+ZaspEvam/xs8nMnotQ==} + engines: {node: '>=18.0.0'} + tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} @@ -3986,6 +3898,10 @@ packages: resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.14: + resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} + engines: {node: '>=12.0.0'} + tinypool@1.0.2: resolution: {integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -4063,38 +3979,38 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - turbo-darwin-64@2.5.3: - resolution: {integrity: sha512-YSItEVBUIvAGPUDpAB9etEmSqZI3T6BHrkBkeSErvICXn3dfqXUfeLx35LfptLDEbrzFUdwYFNmt8QXOwe9yaw==} + turbo-darwin-64@2.5.4: + resolution: {integrity: sha512-ah6YnH2dErojhFooxEzmvsoZQTMImaruZhFPfMKPBq8sb+hALRdvBNLqfc8NWlZq576FkfRZ/MSi4SHvVFT9PQ==} cpu: [x64] os: [darwin] - turbo-darwin-arm64@2.5.3: - resolution: {integrity: sha512-5PefrwHd42UiZX7YA9m1LPW6x9YJBDErXmsegCkVp+GjmWrADfEOxpFrGQNonH3ZMj77WZB2PVE5Aw3gA+IOhg==} + turbo-darwin-arm64@2.5.4: + resolution: {integrity: sha512-2+Nx6LAyuXw2MdXb7pxqle3MYignLvS7OwtsP9SgtSBaMlnNlxl9BovzqdYAgkUW3AsYiQMJ/wBRb7d+xemM5A==} cpu: [arm64] os: [darwin] - turbo-linux-64@2.5.3: - resolution: {integrity: sha512-M9xigFgawn5ofTmRzvjjLj3Lqc05O8VHKuOlWNUlnHPUltFquyEeSkpQNkE/vpPdOR14AzxqHbhhxtfS4qvb1w==} + turbo-linux-64@2.5.4: + resolution: {integrity: sha512-5May2kjWbc8w4XxswGAl74GZ5eM4Gr6IiroqdLhXeXyfvWEdm2mFYCSWOzz0/z5cAgqyGidF1jt1qzUR8hTmOA==} cpu: [x64] os: [linux] - turbo-linux-arm64@2.5.3: - resolution: {integrity: sha512-auJRbYZ8SGJVqvzTikpg1bsRAsiI9Tk0/SDkA5Xgg0GdiHDH/BOzv1ZjDE2mjmlrO/obr19Dw+39OlMhwLffrw==} + turbo-linux-arm64@2.5.4: + resolution: {integrity: sha512-/2yqFaS3TbfxV3P5yG2JUI79P7OUQKOUvAnx4MV9Bdz6jqHsHwc9WZPpO4QseQm+NvmgY6ICORnoVPODxGUiJg==} cpu: [arm64] os: [linux] - turbo-windows-64@2.5.3: - resolution: {integrity: sha512-arLQYohuHtIEKkmQSCU9vtrKUg+/1TTstWB9VYRSsz+khvg81eX6LYHtXJfH/dK7Ho6ck+JaEh5G+QrE1jEmCQ==} + turbo-windows-64@2.5.4: + resolution: {integrity: sha512-EQUO4SmaCDhO6zYohxIjJpOKRN3wlfU7jMAj3CgcyTPvQR/UFLEKAYHqJOnJtymbQmiiM/ihX6c6W6Uq0yC7mA==} cpu: [x64] os: [win32] - turbo-windows-arm64@2.5.3: - resolution: {integrity: sha512-3JPn66HAynJ0gtr6H+hjY4VHpu1RPKcEwGATvGUTmLmYSYBQieVlnGDRMMoYN066YfyPqnNGCfhYbXfH92Cm0g==} + turbo-windows-arm64@2.5.4: + resolution: {integrity: sha512-oQ8RrK1VS8lrxkLriotFq+PiF7iiGgkZtfLKF4DDKsmdbPo0O9R2mQxm7jHLuXraRCuIQDWMIw6dpcr7Iykf4A==} cpu: [arm64] os: [win32] - turbo@2.5.3: - resolution: {integrity: sha512-iHuaNcq5GZZnr3XDZNuu2LSyCzAOPwDuo5Qt+q64DfsTP1i3T2bKfxJhni2ZQxsvAoxRbuUK5QetJki4qc5aYA==} + turbo@2.5.4: + resolution: {integrity: sha512-kc8ZibdRcuWUG1pbYSBFWqmIjynlD8Lp7IB6U3vIzvOv9VG+6Sp8bzyeBWE3Oi8XV5KsQrznyRTBPvrf99E4mA==} hasBin: true txml@5.1.1: @@ -4116,10 +4032,6 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} - type-is@1.6.18: - resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} - engines: {node: '>= 0.6'} - type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -4140,8 +4052,8 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} - typescript-eslint@8.32.1: - resolution: {integrity: sha512-D7el+eaDHAmXvrZBy1zpzSNIRqnCOrkwTgZxTu3MUqRWk8k0q9m9Ho4+vPf7iHtgUfrK/o8IZaEApsxPlHTFCg==} + typescript-eslint@8.33.0: + resolution: {integrity: sha512-5YmNhF24ylCsvdNW2oJwMzTbaeO4bg90KeGtMjUw0AGtHksgEPLRTUil+coHwCfiu4QjVJFnjp94DmU6zV7DhQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -4200,29 +4112,9 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} - engines: {node: '>= 0.4.0'} - v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} - valibot@1.0.0-beta.12: - resolution: {integrity: sha512-j3WIxJ0pmUFMfdfUECn3YnZPYOiG0yHYcFEa/+RVgo0I+MXE3ToLt7gNRLtY5pwGfgNmsmhenGZfU5suu9ijUA==} - peerDependencies: - typescript: '>=5' - peerDependenciesMeta: - typescript: - optional: true - - valibot@1.0.0-beta.9: - resolution: {integrity: sha512-yEX8gMAZ2R1yI2uwOO4NCtVnJQx36zn3vD0omzzj9FhcoblvPukENIiRZXKZwCnqSeV80bMm8wNiGhQ0S8fiww==} - peerDependencies: - typescript: '>=5' - peerDependenciesMeta: - typescript: - optional: true - valibot@1.1.0: resolution: {integrity: sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==} peerDependencies: @@ -4248,37 +4140,6 @@ packages: vite: optional: true - vite@5.4.19: - resolution: {integrity: sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 - peerDependenciesMeta: - '@types/node': - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - vite@6.3.5: resolution: {integrity: sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -4443,14 +4304,14 @@ packages: yup@1.6.1: resolution: {integrity: sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==} - zod@3.25.28: - resolution: {integrity: sha512-/nt/67WYKnr5by3YS7LroZJbtcCBurDKKPBPWWzaxvVCGuG/NOsiKkrjoOhI8mJ+SQUXEbUzeB3S+6XDUEEj7Q==} + zod@3.25.46: + resolution: {integrity: sha512-IqRxcHEIjqLd4LNS/zKffB3Jzg3NwqJxQQ0Ns7pdrvgGkwQsEBdEQcOHaBVqvvZArShRzI39+aMST3FBGmTrLQ==} snapshots: - '@0no-co/graphql.web@1.1.2(graphql@16.10.0)': + '@0no-co/graphql.web@1.1.2(graphql@16.11.0)': optionalDependencies: - graphql: 16.10.0 + graphql: 16.11.0 '@ampproject/remapping@2.3.0': dependencies: @@ -4757,16 +4618,13 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 - '@esbuild/aix-ppc64@0.21.5': - optional: true - '@esbuild/aix-ppc64@0.25.0': optional: true '@esbuild/aix-ppc64@0.25.4': optional: true - '@esbuild/android-arm64@0.21.5': + '@esbuild/aix-ppc64@0.25.5': optional: true '@esbuild/android-arm64@0.25.0': @@ -4775,7 +4633,7 @@ snapshots: '@esbuild/android-arm64@0.25.4': optional: true - '@esbuild/android-arm@0.21.5': + '@esbuild/android-arm64@0.25.5': optional: true '@esbuild/android-arm@0.25.0': @@ -4784,7 +4642,7 @@ snapshots: '@esbuild/android-arm@0.25.4': optional: true - '@esbuild/android-x64@0.21.5': + '@esbuild/android-arm@0.25.5': optional: true '@esbuild/android-x64@0.25.0': @@ -4793,7 +4651,7 @@ snapshots: '@esbuild/android-x64@0.25.4': optional: true - '@esbuild/darwin-arm64@0.21.5': + '@esbuild/android-x64@0.25.5': optional: true '@esbuild/darwin-arm64@0.25.0': @@ -4802,7 +4660,7 @@ snapshots: '@esbuild/darwin-arm64@0.25.4': optional: true - '@esbuild/darwin-x64@0.21.5': + '@esbuild/darwin-arm64@0.25.5': optional: true '@esbuild/darwin-x64@0.25.0': @@ -4811,7 +4669,7 @@ snapshots: '@esbuild/darwin-x64@0.25.4': optional: true - '@esbuild/freebsd-arm64@0.21.5': + '@esbuild/darwin-x64@0.25.5': optional: true '@esbuild/freebsd-arm64@0.25.0': @@ -4820,7 +4678,7 @@ snapshots: '@esbuild/freebsd-arm64@0.25.4': optional: true - '@esbuild/freebsd-x64@0.21.5': + '@esbuild/freebsd-arm64@0.25.5': optional: true '@esbuild/freebsd-x64@0.25.0': @@ -4829,7 +4687,7 @@ snapshots: '@esbuild/freebsd-x64@0.25.4': optional: true - '@esbuild/linux-arm64@0.21.5': + '@esbuild/freebsd-x64@0.25.5': optional: true '@esbuild/linux-arm64@0.25.0': @@ -4838,7 +4696,7 @@ snapshots: '@esbuild/linux-arm64@0.25.4': optional: true - '@esbuild/linux-arm@0.21.5': + '@esbuild/linux-arm64@0.25.5': optional: true '@esbuild/linux-arm@0.25.0': @@ -4847,7 +4705,7 @@ snapshots: '@esbuild/linux-arm@0.25.4': optional: true - '@esbuild/linux-ia32@0.21.5': + '@esbuild/linux-arm@0.25.5': optional: true '@esbuild/linux-ia32@0.25.0': @@ -4856,7 +4714,7 @@ snapshots: '@esbuild/linux-ia32@0.25.4': optional: true - '@esbuild/linux-loong64@0.21.5': + '@esbuild/linux-ia32@0.25.5': optional: true '@esbuild/linux-loong64@0.25.0': @@ -4865,7 +4723,7 @@ snapshots: '@esbuild/linux-loong64@0.25.4': optional: true - '@esbuild/linux-mips64el@0.21.5': + '@esbuild/linux-loong64@0.25.5': optional: true '@esbuild/linux-mips64el@0.25.0': @@ -4874,7 +4732,7 @@ snapshots: '@esbuild/linux-mips64el@0.25.4': optional: true - '@esbuild/linux-ppc64@0.21.5': + '@esbuild/linux-mips64el@0.25.5': optional: true '@esbuild/linux-ppc64@0.25.0': @@ -4883,7 +4741,7 @@ snapshots: '@esbuild/linux-ppc64@0.25.4': optional: true - '@esbuild/linux-riscv64@0.21.5': + '@esbuild/linux-ppc64@0.25.5': optional: true '@esbuild/linux-riscv64@0.25.0': @@ -4892,7 +4750,7 @@ snapshots: '@esbuild/linux-riscv64@0.25.4': optional: true - '@esbuild/linux-s390x@0.21.5': + '@esbuild/linux-riscv64@0.25.5': optional: true '@esbuild/linux-s390x@0.25.0': @@ -4901,7 +4759,7 @@ snapshots: '@esbuild/linux-s390x@0.25.4': optional: true - '@esbuild/linux-x64@0.21.5': + '@esbuild/linux-s390x@0.25.5': optional: true '@esbuild/linux-x64@0.25.0': @@ -4910,13 +4768,16 @@ snapshots: '@esbuild/linux-x64@0.25.4': optional: true + '@esbuild/linux-x64@0.25.5': + optional: true + '@esbuild/netbsd-arm64@0.25.0': optional: true '@esbuild/netbsd-arm64@0.25.4': optional: true - '@esbuild/netbsd-x64@0.21.5': + '@esbuild/netbsd-arm64@0.25.5': optional: true '@esbuild/netbsd-x64@0.25.0': @@ -4925,13 +4786,16 @@ snapshots: '@esbuild/netbsd-x64@0.25.4': optional: true + '@esbuild/netbsd-x64@0.25.5': + optional: true + '@esbuild/openbsd-arm64@0.25.0': optional: true '@esbuild/openbsd-arm64@0.25.4': optional: true - '@esbuild/openbsd-x64@0.21.5': + '@esbuild/openbsd-arm64@0.25.5': optional: true '@esbuild/openbsd-x64@0.25.0': @@ -4940,7 +4804,7 @@ snapshots: '@esbuild/openbsd-x64@0.25.4': optional: true - '@esbuild/sunos-x64@0.21.5': + '@esbuild/openbsd-x64@0.25.5': optional: true '@esbuild/sunos-x64@0.25.0': @@ -4949,7 +4813,7 @@ snapshots: '@esbuild/sunos-x64@0.25.4': optional: true - '@esbuild/win32-arm64@0.21.5': + '@esbuild/sunos-x64@0.25.5': optional: true '@esbuild/win32-arm64@0.25.0': @@ -4958,7 +4822,7 @@ snapshots: '@esbuild/win32-arm64@0.25.4': optional: true - '@esbuild/win32-ia32@0.21.5': + '@esbuild/win32-arm64@0.25.5': optional: true '@esbuild/win32-ia32@0.25.0': @@ -4967,7 +4831,7 @@ snapshots: '@esbuild/win32-ia32@0.25.4': optional: true - '@esbuild/win32-x64@0.21.5': + '@esbuild/win32-ia32@0.25.5': optional: true '@esbuild/win32-x64@0.25.0': @@ -4976,9 +4840,12 @@ snapshots: '@esbuild/win32-x64@0.25.4': optional: true - '@eslint-community/eslint-utils@4.7.0(eslint@9.27.0(jiti@2.4.2))': + '@esbuild/win32-x64@0.25.5': + optional: true + + '@eslint-community/eslint-utils@4.7.0(eslint@9.28.0(jiti@2.4.2))': dependencies: - eslint: 9.27.0(jiti@2.4.2) + eslint: 9.28.0(jiti@2.4.2) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.1': {} @@ -5011,7 +4878,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@9.27.0': {} + '@eslint/js@9.28.0': {} '@eslint/object-schema@2.1.6': {} @@ -5022,14 +4889,14 @@ snapshots: '@figma/plugin-typings@1.113.0': {} - '@hono/node-server@1.14.2(hono@4.7.10)': + '@hono/node-server@1.14.3(hono@4.7.11)': dependencies: - hono: 4.7.10 + hono: 4.7.11 - '@hono/zod-validator@0.4.3(hono@4.7.10)(zod@3.25.28)': + '@hono/zod-validator@0.7.0(hono@4.7.11)(zod@3.25.46)': dependencies: - hono: 4.7.10 - zod: 3.25.28 + hono: 4.7.11 + zod: 3.25.46 '@humanfs/core@0.19.1': {} @@ -5044,7 +4911,7 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@ianvs/prettier-plugin-sort-imports@4.4.1(prettier@3.5.3)': + '@ianvs/prettier-plugin-sort-imports@4.4.2(prettier@3.5.3)': dependencies: '@babel/generator': 7.27.1 '@babel/parser': 7.27.2 @@ -5055,17 +4922,17 @@ snapshots: transitivePeerDependencies: - supports-color - '@inquirer/confirm@5.1.7(@types/node@22.15.21)': + '@inquirer/confirm@5.1.12(@types/node@22.15.29)': dependencies: - '@inquirer/core': 10.1.8(@types/node@22.15.21) - '@inquirer/type': 3.0.5(@types/node@22.15.21) + '@inquirer/core': 10.1.13(@types/node@22.15.29) + '@inquirer/type': 3.0.7(@types/node@22.15.29) optionalDependencies: - '@types/node': 22.15.21 + '@types/node': 22.15.29 - '@inquirer/core@10.1.8(@types/node@22.15.21)': + '@inquirer/core@10.1.13(@types/node@22.15.29)': dependencies: - '@inquirer/figures': 1.0.11 - '@inquirer/type': 3.0.5(@types/node@22.15.21) + '@inquirer/figures': 1.0.12 + '@inquirer/type': 3.0.7(@types/node@22.15.29) ansi-escapes: 4.3.2 cli-width: 4.1.0 mute-stream: 2.0.0 @@ -5073,13 +4940,13 @@ snapshots: wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 22.15.21 + '@types/node': 22.15.29 - '@inquirer/figures@1.0.11': {} + '@inquirer/figures@1.0.12': {} - '@inquirer/type@3.0.5(@types/node@22.15.21)': + '@inquirer/type@3.0.7(@types/node@22.15.29)': optionalDependencies: - '@types/node': 22.15.21 + '@types/node': 22.15.29 '@jridgewell/gen-mapping@0.3.8': dependencies: @@ -5119,7 +4986,7 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - '@mswjs/interceptors@0.37.6': + '@mswjs/interceptors@0.38.7': dependencies: '@open-draft/deferred-promise': 2.2.0 '@open-draft/logger': 0.3.0 @@ -5128,7 +4995,7 @@ snapshots: outvariant: 1.4.3 strict-event-emitter: 0.5.1 - '@next/eslint-plugin-next@15.3.2': + '@next/eslint-plugin-next@15.3.3': dependencies: fast-glob: 3.3.1 @@ -5153,7 +5020,7 @@ snapshots: '@open-draft/until@2.1.0': {} - '@pkgr/core@0.2.4': {} + '@pkgr/core@0.2.5': {} '@redocly/ajv@8.11.2': dependencies: @@ -5318,11 +5185,11 @@ snapshots: '@types/body-parser@1.19.5': dependencies: '@types/connect': 3.4.38 - '@types/node': 22.15.21 + '@types/node': 22.15.29 '@types/connect@3.4.38': dependencies: - '@types/node': 22.15.21 + '@types/node': 22.15.29 '@types/cookie@0.6.0': {} @@ -5330,7 +5197,7 @@ snapshots: '@types/express-serve-static-core@5.0.6': dependencies: - '@types/node': 22.15.21 + '@types/node': 22.15.29 '@types/qs': 6.9.18 '@types/range-parser': 1.2.7 '@types/send': 0.17.4 @@ -5349,7 +5216,7 @@ snapshots: '@types/node@12.20.55': {} - '@types/node@22.15.21': + '@types/node@22.15.29': dependencies: undici-types: 6.21.0 @@ -5357,27 +5224,27 @@ snapshots: '@types/range-parser@1.2.7': {} - '@types/react-dom@19.1.5(@types/react@19.1.5)': + '@types/react-dom@19.1.5(@types/react@19.1.6)': dependencies: - '@types/react': 19.1.5 + '@types/react': 19.1.6 - '@types/react@19.1.5': + '@types/react@19.1.6': dependencies: csstype: 3.1.3 '@types/sax@1.2.7': dependencies: - '@types/node': 22.15.21 + '@types/node': 22.15.29 '@types/send@0.17.4': dependencies: '@types/mime': 1.3.5 - '@types/node': 22.15.21 + '@types/node': 22.15.29 '@types/serve-static@1.15.7': dependencies: '@types/http-errors': 2.0.4 - '@types/node': 22.15.21 + '@types/node': 22.15.29 '@types/send': 0.17.4 '@types/statuses@2.0.5': {} @@ -5388,17 +5255,17 @@ snapshots: '@types/xml2js@0.4.14': dependencies: - '@types/node': 22.15.21 + '@types/node': 22.15.29 - '@typescript-eslint/eslint-plugin@8.32.1(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3)': + '@typescript-eslint/eslint-plugin@8.33.0(@typescript-eslint/parser@8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) - '@typescript-eslint/scope-manager': 8.32.1 - '@typescript-eslint/type-utils': 8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) - '@typescript-eslint/utils': 8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) - '@typescript-eslint/visitor-keys': 8.32.1 - eslint: 9.27.0(jiti@2.4.2) + '@typescript-eslint/parser': 8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/scope-manager': 8.33.0 + '@typescript-eslint/type-utils': 8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/utils': 8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.33.0 + eslint: 9.28.0(jiti@2.4.2) graphemer: 1.4.0 ignore: 7.0.4 natural-compare: 1.4.0 @@ -5407,40 +5274,55 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3)': + '@typescript-eslint/parser@8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: - '@typescript-eslint/scope-manager': 8.32.1 - '@typescript-eslint/types': 8.32.1 - '@typescript-eslint/typescript-estree': 8.32.1(typescript@5.8.3) - '@typescript-eslint/visitor-keys': 8.32.1 + '@typescript-eslint/scope-manager': 8.33.0 + '@typescript-eslint/types': 8.33.0 + '@typescript-eslint/typescript-estree': 8.33.0(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.33.0 debug: 4.4.0(supports-color@5.5.0) - eslint: 9.27.0(jiti@2.4.2) + eslint: 9.28.0(jiti@2.4.2) typescript: 5.8.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.32.1': + '@typescript-eslint/project-service@8.33.0(typescript@5.8.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.33.0(typescript@5.8.3) + '@typescript-eslint/types': 8.33.0 + debug: 4.4.0(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/scope-manager@8.33.0': dependencies: - '@typescript-eslint/types': 8.32.1 - '@typescript-eslint/visitor-keys': 8.32.1 + '@typescript-eslint/types': 8.33.0 + '@typescript-eslint/visitor-keys': 8.33.0 + + '@typescript-eslint/tsconfig-utils@8.33.0(typescript@5.8.3)': + dependencies: + typescript: 5.8.3 - '@typescript-eslint/type-utils@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3)': + '@typescript-eslint/type-utils@8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: - '@typescript-eslint/typescript-estree': 8.32.1(typescript@5.8.3) - '@typescript-eslint/utils': 8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/typescript-estree': 8.33.0(typescript@5.8.3) + '@typescript-eslint/utils': 8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) debug: 4.4.1(supports-color@10.0.0) - eslint: 9.27.0(jiti@2.4.2) + eslint: 9.28.0(jiti@2.4.2) ts-api-utils: 2.1.0(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.32.1': {} + '@typescript-eslint/types@8.33.0': {} - '@typescript-eslint/typescript-estree@8.32.1(typescript@5.8.3)': + '@typescript-eslint/typescript-estree@8.33.0(typescript@5.8.3)': dependencies: - '@typescript-eslint/types': 8.32.1 - '@typescript-eslint/visitor-keys': 8.32.1 + '@typescript-eslint/project-service': 8.33.0(typescript@5.8.3) + '@typescript-eslint/tsconfig-utils': 8.33.0(typescript@5.8.3) + '@typescript-eslint/types': 8.33.0 + '@typescript-eslint/visitor-keys': 8.33.0 debug: 4.4.0(supports-color@5.5.0) fast-glob: 3.3.3 is-glob: 4.0.3 @@ -5451,23 +5333,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3)': + '@typescript-eslint/utils@8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.27.0(jiti@2.4.2)) - '@typescript-eslint/scope-manager': 8.32.1 - '@typescript-eslint/types': 8.32.1 - '@typescript-eslint/typescript-estree': 8.32.1(typescript@5.8.3) - eslint: 9.27.0(jiti@2.4.2) + '@eslint-community/eslint-utils': 4.7.0(eslint@9.28.0(jiti@2.4.2)) + '@typescript-eslint/scope-manager': 8.33.0 + '@typescript-eslint/types': 8.33.0 + '@typescript-eslint/typescript-estree': 8.33.0(typescript@5.8.3) + eslint: 9.28.0(jiti@2.4.2) typescript: 5.8.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.32.1': + '@typescript-eslint/visitor-keys@8.33.0': dependencies: - '@typescript-eslint/types': 8.32.1 + '@typescript-eslint/types': 8.33.0 eslint-visitor-keys: 4.2.0 - '@vitejs/plugin-react@4.5.0(vite@6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4))': + '@vitejs/plugin-react@4.5.0(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4))': dependencies: '@babel/core': 7.27.1 '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.27.1) @@ -5475,7 +5357,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.9 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4) + vite: 6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4) transitivePeerDependencies: - supports-color @@ -5486,14 +5368,14 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.1.4(msw@2.8.4(@types/node@22.15.21)(typescript@5.8.3))(vite@6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4))': + '@vitest/mocker@3.1.4(msw@2.8.7(@types/node@22.15.29)(typescript@5.8.3))(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4))': dependencies: '@vitest/spy': 3.1.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - msw: 2.8.4(@types/node@22.15.21)(typescript@5.8.3) - vite: 6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4) + msw: 2.8.7(@types/node@22.15.29)(typescript@5.8.3) + vite: 6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4) '@vitest/pretty-format@3.1.4': dependencies: @@ -5520,11 +5402,6 @@ snapshots: loupe: 3.1.3 tinyrainbow: 2.0.0 - accepts@1.3.8: - dependencies: - mime-types: 2.1.35 - negotiator: 0.6.3 - accepts@2.0.0: dependencies: mime-types: 3.0.1 @@ -5579,8 +5456,6 @@ snapshots: call-bound: 1.0.4 is-array-buffer: 3.0.5 - array-flatten@1.1.1: {} - array-includes@3.1.8: dependencies: call-bind: 1.0.8 @@ -5653,23 +5528,6 @@ snapshots: bitecs@https://codeload.github.com/NateTheGreatt/bitECS/tar.gz/caa1f58be2ccc304c1f0a085de34ca5904b3b80f: {} - body-parser@1.20.3: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - http-errors: 2.0.0 - iconv-lite: 0.4.24 - on-finished: 2.4.1 - qs: 6.13.0 - raw-body: 2.5.2 - type-is: 1.6.18 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - body-parser@2.2.0: dependencies: bytes: 3.1.2 @@ -5796,10 +5654,6 @@ snapshots: concat-map@0.0.1: {} - content-disposition@0.5.4: - dependencies: - safe-buffer: 5.2.1 - content-disposition@1.0.0: dependencies: safe-buffer: 5.2.1 @@ -5808,8 +5662,6 @@ snapshots: convert-source-map@2.0.0: {} - cookie-signature@1.0.6: {} - cookie-signature@1.2.2: {} cookie@0.7.1: {} @@ -5832,9 +5684,9 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-declaration-sorter@7.2.0(postcss@8.5.3): + css-declaration-sorter@7.2.0(postcss@8.5.4): dependencies: - postcss: 8.5.3 + postcss: 8.5.4 csstype@3.1.3: {} @@ -5858,10 +5710,6 @@ snapshots: dataloader@1.4.0: {} - debug@2.6.9: - dependencies: - ms: 2.0.0 - debug@4.4.0(supports-color@5.5.0): dependencies: ms: 2.1.3 @@ -5903,8 +5751,6 @@ snapshots: depd@2.0.0: {} - destroy@1.2.0: {} - detect-indent@6.1.0: {} detect-indent@7.0.1: {} @@ -5939,8 +5785,6 @@ snapshots: emoji-regex@8.0.0: {} - encodeurl@1.0.2: {} - encodeurl@2.0.0: {} end-of-stream@1.4.4: @@ -6060,32 +5904,6 @@ snapshots: picomatch: 4.0.2 yargs: 17.7.2 - esbuild@0.21.5: - optionalDependencies: - '@esbuild/aix-ppc64': 0.21.5 - '@esbuild/android-arm': 0.21.5 - '@esbuild/android-arm64': 0.21.5 - '@esbuild/android-x64': 0.21.5 - '@esbuild/darwin-arm64': 0.21.5 - '@esbuild/darwin-x64': 0.21.5 - '@esbuild/freebsd-arm64': 0.21.5 - '@esbuild/freebsd-x64': 0.21.5 - '@esbuild/linux-arm': 0.21.5 - '@esbuild/linux-arm64': 0.21.5 - '@esbuild/linux-ia32': 0.21.5 - '@esbuild/linux-loong64': 0.21.5 - '@esbuild/linux-mips64el': 0.21.5 - '@esbuild/linux-ppc64': 0.21.5 - '@esbuild/linux-riscv64': 0.21.5 - '@esbuild/linux-s390x': 0.21.5 - '@esbuild/linux-x64': 0.21.5 - '@esbuild/netbsd-x64': 0.21.5 - '@esbuild/openbsd-x64': 0.21.5 - '@esbuild/sunos-x64': 0.21.5 - '@esbuild/win32-arm64': 0.21.5 - '@esbuild/win32-ia32': 0.21.5 - '@esbuild/win32-x64': 0.21.5 - esbuild@0.25.0: optionalDependencies: '@esbuild/aix-ppc64': 0.25.0 @@ -6142,27 +5960,55 @@ snapshots: '@esbuild/win32-ia32': 0.25.4 '@esbuild/win32-x64': 0.25.4 + esbuild@0.25.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.5 + '@esbuild/android-arm': 0.25.5 + '@esbuild/android-arm64': 0.25.5 + '@esbuild/android-x64': 0.25.5 + '@esbuild/darwin-arm64': 0.25.5 + '@esbuild/darwin-x64': 0.25.5 + '@esbuild/freebsd-arm64': 0.25.5 + '@esbuild/freebsd-x64': 0.25.5 + '@esbuild/linux-arm': 0.25.5 + '@esbuild/linux-arm64': 0.25.5 + '@esbuild/linux-ia32': 0.25.5 + '@esbuild/linux-loong64': 0.25.5 + '@esbuild/linux-mips64el': 0.25.5 + '@esbuild/linux-ppc64': 0.25.5 + '@esbuild/linux-riscv64': 0.25.5 + '@esbuild/linux-s390x': 0.25.5 + '@esbuild/linux-x64': 0.25.5 + '@esbuild/netbsd-arm64': 0.25.5 + '@esbuild/netbsd-x64': 0.25.5 + '@esbuild/openbsd-arm64': 0.25.5 + '@esbuild/openbsd-x64': 0.25.5 + '@esbuild/sunos-x64': 0.25.5 + '@esbuild/win32-arm64': 0.25.5 + '@esbuild/win32-ia32': 0.25.5 + '@esbuild/win32-x64': 0.25.5 + escalade@3.2.0: {} escape-html@1.0.3: {} escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.5(eslint@9.27.0(jiti@2.4.2)): + eslint-config-prettier@10.1.5(eslint@9.28.0(jiti@2.4.2)): dependencies: - eslint: 9.27.0(jiti@2.4.2) + eslint: 9.28.0(jiti@2.4.2) eslint-plugin-only-warn@1.1.0: {} - eslint-plugin-react-hooks@5.2.0(eslint@9.27.0(jiti@2.4.2)): + eslint-plugin-react-hooks@5.2.0(eslint@9.28.0(jiti@2.4.2)): dependencies: - eslint: 9.27.0(jiti@2.4.2) + eslint: 9.28.0(jiti@2.4.2) - eslint-plugin-react-refresh@0.4.20(eslint@9.27.0(jiti@2.4.2)): + eslint-plugin-react-refresh@0.4.20(eslint@9.28.0(jiti@2.4.2)): dependencies: - eslint: 9.27.0(jiti@2.4.2) + eslint: 9.28.0(jiti@2.4.2) - eslint-plugin-react@7.37.5(eslint@9.27.0(jiti@2.4.2)): + eslint-plugin-react@7.37.5(eslint@9.28.0(jiti@2.4.2)): dependencies: array-includes: 3.1.8 array.prototype.findlast: 1.2.5 @@ -6170,7 +6016,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.2.1 - eslint: 9.27.0(jiti@2.4.2) + eslint: 9.28.0(jiti@2.4.2) estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 @@ -6184,11 +6030,11 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-turbo@2.5.3(eslint@9.27.0(jiti@2.4.2))(turbo@2.5.3): + eslint-plugin-turbo@2.5.4(eslint@9.28.0(jiti@2.4.2))(turbo@2.5.4): dependencies: dotenv: 16.0.3 - eslint: 9.27.0(jiti@2.4.2) - turbo: 2.5.3 + eslint: 9.28.0(jiti@2.4.2) + turbo: 2.5.4 eslint-scope@8.3.0: dependencies: @@ -6199,15 +6045,15 @@ snapshots: eslint-visitor-keys@4.2.0: {} - eslint@9.27.0(jiti@2.4.2): + eslint@9.28.0(jiti@2.4.2): dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.27.0(jiti@2.4.2)) + '@eslint-community/eslint-utils': 4.7.0(eslint@9.28.0(jiti@2.4.2)) '@eslint-community/regexpp': 4.12.1 '@eslint/config-array': 0.20.0 '@eslint/config-helpers': 0.2.2 '@eslint/core': 0.14.0 '@eslint/eslintrc': 3.3.1 - '@eslint/js': 9.27.0 + '@eslint/js': 9.28.0 '@eslint/plugin-kit': 0.3.1 '@humanfs/node': 0.16.6 '@humanwhocodes/module-importer': 1.0.1 @@ -6281,13 +6127,13 @@ snapshots: signal-exit: 3.0.7 strip-eof: 1.0.0 - execa@9.5.3: + execa@9.6.0: dependencies: '@sindresorhus/merge-streams': 4.0.0 cross-spawn: 7.0.6 figures: 6.1.0 get-stream: 9.0.1 - human-signals: 8.0.0 + human-signals: 8.0.1 is-plain-obj: 4.1.0 is-stream: 4.0.1 npm-run-path: 6.0.0 @@ -6298,42 +6144,6 @@ snapshots: expect-type@1.2.1: {} - express@4.21.2: - dependencies: - accepts: 1.3.8 - array-flatten: 1.1.1 - body-parser: 1.20.3 - content-disposition: 0.5.4 - content-type: 1.0.5 - cookie: 0.7.1 - cookie-signature: 1.0.6 - debug: 2.6.9 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 1.3.1 - fresh: 0.5.2 - http-errors: 2.0.0 - merge-descriptors: 1.0.3 - methods: 1.1.2 - on-finished: 2.4.1 - parseurl: 1.3.3 - path-to-regexp: 0.1.12 - proxy-addr: 2.0.7 - qs: 6.13.0 - range-parser: 1.2.1 - safe-buffer: 5.2.1 - send: 0.19.0 - serve-static: 1.16.2 - setprototypeof: 1.2.0 - statuses: 2.0.1 - type-is: 1.6.18 - utils-merge: 1.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - express@5.1.0: dependencies: accepts: 2.0.0 @@ -6396,10 +6206,6 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-xml-parser@4.5.3: - dependencies: - strnum: 1.1.2 - fast-xml-parser@5.2.3: dependencies: strnum: 2.1.0 @@ -6416,6 +6222,10 @@ snapshots: optionalDependencies: picomatch: 4.0.2 + fdir@6.4.5(picomatch@4.0.2): + optionalDependencies: + picomatch: 4.0.2 + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -6428,18 +6238,6 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@1.3.1: - dependencies: - debug: 2.6.9 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.1 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - finalhandler@2.1.0: dependencies: debug: 4.4.1(supports-color@10.0.0) @@ -6474,8 +6272,6 @@ snapshots: forwarded@0.2.0: {} - fresh@0.5.2: {} - fresh@2.0.0: {} fs-extra@7.0.1: @@ -6583,7 +6379,7 @@ snapshots: graphemer@1.4.0: {} - graphql@16.10.0: {} + graphql@16.11.0: {} has-bigints@1.1.0: {} @@ -6619,7 +6415,7 @@ snapshots: headers-polyfill@4.0.3: {} - hono@4.7.10: {} + hono@4.7.11: {} http-errors@2.0.0: dependencies: @@ -6638,7 +6434,7 @@ snapshots: human-id@4.1.1: {} - human-signals@8.0.0: {} + human-signals@8.0.1: {} iconv-lite@0.4.24: dependencies: @@ -6916,37 +6712,23 @@ snapshots: math-intrinsics@1.1.0: {} - media-typer@0.3.0: {} - media-typer@1.1.0: {} - merge-descriptors@1.0.3: {} - merge-descriptors@2.0.0: {} merge2@1.4.1: {} - methods@1.1.2: {} - micromatch@4.0.8: dependencies: braces: 3.0.3 picomatch: 2.3.1 - mime-db@1.52.0: {} - mime-db@1.54.0: {} - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - mime-types@3.0.1: dependencies: mime-db: 1.54.0 - mime@1.6.0: {} - minimatch@3.1.2: dependencies: brace-expansion: 1.1.11 @@ -6963,22 +6745,20 @@ snapshots: mri@1.2.0: {} - ms@2.0.0: {} - ms@2.1.3: {} - msw@2.8.4(@types/node@22.15.21)(typescript@5.8.3): + msw@2.8.7(@types/node@22.15.29)(typescript@5.8.3): dependencies: '@bundled-es-modules/cookie': 2.0.1 '@bundled-es-modules/statuses': 1.0.1 '@bundled-es-modules/tough-cookie': 0.1.6 - '@inquirer/confirm': 5.1.7(@types/node@22.15.21) - '@mswjs/interceptors': 0.37.6 + '@inquirer/confirm': 5.1.12(@types/node@22.15.29) + '@mswjs/interceptors': 0.38.7 '@open-draft/deferred-promise': 2.2.0 '@open-draft/until': 2.1.0 '@types/cookie': 0.6.0 '@types/statuses': 2.0.5 - graphql: 16.10.0 + graphql: 16.11.0 headers-polyfill: 4.0.3 is-node-process: 1.2.0 outvariant: 1.4.3 @@ -7004,8 +6784,6 @@ snapshots: natural-compare@1.4.0: {} - negotiator@0.6.3: {} - negotiator@1.0.0: {} nice-napi@1.0.2: @@ -7196,8 +6974,6 @@ snapshots: path-parse@1.0.7: {} - path-to-regexp@0.1.12: {} - path-to-regexp@6.3.0: {} path-to-regexp@8.2.0: {} @@ -7228,15 +7004,15 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-less@6.0.0(postcss@8.5.3): + postcss-less@6.0.0(postcss@8.5.4): dependencies: - postcss: 8.5.3 + postcss: 8.5.4 - postcss-scss@4.0.9(postcss@8.5.3): + postcss-scss@4.0.9(postcss@8.5.4): dependencies: - postcss: 8.5.3 + postcss: 8.5.4 - postcss@8.5.3: + postcss@8.5.4: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -7244,28 +7020,28 @@ snapshots: prelude-ls@1.2.1: {} - prettier-plugin-css-order@2.1.2(postcss@8.5.3)(prettier@3.5.3): + prettier-plugin-css-order@2.1.2(postcss@8.5.4)(prettier@3.5.3): dependencies: - css-declaration-sorter: 7.2.0(postcss@8.5.3) - postcss-less: 6.0.0(postcss@8.5.3) - postcss-scss: 4.0.9(postcss@8.5.3) + css-declaration-sorter: 7.2.0(postcss@8.5.4) + postcss-less: 6.0.0(postcss@8.5.4) + postcss-scss: 4.0.9(postcss@8.5.4) prettier: 3.5.3 transitivePeerDependencies: - postcss - prettier-plugin-packagejson@2.5.14(prettier@3.5.3): + prettier-plugin-packagejson@2.5.15(prettier@3.5.3): dependencies: sort-package-json: 3.2.1 - synckit: 0.11.6 + synckit: 0.11.8 optionalDependencies: prettier: 3.5.3 - prettier-plugin-tailwindcss@0.6.11(@ianvs/prettier-plugin-sort-imports@4.4.1(prettier@3.5.3))(prettier-plugin-css-order@2.1.2(postcss@8.5.3)(prettier@3.5.3))(prettier@3.5.3): + prettier-plugin-tailwindcss@0.6.12(@ianvs/prettier-plugin-sort-imports@4.4.2(prettier@3.5.3))(prettier-plugin-css-order@2.1.2(postcss@8.5.4)(prettier@3.5.3))(prettier@3.5.3): dependencies: prettier: 3.5.3 optionalDependencies: - '@ianvs/prettier-plugin-sort-imports': 4.4.1(prettier@3.5.3) - prettier-plugin-css-order: 2.1.2(postcss@8.5.3)(prettier@3.5.3) + '@ianvs/prettier-plugin-sort-imports': 4.4.2(prettier@3.5.3) + prettier-plugin-css-order: 2.1.2(postcss@8.5.4)(prettier@3.5.3) prettier@2.8.8: {} @@ -7301,10 +7077,6 @@ snapshots: punycode@2.3.1: {} - qs@6.13.0: - dependencies: - side-channel: 1.1.0 - qs@6.14.0: dependencies: side-channel: 1.1.0 @@ -7317,13 +7089,6 @@ snapshots: range-parser@1.2.1: {} - raw-body@2.5.2: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.0 - iconv-lite: 0.4.24 - unpipe: 1.0.0 - raw-body@3.0.0: dependencies: bytes: 3.1.2 @@ -7419,11 +7184,11 @@ snapshots: optionalDependencies: '@babel/code-frame': 7.26.2 - rollup-plugin-esbuild@6.2.1(esbuild@0.25.4)(rollup@4.41.1): + rollup-plugin-esbuild@6.2.1(esbuild@0.25.5)(rollup@4.41.1): dependencies: debug: 4.4.0(supports-color@5.5.0) es-module-lexer: 1.6.0 - esbuild: 0.25.4 + esbuild: 0.25.5 get-tsconfig: 4.10.0 rollup: 4.41.1 unplugin-utils: 0.2.4 @@ -7513,24 +7278,6 @@ snapshots: semver@7.7.2: {} - send@0.19.0: - dependencies: - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - encodeurl: 1.0.2 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 0.5.2 - http-errors: 2.0.0 - mime: 1.6.0 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.1 - transitivePeerDependencies: - - supports-color - send@1.2.0: dependencies: debug: 4.4.1(supports-color@10.0.0) @@ -7547,15 +7294,6 @@ snapshots: transitivePeerDependencies: - supports-color - serve-static@1.16.2: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 0.19.0 - transitivePeerDependencies: - - supports-color - serve-static@2.2.0: dependencies: encodeurl: 2.0.0 @@ -7758,8 +7496,6 @@ snapshots: strip-json-comments@3.1.1: {} - strnum@1.1.2: {} - strnum@2.1.0: {} supports-color@10.0.0: {} @@ -7774,9 +7510,9 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - synckit@0.11.6: + synckit@0.11.8: dependencies: - '@pkgr/core': 0.2.4 + '@pkgr/core': 0.2.5 term-size@2.2.1: {} @@ -7789,6 +7525,8 @@ snapshots: tinybench@2.9.0: {} + tinybench@4.0.1: {} + tinyexec@0.3.2: {} tinyglobby@0.2.13: @@ -7796,6 +7534,11 @@ snapshots: fdir: 6.4.4(picomatch@4.0.2) picomatch: 4.0.2 + tinyglobby@0.2.14: + dependencies: + fdir: 6.4.5(picomatch@4.0.2) + picomatch: 4.0.2 + tinypool@1.0.2: {} tinyrainbow@2.0.0: {} @@ -7829,14 +7572,14 @@ snapshots: dependencies: typescript: 5.8.3 - ts-node@10.9.2(@types/node@22.15.21)(typescript@5.8.3): + ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 22.15.21 + '@types/node': 22.15.29 acorn: 8.14.1 acorn-walk: 8.3.4 arg: 4.1.3 @@ -7864,32 +7607,32 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - turbo-darwin-64@2.5.3: + turbo-darwin-64@2.5.4: optional: true - turbo-darwin-arm64@2.5.3: + turbo-darwin-arm64@2.5.4: optional: true - turbo-linux-64@2.5.3: + turbo-linux-64@2.5.4: optional: true - turbo-linux-arm64@2.5.3: + turbo-linux-arm64@2.5.4: optional: true - turbo-windows-64@2.5.3: + turbo-windows-64@2.5.4: optional: true - turbo-windows-arm64@2.5.3: + turbo-windows-arm64@2.5.4: optional: true - turbo@2.5.3: + turbo@2.5.4: optionalDependencies: - turbo-darwin-64: 2.5.3 - turbo-darwin-arm64: 2.5.3 - turbo-linux-64: 2.5.3 - turbo-linux-arm64: 2.5.3 - turbo-windows-64: 2.5.3 - turbo-windows-arm64: 2.5.3 + turbo-darwin-64: 2.5.4 + turbo-darwin-arm64: 2.5.4 + turbo-linux-64: 2.5.4 + turbo-linux-arm64: 2.5.4 + turbo-windows-64: 2.5.4 + turbo-windows-arm64: 2.5.4 txml@5.1.1: dependencies: @@ -7905,11 +7648,6 @@ snapshots: type-fest@4.41.0: {} - type-is@1.6.18: - dependencies: - media-typer: 0.3.0 - mime-types: 2.1.35 - type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -7949,12 +7687,12 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3): + typescript-eslint@8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.32.1(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) - '@typescript-eslint/parser': 8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) - '@typescript-eslint/utils': 8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) - eslint: 9.27.0(jiti@2.4.2) + '@typescript-eslint/eslint-plugin': 8.33.0(@typescript-eslint/parser@8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/parser': 8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/utils': 8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) + eslint: 9.28.0(jiti@2.4.2) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -8004,31 +7742,21 @@ snapshots: util-deprecate@1.0.2: {} - utils-merge@1.0.1: {} - v8-compile-cache-lib@3.0.1: {} - valibot@1.0.0-beta.12(typescript@5.8.3): - optionalDependencies: - typescript: 5.8.3 - - valibot@1.0.0-beta.9(typescript@5.8.3): - optionalDependencies: - typescript: 5.8.3 - valibot@1.1.0(typescript@5.8.3): optionalDependencies: typescript: 5.8.3 vary@1.1.2: {} - vite-node@3.1.4(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4): + vite-node@3.1.4(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4): dependencies: cac: 6.7.14 debug: 4.4.1(supports-color@10.0.0) es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4) + vite: 6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4) transitivePeerDependencies: - '@types/node' - jiti @@ -8043,44 +7771,35 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4)): + vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4)): dependencies: debug: 4.4.0(supports-color@5.5.0) globrex: 0.1.2 tsconfck: 3.1.5(typescript@5.8.3) optionalDependencies: - vite: 6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4) + vite: 6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4) transitivePeerDependencies: - supports-color - typescript - vite@5.4.19(@types/node@22.15.21): - dependencies: - esbuild: 0.21.5 - postcss: 8.5.3 - rollup: 4.41.1 - optionalDependencies: - '@types/node': 22.15.21 - fsevents: 2.3.3 - - vite@6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4): + vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4): dependencies: - esbuild: 0.25.4 - fdir: 6.4.4(picomatch@4.0.2) + esbuild: 0.25.5 + fdir: 6.4.5(picomatch@4.0.2) picomatch: 4.0.2 - postcss: 8.5.3 + postcss: 8.5.4 rollup: 4.41.1 - tinyglobby: 0.2.13 + tinyglobby: 0.2.14 optionalDependencies: - '@types/node': 22.15.21 + '@types/node': 22.15.29 fsevents: 2.3.3 jiti: 2.4.2 tsx: 4.19.4 - vitest@3.1.4(@types/node@22.15.21)(jiti@2.4.2)(msw@2.8.4(@types/node@22.15.21)(typescript@5.8.3))(tsx@4.19.4): + vitest@3.1.4(@types/node@22.15.29)(jiti@2.4.2)(msw@2.8.7(@types/node@22.15.29)(typescript@5.8.3))(tsx@4.19.4): dependencies: '@vitest/expect': 3.1.4 - '@vitest/mocker': 3.1.4(msw@2.8.4(@types/node@22.15.21)(typescript@5.8.3))(vite@6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4)) + '@vitest/mocker': 3.1.4(msw@2.8.7(@types/node@22.15.29)(typescript@5.8.3))(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4)) '@vitest/pretty-format': 3.1.4 '@vitest/runner': 3.1.4 '@vitest/snapshot': 3.1.4 @@ -8097,11 +7816,11 @@ snapshots: tinyglobby: 0.2.13 tinypool: 1.0.2 tinyrainbow: 2.0.0 - vite: 6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4) - vite-node: 3.1.4(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4) + vite: 6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4) + vite-node: 3.1.4(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 22.15.21 + '@types/node': 22.15.29 transitivePeerDependencies: - jiti - less @@ -8233,4 +7952,4 @@ snapshots: toposort: 2.0.2 type-fest: 2.19.0 - zod@3.25.28: {} + zod@3.25.46: {} From 3816188d175d5beafa1a6d76df7db3fcbdc605b5 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Mon, 2 Jun 2025 10:40:11 +0200 Subject: [PATCH 36/39] #105 fixed typos --- packages/feature-ecs/README.md | 2 +- .../__tests__/component-variant.bench.ts | 2 +- .../create-component-registry.test.ts | 112 +++++++++++++++++- .../component/create-component-registry.ts | 80 ++++++++++++- packages/feature-ecs/src/component/types.ts | 32 +++++ .../src/query/create-query-registry.test.ts | 17 +-- .../src/query/create-query-registry.ts | 30 ++--- packages/feature-ecs/src/query/types.ts | 15 +-- packages/feature-ecs/src/world.ts | 74 ++++++++++-- 9 files changed, 312 insertions(+), 52 deletions(-) diff --git a/packages/feature-ecs/README.md b/packages/feature-ecs/README.md index c914d63e..22cf611a 100644 --- a/packages/feature-ecs/README.md +++ b/packages/feature-ecs/README.md @@ -143,7 +143,7 @@ Position.y[eid] = 20; const Transform = []; Transform[eid] = { x: 10, y: 20 }; -// Single arrays and tag components +// Single arrays and marker components const Health = []; // Health[eid] = 100 const Player = {}; // Just presence/absence ``` diff --git a/packages/feature-ecs/__tests__/component-variant.bench.ts b/packages/feature-ecs/__tests__/component-variant.bench.ts index 1cfbf6b9..8c03634f 100644 --- a/packages/feature-ecs/__tests__/component-variant.bench.ts +++ b/packages/feature-ecs/__tests__/component-variant.bench.ts @@ -10,7 +10,7 @@ describe('Component Variants Performance', () => { const Position: { x: number[]; y: number[] } = { x: [], y: [] }; // Object with arrays (AoS) const Transform: { x: number; y: number }[] = []; // Array of objects (SoA) const Health: number[] = []; // Single value array - const Player: {} = {}; // Tag component + const Player: {} = {}; // Marker component describe('Add Component', () => { bench('AoS - Position', () => { diff --git a/packages/feature-ecs/src/component/create-component-registry.test.ts b/packages/feature-ecs/src/component/create-component-registry.test.ts index 1fbd4c1f..3af10a01 100644 --- a/packages/feature-ecs/src/component/create-component-registry.test.ts +++ b/packages/feature-ecs/src/component/create-component-registry.test.ts @@ -207,7 +207,7 @@ describe('createComponentRegistry', () => { expect(Health[eid2]).toBe(80); }); - it('should support tag component pattern', () => { + it('should support marker component pattern', () => { const Player: TPlayer = {}; const Enemy: TEnemy = {}; const Frozen: TFrozen = {}; @@ -220,7 +220,7 @@ describe('createComponentRegistry', () => { const eid2 = entityIndex.addEntity(); const eid3 = entityIndex.addEntity(); - // Add tag components (no data, just flags) + // Add marker components (no data, just flags) registry.addComponent(eid1, Player); registry.addComponent(eid1, Frozen); registry.addComponent(eid2, Enemy); @@ -302,6 +302,114 @@ describe('createComponentRegistry', () => { }); }); + describe('updateComponent', () => { + it('should update array components and mark as changed by default', () => { + const Health: THealth = []; + const eid = entityIndex.addEntity(); + + registry.addComponent(eid, Health); + registry.updateComponent(eid, Health, 100); + + expect(Health[eid]).toBe(100); + expect(registry.wasChanged(eid, Health)).toBe(true); + }); + + it('should update array components without marking as changed when explicitly false', () => { + const Health: THealth = []; + const eid = entityIndex.addEntity(); + + registry.addComponent(eid, Health); + registry.updateComponent(eid, Health, 75, false); + + expect(Health[eid]).toBe(75); + expect(registry.wasChanged(eid, Health)).toBe(false); + }); + + it('should update object with arrays (AoS) and mark as changed by default', () => { + const Position: TPosition = { x: [], y: [] }; + const eid = entityIndex.addEntity(); + + registry.addComponent(eid, Position); + registry.updateComponent(eid, Position, { x: 10, y: 20 }); + + expect(Position.x[eid]).toBe(10); + expect(Position.y[eid]).toBe(20); + expect(registry.wasChanged(eid, Position)).toBe(true); + }); + + it('should update object with arrays without marking as changed when explicitly false', () => { + const Position: TPosition = { x: [], y: [] }; + const eid = entityIndex.addEntity(); + + registry.addComponent(eid, Position); + registry.updateComponent(eid, Position, { x: 15, y: 25 }, false); + + expect(Position.x[eid]).toBe(15); + expect(Position.y[eid]).toBe(25); + expect(registry.wasChanged(eid, Position)).toBe(false); + }); + + it('should add marker component when value is true', () => { + const Player: TPlayer = {}; + const eid = entityIndex.addEntity(); + + registry.updateComponent(eid, Player, true); + + expect(registry.hasComponent(eid, Player)).toBe(true); + expect(registry.wasAdded(eid, Player)).toBe(true); + }); + + it('should remove marker component when value is false', () => { + const Player: TPlayer = {}; + const eid = entityIndex.addEntity(); + + registry.addComponent(eid, Player); + registry.updateComponent(eid, Player, false); + + expect(registry.hasComponent(eid, Player)).toBe(false); + expect(registry.wasRemoved(eid, Player)).toBe(true); + }); + + it('should not add marker component if already present', () => { + const Player: TPlayer = {}; + const eid = entityIndex.addEntity(); + + registry.addComponent(eid, Player); + const wasAddedBefore = registry.wasAdded(eid, Player); + + registry.flush(); // Clear tracking + registry.updateComponent(eid, Player, true); + + expect(registry.hasComponent(eid, Player)).toBe(true); + expect(registry.wasAdded(eid, Player)).toBe(false); // Should not be marked as added again + }); + + it('should handle partial object updates', () => { + const Position: TPosition = { x: [], y: [] }; + const eid = entityIndex.addEntity(); + + registry.addComponent(eid, Position); + Position.x[eid] = 100; + Position.y[eid] = 200; + + registry.updateComponent(eid, Position, { x: 50 }, false); + + expect(Position.x[eid]).toBe(50); + expect(Position.y[eid]).toBe(200); // Should remain unchanged + }); + + it('should handle mixed array types in objects', () => { + const Mixed = { numbers: [] as number[], strings: [] as string[] }; + const eid = entityIndex.addEntity(); + + registry.addComponent(eid, Mixed); + registry.updateComponent(eid, Mixed, { numbers: 42, strings: 'test' }, false); + + expect(Mixed.numbers[eid]).toBe(42); + expect(Mixed.strings[eid]).toBe('test'); + }); + }); + describe('removeComponent', () => { it('should remove components and clear data', () => { const Position: TPosition = { x: [], y: [] }; diff --git a/packages/feature-ecs/src/component/create-component-registry.ts b/packages/feature-ecs/src/component/create-component-registry.ts index 77ab6958..e8ea9582 100644 --- a/packages/feature-ecs/src/component/create-component-registry.ts +++ b/packages/feature-ecs/src/component/create-component-registry.ts @@ -14,7 +14,7 @@ */ import { TEntityId } from '../entity'; -import { TComponentCallbacks, TComponentData, TComponentRef } from './types'; +import { TComponentCallbacks, TComponentData, TComponentRef, TUpdateComponentValue } from './types'; /** * Creates a new component registry. @@ -29,7 +29,7 @@ import { TComponentCallbacks, TComponentData, TComponentRef } from './types'; * const Position: { x: number[]; y: number[] } = { x: [], y: [] }; // Object with arrays (AoS) * const Transform: { x: number; y: number }[] = []; // Array of objects (SoA) * const Health: number[] = []; // Single value array - * const Player: {} = {}; // Tag component + * const Player: {} = {}; // Marker component * * // Register all components * registry.registerComponent(Position); @@ -117,7 +117,11 @@ export function createComponentRegistry(): TComponentRegistry { // Step 3: Store new mask: entityMasks[1][5] = 1 // // After: entityMasks: [[<1 empty>, 5, <3 empty>, 2], [<5 empty>, 1]] (entity 5 has Armor) - addComponent(eid, component) { + addComponent<GComponent extends TComponentRef>( + eid: TEntityId, + component: GComponent, + value?: TUpdateComponentValue<GComponent> + ): void { // Auto-register component if not already registered if (!this._componentMap.has(component)) { this.registerComponent(component); @@ -142,6 +146,11 @@ export function createComponentRegistry(): TComponentRegistry { // @ts-expect-error - generationId exists because we ensure it when registering the component this._addedMasks[generationId][eid] = currentAddedMask | bitflag; + // Set component data if value is provided + if (value !== undefined) { + this.updateComponent(eid, component, value, false); + } + // Fire callbacks if registered const callbacks = this._callbacks.get(component); if (callbacks?.onAdd != null) { @@ -152,6 +161,51 @@ export function createComponentRegistry(): TComponentRegistry { this._componentsToFlush.add(component); }, + updateComponent<GComponent extends TComponentRef>( + eid: TEntityId, + component: GComponent, + value: TUpdateComponentValue<GComponent>, + markAsChanged = true + ): void { + // Array component (SoA): Health[eid] = 100 + if (Array.isArray(component)) { + component[eid] = value; + if (markAsChanged) { + this.markChanged(eid, component); + } + return; + } + + // Marker component (empty object): add/remove based on boolean + if ( + typeof component === 'object' && + component !== null && + Object.keys(component).length === 0 + ) { + if (value === true && !this.hasComponent(eid, component)) { + this.addComponent(eid, component); + } else if (value === false) { + this.removeComponent(eid, component); + } + return; + } + + // Object component (AoS): Position.x[eid] = value.x + if (typeof component === 'object' && component !== null) { + const valueObj = value as Record<string, any>; + for (const [key, val] of Object.entries(valueObj)) { + const targetArray = (component as Record<string, any[]>)[key]; + if (Array.isArray(targetArray)) { + targetArray[eid] = val; + } + } + if (markAsChanged) { + this.markChanged(eid, component); + } + return; + } + }, + // Component Removal Flow // Before: entityMasks: [[<1 empty>, 5, <3 empty>, 2], [<5 empty>, 1]] // removeComponent(eid=5, Armor) where Armor.generationId=1, bitflag=1 ↓ @@ -464,7 +518,11 @@ export interface TComponentRegistry { * @param eid - The entity ID * @param component - The component to add */ - addComponent(eid: TEntityId, component: TComponentRef): void; + addComponent<GComponent extends TComponentRef>( + eid: TEntityId, + component: GComponent, + value?: TUpdateComponentValue<GComponent> + ): void; /** * Removes a component from an entity and clears its data. @@ -549,4 +607,18 @@ export interface TComponentRegistry { * Resets the registry to its initial empty state. */ reset(): void; + + /** + * Updates component values with type safety. + * - For arrays: sets value directly + * - For marker components (empty objects): true adds component, false removes it + * - For objects with arrays: sets each property value + * @param markAsChanged - Whether to mark the component as changed (default: true) + */ + updateComponent<GComponent extends TComponentRef>( + eid: TEntityId, + component: GComponent, + value: TUpdateComponentValue<GComponent>, + markAsChanged?: boolean + ): void; } diff --git a/packages/feature-ecs/src/component/types.ts b/packages/feature-ecs/src/component/types.ts index fe824d02..2c1e7507 100644 --- a/packages/feature-ecs/src/component/types.ts +++ b/packages/feature-ecs/src/component/types.ts @@ -19,3 +19,35 @@ export interface TComponentCallbacks { onRemove?: ((eid: TEntityId) => void)[]; onFlush?: (() => void)[]; } + +/** + * Infers the appropriate value type for different component patterns: + * - Marker component: true + * - Object with array properties (AoS): { x: 10, y: 20 } + * - Array of objects (SoA): { x: 10, y: 20 } + * - Single value array: 100 + */ +export type TComponentValue<GComponent extends TComponentRef> = + GComponent extends Record<string, never> + ? true // Marker component: {} -> true + : GComponent extends (infer U)[] + ? U // SoA: { x: number; y: number }[] -> { x: number; y: number } or number[] -> number + : GComponent extends Record<string, unknown[]> + ? { [K in keyof GComponent]: GComponent[K] extends (infer U)[] ? U : never } // AoS: { x: number[], y: number[] } -> { x: number, y: number } + : never; + +/** + * Infers the appropriate value type for updateComponent operations: + * - Marker component: boolean (true = add, false = remove) + * - Object with array properties (AoS): Partial<{ x: 10, y: 20 }> (can update subset) + * - Array of objects (SoA): { x: 10, y: 20 } (full object replacement) + * - Single value array: 100 (full value replacement) + */ +export type TUpdateComponentValue<GComponent extends TComponentRef> = + GComponent extends Record<string, never> + ? boolean // Marker component: {} -> boolean (true = add, false = remove) + : GComponent extends (infer U)[] + ? U // SoA: { x: number; y: number }[] -> { x: number; y: number } or number[] -> number + : GComponent extends Record<string, unknown[]> + ? Partial<{ [K in keyof GComponent]: GComponent[K] extends (infer U)[] ? U : never }> // AoS: { x: number[], y: number[] } -> { x?: number, y?: number } + : never; diff --git a/packages/feature-ecs/src/query/create-query-registry.test.ts b/packages/feature-ecs/src/query/create-query-registry.test.ts index 4e737b75..c30b0d3b 100644 --- a/packages/feature-ecs/src/query/create-query-registry.test.ts +++ b/packages/feature-ecs/src/query/create-query-registry.test.ts @@ -192,13 +192,16 @@ describe('createQueryRegistry', () => { Health[eid3] = 50; // Query only players - const playerResults = world._queryRegistry.queryComponents([Entity, Health], With(Player)); + const playerResults = world._queryRegistry.queryComponents( + [Entity, Health] as const, + With(Player) + ); expect(playerResults).toHaveLength(1); expect(playerResults[0]).toEqual([eid1, 100]); - // Query entities without Player tag + // Query entities without Player marker const nonPlayerResults = world._queryRegistry.queryComponents( - [Entity, Health], + [Entity, Health] as const, Without(Player) ); expect(nonPlayerResults).toHaveLength(2); @@ -217,14 +220,14 @@ describe('createQueryRegistry', () => { Health[eid1] = 100; Health[eid2] = 75; - const results = world._queryRegistry.queryComponents([Entity, Health]); + const results = world._queryRegistry.queryComponents([Entity, Health] as const); expect(results).toHaveLength(2); expect(results[0]).toEqual([eid1, 100]); expect(results[1]).toEqual([eid2, 75]); }); - it('should handle tag components', () => { + it('should handle marker components', () => { const Player = {}; const eid1 = world.createEntity(); @@ -232,7 +235,7 @@ describe('createQueryRegistry', () => { world.addComponent(eid1, Player); - const results = world._queryRegistry.queryComponents([Entity, Player]); + const results = world._queryRegistry.queryComponents([Entity, Player] as const); expect(results).toHaveLength(1); expect(results[0]).toEqual([eid1, true]); @@ -258,7 +261,7 @@ describe('createQueryRegistry', () => { Position.x[eid2] = 20; Position.y[eid2] = 15; - const results = world._queryRegistry.queryComponents([Entity, Position, Velocity]); + const results = world._queryRegistry.queryComponents([Entity, Position, Velocity] as const); // Only eid1 should be included expect(results).toHaveLength(1); diff --git a/packages/feature-ecs/src/query/create-query-registry.ts b/packages/feature-ecs/src/query/create-query-registry.ts index 78f6748d..381a0a3d 100644 --- a/packages/feature-ecs/src/query/create-query-registry.ts +++ b/packages/feature-ecs/src/query/create-query-registry.ts @@ -8,13 +8,7 @@ import { TComponentRef } from '../component'; import { TEntityId } from '../entity'; import { TWorld } from '../world'; import { categorizeEvaluationStrategy } from './categorize-evaluation-strategy'; -import { - Entity, - TEntity, - InferComponentType as TInferComponentType, - TQueryData, - TQueryFilter -} from './types'; +import { Entity, TEntity, TQueryComponentValue, TQueryData, TQueryFilter } from './types'; /** * Creates a new query registry @@ -56,17 +50,17 @@ export function createQueryRegistry(world: TWorld): TQueryRegistry { return matchingEntities; }, - queryComponents<T extends readonly (TComponentRef | TEntity)[]>( - components: T, + queryComponents<GComponents extends readonly (TComponentRef | TEntity)[]>( + components: GComponents, filter?: TQueryFilter - ): TComponentDataTuple<T>[] { + ): TComponentDataTuple<GComponents>[] { // Get entities that match the filter (or all alive entities if no filter) const matchingEntities = filter ? this.queryEntities(filter) : this._world._entityIndex.getAliveEntities(); // For each entity, check if it has all components and get their data - const results: TComponentDataTuple<T>[] = []; + const results: TComponentDataTuple<GComponents>[] = []; for (const eid of matchingEntities) { const row: unknown[] = []; let hasAllComponents = true; @@ -97,7 +91,7 @@ export function createQueryRegistry(world: TWorld): TQueryRegistry { } } - // If no array properties found, it's a tag component + // If no array properties found, it's a marker component if (!hasArrayProperties) { componentData = true; } @@ -113,7 +107,7 @@ export function createQueryRegistry(world: TWorld): TQueryRegistry { // Only include entities that have all requested components if (hasAllComponents) { - results.push(row as TComponentDataTuple<T>); + results.push(row as TComponentDataTuple<GComponents>); } } @@ -183,10 +177,10 @@ export interface TQueryRegistry { /** * Queries components and returns matching entities with component data. */ - queryComponents<T extends readonly (TComponentRef | TEntity)[]>( - components: T, + queryComponents<GComponents extends readonly (TComponentRef | TEntity)[]>( + components: GComponents, filter?: TQueryFilter - ): TComponentDataTuple<T>[]; + ): TComponentDataTuple<GComponents>[]; /** * Gets or creates a compiled query @@ -224,6 +218,6 @@ export interface TExecuteQueryOptions extends TGetQueryOptions { cache?: boolean; } -export type TComponentDataTuple<T extends readonly unknown[]> = { - [K in keyof T]: TInferComponentType<T[K]>; +export type TComponentDataTuple<GComponents extends readonly (TComponentRef | TEntity)[]> = { + [K in keyof GComponents]: TQueryComponentValue<GComponents[K]>; }; diff --git a/packages/feature-ecs/src/query/types.ts b/packages/feature-ecs/src/query/types.ts index d772932a..a762bad4 100644 --- a/packages/feature-ecs/src/query/types.ts +++ b/packages/feature-ecs/src/query/types.ts @@ -1,4 +1,4 @@ -import { TComponentRef } from '../component'; +import { TComponentRef, TComponentValue } from '../component'; import { TEntityId } from '../entity'; import { TWorld } from '../world'; @@ -74,14 +74,5 @@ export type TQueryFilter = | (TBaseQueryFilter & { type: 'And'; filters: TQueryFilter[] }) | (TBaseQueryFilter & { type: 'Or'; filters: TQueryFilter[] }); -export type InferComponentType<T> = T extends TEntity - ? TEntityId - : T extends readonly (infer U)[] // Array of components (AoS) - ? U - : T extends Record<string, any[]> // Object with arrays (SoA) - ? { - [K in keyof T]: T[K] extends Array<infer V> ? V : never; - } - : T extends {} // Empty object (tag component) - ? true - : never; +export type TQueryComponentValue<GComponent extends TComponentRef | TEntity> = + GComponent extends TEntity ? TEntityId : TComponentValue<GComponent>; diff --git a/packages/feature-ecs/src/world.ts b/packages/feature-ecs/src/world.ts index f4b6c1dc..daf7add8 100644 --- a/packages/feature-ecs/src/world.ts +++ b/packages/feature-ecs/src/world.ts @@ -1,5 +1,11 @@ import { withNew } from '@blgc/utils'; -import { createComponentRegistry, TComponentRef, TComponentRegistry } from './component'; +import { + createComponentRegistry, + TComponentRef, + TComponentRegistry, + TUpdateComponentValue +} from './component'; +import type { TComponentValue } from './component/types'; import { createEntityIndex, TEntityId, TEntityIndex } from './entity'; import { createQueryRegistry, @@ -25,13 +31,25 @@ import { TEntity } from './query/types'; * ```typescript * const world = createWorld(); * - * // Create entities and add components + * // Define components + * const Position = { x: [], y: [] }; // AoS pattern + * const Health = []; // Single value array + * const Player = {}; // Marker component + * + * // Create entities and add components with values * const entity = world.createEntity(); - * world.addComponent(entity, Position); - * world.addComponent(entity, Velocity); + * world.addComponent(entity, Position, { x: 10, y: 20 }); + * world.addComponent(entity, Health, 100); + * world.addComponent(entity, Player, true); + * + * // Update component values + * world.updateComponent(entity, Position, { x: 15 }); // Partial AoS update (y unchanged) + * world.updateComponent(entity, Health, 90, true); // Update & mark changed + * world.updateComponent(entity, Player, false); // Remove marker component + * world.updateComponent(entity, Player, true); // Add marker component back * * // Query entities - * const entities = world.query(And(With(Position), With(Velocity))); + * const entities = world.queryEntities(And(With(Position), With(Health))); * ``` */ export function createWorld(): TWorld { @@ -53,8 +71,21 @@ export function createWorld(): TWorld { this._entityIndex.removeEntity(eid); }, - addComponent(eid, component) { - this._componentRegistry.addComponent(eid, component); + addComponent<GComponent extends TComponentRef>( + eid: TEntityId, + component: GComponent, + value?: TComponentValue<GComponent> + ): void { + this._componentRegistry.addComponent(eid, component, value); + }, + + updateComponent<GComponent extends TComponentRef>( + eid: TEntityId, + component: GComponent, + value: TUpdateComponentValue<GComponent>, + markAsChanged?: boolean + ): void { + this._componentRegistry.updateComponent(eid, component, value, markAsChanged); }, removeComponent(eid, component) { @@ -112,6 +143,35 @@ export interface TWorld { */ addComponent(eid: TEntityId, component: TComponentRef): void; + /** + * Adds a component to an entity with initial data. + * @param eid - The entity ID + * @param component - The component to add + * @param value - Initial component data + */ + addComponent<GComponent>( + eid: TEntityId, + component: GComponent, + value: TComponentValue<GComponent> + ): void; + + /** + * Updates a component for an entity. + * - For arrays: sets value directly + * - For marker components (empty objects): true adds component, false removes it + * - For objects with arrays: sets each property value (supports partial updates) + * @param eid - The entity ID + * @param component - The component to update + * @param value - New component data (partial for AoS, boolean for marker components) + * @param markAsChanged - Whether to mark the component as changed (default: true) + */ + updateComponent<T>( + eid: TEntityId, + component: T, + value: TUpdateComponentValue<T>, + markAsChanged?: boolean + ): void; + /** * Removes a component from an entity. * @param eid - The entity ID From 9de4bd787a4ab82b6ee27aa110c2a1843256b578 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Mon, 2 Jun 2025 11:33:47 +0200 Subject: [PATCH 37/39] #105 updated readme --- README.md | 3 +- .../kleinanzeigen-client/README.md | 2 +- packages/feature-ecs/.github/banner.svg | 6 + packages/feature-ecs/README.md | 203 +++++++++++++++--- packages/feature-ecs/package.json | 2 +- packages/feature-ecs/src/world.ts | 12 +- packages/head-metadata/README.md | 10 +- 7 files changed, 200 insertions(+), 38 deletions(-) create mode 100644 packages/feature-ecs/.github/banner.svg diff --git a/README.md b/README.md index b3c08893..368cb22b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ A collection of open source libraries maintained by [builder.group](https://buil | [config](https://github.com/builder-group/community/blob/develop/packages/config) | Collection of ESLint, Vite, and Typescript configurations | [`@blgc/config`](https://www.npmjs.com/package/@blgc/config) | | [elevenlabs-client](https://github.com/builder-group/community/blob/develop/packages/elevenlabs-client) | Typesafe and straightforward fetch client for interacting with the ElevenLabs API using feature-fetch | [`elevenlabs-client`](https://www.npmjs.com/package/elevenlabs-client) | | [eprel-client](https://github.com/builder-group/community/blob/develop/packages/eprel-client) | Typesafe and straightforward fetch client for interacting with the European Product Registry for Energy Labelling (EPREL) API using feature-fetch | [`eprel-client`](https://www.npmjs.com/package/eprel-client) | +| [feature-ecs](https://github.com/builder-group/community/blob/develop/packages/feature-ecs) | A flexible, typesafe, and performance-focused Entity Component System (ECS) library for TypeScript | [`feature-ecs`](https://www.npmjs.com/package/feature-ecs) | | [feature-fetch](https://github.com/builder-group/community/blob/develop/packages/feature-fetch) | Straightforward, typesafe, and feature-based fetch wrapper supporting OpenAPI types | [`feature-fetch`](https://www.npmjs.com/package/feature-fetch) | | [feature-form](https://github.com/builder-group/community/blob/develop/packages/feature-form) | Straightforward, typesafe, and feature-based form library | [`feature-form`](https://www.npmjs.com/package/feature-form) | | [feature-logger](https://github.com/builder-group/community/blob/develop/packages/feature-logger) | Straightforward, typesafe, and feature-based logging library | [`feature-logger`](https://www.npmjs.com/package/feature-logger) | @@ -27,7 +28,7 @@ A collection of open source libraries maintained by [builder.group](https://buil | [feature-state](https://github.com/builder-group/community/blob/develop/packages/feature-state) | Straightforward, typesafe, and feature-based state management library for ReactJs | [`feature-state`](https://www.npmjs.com/package/feature-state) | | [figma-connect](https://github.com/builder-group/community/blob/develop/packages/figma-connect) | Straightforward and typesafe wrapper around the communication between the app/ui (iframe) and plugin (sandbox) part of a Figma Plugin | [`figma-connect`](https://www.npmjs.com/package/figma-connect) | | [google-webfonts-client](https://github.com/builder-group/community/blob/develop/packages/google-webfonts-client) | Typesafe and straightforward fetch client for interacting with the Google Web Fonts API using feature-fetch | [`google-webfonts-client`](https://www.npmjs.com/package/google-webfonts-client) | -| [head-metadata](https://github.com/builder-group/community/blob/develop/packages/head-metadata) | Typesafe and straightforward utility for extracting structured metadata (like `<meta>`, `<title>`, and `<link>`) from the `<head>` of an HTML document. | [`head-metadata`](https://www.npmjs.com/package/head-metadata) | +| [head-metadata](https://github.com/builder-group/community/blob/develop/packages/head-metadata) | Typesafe and straightforward utility for extracting structured metadata (like `<meta>`, `<title>`, and `<link>`) from the `<head>` of an HTML document. | [`head-metadata`](https://www.npmjs.com/package/head-metadata) | | [openapi-ts-router](https://github.com/builder-group/community/blob/develop/packages/openapi-ts-router) | Thin wrapper around the router of web frameworks like Express and Hono, offering OpenAPI typesafety and seamless integration with validation libraries such as Valibot and Zod | [`openapi-ts-router`](https://www.npmjs.com/package/openapi-ts-router) | | [rollup-presets](https://github.com/builder-group/community/blob/develop/packages/rollup-presets) | A collection of opinionated, production-ready Rollup presets | [`rollup-presets`](https://www.npmjs.com/package/rollup-presets) | | [types](https://github.com/builder-group/community/blob/develop/packages/types) | Shared TypeScript type definitions used across builder.group community packages | [`@blgc/types`](https://www.npmjs.com/package/@blgc/types) | diff --git a/packages/_deprecated/kleinanzeigen-client/README.md b/packages/_deprecated/kleinanzeigen-client/README.md index 4d944060..258cd572 100644 --- a/packages/_deprecated/kleinanzeigen-client/README.md +++ b/packages/_deprecated/kleinanzeigen-client/README.md @@ -1 +1 @@ -todo \ No newline at end of file +todo diff --git a/packages/feature-ecs/.github/banner.svg b/packages/feature-ecs/.github/banner.svg new file mode 100644 index 00000000..a41d88ec --- /dev/null +++ b/packages/feature-ecs/.github/banner.svg @@ -0,0 +1,6 @@ +<svg width="2000" height="250" viewBox="0 0 2000 250" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="2000" height="250" rx="48" fill="#FDE200"/> +<path d="M148.121 63.9112C151.892 65.4726 155.384 67.6248 158.466 70.2816C159.177 70.895 159.867 71.5353 160.534 72.2014C164.086 75.7527 166.905 79.9687 168.827 84.6086C170.75 89.2486 171.74 94.2217 171.74 99.2439C171.74 103.776 170.934 108.269 169.363 112.512H171.74C176.764 112.512 181.739 113.501 186.381 115.423C190.153 116.985 193.644 119.137 196.726 121.794C197.438 122.407 198.127 123.047 198.794 123.714C202.347 127.265 205.165 131.481 207.088 136.121C209.01 140.761 210 145.734 210 150.756C210 155.778 209.01 160.751 207.088 165.391C205.165 170.031 202.347 174.247 198.794 177.799C198.127 178.465 197.438 179.105 196.726 179.718C193.644 182.375 190.152 184.527 186.381 186.089C181.739 188.011 176.764 189 171.74 189H146.753C139.854 189 134.26 183.409 134.26 176.512C134.26 169.615 139.854 164.024 146.753 164.024H171.74L171.74 164.028C173.483 164.028 175.21 163.684 176.821 163.017C178.431 162.35 179.895 161.373 181.128 160.14C182.361 158.908 183.339 157.445 184.006 155.835C184.673 154.225 185.017 152.499 185.017 150.756C185.017 149.013 184.673 147.288 184.006 145.677C183.339 144.067 182.361 142.604 181.128 141.372C179.895 140.139 178.432 139.162 176.821 138.495C175.21 137.828 173.483 137.485 171.74 137.485V137.488H108.493C101.593 137.488 96 131.897 96 125C96 118.103 101.593 112.512 108.493 112.512H133.767C135.413 112.477 137.038 112.135 138.56 111.505C140.171 110.838 141.635 109.861 142.868 108.628C144.101 107.396 145.079 105.933 145.746 104.323C146.413 102.713 146.757 100.987 146.757 99.2439C146.757 97.5011 146.413 95.7753 145.746 94.1652C145.079 92.555 144.101 91.092 142.868 89.8596C141.635 88.6272 140.171 87.6497 138.56 86.9827C136.95 86.3158 135.223 85.9725 133.479 85.9725V85.9756H108.493C101.593 85.9756 96 80.3846 96 73.4878C96 66.591 101.593 61 108.493 61H133.479C138.504 61 143.479 61.9892 148.121 63.9112Z" fill="black"/> +<path d="M96 176.512C96 169.615 101.593 164.024 108.493 164.024H115.521C122.42 164.024 128.014 169.615 128.014 176.512C128.014 183.409 122.42 189 115.521 189H108.493C101.593 189 96 183.409 96 176.512Z" fill="black"/> +<path d="M291.955 107.636V118.545H259.636V107.636H291.955ZM267.034 160V103.852C267.034 100.057 267.773 96.9091 269.25 94.4091C270.75 91.9091 272.795 90.0341 275.386 88.7841C277.977 87.5341 280.92 86.9091 284.216 86.9091C286.443 86.9091 288.477 87.0795 290.318 87.4205C292.182 87.7614 293.568 88.0682 294.477 88.3409L291.886 99.25C291.318 99.0682 290.614 98.8977 289.773 98.7386C288.955 98.5795 288.114 98.5 287.25 98.5C285.114 98.5 283.625 99 282.784 100C281.943 100.977 281.523 102.352 281.523 104.125V160H267.034ZM322.815 161.023C317.429 161.023 312.793 159.932 308.906 157.75C305.043 155.545 302.065 152.432 299.974 148.409C297.884 144.364 296.838 139.58 296.838 134.057C296.838 128.67 297.884 123.943 299.974 119.875C302.065 115.807 305.009 112.636 308.804 110.364C312.622 108.091 317.099 106.955 322.236 106.955C325.69 106.955 328.906 107.511 331.884 108.625C334.884 109.716 337.497 111.364 339.724 113.568C341.974 115.773 343.724 118.545 344.974 121.886C346.224 125.205 346.849 129.091 346.849 133.545V137.534H302.634V128.534H333.179C333.179 126.443 332.724 124.591 331.815 122.977C330.906 121.364 329.645 120.102 328.031 119.193C326.44 118.261 324.588 117.795 322.474 117.795C320.27 117.795 318.315 118.307 316.611 119.33C314.929 120.33 313.611 121.682 312.656 123.386C311.702 125.068 311.213 126.943 311.19 129.011V137.568C311.19 140.159 311.668 142.398 312.622 144.284C313.599 146.17 314.974 147.625 316.747 148.648C318.52 149.67 320.622 150.182 323.054 150.182C324.668 150.182 326.145 149.955 327.486 149.5C328.827 149.045 329.974 148.364 330.929 147.455C331.884 146.545 332.611 145.432 333.111 144.114L346.543 145C345.861 148.227 344.463 151.045 342.349 153.455C340.259 155.841 337.554 157.705 334.236 159.045C330.94 160.364 327.134 161.023 322.815 161.023ZM371.259 160.989C367.918 160.989 364.94 160.409 362.327 159.25C359.713 158.068 357.645 156.33 356.122 154.034C354.622 151.716 353.872 148.83 353.872 145.375C353.872 142.466 354.406 140.023 355.474 138.045C356.543 136.068 357.997 134.477 359.838 133.273C361.679 132.068 363.77 131.159 366.111 130.545C368.474 129.932 370.952 129.5 373.543 129.25C376.588 128.932 379.043 128.636 380.906 128.364C382.77 128.068 384.122 127.636 384.963 127.068C385.804 126.5 386.224 125.659 386.224 124.545V124.341C386.224 122.182 385.543 120.511 384.179 119.33C382.838 118.148 380.929 117.557 378.452 117.557C375.838 117.557 373.759 118.136 372.213 119.295C370.668 120.432 369.645 121.864 369.145 123.591L355.713 122.5C356.395 119.318 357.736 116.568 359.736 114.25C361.736 111.909 364.315 110.114 367.474 108.864C370.656 107.591 374.338 106.955 378.52 106.955C381.429 106.955 384.213 107.295 386.872 107.977C389.554 108.659 391.929 109.716 393.997 111.148C396.088 112.58 397.736 114.42 398.94 116.67C400.145 118.898 400.747 121.568 400.747 124.682V160H386.974V152.739H386.565C385.724 154.375 384.599 155.818 383.19 157.068C381.781 158.295 380.088 159.261 378.111 159.966C376.134 160.648 373.849 160.989 371.259 160.989ZM375.418 150.966C377.554 150.966 379.44 150.545 381.077 149.705C382.713 148.841 383.997 147.682 384.929 146.227C385.861 144.773 386.327 143.125 386.327 141.284V135.727C385.872 136.023 385.247 136.295 384.452 136.545C383.679 136.773 382.804 136.989 381.827 137.193C380.849 137.375 379.872 137.545 378.895 137.705C377.918 137.841 377.031 137.966 376.236 138.08C374.531 138.33 373.043 138.727 371.77 139.273C370.497 139.818 369.509 140.557 368.804 141.489C368.099 142.398 367.747 143.534 367.747 144.898C367.747 146.875 368.463 148.386 369.895 149.432C371.349 150.455 373.19 150.966 375.418 150.966ZM439.866 107.636V118.545H408.332V107.636H439.866ZM415.491 95.0909H430.014V143.909C430.014 145.25 430.219 146.295 430.628 147.045C431.037 147.773 431.605 148.284 432.332 148.58C433.082 148.875 433.946 149.023 434.923 149.023C435.605 149.023 436.287 148.966 436.969 148.852C437.651 148.716 438.173 148.614 438.537 148.545L440.821 159.352C440.094 159.58 439.071 159.841 437.753 160.136C436.435 160.455 434.832 160.648 432.946 160.716C429.446 160.852 426.378 160.386 423.741 159.318C421.128 158.25 419.094 156.591 417.639 154.341C416.185 152.091 415.469 149.25 415.491 145.818V95.0909ZM482.906 137.705V107.636H497.429V160H483.486V150.489H482.94C481.759 153.557 479.793 156.023 477.043 157.886C474.315 159.75 470.986 160.682 467.054 160.682C463.554 160.682 460.474 159.886 457.815 158.295C455.156 156.705 453.077 154.443 451.577 151.511C450.099 148.58 449.349 145.068 449.327 140.977V107.636H463.849V138.386C463.872 141.477 464.702 143.92 466.338 145.716C467.974 147.511 470.168 148.409 472.918 148.409C474.668 148.409 476.304 148.011 477.827 147.216C479.349 146.398 480.577 145.193 481.509 143.602C482.463 142.011 482.929 140.045 482.906 137.705ZM509.045 160V107.636H523.125V116.773H523.67C524.625 113.523 526.227 111.068 528.477 109.409C530.727 107.727 533.318 106.886 536.25 106.886C536.977 106.886 537.761 106.932 538.602 107.023C539.443 107.114 540.182 107.239 540.818 107.398V120.284C540.136 120.08 539.193 119.898 537.989 119.739C536.784 119.58 535.682 119.5 534.682 119.5C532.545 119.5 530.636 119.966 528.955 120.898C527.295 121.807 525.977 123.08 525 124.716C524.045 126.352 523.568 128.239 523.568 130.375V160H509.045ZM569.753 161.023C564.366 161.023 559.73 159.932 555.844 157.75C551.98 155.545 549.003 152.432 546.912 148.409C544.821 144.364 543.776 139.58 543.776 134.057C543.776 128.67 544.821 123.943 546.912 119.875C549.003 115.807 551.946 112.636 555.741 110.364C559.56 108.091 564.037 106.955 569.173 106.955C572.628 106.955 575.844 107.511 578.821 108.625C581.821 109.716 584.435 111.364 586.662 113.568C588.912 115.773 590.662 118.545 591.912 121.886C593.162 125.205 593.787 129.091 593.787 133.545V137.534H549.571V128.534H580.116C580.116 126.443 579.662 124.591 578.753 122.977C577.844 121.364 576.582 120.102 574.969 119.193C573.378 118.261 571.526 117.795 569.412 117.795C567.207 117.795 565.253 118.307 563.548 119.33C561.866 120.33 560.548 121.682 559.594 123.386C558.639 125.068 558.151 126.943 558.128 129.011V137.568C558.128 140.159 558.605 142.398 559.56 144.284C560.537 146.17 561.912 147.625 563.685 148.648C565.457 149.67 567.56 150.182 569.991 150.182C571.605 150.182 573.082 149.955 574.423 149.5C575.764 149.045 576.912 148.364 577.866 147.455C578.821 146.545 579.548 145.432 580.048 144.114L593.48 145C592.798 148.227 591.401 151.045 589.287 153.455C587.196 155.841 584.491 157.705 581.173 159.045C577.878 160.364 574.071 161.023 569.753 161.023ZM635.889 125.568V137.091H604.048V125.568H635.889ZM672.128 161.023C666.741 161.023 662.105 159.932 658.219 157.75C654.355 155.545 651.378 152.432 649.287 148.409C647.196 144.364 646.151 139.58 646.151 134.057C646.151 128.67 647.196 123.943 649.287 119.875C651.378 115.807 654.321 112.636 658.116 110.364C661.935 108.091 666.412 106.955 671.548 106.955C675.003 106.955 678.219 107.511 681.196 108.625C684.196 109.716 686.81 111.364 689.037 113.568C691.287 115.773 693.037 118.545 694.287 121.886C695.537 125.205 696.162 129.091 696.162 133.545V137.534H651.946V128.534H682.491C682.491 126.443 682.037 124.591 681.128 122.977C680.219 121.364 678.957 120.102 677.344 119.193C675.753 118.261 673.901 117.795 671.787 117.795C669.582 117.795 667.628 118.307 665.923 119.33C664.241 120.33 662.923 121.682 661.969 123.386C661.014 125.068 660.526 126.943 660.503 129.011V137.568C660.503 140.159 660.98 142.398 661.935 144.284C662.912 146.17 664.287 147.625 666.06 148.648C667.832 149.67 669.935 150.182 672.366 150.182C673.98 150.182 675.457 149.955 676.798 149.5C678.139 149.045 679.287 148.364 680.241 147.455C681.196 146.545 681.923 145.432 682.423 144.114L695.855 145C695.173 148.227 693.776 151.045 691.662 153.455C689.571 155.841 686.866 157.705 683.548 159.045C680.253 160.364 676.446 161.023 672.128 161.023ZM729.298 161.023C723.935 161.023 719.321 159.886 715.457 157.614C711.616 155.318 708.662 152.136 706.594 148.068C704.548 144 703.526 139.318 703.526 134.023C703.526 128.659 704.56 123.955 706.628 119.909C708.719 115.841 711.685 112.67 715.526 110.398C719.366 108.102 723.935 106.955 729.23 106.955C733.798 106.955 737.798 107.784 741.23 109.443C744.662 111.102 747.378 113.432 749.378 116.432C751.378 119.432 752.48 122.955 752.685 127H738.98C738.594 124.386 737.571 122.284 735.912 120.693C734.276 119.08 732.128 118.273 729.469 118.273C727.219 118.273 725.253 118.886 723.571 120.114C721.912 121.318 720.616 123.08 719.685 125.398C718.753 127.716 718.287 130.523 718.287 133.818C718.287 137.159 718.741 140 719.651 142.341C720.582 144.682 721.889 146.466 723.571 147.693C725.253 148.92 727.219 149.534 729.469 149.534C731.128 149.534 732.616 149.193 733.935 148.511C735.276 147.83 736.378 146.841 737.241 145.545C738.128 144.227 738.707 142.648 738.98 140.807H752.685C752.457 144.807 751.366 148.33 749.412 151.375C747.48 154.398 744.81 156.761 741.401 158.466C737.991 160.17 733.957 161.023 729.298 161.023ZM805.551 122.568L792.256 123.386C792.028 122.25 791.54 121.227 790.79 120.318C790.04 119.386 789.051 118.648 787.824 118.102C786.619 117.534 785.176 117.25 783.494 117.25C781.244 117.25 779.347 117.727 777.801 118.682C776.256 119.614 775.483 120.864 775.483 122.432C775.483 123.682 775.983 124.739 776.983 125.602C777.983 126.466 779.699 127.159 782.131 127.682L791.608 129.591C796.699 130.636 800.494 132.318 802.994 134.636C805.494 136.955 806.744 140 806.744 143.773C806.744 147.205 805.733 150.216 803.71 152.807C801.71 155.398 798.96 157.42 795.46 158.875C791.983 160.307 787.972 161.023 783.426 161.023C776.494 161.023 770.972 159.58 766.858 156.693C762.767 153.784 760.369 149.83 759.665 144.83L773.949 144.08C774.381 146.193 775.426 147.807 777.085 148.92C778.744 150.011 780.869 150.557 783.46 150.557C786.006 150.557 788.051 150.068 789.597 149.091C791.165 148.091 791.96 146.807 791.983 145.239C791.96 143.92 791.403 142.841 790.312 142C789.222 141.136 787.54 140.477 785.267 140.023L776.199 138.216C771.085 137.193 767.278 135.42 764.778 132.898C762.301 130.375 761.062 127.159 761.062 123.25C761.062 119.886 761.972 116.989 763.79 114.557C765.631 112.125 768.21 110.25 771.528 108.932C774.869 107.614 778.778 106.955 783.256 106.955C789.869 106.955 795.074 108.352 798.869 111.148C802.688 113.943 804.915 117.75 805.551 122.568Z" fill="black"/> +</svg> diff --git a/packages/feature-ecs/README.md b/packages/feature-ecs/README.md index 22cf611a..461fa42f 100644 --- a/packages/feature-ecs/README.md +++ b/packages/feature-ecs/README.md @@ -1,8 +1,147 @@ -# feature-ecs +<h1 align="center"> + <img src="https://raw.githubusercontent.com/builder-group/community/develop/packages/feature-ecs/.github/banner.svg" alt="feature-ecs banner"> +</h1> -TODO +<p align="left"> + <a href="https://github.com/builder-group/community/blob/develop/LICENSE"> + <img src="https://img.shields.io/github/license/builder-group/community.svg?label=license&style=flat&colorA=293140&colorB=FDE200" alt="GitHub License"/> + </a> + <a href="https://www.npmjs.com/package/feature-ecs"> + <img src="https://img.shields.io/bundlephobia/minzip/feature-ecs.svg?label=minzipped%20size&style=flat&colorA=293140&colorB=FDE200" alt="NPM bundle minzipped size"/> + </a> + <a href="https://www.npmjs.com/package/feature-ecs"> + <img src="https://img.shields.io/npm/dt/featuer-state.svg?label=downloads&style=flat&colorA=293140&colorB=FDE200" alt="NPM total downloads"/> + </a> + <a href="https://discord.gg/w4xE3bSjhQ"> + <img src="https://img.shields.io/discord/795291052897992724.svg?label=&logo=discord&logoColor=000000&color=293140&labelColor=FDE200" alt="Join Discord"/> + </a> +</p> -## Architecture +`feature-ecs` is a flexible, typesafe, and performance-focused Entity Component System (ECS) library for TypeScript. + +- **🔮 Simple, declarative API**: Intuitive component patterns with full type safety +- **🍃 Lightweight & Tree Shakable**: Function-based and modular design +- **⚡ High Performance**: O(1) component checks using bitflags, cache-friendly sparse arrays +- **🔍 Powerful Querying**: Query entities with complex filters and get component data efficiently +- **📦 Zero Dependencies**: Standalone library ensuring ease of use in various environments +- **🔧 Flexible Storage**: Supports AoS, SoA, and marker component patterns +- **🧵 Change Tracking**: Built-in tracking for added, changed, and removed components + +### 📚 Examples + +- [Basic](https://github.com/builder-group/community/tree/develop/examples/feature-ecs/bsic) + +### 🌟 Motivation + +Create a modern, typesafe ECS library that embraces TypeScript's type system while maintaining the performance characteristics that make ECS powerful. While there are some promising ECS libraries like bitECS with great performance, they often lack TypeScript support and advanced query features like `Added()`, `Removed()`, `Changed()` filters for reactive systems. `feature-ecs` aims to provide the best of both worlds: high performance with full TypeScript integration and powerful querying capabilities, following the KISS principle to keep the API simple yet comprehensive. + +### ⚖️ Alternatives + +- [bitECS](https://github.com/NateTheGreatt/bitECS) +- [ecsy](https://github.com/ecsyjs/ecsy) + +## 📖 Usage + +`feature-ecs` offers core ECS concepts without imposing strict rules onto your architecture: + +- **Entities** are numerical IDs representing game objects +- **Components** are data containers that can follow different storage patterns +- **Systems** are just functions that query and process entities +- **Queries** provide powerful filtering with change detection + +For optimal performance: + +- Use [Array of Structures (AoS) format](https://en.wikipedia.org/wiki/AoS_and_SoA) for related component properties +- Implement systems as pure functions operating on query results + +### Basic Setup + +```ts +import { And, createWorld, With } from 'feature-ecs'; + +// Define components - no registration needed! +const Position = { x: [], y: [] }; // AoS pattern +const Velocity = { dx: [], dy: [] }; // AoS pattern +const Health = []; // Single value array +const Player = {}; // Marker component + +// Create world +const world = createWorld(); +``` + +### Entity Management + +```ts +// Create entity +const entity = world.createEntity(); + +// Destroy entity (removes all components) +world.destroyEntity(entity); +``` + +### Component Operations + +```ts +// Add components +world.addComponent(entity, Position, { x: 100, y: 50 }); +world.addComponent(entity, Velocity, { dx: 2, dy: 1 }); +world.addComponent(entity, Health, 100); +world.addComponent(entity, Player, true); + +// Update components (AoS) +world.updateComponent(entity, Position, { x: 110 }); +world.updateComponent(entity, Health, 95); +world.updateComponent(entity, Player, false); // Also removes marker + +// Direct updates - mark as changed for reactive queries +Position.x[entity] = 110; +world.markComponentChanged(entity, Position); +Health[entity] = 95; +world.markComponentChanged(entity, Health); + +// Remove component +world.removeComponent(entity, Velocity); + +// Check component +if (world.hasComponent(entity, Player)) { + // Entity is a player +} +``` + +### Querying + +```ts +import { Added, And, Changed, Or, Removed, With, Without } from 'feature-ecs'; + +// Query entity IDs +const players = world.queryEntities(With(Player)); +const moving = world.queryEntities(And(With(Position), With(Velocity))); +const damaged = world.queryEntities(Changed(Health)); + +// Query with component data +for (const [eid, pos, health] of world.queryComponents([Entity, Position, Health])) { + console.log(`Entity ${eid} at (${pos.x}, ${pos.y}) with ${health} health`); +} +``` + +### Game Loop + +```ts +function update(deltaTime: number) { + // Movement system + for (const [eid, pos, vel] of world.queryComponents([Entity, Position, Velocity])) { + world.updateComponent(eid, Position, { + x: pos.x + vel.dx * deltaTime, + y: pos.y + vel.dy * deltaTime + }); + } + + // Clear change tracking + world.flush(); +} +``` + +## 📐 Architecture ### Entity Index @@ -22,6 +161,7 @@ aliveCount: 3 ← First 3 elements are alive ``` **Core Data:** + - **Sparse Array**: Maps base entity IDs to dense array positions - **Dense Array**: Contiguous alive entities, with dead entities at end - **Alive Count**: Boundary between alive/dead entities @@ -41,22 +181,25 @@ Example with 8 version bits: #### Why This Design? **Problem: Stale References** + ```typescript -const entity = addEntity(); // Returns ID 5 -removeEntity(entity); // Removes ID 5 -const newEntity = addEntity(); // Might reuse ID 5! +const entity = addEntity(); // Returns ID 5 +removeEntity(entity); // Removes ID 5 +const newEntity = addEntity(); // Might reuse ID 5! // Bug: old reference to ID 5 now points to wrong entity ``` **Solution: Versioning** + ```typescript -const entity = addEntity(); // Returns 5v0 (ID 5, version 0) -removeEntity(entity); // Increments to 5v1 -const newEntity = addEntity(); // Reuses base ID 5 but as 5v1 +const entity = addEntity(); // Returns 5v0 (ID 5, version 0) +removeEntity(entity); // Increments to 5v1 +const newEntity = addEntity(); // Reuses base ID 5 but as 5v1 // Safe: old reference (5v0) won't match new entity (5v1) ``` **Swap-and-Pop for O(1) Removal** + ```typescript // Remove entity at index 1: dense = [1, 2, 3, 4, 5]; @@ -75,22 +218,23 @@ Entity filtering with two strategies: bitmask optimization for simple queries, i ```typescript // Component filters -With(Position) // Entity must have component -Without(Dead) // Entity must not have component +With(Position); // Entity must have component +Without(Dead); // Entity must not have component // Change detection -Added(Position) // Component added this frame -Changed(Health) // Component modified this frame -Removed(Velocity) // Component removed this frame +Added(Position); // Component added this frame +Changed(Health); // Component modified this frame +Removed(Velocity); // Component removed this frame // Logical operators -And(With(Position), With(Velocity)) // All must match -Or(With(Player), With(Enemy)) // Any must match +And(With(Position), With(Velocity)); // All must match +Or(With(Player), With(Enemy)); // Any must match ``` #### Evaluation Strategies **Bitmask Strategy** - Fast bitwise operations: + ```typescript // Components get bit positions Position: bitflag=0b001, Velocity: bitflag=0b010, Health: bitflag=0b100 @@ -106,6 +250,7 @@ entity2: (0b101 & 0b011) === 0b011 ✗ false ``` **Individual Strategy** - Per-filter evaluation for complex queries: + ```typescript // Complex queries like Or(With(Position), Changed(Health)) // Fall back to: filters.some(filter => filter.evaluate(world, eid)) @@ -139,13 +284,13 @@ const Position = { x: [], y: [] }; Position.x[eid] = 10; Position.y[eid] = 20; -// Array of Structures (AoS) - good for complete entity data +// Array of Structures (AoS) - good for complete entity data const Transform = []; Transform[eid] = { x: 10, y: 20 }; // Single arrays and marker components -const Health = []; // Health[eid] = 100 -const Player = {}; // Just presence/absence +const Health = []; // Health[eid] = 100 +const Player = {}; // Just presence/absence ``` #### Generation System @@ -177,10 +322,10 @@ _entityMasks[1][eid] = 0b001; // Has Armor ```typescript // Adding component: OR with bitflag -entityMask |= 0b010; // Add Velocity +entityMask |= 0b010; // Add Velocity -// Removing component: AND with inverted bitflag -entityMask &= ~0b010; // Remove Velocity +// Removing component: AND with inverted bitflag +entityMask &= ~0b010; // Remove Velocity // Checking component: AND with bitflag const hasVelocity = (entityMask & 0b010) !== 0; @@ -218,23 +363,23 @@ JavaScript sparse arrays store only assigned indices, making them memory-efficie ```ts const sparse = []; -sparse[1000] = 5; // [<1000 empty items>, 5] +sparse[1000] = 5; // [<1000 empty items>, 5] -console.log(sparse.length); // 1001 -console.log(sparse[500]); // undefined (no memory used) +console.log(sparse.length); // 1001 +console.log(sparse[500]); // undefined (no memory used) ``` In contrast, dense arrays allocate memory for every element, even if unused: ```ts -const dense = new Array(1001).fill(0); // Allocates 1001 × 4 bytes = ~4KB +const dense = new Array(1001).fill(0); // Allocates 1001 × 4 bytes = ~4KB -console.log(dense.length); // 1001 -console.log(dense[500]); // 0 +console.log(dense.length); // 1001 +console.log(dense[500]); // 0 ``` Use sparse arrays for large, mostly empty datasets. Use dense arrays when you need consistent iteration and performance. ## 💡 Resources / References -- [BitECS](https://github.com/NateTheGreatt/bitECS) - High-performance ECS library that inspired our implementation \ No newline at end of file +- [BitECS](https://github.com/NateTheGreatt/bitECS) - High-performance ECS library that inspired our implementation diff --git a/packages/feature-ecs/package.json b/packages/feature-ecs/package.json index 2cd4d6b6..89f340e5 100644 --- a/packages/feature-ecs/package.json +++ b/packages/feature-ecs/package.json @@ -2,7 +2,7 @@ "name": "feature-ecs", "version": "0.0.1", "private": false, - "description": "Straightforward, typesafe, and feature-based Entity Component System (ECS)", + "description": "A flexible, typesafe, and performance-focused Entity Component System (ECS) library for TypeScript.", "keywords": [], "homepage": "https://builder.group/?source=github", "bugs": { diff --git a/packages/feature-ecs/src/world.ts b/packages/feature-ecs/src/world.ts index daf7add8..18f34e42 100644 --- a/packages/feature-ecs/src/world.ts +++ b/packages/feature-ecs/src/world.ts @@ -17,7 +17,6 @@ import { import { TEntity } from './query/types'; // TODO: -// Pass component data directly into addComponent (optional) // Events // Systems // Resources @@ -96,6 +95,10 @@ export function createWorld(): TWorld { return this._componentRegistry.hasComponent(eid, component); }, + markComponentChanged(eid, component) { + return this._componentRegistry.markChanged(eid, component); + }, + queryEntities(filter, options) { return this._queryRegistry.queryEntities(filter, options); }, @@ -188,6 +191,13 @@ export interface TWorld { */ hasComponent(eid: TEntityId, component: TComponentRef): boolean; + /** + * Marks a component as changed for the current frame. + * @param eid - The entity ID + * @param component - The component to mark as changed + */ + markComponentChanged(eid: TEntityId, component: TComponentRef): void; + /** * Queries entities that match the specified filter and returns only entity IDs. * diff --git a/packages/head-metadata/README.md b/packages/head-metadata/README.md index f926f4e5..84be33f5 100644 --- a/packages/head-metadata/README.md +++ b/packages/head-metadata/README.md @@ -19,14 +19,14 @@ > Status: Experimental -`head-metadata` is a typesafe and straightforward utility for extracting structured metadata (like `<meta>`, `<title>`, and `<link>`) from the `<head>` of an HTML document. +`head-metadata` is a typesafe and straightforward utility for extracting structured metadata (like `<meta>`, `<title>`, and `<link>`) from the `<head>` of an HTML document. ## 📖 Usage ### Extract Metadata from `<head>` ```ts -import { extractHeadMetadata, metaExtractor, titleExtractor, linkExtractor } from 'head-metadata'; +import { extractHeadMetadata, linkExtractor, metaExtractor, titleExtractor } from 'head-metadata'; const html = ` <html> @@ -39,9 +39,9 @@ const html = ` `; const metadata = extractHeadMetadata(html, { - meta: metaExtractor, - title: titleExtractor, - link: linkExtractor + meta: metaExtractor, + title: titleExtractor, + link: linkExtractor }); console.log(metadata); From 5fe4f0e309c6bf9cc26f027b921b97ecbaacef22 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Mon, 2 Jun 2025 15:05:10 +0200 Subject: [PATCH 38/39] #105 added basic feature-ecs example --- examples/feature-ecs/vanilla/basic/.gitignore | 24 + examples/feature-ecs/vanilla/basic/index.html | 13 + .../feature-ecs/vanilla/basic/package.json | 18 + .../feature-ecs/vanilla/basic/public/vite.svg | 1 + .../feature-ecs/vanilla/basic/src/main.ts | 113 +++ .../feature-ecs/vanilla/basic/src/style.css | 14 + .../vanilla/basic/src/vite-env.d.ts | 1 + .../feature-ecs/vanilla/basic/tsconfig.json | 24 + packages/feature-ecs/README.md | 4 +- pnpm-lock.yaml | 655 +++++++++++++++++- 10 files changed, 827 insertions(+), 40 deletions(-) create mode 100644 examples/feature-ecs/vanilla/basic/.gitignore create mode 100644 examples/feature-ecs/vanilla/basic/index.html create mode 100644 examples/feature-ecs/vanilla/basic/package.json create mode 100644 examples/feature-ecs/vanilla/basic/public/vite.svg create mode 100644 examples/feature-ecs/vanilla/basic/src/main.ts create mode 100644 examples/feature-ecs/vanilla/basic/src/style.css create mode 100644 examples/feature-ecs/vanilla/basic/src/vite-env.d.ts create mode 100644 examples/feature-ecs/vanilla/basic/tsconfig.json diff --git a/examples/feature-ecs/vanilla/basic/.gitignore b/examples/feature-ecs/vanilla/basic/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/examples/feature-ecs/vanilla/basic/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/examples/feature-ecs/vanilla/basic/index.html b/examples/feature-ecs/vanilla/basic/index.html new file mode 100644 index 00000000..44a93350 --- /dev/null +++ b/examples/feature-ecs/vanilla/basic/index.html @@ -0,0 +1,13 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/vite.svg" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Vite + TS + + +
+ + + diff --git a/examples/feature-ecs/vanilla/basic/package.json b/examples/feature-ecs/vanilla/basic/package.json new file mode 100644 index 00000000..6fe1b447 --- /dev/null +++ b/examples/feature-ecs/vanilla/basic/package.json @@ -0,0 +1,18 @@ +{ + "name": "basic", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc && vite build", + "dev": "vite", + "preview": "vite preview" + }, + "dependencies": { + "feature-ecs": "workspace:*" + }, + "devDependencies": { + "typescript": "~5.8.3", + "vite": "^6.3.5" + } +} diff --git a/examples/feature-ecs/vanilla/basic/public/vite.svg b/examples/feature-ecs/vanilla/basic/public/vite.svg new file mode 100644 index 00000000..e7b8dfb1 --- /dev/null +++ b/examples/feature-ecs/vanilla/basic/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/feature-ecs/vanilla/basic/src/main.ts b/examples/feature-ecs/vanilla/basic/src/main.ts new file mode 100644 index 00000000..d73f4793 --- /dev/null +++ b/examples/feature-ecs/vanilla/basic/src/main.ts @@ -0,0 +1,113 @@ +import { createWorld, Entity } from 'feature-ecs'; +import './style.css'; + +// Get canvas and context +const canvas = + document.querySelector('#app canvas') || document.createElement('canvas'); +if (canvas.parentElement == null) { + canvas.width = 800; + canvas.height = 600; + document.querySelector('#app')!.innerHTML = ''; + document.querySelector('#app')!.appendChild(canvas); +} +const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; + +// Define components +const Position: { x: number[]; y: number[] } = { x: [], y: [] }; +const Velocity: { dx: number[]; dy: number[] } = { dx: [], dy: [] }; +const Rectangle: { width: number[]; height: number[] } = { width: [], height: [] }; +const Color: { value: string[] } = { value: [] }; + +// Create world +const world = createWorld(); + +// Create entities +for (let i = 0; i < 100; i++) { + const entity = world.createEntity(); + + world.addComponent(entity, Position, { + x: getRandom(canvas.width), + y: getRandom(canvas.height) + }); + world.addComponent(entity, Velocity, { + dx: getRandom(100, 20), + dy: getRandom(100, 20) + }); + world.addComponent(entity, Rectangle, { + width: getRandom(20, 10), + height: getRandom(20, 10) + }); + world.addComponent(entity, Color, { + value: `rgba(${getRandom(255)}, ${getRandom(255)}, ${getRandom(255)}, 1)` + }); +} + +// Physics system +function physicsSystem(dt: number): void { + for (const [eid, pos, vel, rect] of world.queryComponents([ + Entity, + Position, + Velocity, + Rectangle + ] as const)) { + // Move position + pos.x += vel.dx * dt; + pos.y += vel.dy * dt; + + // Boundary collision + if (pos.x + rect.width > canvas.width) { + pos.x = canvas.width - rect.width; + vel.dx = -vel.dx; + } else if (pos.x < 0) { + pos.x = 0; + vel.dx = -vel.dx; + } + + if (pos.y + rect.height > canvas.height) { + pos.y = canvas.height - rect.height; + vel.dy = -vel.dy; + } else if (pos.y < 0) { + pos.y = 0; + vel.dy = -vel.dy; + } + + // Update components + world.updateComponent(eid, Position, pos, false); + world.updateComponent(eid, Velocity, vel, false); + } +} + +// Rendering system +function renderingSystem(): void { + ctx.clearRect(0, 0, canvas.width, canvas.height); + + for (const [pos, color, rect] of world.queryComponents([Position, Color, Rectangle] as const)) { + ctx.fillStyle = color.value; + ctx.fillRect(pos.x, pos.y, rect.width, rect.height); + } +} + +// Game loop +let lastTime = 0; + +function gameLoop(currentTime: number): void { + const dt = (currentTime - lastTime) / 1000; + lastTime = currentTime; + + // Run systems + physicsSystem(dt); + renderingSystem(); + + // Clear change tracking + world.flush(); + + requestAnimationFrame(gameLoop); +} + +// Start game +requestAnimationFrame(gameLoop); + +// Helper function +function getRandom(max: number, min = 0): number { + return Math.floor(Math.random() * (max - min)) + min; +} diff --git a/examples/feature-ecs/vanilla/basic/src/style.css b/examples/feature-ecs/vanilla/basic/src/style.css new file mode 100644 index 00000000..62d7d443 --- /dev/null +++ b/examples/feature-ecs/vanilla/basic/src/style.css @@ -0,0 +1,14 @@ +:root { + background-color: #242424; + color: rgba(255, 255, 255, 0.87); + + color-scheme: light dark; + font-weight: 400; + line-height: 1.5; + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/examples/feature-ecs/vanilla/basic/src/vite-env.d.ts b/examples/feature-ecs/vanilla/basic/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/examples/feature-ecs/vanilla/basic/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/feature-ecs/vanilla/basic/tsconfig.json b/examples/feature-ecs/vanilla/basic/tsconfig.json new file mode 100644 index 00000000..1eec35be --- /dev/null +++ b/examples/feature-ecs/vanilla/basic/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/packages/feature-ecs/README.md b/packages/feature-ecs/README.md index 461fa42f..e3804bd2 100644 --- a/packages/feature-ecs/README.md +++ b/packages/feature-ecs/README.md @@ -119,7 +119,7 @@ const moving = world.queryEntities(And(With(Position), With(Velocity))); const damaged = world.queryEntities(Changed(Health)); // Query with component data -for (const [eid, pos, health] of world.queryComponents([Entity, Position, Health])) { +for (const [eid, pos, health] of world.queryComponents([Entity, Position, Health] as const)) { console.log(`Entity ${eid} at (${pos.x}, ${pos.y}) with ${health} health`); } ``` @@ -129,7 +129,7 @@ for (const [eid, pos, health] of world.queryComponents([Entity, Position, Health ```ts function update(deltaTime: number) { // Movement system - for (const [eid, pos, vel] of world.queryComponents([Entity, Position, Velocity])) { + for (const [eid, pos, vel] of world.queryComponents([Entity, Position, Velocity] as const)) { world.updateComponent(eid, Position, { x: pos.x + vel.dx * deltaTime, y: pos.y + vel.dy * deltaTime diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5a7d0b5..3db9335a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,19 @@ importers: specifier: ^3.1.4 version: 3.1.4(@types/node@22.15.29)(jiti@2.4.2)(msw@2.8.7(@types/node@22.15.29)(typescript@5.8.3))(tsx@4.19.4) + examples/feature-ecs/vanilla/basic: + dependencies: + feature-ecs: + specifier: workspace:* + version: link:../../../../packages/feature-ecs + devDependencies: + typescript: + specifier: ~5.8.3 + version: 5.8.3 + vite: + specifier: ^6.3.5 + version: 6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4) + examples/feature-fetch/vanilla/open-meteo: dependencies: feature-fetch: @@ -92,7 +105,7 @@ importers: version: 19.1.0(react@19.1.0) valibot: specifier: 1.0.0-beta.9 - version: 1.1.0(typescript@5.8.3) + version: 1.0.0-beta.9(typescript@5.8.3) validation-adapters: specifier: workspace:* version: link:../../../../packages/validation-adapters @@ -184,13 +197,13 @@ importers: dependencies: express: specifier: ^4.21.2 - version: 5.1.0 + version: 4.21.2 openapi-ts-router: specifier: workspace:* version: link:../../../../packages/openapi-ts-router valibot: specifier: 1.0.0-beta.12 - version: 1.1.0(typescript@5.8.3) + version: 1.0.0-beta.12(typescript@5.8.3) validation-adapters: specifier: workspace:* version: link:../../../../packages/validation-adapters @@ -224,7 +237,7 @@ importers: version: 1.14.3(hono@4.7.11) '@hono/zod-validator': specifier: ^0.4.2 - version: 0.7.0(hono@4.7.11)(zod@3.25.46) + version: 0.4.3(hono@4.7.11)(zod@3.25.46) hono: specifier: ^4.6.15 version: 4.7.11 @@ -252,10 +265,10 @@ importers: version: 6.2.3 fast-xml-parser: specifier: ^4.4.1 - version: 5.2.3 + version: 4.5.3 tinybench: specifier: ^2.9.0 - version: 4.0.1 + version: 2.9.0 txml: specifier: ^5.1.1 version: 5.1.1 @@ -274,21 +287,21 @@ importers: version: 5.8.3 vite: specifier: ^5.3.4 - version: 6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4) + version: 5.4.19(@types/node@22.15.29) packages/config: dependencies: '@ianvs/prettier-plugin-sort-imports': - specifier: ^4.4.1 + specifier: ^4.4.2 version: 4.4.2(prettier@3.5.3) '@next/eslint-plugin-next': - specifier: ^15.3.2 + specifier: ^15.3.3 version: 15.3.3 '@typescript-eslint/eslint-plugin': - specifier: ^8.32.1 + specifier: ^8.33.0 version: 8.33.0(@typescript-eslint/parser@8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) '@typescript-eslint/parser': - specifier: ^8.32.1 + specifier: ^8.33.0 version: 8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) eslint-config-prettier: specifier: ^10.1.5 @@ -303,26 +316,26 @@ importers: specifier: ^5.2.0 version: 5.2.0(eslint@9.28.0(jiti@2.4.2)) eslint-plugin-turbo: - specifier: ^2.5.3 + specifier: ^2.5.4 version: 2.5.4(eslint@9.28.0(jiti@2.4.2))(turbo@2.5.4) prettier-plugin-css-order: specifier: ^2.1.2 version: 2.1.2(postcss@8.5.4)(prettier@3.5.3) prettier-plugin-packagejson: - specifier: ^2.5.14 + specifier: ^2.5.15 version: 2.5.15(prettier@3.5.3) prettier-plugin-tailwindcss: - specifier: ^0.6.11 + specifier: ^0.6.12 version: 0.6.12(@ianvs/prettier-plugin-sort-imports@4.4.2(prettier@3.5.3))(prettier-plugin-css-order@2.1.2(postcss@8.5.4)(prettier@3.5.3))(prettier@3.5.3) typescript-eslint: - specifier: ^8.32.1 + specifier: ^8.33.0 version: 8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) vite-tsconfig-paths: specifier: ^5.1.4 version: 5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4)) devDependencies: eslint: - specifier: ^9.27.0 + specifier: ^9.28.0 version: 9.28.0(jiti@2.4.2) prettier: specifier: ^3.5.3 @@ -350,7 +363,7 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 + specifier: ^22.15.29 version: 22.15.29 dotenv: specifier: ^16.5.0 @@ -525,7 +538,7 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 + specifier: ^22.15.29 version: 22.15.29 rollup-presets: specifier: workspace:* @@ -544,7 +557,7 @@ importers: specifier: ^1.113.0 version: 1.113.0 '@types/node': - specifier: ^22.15.21 + specifier: ^22.15.29 version: 22.15.29 rollup-presets: specifier: workspace:* @@ -566,7 +579,7 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 + specifier: ^22.15.29 version: 22.15.29 dotenv: specifier: ^16.5.0 @@ -585,7 +598,7 @@ importers: version: link:../xml-tokenizer devDependencies: '@types/node': - specifier: ^22.15.21 + specifier: ^22.15.29 version: 22.15.29 rollup-presets: specifier: workspace:* @@ -634,7 +647,7 @@ importers: specifier: ^28.0.3 version: 28.0.3(rollup@4.41.1) execa: - specifier: 9.5.3 + specifier: 9.6.0 version: 9.6.0 picocolors: specifier: ^1.1.1 @@ -653,7 +666,7 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 + specifier: ^22.15.29 version: 22.15.29 rollup: specifier: ^4.41.1 @@ -696,7 +709,7 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 + specifier: ^22.15.29 version: 22.15.29 rollup-presets: specifier: workspace:* @@ -712,7 +725,7 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 + specifier: ^22.15.29 version: 22.15.29 rollup-presets: specifier: workspace:* @@ -749,7 +762,7 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 + specifier: ^22.15.29 version: 22.15.29 '@types/sax': specifier: ^1.2.7 @@ -964,6 +977,12 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.25.0': resolution: {integrity: sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==} engines: {node: '>=18'} @@ -982,6 +1001,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.25.0': resolution: {integrity: sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==} engines: {node: '>=18'} @@ -1000,6 +1025,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.0': resolution: {integrity: sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==} engines: {node: '>=18'} @@ -1018,6 +1049,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.25.0': resolution: {integrity: sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==} engines: {node: '>=18'} @@ -1036,6 +1073,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.25.0': resolution: {integrity: sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==} engines: {node: '>=18'} @@ -1054,6 +1097,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.25.0': resolution: {integrity: sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==} engines: {node: '>=18'} @@ -1072,6 +1121,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.25.0': resolution: {integrity: sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==} engines: {node: '>=18'} @@ -1090,6 +1145,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.0': resolution: {integrity: sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==} engines: {node: '>=18'} @@ -1108,6 +1169,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.25.0': resolution: {integrity: sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==} engines: {node: '>=18'} @@ -1126,6 +1193,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.25.0': resolution: {integrity: sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==} engines: {node: '>=18'} @@ -1144,6 +1217,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.25.0': resolution: {integrity: sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==} engines: {node: '>=18'} @@ -1162,6 +1241,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.0': resolution: {integrity: sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==} engines: {node: '>=18'} @@ -1180,6 +1265,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.25.0': resolution: {integrity: sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==} engines: {node: '>=18'} @@ -1198,6 +1289,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.25.0': resolution: {integrity: sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==} engines: {node: '>=18'} @@ -1216,6 +1313,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.25.0': resolution: {integrity: sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==} engines: {node: '>=18'} @@ -1234,6 +1337,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.25.0': resolution: {integrity: sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==} engines: {node: '>=18'} @@ -1252,6 +1361,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.25.0': resolution: {integrity: sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==} engines: {node: '>=18'} @@ -1288,6 +1403,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.0': resolution: {integrity: sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==} engines: {node: '>=18'} @@ -1324,6 +1445,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.0': resolution: {integrity: sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==} engines: {node: '>=18'} @@ -1342,6 +1469,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.0': resolution: {integrity: sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==} engines: {node: '>=18'} @@ -1360,6 +1493,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.25.0': resolution: {integrity: sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==} engines: {node: '>=18'} @@ -1378,6 +1517,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.25.0': resolution: {integrity: sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==} engines: {node: '>=18'} @@ -1396,6 +1541,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.25.0': resolution: {integrity: sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==} engines: {node: '>=18'} @@ -1461,11 +1612,11 @@ packages: peerDependencies: hono: ^4 - '@hono/zod-validator@0.7.0': - resolution: {integrity: sha512-qe2ZE6sHFE98dcUrbYMtS3bAV8hqcCOflykvZga2S7XhmNSZzT+dIz4OuMILsjLHkJw9JMn912/dB7dQOmuPvg==} + '@hono/zod-validator@0.4.3': + resolution: {integrity: sha512-xIgMYXDyJ4Hj6ekm9T9Y27s080Nl9NXHcJkOvkXPhubOLj8hZkOL8pDnnXfvCf5xEE8Q4oMFenQUZZREUY2gqQ==} peerDependencies: hono: '>=3.9.0' - zod: ^3.25.0 + zod: ^3.19.1 '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} @@ -1931,6 +2082,10 @@ packages: '@vitest/utils@3.1.4': resolution: {integrity: sha512-yriMuO1cfFhmiGc8ataN51+9ooHRuURdfAZfwFd3usWynjzpLslZdYnRegTv32qdgtJTsj15FoeZe2g15fY1gg==} + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -1989,6 +2144,9 @@ packages: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + array-includes@3.1.8: resolution: {integrity: sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==} engines: {node: '>= 0.4'} @@ -2047,6 +2205,10 @@ packages: resolution: {tarball: https://codeload.github.com/NateTheGreatt/bitECS/tar.gz/caa1f58be2ccc304c1f0a085de34ca5904b3b80f} version: 0.4.0 + body-parser@1.20.3: + resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + body-parser@2.2.0: resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} engines: {node: '>=18'} @@ -2159,6 +2321,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + content-disposition@1.0.0: resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} engines: {node: '>= 0.6'} @@ -2170,6 +2336,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -2217,6 +2386,14 @@ packages: dataloader@1.4.0: resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.0: resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} engines: {node: '>=6.0'} @@ -2270,6 +2447,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -2319,6 +2500,10 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -2373,6 +2558,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.0: resolution: {integrity: sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==} engines: {node: '>=18'} @@ -2504,6 +2694,10 @@ packages: resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==} engines: {node: '>=12.0.0'} + express@4.21.2: + resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} + engines: {node: '>= 0.10.0'} + express@5.1.0: resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} engines: {node: '>= 18'} @@ -2532,6 +2726,10 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-xml-parser@4.5.3: + resolution: {integrity: sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==} + hasBin: true + fast-xml-parser@5.2.3: resolution: {integrity: sha512-OdCYfRqfpuLUFonTNjvd30rCBZUneHpSQkCqfaeWQ9qrKcl6XlWeDBNVwGb+INAIxRshuN2jF+BE0L6gbBO2mw==} hasBin: true @@ -2575,6 +2773,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@1.3.1: + resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} + engines: {node: '>= 0.8'} + finalhandler@2.1.0: resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} engines: {node: '>= 0.8'} @@ -2602,6 +2804,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -3073,10 +3279,17 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} @@ -3085,18 +3298,35 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime-types@3.0.1: resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} engines: {node: '>= 0.6'} + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -3115,6 +3345,9 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -3148,6 +3381,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -3335,6 +3572,9 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-to-regexp@0.1.12: + resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -3505,6 +3745,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + engines: {node: '>=0.6'} + qs@6.14.0: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} @@ -3522,6 +3766,10 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} + raw-body@2.5.2: + resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + engines: {node: '>= 0.8'} + raw-body@3.0.0: resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==} engines: {node: '>= 0.8'} @@ -3687,10 +3935,18 @@ packages: engines: {node: '>=10'} hasBin: true + send@0.19.0: + resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} + engines: {node: '>= 0.8.0'} + send@1.2.0: resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} engines: {node: '>= 18'} + serve-static@1.16.2: + resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} + engines: {node: '>= 0.8.0'} + serve-static@2.2.0: resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} engines: {node: '>= 18'} @@ -3851,6 +4107,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strnum@1.1.2: + resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} + strnum@2.1.0: resolution: {integrity: sha512-w0S//9BqZZGw0L0Y8uLSelFGnDJgTyyNQLmSlPnVz43zPAiqu3w4t8J8sDqqANOGeZIZ/9jWuPguYcEnsoHv4A==} @@ -3887,10 +4146,6 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinybench@4.0.1: - resolution: {integrity: sha512-Nb1srn7dvzkVx0J5h1vq8f48e3TIcbrS7e/UfAI/cDSef/n8yLh4zsAEsFkfpw6auTY+ZaspEvam/xs8nMnotQ==} - engines: {node: '>=18.0.0'} - tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} @@ -4032,6 +4287,10 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -4112,9 +4371,29 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + valibot@1.0.0-beta.12: + resolution: {integrity: sha512-j3WIxJ0pmUFMfdfUECn3YnZPYOiG0yHYcFEa/+RVgo0I+MXE3ToLt7gNRLtY5pwGfgNmsmhenGZfU5suu9ijUA==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + + valibot@1.0.0-beta.9: + resolution: {integrity: sha512-yEX8gMAZ2R1yI2uwOO4NCtVnJQx36zn3vD0omzzj9FhcoblvPukENIiRZXKZwCnqSeV80bMm8wNiGhQ0S8fiww==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + valibot@1.1.0: resolution: {integrity: sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==} peerDependencies: @@ -4140,6 +4419,37 @@ packages: vite: optional: true + vite@5.4.19: + resolution: {integrity: sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + vite@6.3.5: resolution: {integrity: sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -4618,6 +4928,9 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.25.0': optional: true @@ -4627,6 +4940,9 @@ snapshots: '@esbuild/aix-ppc64@0.25.5': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.25.0': optional: true @@ -4636,6 +4952,9 @@ snapshots: '@esbuild/android-arm64@0.25.5': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.25.0': optional: true @@ -4645,6 +4964,9 @@ snapshots: '@esbuild/android-arm@0.25.5': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.25.0': optional: true @@ -4654,6 +4976,9 @@ snapshots: '@esbuild/android-x64@0.25.5': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.25.0': optional: true @@ -4663,6 +4988,9 @@ snapshots: '@esbuild/darwin-arm64@0.25.5': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.25.0': optional: true @@ -4672,6 +5000,9 @@ snapshots: '@esbuild/darwin-x64@0.25.5': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.25.0': optional: true @@ -4681,6 +5012,9 @@ snapshots: '@esbuild/freebsd-arm64@0.25.5': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.25.0': optional: true @@ -4690,6 +5024,9 @@ snapshots: '@esbuild/freebsd-x64@0.25.5': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.25.0': optional: true @@ -4699,6 +5036,9 @@ snapshots: '@esbuild/linux-arm64@0.25.5': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.25.0': optional: true @@ -4708,6 +5048,9 @@ snapshots: '@esbuild/linux-arm@0.25.5': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.25.0': optional: true @@ -4717,6 +5060,9 @@ snapshots: '@esbuild/linux-ia32@0.25.5': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.25.0': optional: true @@ -4726,6 +5072,9 @@ snapshots: '@esbuild/linux-loong64@0.25.5': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.25.0': optional: true @@ -4735,6 +5084,9 @@ snapshots: '@esbuild/linux-mips64el@0.25.5': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.25.0': optional: true @@ -4744,6 +5096,9 @@ snapshots: '@esbuild/linux-ppc64@0.25.5': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.25.0': optional: true @@ -4753,6 +5108,9 @@ snapshots: '@esbuild/linux-riscv64@0.25.5': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.25.0': optional: true @@ -4762,6 +5120,9 @@ snapshots: '@esbuild/linux-s390x@0.25.5': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.25.0': optional: true @@ -4780,6 +5141,9 @@ snapshots: '@esbuild/netbsd-arm64@0.25.5': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.25.0': optional: true @@ -4798,6 +5162,9 @@ snapshots: '@esbuild/openbsd-arm64@0.25.5': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.25.0': optional: true @@ -4807,6 +5174,9 @@ snapshots: '@esbuild/openbsd-x64@0.25.5': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.25.0': optional: true @@ -4816,6 +5186,9 @@ snapshots: '@esbuild/sunos-x64@0.25.5': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.25.0': optional: true @@ -4825,6 +5198,9 @@ snapshots: '@esbuild/win32-arm64@0.25.5': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.25.0': optional: true @@ -4834,6 +5210,9 @@ snapshots: '@esbuild/win32-ia32@0.25.5': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.25.0': optional: true @@ -4893,7 +5272,7 @@ snapshots: dependencies: hono: 4.7.11 - '@hono/zod-validator@0.7.0(hono@4.7.11)(zod@3.25.46)': + '@hono/zod-validator@0.4.3(hono@4.7.11)(zod@3.25.46)': dependencies: hono: 4.7.11 zod: 3.25.46 @@ -5290,7 +5669,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.33.0(typescript@5.8.3) '@typescript-eslint/types': 8.33.0 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) transitivePeerDependencies: - supports-color - typescript @@ -5402,6 +5781,11 @@ snapshots: loupe: 3.1.3 tinyrainbow: 2.0.0 + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + accepts@2.0.0: dependencies: mime-types: 3.0.1 @@ -5456,6 +5840,8 @@ snapshots: call-bound: 1.0.4 is-array-buffer: 3.0.5 + array-flatten@1.1.1: {} + array-includes@3.1.8: dependencies: call-bind: 1.0.8 @@ -5528,6 +5914,23 @@ snapshots: bitecs@https://codeload.github.com/NateTheGreatt/bitECS/tar.gz/caa1f58be2ccc304c1f0a085de34ca5904b3b80f: {} + body-parser@1.20.3: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.13.0 + raw-body: 2.5.2 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + body-parser@2.2.0: dependencies: bytes: 3.1.2 @@ -5654,6 +6057,10 @@ snapshots: concat-map@0.0.1: {} + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + content-disposition@1.0.0: dependencies: safe-buffer: 5.2.1 @@ -5662,6 +6069,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie-signature@1.0.6: {} + cookie-signature@1.2.2: {} cookie@0.7.1: {} @@ -5710,6 +6119,10 @@ snapshots: dataloader@1.4.0: {} + debug@2.6.9: + dependencies: + ms: 2.0.0 + debug@4.4.0(supports-color@5.5.0): dependencies: ms: 2.1.3 @@ -5751,6 +6164,8 @@ snapshots: depd@2.0.0: {} + destroy@1.2.0: {} + detect-indent@6.1.0: {} detect-indent@7.0.1: {} @@ -5785,6 +6200,8 @@ snapshots: emoji-regex@8.0.0: {} + encodeurl@1.0.2: {} + encodeurl@2.0.0: {} end-of-stream@1.4.4: @@ -5904,6 +6321,32 @@ snapshots: picomatch: 4.0.2 yargs: 17.7.2 + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.25.0: optionalDependencies: '@esbuild/aix-ppc64': 0.25.0 @@ -6144,6 +6587,42 @@ snapshots: expect-type@1.2.1: {} + express@4.21.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.3 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.1 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.1 + fresh: 0.5.2 + http-errors: 2.0.0 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.12 + proxy-addr: 2.0.7 + qs: 6.13.0 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.0 + serve-static: 1.16.2 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + express@5.1.0: dependencies: accepts: 2.0.0 @@ -6206,6 +6685,10 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-xml-parser@4.5.3: + dependencies: + strnum: 1.1.2 + fast-xml-parser@5.2.3: dependencies: strnum: 2.1.0 @@ -6238,6 +6721,18 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@1.3.1: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + finalhandler@2.1.0: dependencies: debug: 4.4.1(supports-color@10.0.0) @@ -6272,6 +6767,8 @@ snapshots: forwarded@0.2.0: {} + fresh@0.5.2: {} + fresh@2.0.0: {} fs-extra@7.0.1: @@ -6712,23 +7209,37 @@ snapshots: math-intrinsics@1.1.0: {} + media-typer@0.3.0: {} + media-typer@1.1.0: {} + merge-descriptors@1.0.3: {} + merge-descriptors@2.0.0: {} merge2@1.4.1: {} + methods@1.1.2: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime-types@3.0.1: dependencies: mime-db: 1.54.0 + mime@1.6.0: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.11 @@ -6745,6 +7256,8 @@ snapshots: mri@1.2.0: {} + ms@2.0.0: {} + ms@2.1.3: {} msw@2.8.7(@types/node@22.15.29)(typescript@5.8.3): @@ -6784,6 +7297,8 @@ snapshots: natural-compare@1.4.0: {} + negotiator@0.6.3: {} + negotiator@1.0.0: {} nice-napi@1.0.2: @@ -6974,6 +7489,8 @@ snapshots: path-parse@1.0.7: {} + path-to-regexp@0.1.12: {} + path-to-regexp@6.3.0: {} path-to-regexp@8.2.0: {} @@ -7077,6 +7594,10 @@ snapshots: punycode@2.3.1: {} + qs@6.13.0: + dependencies: + side-channel: 1.1.0 + qs@6.14.0: dependencies: side-channel: 1.1.0 @@ -7089,6 +7610,13 @@ snapshots: range-parser@1.2.1: {} + raw-body@2.5.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + raw-body@3.0.0: dependencies: bytes: 3.1.2 @@ -7278,6 +7806,24 @@ snapshots: semver@7.7.2: {} + send@0.19.0: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + send@1.2.0: dependencies: debug: 4.4.1(supports-color@10.0.0) @@ -7294,6 +7840,15 @@ snapshots: transitivePeerDependencies: - supports-color + serve-static@1.16.2: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.0 + transitivePeerDependencies: + - supports-color + serve-static@2.2.0: dependencies: encodeurl: 2.0.0 @@ -7411,7 +7966,7 @@ snapshots: is-plain-obj: 4.1.0 semver: 7.7.2 sort-object-keys: 1.1.3 - tinyglobby: 0.2.13 + tinyglobby: 0.2.14 source-map-js@1.2.1: {} @@ -7496,6 +8051,8 @@ snapshots: strip-json-comments@3.1.1: {} + strnum@1.1.2: {} + strnum@2.1.0: {} supports-color@10.0.0: {} @@ -7525,8 +8082,6 @@ snapshots: tinybench@2.9.0: {} - tinybench@4.0.1: {} - tinyexec@0.3.2: {} tinyglobby@0.2.13: @@ -7648,6 +8203,11 @@ snapshots: type-fest@4.41.0: {} + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -7742,8 +8302,18 @@ snapshots: util-deprecate@1.0.2: {} + utils-merge@1.0.1: {} + v8-compile-cache-lib@3.0.1: {} + valibot@1.0.0-beta.12(typescript@5.8.3): + optionalDependencies: + typescript: 5.8.3 + + valibot@1.0.0-beta.9(typescript@5.8.3): + optionalDependencies: + typescript: 5.8.3 + valibot@1.1.0(typescript@5.8.3): optionalDependencies: typescript: 5.8.3 @@ -7782,6 +8352,15 @@ snapshots: - supports-color - typescript + vite@5.4.19(@types/node@22.15.29): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.4 + rollup: 4.41.1 + optionalDependencies: + '@types/node': 22.15.29 + fsevents: 2.3.3 + vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4): dependencies: esbuild: 0.25.5 From 02646963636f9e486c163125b0ab9b8ef266d9c3 Mon Sep 17 00:00:00 2001 From: Benno <57860196+bennoinbeta@users.noreply.github.com> Date: Mon, 2 Jun 2025 15:08:13 +0200 Subject: [PATCH 39/39] #105 bumped version --- examples/feature-ecs/vanilla/basic/src/main.ts | 3 +++ packages/feature-ecs/package.json | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/feature-ecs/vanilla/basic/src/main.ts b/examples/feature-ecs/vanilla/basic/src/main.ts index d73f4793..d387e2b6 100644 --- a/examples/feature-ecs/vanilla/basic/src/main.ts +++ b/examples/feature-ecs/vanilla/basic/src/main.ts @@ -21,6 +21,9 @@ const Color: { value: string[] } = { value: [] }; // Create world const world = createWorld(); +// @ts-ignore +// globalThis['__world'] = world; + // Create entities for (let i = 0; i < 100; i++) { const entity = world.createEntity(); diff --git a/packages/feature-ecs/package.json b/packages/feature-ecs/package.json index 89f340e5..e57dd45f 100644 --- a/packages/feature-ecs/package.json +++ b/packages/feature-ecs/package.json @@ -1,6 +1,6 @@ { "name": "feature-ecs", - "version": "0.0.1", + "version": "0.0.2", "private": false, "description": "A flexible, typesafe, and performance-focused Entity Component System (ECS) library for TypeScript.", "keywords": [],