diff --git a/.dockerignore b/.dockerignore index 2bd8264..4c16ffc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,3 @@ -# The server image only needs `shared/` and `backend/`. **/node_modules **/dist **/lib diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 1136a06..4d72944 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -13,7 +13,6 @@ concurrency: cancel-in-progress: true jobs: - # ---- Static website -> /pages mount on the runner host -------------------- website: name: Build & deploy website runs-on: docker @@ -26,8 +25,11 @@ jobs: node-version-file: .nvmrc - name: Install dependencies - # No lockfiles are committed (see .gitignore), so use `npm install`. - run: npm install && npm run init + run: | + npm ci + npm --prefix shared ci + npm --prefix frontend ci + npm --prefix backend ci - name: Build (shared -> frontend -> backend) run: npm run build @@ -45,11 +47,9 @@ jobs: source: frontend/dist target: declared - # ---- Server Docker image -> Forgejo container registry -------------------- server-image: name: Build & publish server image runs-on: docker - # No registry push on PRs; build validation still happens in the website job. if: github.event_name != 'pull_request' steps: - name: Checkout diff --git a/.gitignore b/.gitignore index 6e9e967..fd7f6c5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,4 @@ node_modules .firebase yarm-error.log yarn.lock -package-lock.json .DS_Store diff --git a/.vscode/launch.json b/.vscode/launch.json index 3b0b653..a685a4c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,7 +1,4 @@ { - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { diff --git a/Dockerfile b/Dockerfile index 327d1f2..909413b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,38 +1,29 @@ # syntax=docker/dockerfile:1 -# doppler game server (the `doppler-server` package). -# The frontend is a static site and is NOT built here — see .forgejo/workflows. - -# ---- Stage 1: build the shared lib, then bundle the server ------------------- -# Node 22 (current LTS) matches the project toolchain (webpack 5 / TypeScript 6, -# see .devcontainer). The full (non-slim) image carries the build tools that -# socket.io's optional native deps (bufferutil/utf-8-validate) compile against. +# The full image carries the build tools for socket.io's optional native dependencies. FROM node:22-bookworm AS build WORKDIR /app # `shared` is consumed by the backend as `file:../shared` and is bundled into # the server bundle by webpack, so it must be installed and built first. -COPY shared/package.json shared/ -RUN cd shared && npm install +COPY shared/package.json shared/package-lock.json shared/ +RUN npm --prefix shared ci COPY shared/ shared/ -RUN cd shared && npm run build +RUN npm --prefix shared run build -# Install the backend deps. `file:../shared` now resolves to the built /app/shared. -COPY backend/package.json backend/ -RUN cd backend && npm install +COPY backend/package.json backend/package-lock.json backend/ +RUN npm --prefix backend ci COPY backend/ backend/ -RUN cd backend && npm run build +RUN npm --prefix backend run build # Drop devDependencies; the runtime only needs the production deps that webpack # left external (express, socket.io, cors, gl-matrix, minimist, msgpack parser). -RUN cd backend && npm prune --production +RUN npm --prefix backend prune --production -# ---- Stage 2: minimal runtime ---------------------------------------------- FROM node:22-bookworm-slim WORKDIR /app ENV NODE_ENV=production -# Run as an unprivileged user. RUN groupadd -r app && useradd -r -g app -d /app app COPY --from=build /app/backend/dist ./dist @@ -41,7 +32,6 @@ COPY --from=build /app/backend/node_modules ./node_modules USER app EXPOSE 3000 -# Hits the same /state endpoint the website polls (see serverInformationEndpoint). HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ CMD node -e "require('http').get('http://localhost:3000/state',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" diff --git a/README.md b/README.md index ce57285..1a77fd5 100644 --- a/README.md +++ b/README.md @@ -9,19 +9,11 @@ A 2-dimensional multiplayer game utilising ray tracing. For optimised 2D ray tracing, [SDF-2D](https://github.com/schmelczerandras/sdf-2d) is used. -## Deployment +## Development -CI/CD runs on Forgejo Actions (`.forgejo/workflows/deploy.yml`). On a push to -`main` it: - -- builds the static frontend and rsyncs `frontend/dist/` to the `/pages/declared` - mount on the runner host (the mount keeps its pre-rebrand name), and -- builds the server image from the root `Dockerfile` and pushes it to the Forgejo - container registry as `//-server`. - -The registry job needs a `FORGEJO_PACKAGE_TOKEN` secret (with package write -scope) and, optionally, a `CONTAINER_REGISTRY_HOST` variable to override the -registry host. +Run `npm install && npm run init` once, then `npm run dev`. Run `npm test` for +regressions, `npm run lint:check` for formatting and lint, and `npm run build` for +all production bundles. Tests rebuild the shared library before running. The website's server list is hardcoded in [`frontend/src/scripts/configuration.ts`](frontend/src/scripts/configuration.ts) — diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..5c45e81 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,3635 @@ +{ + "name": "doppler-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "doppler-server", + "version": "0.1.0", + "dependencies": { + "cors": "^2.8.6", + "express": "^5.2.1", + "gl-matrix": "3.3.0", + "minimist": "^1.2.8", + "socket.io": "^4.8.3", + "socket.io-msgpack-parser": "^3.0.2" + }, + "bin": { + "doppler-server": "dist/main.js" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/minimist": "^1.2.5", + "@types/node": "^22.0.0", + "clean-webpack-plugin": "^4.0.0", + "concurrently": "^10.0.3", + "nodemon": "^3.1.14", + "shared": "file:../shared", + "terser-webpack-plugin": "^5.6.1", + "ts-loader": "^9.6.0", + "typescript": "^6.0.3", + "webpack": "^5.107.2", + "webpack-cli": "^7.0.3", + "webpack-node-externals": "^3.0.0" + } + }, + "../shared": { + "version": "0.0.0", + "dev": true, + "devDependencies": { + "clean-webpack-plugin": "^4.0.0", + "file-loader": "^6.2.0", + "gl-matrix": "^3.3.0", + "prettier": "^3.8.3", + "terser-webpack-plugin": "^5.6.1", + "ts-loader": "^9.6.0", + "typescript": "^6.0.3", + "webpack": "^5.107.2", + "webpack-cli": "^7.0.3" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-1.1.0.tgz", + "integrity": "sha512-Xc3VhU02wqZ1HvHRJUwL09HkZSTvidqY5Ya0NXBSYOxAp+Ln9dcJr9fySI+CkONzP3PekQo9WdzCv0PGER/mOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.17.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimatch": "*", + "@types/node": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/minimatch": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-uniq": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/clean-webpack-plugin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/clean-webpack-plugin/-/clean-webpack-plugin-4.0.0.tgz", + "integrity": "sha512-WuWE1nyTNAyW5T7oNyys2EN0cfP2fdRxhxnIQWiAp0bMabPdHhoGxM8A6YL2GhqwgrPnnaemVE7nv5XJ2Fhh2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "del": "^4.1.1" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "webpack": ">=4.0.0 <6.0.0" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", + "integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.4", + "supports-color": "10.2.2", + "tree-kill": "1.2.2", + "yargs": "18.0.0" + }, + "bin": { + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/del": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/del/-/del-4.1.1.tgz", + "integrity": "sha512-QwGuEUouP2kVwQenAsOof5Fv8K9t3D8Ca8NxcXKrIpEHjTXK5J2nXLdP+ALI1cgv8wj7KuwBhTwBkOZSJKM5XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/glob": "^7.1.1", + "globby": "^6.1.0", + "is-path-cwd": "^2.0.0", + "is-path-in-cwd": "^2.0.0", + "p-map": "^2.0.0", + "pify": "^4.0.1", + "rimraf": "^2.6.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.366", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.366.tgz", + "integrity": "sha512-OlRuhb688YTCzzU3gXPLn6nGyd+F+53INE1qaKKlu6kETErE8FYsyDh0XqXEU+uBRn0MpCzz2vfNwORhkap8qg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/engine.io": { + "version": "6.6.8", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.8.tgz", + "integrity": "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.20.1" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.22.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.2.tgz", + "integrity": "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/envinfo": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.21.0.tgz", + "integrity": "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==", + "dev": true, + "license": "MIT", + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gl-matrix": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.3.0.tgz", + "integrity": "sha512-COb7LDz+SXaHtl/h4LeaFcNdJdAQSDeVqjiIihSXNrkWObZLhDI4hIkZC11Aeqp7bcE72clzB0BnDXr2SmslRA==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/globby": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-6.1.0.tgz", + "integrity": "sha512-KVbFv2TQtbzCoxAnfD6JcHZTYCzyliEaaeM/gH8qQdkKr5s0OP9scEgvdcngyk7AVdY6YVW/TJHd+lQ/Df3Daw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^1.0.1", + "glob": "^7.0.3", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/globby/node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-cwd": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", + "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-path-in-cwd": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-2.1.0.tgz", + "integrity": "sha512-rNocXHgipO+rvnP6dk3zI20RpOtrAM/kzbB258Uw5BWr3TpXi861yzjo16Dn4hUox07iw5AyeMLHWsujkjzvRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-path-inside": "^2.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-path-inside": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-2.1.0.tgz", + "integrity": "sha512-wiyhTzfDWsvwAW53OBWF5zuvaOGlZ6PwYxAbPVDhpm+gM09xKQGjBq/8uYN12aDvMxnAnq3dxTyoSoRNmg5YFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-is-inside": "^1.0.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/notepack.io": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-2.2.0.tgz", + "integrity": "sha512-9b5w3t5VSH6ZPosoYnyDONnUTF8o0UkBw7JLA6eBlYJWyGT1Q3vQa8Hmuj1/X6RYvHjjygBDgw6fJhe0JEojfw==", + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pinkie": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shared": { + "resolved": "../shared", + "link": true + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.7.tgz", + "integrity": "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.20.1" + } + }, + "node_modules/socket.io-msgpack-parser": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/socket.io-msgpack-parser/-/socket.io-msgpack-parser-3.0.2.tgz", + "integrity": "sha512-1e76bJ1PCKi9H+JiYk+S29PBJvknHjQWM7Mtj0hjF2KxDA6b6rQxv3rTsnwBoz/haZOhlCDIMQvPATbqYeuMxg==", + "license": "MIT", + "dependencies": { + "component-emitter": "~1.3.0", + "notepack.io": "~2.2.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-loader": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.6.0.tgz", + "integrity": "sha512-dsJO0S+T7grTDWTc4a0nTygXGjKncVUpx8Y+af8EvI/D5WgTJby5UEk5eoMCB9EcLQmnvitqh99MqtjtHgAwFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "loader-utils": "*", + "typescript": "*", + "webpack": "^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "loader-utils": { + "optional": true + } + } + }, + "node_modules/ts-loader/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ts-loader/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ts-loader/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ts-loader/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.107.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.107.2.tgz", + "integrity": "sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.22.0", + "es-module-lexer": "^2.1.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "loader-runner": "^4.3.2", + "mime-db": "^1.54.0", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.5.0", + "watchpack": "^2.5.1", + "webpack-sources": "^3.5.0" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-7.0.3.tgz", + "integrity": "sha512-2E2C6A1e2El7791zQgTH7LPIuwLjRliow9OHS/qlJc9pwhZlCoL/uiwqd/1WSlXT83wJfmfDbkcqHXuXoPJZ3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@discoveryjs/json-ext": "^1.1.0", + "commander": "^14.0.3", + "cross-spawn": "^7.0.6", + "envinfo": "^7.14.0", + "import-local": "^3.0.2", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", + "webpack-merge": "^6.0.1" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.101.0", + "webpack-bundle-analyzer": "^4.0.0 || ^5.0.0", + "webpack-dev-server": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/webpack-merge": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", + "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/webpack-node-externals": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz", + "integrity": "sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/webpack-sources": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz", + "integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + } + } +} diff --git a/backend/package.json b/backend/package.json index d655b4c..02b9087 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,7 +13,7 @@ }, "scripts": { "build": "npx webpack --mode production", - "dev": "concurrently --kill-others-on-fail \"webpack --mode development -w\" \"nodemon --legacy-watch dist/main.js\"", + "dev": "concurrently --kill-others-on-fail \"webpack --mode development -w\" \"nodemon --legacy-watch --delay 500ms dist/main.js\"", "try-build": "npm run build && cd dist && node main.js && cd -" }, "dependencies": { diff --git a/backend/src/commands/announce.ts b/backend/src/commands/announce.ts deleted file mode 100644 index 2e8aadf..0000000 --- a/backend/src/commands/announce.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Command } from 'shared'; - -// Internal request from a game object (e.g. a keystone planet flipping) asking -// the GameServer to broadcast a one-off announcement to every connected client. -export class AnnounceCommand extends Command { - public constructor(public readonly text: string) { - super(); - } -} diff --git a/backend/src/commands/generate-points.ts b/backend/src/commands/generate-points.ts deleted file mode 100644 index 3f1ac98..0000000 --- a/backend/src/commands/generate-points.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Command } from 'shared'; - -export class GeneratePointsCommand extends Command { - public constructor( - public readonly blue: number, - public readonly red: number, - ) { - super(); - } -} diff --git a/backend/src/commands/react-to-collision.ts b/backend/src/commands/react-to-collision.ts deleted file mode 100644 index fdcd492..0000000 --- a/backend/src/commands/react-to-collision.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Command, GameObject } from 'shared'; - -export class ReactToCollisionCommand extends Command { - public constructor(public readonly other: GameObject) { - super(); - } -} diff --git a/backend/src/commands/step.ts b/backend/src/commands/step.ts deleted file mode 100644 index ee0f6f7..0000000 --- a/backend/src/commands/step.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Command, CommandReceiver } from 'shared'; - -export class StepCommand extends Command { - public constructor( - public readonly deltaTimeInSeconds: number, - public readonly game: CommandReceiver, - ) { - super(); - } -} diff --git a/backend/src/create-world.ts b/backend/src/create-world.ts index a1039fc..ace7180 100644 --- a/backend/src/create-world.ts +++ b/backend/src/create-world.ts @@ -2,16 +2,14 @@ import { vec2 } from 'gl-matrix'; import { Random, PlanetBase, hsl, settings, evaluateSdf } from 'shared'; import { LampPhysical } from './objects/lamp-physical'; import { PlanetPhysical } from './objects/planet-physical'; -import { PhysicalContainer } from './physics/containers/physical-container'; -import { Physical } from './physics/physicals/physical'; +import { PhysicalContainer } from './physics/physical-container'; export const createWorld = (objectContainer: PhysicalContainer) => { - const objects: Array = []; - const lights: Array = []; + const planets: Array = []; + const lamps: Array = []; for (let r = 0; r < settings.worldRadius; r += settings.radiusSteps) { - const circumference = 2 * Math.PI * r; - const stepCount = circumference * settings.objectsOnCircleLength; + const stepCount = 2 * Math.PI * r * settings.objectsOnCircleLength; for (let rad = 0; rad < 2 * Math.PI; rad += (2 * Math.PI) / stepCount) { const position = vec2.rotate( vec2.create(), @@ -20,12 +18,12 @@ export const createWorld = (objectContainer: PhysicalContainer) => { rad, ); - if (objects.length !== 0 && Random.getRandom() > 0.5) { + if (planets.length !== 0 && Random.getRandom() > 0.5) { if ( - evaluateSdf(position, objects) > 200 && - !lights.find((l) => l.distance(position) < 2500) + evaluateSdf(position, planets) > 200 && + !lamps.find((l) => l.distance(position) < 2500) ) { - lights.push( + lamps.push( new LampPhysical( position, hsl( @@ -37,61 +35,43 @@ export const createWorld = (objectContainer: PhysicalContainer) => { ), ); } - } else { - if ( - evaluateSdf(position, objects) > 1400 && - !lights.find((l) => l.distance(position) < 1700) - ) { - const planet = - objects.length === 0 - ? // The first, central giant is the keystone "Heart": a named, - // always-contested focal objective the whole match orbits. - new PlanetPhysical( - PlanetBase.createPlanetVertices( - position, - Random.getRandomInRange(1600, 2400), - Random.getRandomInRange(1600, 2400), - Random.getRandomInRange(80, 300), - ), - true, - ) - : new PlanetPhysical( - PlanetBase.createPlanetVertices( - position, - Random.getRandomInRange(300, 1600), - Random.getRandomInRange(300, 1600), - Random.getRandomInRange(20, 100), - ), - ); - - objects.push(planet); - } + } else if ( + evaluateSdf(position, planets) > 1400 && + !lamps.find((l) => l.distance(position) < 1700) + ) { + const isKeystone = planets.length === 0; + const [sizeMin, sizeMax, roughMin, roughMax] = isKeystone + ? [1600, 2400, 80, 300] + : [300, 1600, 20, 100]; + planets.push( + new PlanetPhysical( + PlanetBase.createPlanetVertices( + position, + Random.getRandomInRange(sizeMin, sizeMax), + Random.getRandomInRange(sizeMin, sizeMax), + Random.getRandomInRange(roughMin, roughMax), + ), + isKeystone, + objectContainer.game, + ), + ); } } } - console.info(`Generated ${objects.length} planets`); - console.info(`Generated ${lights.length} light`); + console.info(`Generated ${planets.length} planets and ${lamps.length} lamps`); - // Associate each lamp with its NEAREST planet, so a planet can repaint "its" - // lamps to the owning team's colour when it flips. Lamps are already placed by - // proximity during world-gen, so the nearest planet is the one whose capture - // they should advertise. Distances use the planet SDF (negative inside), which - // is exactly the "closest planet" metric we want. - const planets = objects.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical); - lights - .filter((l): l is LampPhysical => l instanceof LampPhysical) - .forEach((lamp) => { - let nearest: PlanetPhysical | undefined; - let nearestDistance = Infinity; - planets.forEach((planet) => { - const distance = planet.distance(lamp.center); - if (distance < nearestDistance) { - nearestDistance = distance; - nearest = planet; - } - }); - nearest?.addLamp(lamp); - }); + for (const lamp of lamps) { + let nearest: PlanetPhysical | undefined; + let nearestDistance = Infinity; + for (const planet of planets) { + const distance = planet.distance(lamp.center); + if (distance < nearestDistance) { + nearestDistance = distance; + nearest = planet; + } + } + nearest?.addLamp(lamp); + } - [...objects, ...lights].forEach((o) => objectContainer.addObject(o)); + [...planets, ...lamps].forEach((o) => objectContainer.addObject(o)); }; diff --git a/backend/src/default-options.ts b/backend/src/default-options.ts deleted file mode 100644 index 1580103..0000000 --- a/backend/src/default-options.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Options } from './options'; - -export const defaultOptions: Options = { - port: 3000, - name: 'Test server', - playerLimit: 16, - npcCount: 8, - seed: Math.random(), - scoreLimit: 2500, -}; diff --git a/backend/src/game-events.ts b/backend/src/game-events.ts new file mode 100644 index 0000000..e4ae22b --- /dev/null +++ b/backend/src/game-events.ts @@ -0,0 +1,17 @@ +import { CharacterTeam } from 'shared'; + +export interface GameEvents { + addPoints(blue: number, red: number): void; + announce(text: string): void; +} + +export const addPointsForTeam = ( + game: GameEvents, + team: CharacterTeam, + points: number, +): void => { + game.addPoints( + team === CharacterTeam.blue ? points : 0, + team === CharacterTeam.red ? points : 0, + ); +}; diff --git a/backend/src/game-server.ts b/backend/src/game-server.ts index 5e92f3b..5e8d20c 100644 --- a/backend/src/game-server.ts +++ b/backend/src/game-server.ts @@ -1,5 +1,6 @@ -import { PhysicalContainer } from './physics/containers/physical-container'; import { Server, Socket } from 'socket.io'; +import { randomUUID } from 'node:crypto'; +import { performance } from 'node:perf_hooks'; import { TransportEvents, deserialize, @@ -11,25 +12,36 @@ import { GameEndCommand, GameStartCommand, Command, - CommandReceiver, - CommandExecutors, ServerAnnouncement, + JoinRejectionReason, + GameObject, + PropertyUpdatesForObject, } from 'shared'; +import { PhysicalContainer } from './physics/physical-container'; import { createWorld } from './create-world'; -import { DeltaTimeCalculator } from './helper/delta-time-calculator'; import { Options } from './options'; -import { PlayerContainer } from './players/player-container'; -import { StepCommand } from './commands/step'; -import { GeneratePointsCommand } from './commands/generate-points'; -import { AnnounceCommand } from './commands/announce'; +import { GameEvents } from './game-events'; +import { CarriedScore, PlayerContainer } from './players/player-container'; +import { Player } from './players/player'; const gameStateSubscribedRoom = 'gameStateSubscribedRoom'; +const endTitleSeconds = 6; -export class GameServer extends CommandReceiver { +const asPlayerInformation = (payload: unknown): PlayerInformation | undefined => { + if (!payload || typeof payload !== 'object') { + return undefined; + } + const { name, reconnectToken } = payload as Record; + return { + name: name as string, + reconnectToken: typeof reconnectToken === 'string' ? reconnectToken : undefined, + }; +}; + +export class GameServer implements GameEvents { private objects!: PhysicalContainer; private players!: PlayerContainer; - private deltaTimes!: Array; - private deltaTimeCalculator!: DeltaTimeCalculator; + private lastPhysicsBase = process.hrtime.bigint(); private bluePoints = 0; private redPoints = 0; @@ -37,92 +49,202 @@ export class GameServer extends CommandReceiver { private isInEndGame = false; private timeScaling = 1; + private statReportMs = Date.now(); + private timeSinceLastServerStateUpdate = 0; + private timeSinceLastPointUpdate = 0; + private physicsAccumulator = 0; + private saturatedFrames = 0; + private droppedInboundMessages = 0; - private serverName: string; - private playerLimit: number; - - private initialize() { - const previousPlayers = this.players; - this.objects = new PhysicalContainer(); - createWorld(this.objects); - this.objects.initialize(); - this.players = new PlayerContainer( - this.objects, - this.options.playerLimit, - this.options.npcCount, - ); - this.deltaTimeCalculator = new DeltaTimeCalculator(); - this.deltaTimes = []; - this.bluePoints = 0; - this.redPoints = 0; - this.matchPointAnnounced = {}; - this.isInEndGame = false; - this.timeScaling = 1; - previousPlayers?.queueCommandForEachClient(new GameStartCommand()); - previousPlayers?.sendQueuedCommands(); - } - - protected commandExecutors: CommandExecutors = { - [GeneratePointsCommand.type]: this.addPoints.bind(this), - [AnnounceCommand.type]: ({ text }: AnnounceCommand) => - this.players.queueCommandForEachClient(new ServerAnnouncement(text)), - }; + private readonly joinedSockets = new Map< + Socket, + { player: Player; release: () => void } + >(); + private inboundBudget = new WeakMap(); constructor( private readonly io: Server, private options: Options, ) { - super(); - - this.serverName = options.name; - this.playerLimit = options.playerLimit; - this.initialize(); io.on('connection', (socket: Socket) => { - socket.on(TransportEvents.PlayerJoining, (playerInfo: PlayerInformation) => { + socket.on(TransportEvents.PlayerJoining, (payload: unknown) => { + if (!this.allowInboundMessage(socket)) { + return; + } try { - const player = this.players.createPlayer(playerInfo, socket); - socket.on(TransportEvents.PlayerToServer, (json: string) => { - try { - const commands: Array = deserialize(json); - commands.forEach((c) => player.handleCommand(c)); - } catch (e) { - console.error('Error while processing command', e); - } - }); - - this.sendServerStateUpdate(); - - socket.on('disconnect', () => { - player.destroy(); - this.players.deletePlayer(player); - this.sendServerStateUpdate(); - }); + this.handleJoin(socket, payload); } catch (e) { - console.error('Failed to register joining player; disconnecting socket', e); + console.error('Failed to handle a join request', e); + socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.InvalidRequest); socket.disconnect(); } }); socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => { - socket.join(gameStateSubscribedRoom); + if (this.allowInboundMessage(socket)) { + socket.join(gameStateSubscribedRoom); + } }); }); } - private timeSinceLastServerStateUpdate = 0; + private initialize() { + const previousPlayers = this.players; + this.joinedSockets.forEach(({ release }) => release()); + this.joinedSockets.clear(); + this.inboundBudget = new WeakMap(); + + this.objects = new PhysicalContainer(this); + createWorld(this.objects); + this.players = new PlayerContainer( + this.objects, + this.options.playerLimit, + this.options.npcCount, + ); + this.lastPhysicsBase = process.hrtime.bigint(); + this.bluePoints = 0; + this.redPoints = 0; + this.matchPointAnnounced = {}; + this.isInEndGame = false; + this.timeScaling = 1; + this.physicsAccumulator = 0; + this.timeSinceLastServerStateUpdate = 0; + this.timeSinceLastPointUpdate = 0; + previousPlayers?.queueCommandForEachClient(new GameStartCommand()); + previousPlayers?.sendQueuedCommands(); + } + + private handleJoin(socket: Socket, payload: unknown) { + const playerInfo = asPlayerInformation(payload); + if (!playerInfo) { + socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.InvalidRequest); + return; + } + + if (this.joinedSockets.has(socket)) { + socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.AlreadyJoined); + return; + } + + if (this.isInEndGame) { + socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.RoundEnding); + return; + } + + const carried = this.retireGhost(playerInfo.reconnectToken); + + if (this.players.isFull) { + socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.ServerFull); + return; + } + + const player = this.players.createPlayer(playerInfo, socket, carried); + + const onPlayerToServer = (json: unknown) => { + try { + if (typeof json !== 'string' || json.length > settings.maxInboundMessageBytes) { + return; + } + if (!this.allowInboundMessage(socket)) { + return; + } + const commands: Array = deserialize(json); + if (!Array.isArray(commands)) { + return; + } + commands.forEach((c) => player.handleCommand(c)); + } catch (e) { + console.error('Error while processing command', e); + } + }; + + const onDisconnect = () => { + this.removeJoined(socket); + this.sendServerStateUpdate(); + }; + + socket.on(TransportEvents.PlayerToServer, onPlayerToServer); + socket.on('disconnect', onDisconnect); + this.joinedSockets.set(socket, { + player, + release: () => { + socket.off(TransportEvents.PlayerToServer, onPlayerToServer); + socket.off('disconnect', onDisconnect); + player.detachFromSocket(); + }, + }); + + player.reconnectToken = randomUUID(); + socket.emit(TransportEvents.PlayerJoined, player.reconnectToken); + + this.sendServerStateUpdate(); + } + + private allowInboundMessage(socket: Socket): boolean { + // performance.now(), not Date.now(): a backwards wall-clock step would lock the client out. + const nowMs = performance.now(); + const burst = settings.maxInboundMessageBurst; + const perSecond = settings.maxInboundMessagesPerSecond; + + const budget = this.inboundBudget.get(socket) ?? { tokens: burst, lastMs: nowMs }; + budget.tokens = Math.min( + burst, + budget.tokens + (Math.max(0, nowMs - budget.lastMs) / 1000) * perSecond, + ); + budget.lastMs = nowMs; + this.inboundBudget.set(socket, budget); + + if (budget.tokens < 1) { + this.droppedInboundMessages++; + return false; + } + + budget.tokens -= 1; + return true; + } + + private retireGhost(token?: string): CarriedScore | undefined { + if (!token) { + return undefined; + } + for (const [socket, { player }] of this.joinedSockets) { + if (player.reconnectToken === token) { + const carried = { team: player.team, ...player.score }; + this.removeJoined(socket); + return carried; + } + } + return undefined; + } + + private removeJoined(socket: Socket) { + const joined = this.joinedSockets.get(socket); + if (!joined) { + return; + } + this.joinedSockets.delete(socket); + this.inboundBudget.delete(socket); + joined.release(); + joined.player.destroy(); + this.players.deletePlayer(joined.player); + } + public sendServerStateUpdate() { this.io .to(gameStateSubscribedRoom) - .emit(TransportEvents.ServerInfoUpdate, [this.players.count, this.gameProgress]); + .emit(TransportEvents.ServerInfoUpdate, [ + this.players.count, + this.serverInfo.gameStatePercent, + ]); } public start() { this.handlePhysics(); } - private addPoints({ blue, red }: GeneratePointsCommand) { + public addPoints(blue: number, red: number) { if (this.isInEndGame) { return; } @@ -139,45 +261,60 @@ export class GameServer extends CommandReceiver { } } + public announce(text: string) { + this.players.queueCommandForEachClient(new ServerAnnouncement(text)); + } + private announceMatchPointOnce(team: CharacterTeam, points: number) { if ( !this.matchPointAnnounced[team] && points >= this.options.scoreLimit * settings.matchPointScoreRatio ) { this.matchPointAnnounced[team] = true; - this.players.queueCommandForEachClient( - new ServerAnnouncement( - `Match point — team ${team}!`, - ), - ); + this.announce(`Match point — team ${team}!`); } } private endGame(winningTeam: CharacterTeam) { this.isInEndGame = true; - const endTitleLength = 6; this.players.endGame(winningTeam); - this.players.queueCommandForEachClient( - new GameEndCommand(winningTeam, endTitleLength, true), - ); - setTimeout(() => this.destroy(), endTitleLength * 1000 * 1.1); + this.players.queueCommandForEachClient(new GameEndCommand()); + setTimeout(() => this.initialize(), endTitleSeconds * 1000 * 1.1); } - private destroy() { - this.initialize(); - } + private reportStats() { + const mem = `${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`; + console.info(`Memory: ${mem}, Players: ${this.players.count}`); - private timeSinceLastPointUpdate = 0; - private physicsAccumulator = 0; - // Frames since the last stats report where physics ran over budget (more - // substeps than the cap). Surfaced by handleStats as a saturation signal. - private saturatedFrames = 0; + const rtts = this.players.connectedPlayerRttsMs.filter((r) => r > 0); + if (rtts.length > 0) { + rtts.sort((a, b) => a - b); + console.info( + `RTT median ${rtts[Math.floor(rtts.length / 2)].toFixed(0)} ms ` + + `(min ${rtts[0].toFixed(0)}, max ${rtts[rtts.length - 1].toFixed(0)}, n=${rtts.length})`, + ); + } + + if (this.droppedInboundMessages > 0) { + console.warn(`Rate limited ${this.droppedInboundMessages} inbound msg(s)`); + this.droppedInboundMessages = 0; + } + if (this.saturatedFrames > 0) { + console.warn(`Physics saturated on ${this.saturatedFrames} frame(s)`); + this.saturatedFrames = 0; + } + } private handlePhysics() { - const delta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds({ setAsBase: true }); - this.deltaTimes.push(delta); + const frameStart = process.hrtime.bigint(); + const frameStartMs = performance.now(); + const delta = Number(frameStart - this.lastPhysicsBase) / 1e9; + this.lastPhysicsBase = frameStart; - this.handleStats(); + if (Date.now() - this.statReportMs > 30000) { + this.statReportMs = Date.now(); + this.reportStats(); + } if ((this.timeSinceLastServerStateUpdate += delta) > 4) { this.timeSinceLastServerStateUpdate = 0; @@ -193,18 +330,11 @@ export class GameServer extends CommandReceiver { const fixedDelta = settings.targetPhysicsDeltaTimeInSeconds; const maxSubstepsPerFrame = 5; - // Cap on retained physics backlog when saturated, so a long stall can't - // accumulate an unrecoverable catch-up. const maxBacklogSeconds = 0.25; this.physicsAccumulator += delta; let substeps = Math.floor(this.physicsAccumulator / fixedDelta); if (substeps > maxSubstepsPerFrame) { - // Saturated: run the cap's worth of substeps but KEEP the remaining - // backlog (clamped) instead of zeroing it. Dropping it silently slowed - // simulated time for everyone — and diverged client prediction, whose - // wall-clock keeps running. Clamping bounds the catch-up so a transient - // spike recovers without a death spiral. this.saturatedFrames++; this.physicsAccumulator = Math.min( this.physicsAccumulator - maxSubstepsPerFrame * fixedDelta, @@ -221,69 +351,41 @@ export class GameServer extends CommandReceiver { this.timeScaling *= Math.pow(settings.endGameDeltaScaling, fixedDelta); scaledDelta /= this.timeScaling; } - this.objects.handleCommand(new StepCommand(scaledDelta, this)); + this.objects.step(scaledDelta); this.players.step(scaledDelta); } - this.players.stepCommunication(delta); - this.objects.resetRemoteCalls(); + // Physics has only advanced to the start of this frame less whatever is still in the + // accumulator. Timing anything off the send time instead would put up to one physics step + // of noise on every interpolated position and on the client's replay anchor. + const simulatedThroughMs = frameStartMs - this.physicsAccumulator * 1000; - const physicsDelta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds(); - - setTimeout( - this.handlePhysics.bind(this), - Math.max(0, fixedDelta - physicsDelta) * 1000, - ); - } - - private handleStats() { - const framesBetweenDeltaTimeCalculation = 10000; - - if (this.deltaTimes.length > framesBetweenDeltaTimeCalculation) { - this.deltaTimes.sort((a, b) => a - b); - console.info( - `Median physics time: ${( - this.deltaTimes[Math.floor(framesBetweenDeltaTimeCalculation / 2)] * 1000 - ).toFixed(2)} ms`, + if (substeps > 0) { + const propertyUpdates = new Map(); + this.players.stepCommunication( + substeps * fixedDelta, + simulatedThroughMs, + (object) => { + if (!propertyUpdates.has(object)) { + propertyUpdates.set(object, object.getPropertyUpdates(1 / this.timeScaling)); + } + return propertyUpdates.get(object); + }, ); - console.info( - 'Tail times: ', - this.deltaTimes.slice(-20).map((v) => `${(v * 1000).toFixed(2)} ms`), - ); - console.info( - `Memory used: ${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`, - ); - - const rtts = this.players.connectedPlayerRttsMs.filter((r) => r > 0); - if (rtts.length > 0) { - rtts.sort((a, b) => a - b); - console.info( - `Player RTT median ${rtts[Math.floor(rtts.length / 2)].toFixed(0)} ms ` + - `(min ${rtts[0].toFixed(0)}, max ${rtts[rtts.length - 1].toFixed(0)}, n=${rtts.length})`, - ); - } - - if (this.saturatedFrames > 0) { - console.warn( - `Physics saturated on ${this.saturatedFrames} frame(s) since last report — shedding backlog`, - ); - this.saturatedFrames = 0; - } - - this.deltaTimes = []; + this.objects.resetRemoteCalls(); } - } - private get gameProgress(): number { - return (Math.max(this.bluePoints, this.redPoints) / this.options.scoreLimit) * 100; + const elapsed = Number(process.hrtime.bigint() - frameStart) / 1e9; + setTimeout(() => this.handlePhysics(), Math.max(0, fixedDelta - elapsed) * 1000); } public get serverInfo(): ServerInformation { return { - serverName: this.serverName, + serverName: this.options.name, playerCount: this.players.count, - playerLimit: this.playerLimit, - gameStatePercent: this.gameProgress, + playerLimit: this.options.playerLimit, + gameStatePercent: + (Math.max(this.bluePoints, this.redPoints) / this.options.scoreLimit) * 100, }; } } diff --git a/backend/src/helper/delta-time-calculator.ts b/backend/src/helper/delta-time-calculator.ts deleted file mode 100644 index 4d3723e..0000000 --- a/backend/src/helper/delta-time-calculator.ts +++ /dev/null @@ -1,13 +0,0 @@ -export class DeltaTimeCalculator { - private previousTime: [number, number] = process.hrtime(); - - public getNextDeltaTimeInSeconds( - { setAsBase = false }: { setAsBase: boolean } = { setAsBase: false }, - ): number { - const [seconds, nanoSeconds] = process.hrtime(this.previousTime); - if (setAsBase) { - this.previousTime = process.hrtime(); - } - return seconds + nanoSeconds / 1e9; - } -} diff --git a/backend/src/main.ts b/backend/src/main.ts index c492c7a..60a92fe 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -2,15 +2,14 @@ import { Server as IoServer } from 'socket.io'; import express from 'express'; import { Server } from 'http'; import cors from 'cors'; -import { applyArrayPlugins, Random, serverInformationEndpoint } from 'shared'; +import { Random, serverInformationEndpoint, settings } from 'shared'; import minimist from 'minimist'; import { glMatrix } from 'gl-matrix'; import { GameServer } from './game-server'; -import { defaultOptions } from './default-options'; +import { defaultOptions } from './options'; import parser from 'socket.io-msgpack-parser'; glMatrix.setMatrixArrayType(Array); -applyArrayPlugins(); const optionOverrides = minimist(process.argv.slice(2)); const options = { @@ -24,6 +23,7 @@ const app = express(); const server = new Server(app); const io = new IoServer(server, { parser, + maxHttpBufferSize: settings.maxInboundMessageBytes, cors: { origin: true, credentials: true, @@ -32,14 +32,7 @@ const io = new IoServer(server, { const gameServer = new GameServer(io, options); -app.use( - cors({ - origin: (_, callback) => { - callback(null, true); - }, - credentials: true, - }), -); +app.use(cors({ origin: true, credentials: true })); app.get(serverInformationEndpoint, (_, res) => { res.json(gameServer.serverInfo); diff --git a/backend/src/objects/character-physical.ts b/backend/src/objects/character-physical.ts index bdc8873..d38b1da 100644 --- a/backend/src/objects/character-physical.ts +++ b/backend/src/objects/character-physical.ts @@ -2,22 +2,23 @@ import { vec2 } from 'gl-matrix'; import { id, settings, - MoveActionCommand, - serializesTo, - last, Circle, CharacterBase, CharacterTeam, PropertyUpdatesForObject, UpdatePropertyCommand, - CommandExecutors, - CommandReceiver, + CharacterMovementSnapshot, + GameObject, mix, clamp01, + strengthToCharge, stepCharacterMovement, + applyForce, applyLeapImpulse, decayMomentum, - CharacterMovementState, + tickPlanetDetachment, + characterCenter, + sumGravity, CharacterWorld, GroundSurface, headRadius, @@ -27,69 +28,45 @@ import { rightFootOffset, boundRadius, } from 'shared'; -import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; +import { addPointsForTeam } from '../game-events'; +import { Physical } from '../physics/physical'; import { CirclePhysical } from './circle-physical'; -import { PhysicalContainer } from '../physics/containers/physical-container'; -import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base'; +import { PhysicalContainer } from '../physics/physical-container'; +import { BoundingBox } from '../physics/bounding-box'; import { ProjectilePhysical } from './projectile-physical'; -import { forceAtPosition } from '../physics/functions/force-at-position'; -import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; -import { PlanetPhysical } from './planet-physical'; -import { StepCommand } from '../commands/step'; -import { ReactToCollisionCommand } from '../commands/react-to-collision'; -import { GeneratePointsCommand } from '../commands/generate-points'; +import { PlanetPhysical, planetsNear } from './planet-physical'; -@serializesTo(CharacterBase) -export class CharacterPhysical extends CharacterBase implements DynamicPhysical { +const placeholder = new Circle(vec2.create(), 0); + +export class CharacterPhysical extends CharacterBase implements Physical { public readonly canCollide = true; - public readonly canMove = true; - - private projectileStrength = settings.playerMaxStrength; - - // Body geometry (head/foot radii, posture offsets, bound radius) is defined - // once in the shared movement module and imported here, so the authoritative - // body and the client's predicted body are bit-identical by construction - // instead of by a hand-synced "copied verbatim" duplicate. Re-exposed as a - // static only because external callers reference CharacterPhysical.boundRadius. - public static readonly boundRadius = boundRadius; - - private timeSinceDying = 0; - private isDestroyed = false; - private timeSinceBorn = 0; - private hasJustBorn = true; - private timeAlive = 0; - - private timeSinceLastShot = settings.projectileCreationInterval; - private timeSinceLastDamage = settings.playerOutOfCombatDelaySeconds; - private lastSyncedHealth = settings.playerMaxHealth; - - private killStreak = 0; - - private direction = 0; - private currentPlanet?: PlanetPhysical; - private secondsSinceOnSurface = settings.planetDetachmentSeconds; - - private bodyVelocity = vec2.create(); - private timeSinceLastLeap = settings.leapCooldownSeconds; public head: CirclePhysical; public leftFoot: CirclePhysical; public rightFoot: CirclePhysical; - private movementState!: CharacterMovementState; - private movementWorld!: CharacterWorld; + public direction = 0; + public currentPlanet: GroundSurface | undefined; + public secondsSinceOnSurface = settings.planetDetachmentSeconds; + public readonly bodyVelocity = vec2.create(); - private movementActions: Array = []; - private lastMovementAction: MoveActionCommand = new MoveActionCommand(vec2.create()); + private projectileStrength = settings.playerMaxStrength; + private timeAlive = 0; + private timeSinceDying = 0; + private isDestroyed = false; + private hasJustBorn = true; + private hasFiredSinceSpawn = false; + private timeSinceLastShot = settings.projectileCreationInterval; + private timeSinceLastLeap = settings.leapCooldownSeconds; + private timeSinceLastDamage = settings.playerOutOfCombatDelaySeconds; + private lastSyncedHealth = settings.playerMaxHealth; + private killStreak = 0; - private headVelocity = new Circle(vec2.create(), 0); - private leftFootVelocity = new Circle(vec2.create(), 0); - private rightFootVelocity = new Circle(vec2.create(), 0); + private readonly movementDirection = vec2.create(); - protected commandExecutors: CommandExecutors = { - [StepCommand.type]: this.step.bind(this), - [ReactToCollisionCommand.type]: this.onCollision.bind(this), - }; + private readonly previousPose = [0, 1, 2].map(() => new Circle(vec2.create(), 0)); + private readonly velocities = [0, 1, 2].map(() => new Circle(vec2.create(), 0)); + private readonly box = new BoundingBox(); constructor( name: string, @@ -99,112 +76,93 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical private readonly container: PhysicalContainer, startPosition: vec2, ) { - super(id(), name, killCount, deathCount, team, settings.playerMaxHealth); - this.head = new CirclePhysical( - vec2.add(vec2.create(), startPosition, headOffset), - headRadius, - this, - container, + super( + id(), + name, + killCount, + deathCount, + team, + settings.playerMaxHealth, + placeholder, + placeholder, + placeholder, ); - this.leftFoot = new CirclePhysical( - vec2.add(vec2.create(), startPosition, leftFootOffset), - feetRadius, - this, - container, - ); - this.rightFoot = new CirclePhysical( - vec2.add(vec2.create(), startPosition, rightFootOffset), - feetRadius, - this, - container, - ); - container.addObject(this.head); - container.addObject(this.leftFoot); - container.addObject(this.rightFoot); - - this.initMovementBridge(); + const part = (offset: vec2, radius: number) => + new CirclePhysical( + vec2.add(vec2.create(), startPosition, offset), + radius, + this, + container, + ); + this.head = part(headOffset, headRadius); + this.leftFoot = part(leftFootOffset, feetRadius); + this.rightFoot = part(rightFootOffset, feetRadius); } - private initMovementBridge() { - // The movementState object-literal getters/setters below can't use `this` - // (it would bind to the literal), so alias the character instance. - // eslint-disable-next-line @typescript-eslint/no-this-alias - const self = this; - this.movementState = { - head: this.head, - leftFoot: this.leftFoot, - rightFoot: this.rightFoot, - get direction() { - return self.direction; - }, - set direction(value: number) { - self.direction = value; - }, - get currentPlanet() { - return self.currentPlanet; - }, - set currentPlanet(value: GroundSurface | undefined) { - // On the server every ground is a PlanetPhysical (the world only ever - // hands back planets), so this narrowing is safe. - self.currentPlanet = value as PlanetPhysical | undefined; - }, - get secondsSinceOnSurface() { - return self.secondsSinceOnSurface; - }, - set secondsSinceOnSurface(value: number) { - self.secondsSinceOnSurface = value; - }, - bodyVelocity: this.bodyVelocity, - }; + private readonly movementWorld: CharacterWorld = { + groundsNear: (center, radius) => planetsNear(this.container, center, radius), + stepBody: (body, deltaTimeInSeconds) => { + const hit = (body as CirclePhysical).stepManually(deltaTimeInSeconds); + return hit instanceof PlanetPhysical ? hit : undefined; + }, + }; - this.movementWorld = { - // Same set and order forceAtPosition used: planets in the force field, - // in container-traversal order (so the f64 gravity sum is unchanged). - groundsNear: (center, radius) => - self.container - .findIntersecting(getBoundingBoxOfCircle(new Circle(center, radius))) - .filter((o): o is PlanetPhysical => o instanceof PlanetPhysical), - stepBody: (body, deltaTimeInSeconds) => { - const { hitObject } = (body as CirclePhysical).stepManually(deltaTimeInSeconds); - return hitObject instanceof PlanetPhysical ? hitObject : undefined; - }, - }; + private get parts(): Array { + return [this.head, this.leftFoot, this.rightFoot]; } private get isSpawnProtected(): boolean { return ( + !this.hasFiredSinceSpawn && this.timeAlive < - settings.spawnDespawnTime + settings.spawnInvulnerabilityExtraSeconds + settings.spawnDespawnTime + settings.spawnInvulnerabilityExtraSeconds ); } - private hasGeneratedPoints = false; - private getPoints(game: CommandReceiver) { - if (!this.isAlive && !this.hasGeneratedPoints) { - this.hasGeneratedPoints = true; - const blue = this.team === CharacterTeam.blue ? 0 : settings.playerKillPoint; - const red = this.team === CharacterTeam.red ? 0 : settings.playerKillPoint; - - game.handleCommand(new GeneratePointsCommand(blue, red)); - } - } - public get isAlive(): boolean { return !this.isDestroyed; } - public handleMovementAction(c: MoveActionCommand) { - this.movementActions.push(c); - } - public get groundPlanet(): PlanetPhysical | undefined { - return this.currentPlanet; + return this.currentPlanet as PlanetPhysical | undefined; } - // Persistent launch momentum, streamed to the owning client so its predictor - // can reproduce a leap/slingshot/recoil flight instead of only snapping to it. - public get launchMomentum(): vec2 { - return this.bodyVelocity; + public get movementSnapshot(): CharacterMovementSnapshot { + return new CharacterMovementSnapshot( + this.direction, + vec2.clone(this.bodyVelocity), + vec2.clone(this.leftFoot.lastNormal), + vec2.clone(this.rightFoot.lastNormal), + this.groundPlanet?.id ?? null, + this.secondsSinceOnSurface, + Math.max(0, settings.leapCooldownSeconds - this.timeSinceLastLeap), + ); + } + + public get boundingBox(): BoundingBox { + return this.box.setCircle(this.center, boundRadius); + } + + public get gameObject(): this { + return this; + } + + public get center(): vec2 { + return characterCenter(this.head, this.leftFoot, this.rightFoot); + } + + public distance(target: vec2): number { + return ( + Math.min( + this.head.distance(target), + this.leftFoot.distance(target), + this.rightFoot.distance(target), + ) - 5 + ); + } + + public setMoveDirection(direction: vec2) { + vec2.normalize(this.movementDirection, direction); } public addKill(victimName: string, charge = 0) { @@ -212,11 +170,13 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical this.killStreak++; this.remoteCall('setKillCount', this.killCount); - this.health = Math.min( - settings.playerMaxHealth, - this.health + settings.playerKillHealthReward, - ); - this.syncHealth(); + if (this.isAlive) { + this.health = Math.min( + settings.playerMaxHealth, + this.health + settings.playerKillHealthReward, + ); + this.syncHealth(); + } this.remoteCall('onKillConfirmed', victimName, this.killStreak, charge); } @@ -232,39 +192,38 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical } } - public onCollision({ other }: ReactToCollisionCommand) { + public onCollision(other: GameObject) { if ( - // A corpse keeps its collidable circles for the despawn animation; the - // isAlive guard stops a flying corpse from eating shots aimed past it. - this.isAlive && - other instanceof ProjectilePhysical && - other.team !== this.team && - other.isAlive + !this.isAlive || + !(other instanceof ProjectilePhysical) || + other.team === this.team || + !other.isAlive ) { - other.destroy(); + return; + } - if (this.isSpawnProtected) { - return; - } + other.destroy(); - this.timeSinceLastDamage = 0; - this.health -= other.strength; - this.lastSyncedHealth = Math.round(this.health); - this.remoteCall('setHealth', this.health); + if (this.isSpawnProtected) { + return; + } - if (this.health <= 0 && this.isAlive) { - // Throw the corpse along the killing shot, harder for charged hits. - vec2.scaleAndAdd( - this.bodyVelocity, - this.bodyVelocity, - other.direction, - mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge), - ); - this.onDie(); - other.originator.addKill(this.name, other.charge); - } else { - other.originator.registerHit(other.charge); - } + this.timeSinceLastDamage = 0; + this.health -= other.strength; + this.lastSyncedHealth = Math.round(this.health); + this.remoteCall('setHealth', this.health); + + if (this.health <= 0) { + vec2.scaleAndAdd( + this.bodyVelocity, + this.bodyVelocity, + other.direction, + mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge), + ); + this.onDie(true); + other.originator.addKill(this.name, other.charge); + } else { + other.originator.registerHit(other.charge); } } @@ -277,42 +236,45 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical return; } + const direction = vec2.subtract(vec2.create(), position, this.center); + if (vec2.length(direction) === 0) { + return; + } + this.timeSinceLastShot = 0; + this.hasFiredSinceSpawn = true; - const c = clamp01(charge); const desiredStrength = mix( settings.chargeShotStrengthMin, settings.chargeShotStrengthMax, - c, + clamp01(charge), ); const strength = Math.min(desiredStrength, this.projectileStrength); this.projectileStrength -= strength; + const c = strengthToCharge(strength); const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c); const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c); - const direction = vec2.subtract(vec2.create(), position, this.center); vec2.normalize(direction, direction); - // Keep the unit direction before vec2.scale repurposes it as the velocity. - const shotDirection = vec2.clone(direction); - const velocity = vec2.scale(direction, direction, speed); - const projectile = new ProjectilePhysical( - vec2.clone(this.center), - radius, - strength, - this.team, - velocity, - this, - this.container, - c, + this.container.addObject( + new ProjectilePhysical( + vec2.clone(this.center), + radius, + strength, + this.team, + vec2.scale(vec2.create(), direction, speed), + this, + this.container, + c, + ), ); - this.container.addObject(projectile); if (c > 0) { vec2.scaleAndAdd( this.bodyVelocity, this.bodyVelocity, - shotDirection, + direction, -settings.chargeShotRecoilMax * c, ); } @@ -333,210 +295,89 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical this.timeSinceLastLeap = 0; this.projectileStrength -= settings.leapStrengthCost; - - // Same impulse the client predicts with (shared), so a leap launches - // identically on both sides. - applyLeapImpulse(this.movementState, this.lastMovementAction.direction); + applyLeapImpulse(this, this.movementDirection); this.remoteCall('onLeap'); } - public get boundingBox(): BoundingBoxBase { - return getBoundingBoxOfCircle(new Circle(this.center, CharacterPhysical.boundRadius)); - } - - public get gameObject(): this { - return this; - } - - public get center(): vec2 { - const bodyCenter = vec2.add(vec2.create(), this.head.center, this.leftFoot.center); - vec2.add(bodyCenter, bodyCenter, this.rightFoot.center); - return vec2.scale(bodyCenter, bodyCenter, 1 / 3); - } - - public distance(target: vec2): number { - return ( - Math.min( - this.head.distance(target), - this.leftFoot.distance(target), - this.rightFoot.distance(target), - ) - 5 - ); - } - - private averageAndResetMovementActions(): vec2 { - let direction: vec2; - if (this.movementActions.length === 0) { - direction = vec2.clone(this.lastMovementAction.direction); - } else { - direction = this.movementActions.reduce( - (sum, current) => vec2.add(sum, sum, current.direction), - vec2.create(), - ); - - vec2.scale(direction, direction, 1 / this.movementActions.length); - - this.lastMovementAction = last(this.movementActions)!; - this.movementActions = []; + public onDie(killedInCombat = false) { + if (this.isDestroyed) { + return; } + this.isDestroyed = true; + this.remoteCall('onDie'); - return vec2.length(direction) > 0 - ? vec2.normalize(direction, direction) - : vec2.create(); + if (killedInCombat) { + const opposingTeam = + this.team === CharacterTeam.blue ? CharacterTeam.red : CharacterTeam.blue; + addPointsForTeam(this.container.game, opposingTeam, settings.playerKillPoint); + } } - private animateScaling(q: number) { - this.head.radius = headRadius * q; - this.leftFoot.radius = this.rightFoot.radius = feetRadius * q; - } - - public getPropertyUpdates(): PropertyUpdatesForObject { + public getPropertyUpdates(timeScale = 1): PropertyUpdatesForObject { + const [headVelocity, leftFootVelocity, rightFootVelocity] = this.velocities.map( + (velocity) => + new Circle( + vec2.scale(vec2.create(), velocity.center, timeScale), + velocity.radius * timeScale, + ), + ); return new PropertyUpdatesForObject(this.id, [ - new UpdatePropertyCommand('head', this.head, this.headVelocity), - new UpdatePropertyCommand('leftFoot', this.leftFoot, this.leftFootVelocity), - new UpdatePropertyCommand('rightFoot', this.rightFoot, this.rightFootVelocity), + new UpdatePropertyCommand('head', this.head, headVelocity), + new UpdatePropertyCommand('leftFoot', this.leftFoot, leftFootVelocity), + new UpdatePropertyCommand('rightFoot', this.rightFoot, rightFootVelocity), new UpdatePropertyCommand( 'strength', this.projectileStrength, - settings.playerStrengthRegenerationPerSeconds, + settings.playerStrengthRegenerationPerSeconds * timeScale, ), ]); } - private setPropertyUpdates( - oldHead: Circle, - oldLeftFoot: Circle, - oldRightFoot: Circle, - deltaTime: number, - ) { - this.headVelocity = new Circle( - vec2.scale( - oldHead.center, - vec2.subtract(oldHead.center, this.head.center, oldHead.center), - 1 / deltaTime, - ), - (this.head.radius - oldHead.radius) / deltaTime, - ); - - this.leftFootVelocity = new Circle( - vec2.scale( - oldLeftFoot.center, - vec2.subtract(oldLeftFoot.center, this.leftFoot.center, oldLeftFoot.center), - 1 / deltaTime, - ), - (this.leftFoot.radius - oldLeftFoot.radius) / deltaTime, - ); - - this.rightFootVelocity = new Circle( - vec2.scale( - oldRightFoot.center, - vec2.subtract(oldRightFoot.center, this.rightFoot.center, oldRightFoot.center), - 1 / deltaTime, - ), - (this.rightFoot.radius - oldRightFoot.radius) / deltaTime, - ); - - this.animateScaling(1); - } - - private step({ deltaTimeInSeconds, game }: StepCommand) { - this.getPoints(game); + public step(deltaTimeInSeconds: number) { this.timeAlive += deltaTimeInSeconds; this.timeSinceLastLeap += deltaTimeInSeconds; - const oldHead = new Circle(vec2.clone(this.head.center), this.head.radius); - const oldLeftFoot = new Circle( - vec2.clone(this.leftFoot.center), - this.leftFoot.radius, - ); - const oldRightFoot = new Circle( - vec2.clone(this.rightFoot.center), - this.rightFoot.radius, - ); + this.parts.forEach((part, i) => { + vec2.copy(this.previousPose[i].center, part.center); + this.previousPose[i].radius = part.radius; + }); if (this.isDestroyed) { if ((this.timeSinceDying += deltaTimeInSeconds) > settings.spawnDespawnTime) { - this.destroy(); + this.container.removeObject(this); } else { this.freeFallCorpse(deltaTimeInSeconds); this.animateScaling(1 - this.timeSinceDying / settings.spawnDespawnTime); } - this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds); - return; - } - - if (this.hasJustBorn) { - if ((this.timeSinceBorn += deltaTimeInSeconds) > settings.spawnDespawnTime) { + } else if (this.hasJustBorn) { + if (this.timeAlive > settings.spawnDespawnTime) { this.hasJustBorn = false; + this.animateScaling(1); } else { - this.animateScaling(this.timeSinceBorn / settings.spawnDespawnTime); + this.animateScaling(this.timeAlive / settings.spawnDespawnTime); } - this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds); - return; + } else { + this.stepAlive(deltaTimeInSeconds); } - if ( - (this.secondsSinceOnSurface += deltaTimeInSeconds) > - settings.planetDetachmentSeconds - ) { - this.currentPlanet = undefined; - } + this.parts.forEach((part, i) => { + const velocity = this.velocities[i]; + const previous = this.previousPose[i]; + vec2.subtract(velocity.center, part.center, previous.center); + vec2.scale(velocity.center, velocity.center, 1 / deltaTimeInSeconds); + velocity.radius = (part.radius - previous.radius) / deltaTimeInSeconds; + }); + } + + private stepAlive(deltaTimeInSeconds: number) { + tickPlanetDetachment(this, deltaTimeInSeconds); this.timeSinceLastShot += deltaTimeInSeconds; - this.projectileStrength = Math.min( settings.playerMaxStrength, this.projectileStrength + settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds, ); - this.regenerateHealth(deltaTimeInSeconds); - - // The planet tallies who is standing on it and resolves capture itself, so - // a contested rock can freeze instead of two squads silently cancelling. - this.currentPlanet?.registerPresence(this); - - // The whole walking model — gravity gather, movement force, on/off-planet - // branch, posture springs, body-momentum, and stepping the three parts — - // is the shared simulation the client predicts with, so the two can never - // drift. Server-only concerns (scoring, health, shooting, spawn/death, - // ownership) stay here around it. - const direction = this.averageAndResetMovementActions(); - stepCharacterMovement( - this.movementState, - this.movementWorld, - direction, - deltaTimeInSeconds, - ); - - this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds); - } - - private freeFallCorpse(deltaTime: number) { - const intersecting = this.container.findIntersecting( - getBoundingBoxOfCircle( - new Circle( - this.center, - CharacterPhysical.boundRadius + settings.maxGravityDistance, - ), - ), - ); - let grounded = false; - for (const part of [this.leftFoot, this.rightFoot, this.head]) { - part.applyForce(forceAtPosition(part.center, intersecting), deltaTime); - vec2.add(part.velocity, part.velocity, this.bodyVelocity); - const { hitObject } = part.stepManually(deltaTime); - if (hitObject instanceof PlanetPhysical) { - grounded = true; - } - } - // Brake with the exact shared model the living body uses: stiff on contact - // so the corpse skids to rest, gentle in the air, plus the constant stop and - // the speed cap — so a flung corpse comes to a definite stop instead of - // sliding forever. - decayMomentum(this.bodyVelocity, grounded, deltaTime); - } - - private regenerateHealth(deltaTimeInSeconds: number) { this.timeSinceLastDamage += deltaTimeInSeconds; if ( this.timeSinceLastDamage > settings.playerOutOfCombatDelaySeconds && @@ -548,17 +389,35 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical ); this.syncHealth(); } + + this.groundPlanet?.registerPresence(this); + + stepCharacterMovement( + this, + this.movementWorld, + this.movementDirection, + deltaTimeInSeconds, + ); } - public onDie() { - this.isDestroyed = true; - this.remoteCall('onDie'); + private animateScaling(q: number) { + this.head.radius = headRadius * q; + this.leftFoot.radius = this.rightFoot.radius = feetRadius * q; } - private destroy() { - this.container.removeObject(this); - this.container.removeObject(this.head); - this.container.removeObject(this.leftFoot); - this.container.removeObject(this.rightFoot); + private freeFallCorpse(deltaTime: number) { + const planets = this.movementWorld.groundsNear( + this.center, + boundRadius + settings.maxGravityDistance, + ); + let grounded = false; + for (const part of this.parts) { + vec2.copy(part.velocity, this.bodyVelocity); + applyForce(part, sumGravity(planets, part.center), deltaTime); + if (part.stepManually(deltaTime) instanceof PlanetPhysical) { + grounded = true; + } + } + decayMomentum(this.bodyVelocity, grounded, deltaTime); } } diff --git a/backend/src/objects/circle-physical.ts b/backend/src/objects/circle-physical.ts index e1a6abf..1b70122 100644 --- a/backend/src/objects/circle-physical.ts +++ b/backend/src/objects/circle-physical.ts @@ -1,143 +1,64 @@ import { vec2 } from 'gl-matrix'; -import { - Circle, - CommandExecutors, - CommandReceiver, - GameObject, - resolveCircleMovement, - serializesTo, -} from 'shared'; -import { BoundingBox } from '../physics/bounding-boxes/bounding-box'; -import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base'; -import { PhysicalContainer } from '../physics/containers/physical-container'; -import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; -import { Physical } from '../physics/physicals/physical'; -import { ReactToCollisionCommand } from '../commands/react-to-collision'; - -@serializesTo(Circle) -export class CirclePhysical extends CommandReceiver implements Circle, DynamicPhysical { - readonly canCollide = true; - readonly canMove = true; +import { Circle, GameObject, resolveCircleMovement } from 'shared'; +import { BoundingBox } from '../physics/bounding-box'; +import { PhysicalContainer } from '../physics/physical-container'; +import { Physical } from '../physics/physical'; +export class CirclePhysical extends Circle implements Physical { + public readonly canCollide = true; public velocity = vec2.create(); public lastNormal = vec2.fromValues(0, 1); - private _boundingBox: BoundingBox; - - protected commandExecutors: CommandExecutors = { - [ReactToCollisionCommand.type]: this.onCollision.bind(this), - }; + private readonly box = new BoundingBox(); constructor( - private _center: vec2, - private _radius: number, - public owner: GameObject, + center: vec2, + radius: number, + public readonly owner: Physical, private readonly container: PhysicalContainer, - // Public + readonly so a CirclePhysical structurally satisfies the shared - // PhysicsBody interface the movement simulation operates on. public readonly restitution = 0, ) { - super(); - this._boundingBox = new BoundingBox(); - this.recalculateBoundingBox(); + super(center, radius); } - public get boundingBox(): BoundingBoxBase { - return this._boundingBox; - } - - public get center(): vec2 { - return this._center; - } - - public set center(value: vec2) { - this._center = value; - this.recalculateBoundingBox(); - } - - public onCollision(c: ReactToCollisionCommand) { - this.owner.handleCommand(c); + public get boundingBox(): BoundingBox { + return this.box.setCircle(this.center, this.radius); } public get gameObject(): GameObject { - return this.owner; + return this.owner.gameObject; } - public get radius(): number { - return this._radius; + public onCollision(other: GameObject) { + this.owner.onCollision?.(other); } - public set radius(value: number) { - this._radius = value; - this.recalculateBoundingBox(); - } - - public distance(target: vec2): number { - return vec2.distance(target, this.center) - this.radius; - } - - private recalculateBoundingBox() { - this._boundingBox.xMin = this.center.x - this._radius; - this._boundingBox.xMax = this.center.x + this._radius; - this._boundingBox.yMin = this.center.y - this._radius; - this._boundingBox.yMax = this.center.y + this._radius; - } - - public applyForce(force: vec2, timeInSeconds: number) { - vec2.add( - this.velocity, - this.velocity, - vec2.scale(vec2.create(), force, timeInSeconds), - ); - } - - // Position-resolution for one tick. Delegates to the shared - // resolveCircleMovement so the server integrates a body with the exact same - // geometry the client predictor runs (shared/physics) — no parallel copy to - // keep in sync. The onHit callback dispatches the collision reactions at the - // same points the old inline move-circle did (both the initial march and the - // post-bounce slide). `possibleIntersectors`, when supplied, lets a caller - // that already broadphased (e.g. a projectile's gravity query) avoid a second - // container query; otherwise it is self-gathered from the swept bounding box. public stepManually( deltaTimeInSeconds: number, possibleIntersectors?: Array, - ): { - hitObject: GameObject | undefined; - velocity: vec2; - } { + ): GameObject | undefined { const intersecting = ( possibleIntersectors ?? this.sweptBroadphase(deltaTimeInSeconds) ).filter((b) => b.gameObject !== this.gameObject && b.canCollide); - const { hitObject, velocity } = resolveCircleMovement( + const hit = resolveCircleMovement( this, deltaTimeInSeconds, intersecting, (intersected) => { const physical = intersected as Physical; - physical.handleCommand(new ReactToCollisionCommand(this.gameObject)); - this.handleCommand(new ReactToCollisionCommand(physical.gameObject)); + physical.onCollision?.(this.gameObject); + this.onCollision(physical.gameObject); }, - ); + ) as Physical | undefined; - return { hitObject: (hitObject as Physical | undefined)?.gameObject, velocity }; + return hit?.gameObject; } - // Query the container with the bounding box grown by this tick's travel, so a - // fast-moving body still sees what it is about to sweep into. private sweptBroadphase(deltaTimeInSeconds: number): Array { - const sweep = vec2.length( - vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds), + const sweep = vec2.length(this.velocity) * deltaTimeInSeconds; + return this.container.findIntersecting( + BoundingBox.ofCircle(this.center, this.radius + sweep), ); - this.radius += sweep; - const intersecting = this.container.findIntersecting(this.boundingBox); - this.radius -= sweep; - return intersecting; - } - - public toArray(): Array { - const { center, radius } = this; - return [center, radius]; } } diff --git a/backend/src/objects/lamp-physical.ts b/backend/src/objects/lamp-physical.ts index 591b17c..07d04ef 100644 --- a/backend/src/objects/lamp-physical.ts +++ b/backend/src/objects/lamp-physical.ts @@ -1,31 +1,15 @@ import { vec2, vec3 } from 'gl-matrix'; -import { LampBase, settings, id, serializesTo } from 'shared'; +import { LampBase, settings, id } from 'shared'; +import { BoundingBox } from '../physics/bounding-box'; +import { Physical } from '../physics/physical'; -import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box'; -import { StaticPhysical } from '../physics/physicals/static-physical'; - -@serializesTo(LampBase) -export class LampPhysical extends LampBase implements StaticPhysical { +export class LampPhysical extends LampBase implements Physical { public readonly canCollide = false; - public readonly canMove = false; + public readonly boundingBox: BoundingBox; constructor(center: vec2, color: vec3, lightness: number) { super(id(), center, color, lightness); - } - - private _boundingBox?: ImmutableBoundingBox; - - public get boundingBox(): ImmutableBoundingBox { - if (!this._boundingBox) { - this._boundingBox = new ImmutableBoundingBox( - this.center.x - settings.lightCutoffDistance, - this.center.x + settings.lightCutoffDistance, - this.center.y - settings.lightCutoffDistance, - this.center.y + settings.lightCutoffDistance, - ); - } - - return this._boundingBox; + this.boundingBox = BoundingBox.ofCircle(center, settings.lightCutoffDistance); } public get gameObject(): this { @@ -33,6 +17,8 @@ export class LampPhysical extends LampBase implements StaticPhysical { } public queueSetLight(color: vec3, lightness: number) { + this.color = vec3.clone(color); + this.lightness = lightness; this.remoteCall('setLight', color, lightness); } diff --git a/backend/src/objects/planet-physical.ts b/backend/src/objects/planet-physical.ts index 4305b18..4c77ba4 100644 --- a/backend/src/objects/planet-physical.ts +++ b/backend/src/objects/planet-physical.ts @@ -1,195 +1,103 @@ import { vec2 } from 'gl-matrix'; - import { - clamp, clamp01, id, mix, Random, - serializesTo, settings, PlanetBase, CharacterTeam, PropertyUpdatesForObject, UpdatePropertyCommand, - CommandExecutors, - CommandReceiver, } from 'shared'; -import { GeneratePointsCommand } from '../commands/generate-points'; -import { AnnounceCommand } from '../commands/announce'; -import { StepCommand } from '../commands/step'; - -import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box'; -import { StaticPhysical } from '../physics/physicals/static-physical'; +import { addPointsForTeam, GameEvents } from '../game-events'; +import { BoundingBox } from '../physics/bounding-box'; +import { Physical } from '../physics/physical'; +import { PhysicalContainer } from '../physics/physical-container'; import { LampPhysical } from './lamp-physical'; import type { CharacterPhysical } from './character-physical'; -@serializesTo(PlanetBase) -export class PlanetPhysical extends PlanetBase implements StaticPhysical { - public readonly canCollide = true; - public readonly canMove = false; - // Marks this as standable ground for the shared movement simulation (a body - // landing on it latches it as currentPlanet). See shared GroundSurface. - public readonly isGround = true; +export const planetsIn = (objects: Array): Array => + objects.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical); +export const planetsNear = ( + container: PhysicalContainer, + center: vec2, + radius: number, +): Array => + planetsIn(container.findIntersecting(BoundingBox.ofCircle(center, radius))); + +export class PlanetPhysical extends PlanetBase implements Physical { + public readonly boundingBox: BoundingBox; public readonly sizePointMultiplier: number; - // Planets slowly spin. The angle is authoritative here and streamed to the - // client (see getPropertyUpdates), so the rendered outline and this collision - // polygon turn as one rigid body. cos/sin are memoised per angle because - // distance() is called many times per tick by the raymarcher and SDF sampling. - private rotation = 0; - private readonly rotationSpeed: number; - private cachedRotation = Number.NaN; - private cosRotation = 1; - private sinRotation = 0; - - private _boundingBox?: ImmutableBoundingBox; - private readonly lamps: Array = []; - private lastTeam: CharacterTeam = CharacterTeam.neutral; - - // Characters standing on the planet this tick. Filled by registerPresence as - // each grounded character steps, drained when the planet resolves capture in - // its own step(). Drives the head-count tug-of-war. private presentCharacters: Array = []; private isContested = false; + private timeSinceLastPointGeneration = 0; - protected commandExecutors: CommandExecutors = { - [StepCommand.type]: this.step.bind(this), - }; - - public addLamp(lamp: LampPhysical) { - this.lamps.push(lamp); - } - - constructor(vertices: Array, isKeystone = false) { + constructor( + vertices: Array, + isKeystone: boolean, + private readonly game: GameEvents, + ) { super(id(), vertices, 0.5, isKeystone); const sizeClass = clamp01( (this.radius - settings.planetMinReferenceRadius) / (settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius), ); - this.sizePointMultiplier = mix(1, settings.planetSizePointMultiplierMax, sizeClass); - - this.rotationSpeed = + this.angularVelocity = (0.05 + Random.getRandom() * 0.07) * (Random.getRandom() < 0.5 ? -1 : 1); + this.boundingBox = BoundingBox.ofCircle( + this.center, + Math.max(...vertices.map((v) => vec2.distance(this.center, v))), + ); + } + + public get gameObject(): this { + return this; + } + + public addLamp(lamp: LampPhysical) { + this.lamps.push(lamp); } - // A grounded character announces itself each tick so the planet can resolve - // contested capture from the net head-count. public registerPresence(character: CharacterPhysical) { this.presentCharacters.push(character); } - public distance(target: vec2): number { - // Evaluate the SDF in the planet's own rotating frame so this collision - // outline turns in lockstep with the rendered planet (see planet-shape.ts). - const local = this.toLocalFrame(target); - - const startEnd = this.vertices[0]; - let vb = startEnd; - - let d = vec2.dist(local, vb); - let sign = 1; - - for (let i = 1; i <= this.vertices.length; i++) { - const va = vb; - vb = i === this.vertices.length ? startEnd : this.vertices[i]; - const targetFromDelta = vec2.subtract(vec2.create(), local, va); - const toFromDelta = vec2.subtract(vec2.create(), vb, va); - const h = clamp01( - vec2.dot(targetFromDelta, toFromDelta) / vec2.squaredLength(toFromDelta), - ); - - const ds = vec2.fromValues( - vec2.dist(targetFromDelta, vec2.scale(vec2.create(), toFromDelta, h)), - toFromDelta.x * targetFromDelta.y - toFromDelta.y * targetFromDelta.x, - ); - - if ( - (local.y >= va.y && local.y < vb.y && ds.y > 0) || - (local.y < va.y && local.y >= vb.y && ds.y <= 0) - ) { - sign *= -1; - } - - d = Math.min(d, ds.x); - } - - return sign * d; - } - - // Rotate a world point by -rotation about the centre, matching the shader's - // `localTarget = center + R(rotation) * (target - center)` transform exactly. - private toLocalFrame(target: vec2): vec2 { - if (this.rotation !== this.cachedRotation) { - this.cachedRotation = this.rotation; - this.cosRotation = Math.cos(this.rotation); - this.sinRotation = Math.sin(this.rotation); - } - - const dx = target.x - this.center.x; - const dy = target.y - this.center.y; - - return vec2.fromValues( - this.center.x + this.cosRotation * dx - this.sinRotation * dy, - this.center.y + this.sinRotation * dx + this.cosRotation * dy, - ); - } - - // Signed angular velocity in rad/s, exposed so a character standing on the - // planet can ride its spin (see carryWithRotatingPlanet in shared). - public get angularVelocity(): number { - return this.rotationSpeed; - } - - public get team(): CharacterTeam { - return Math.abs(this.ownership - 0.5) < settings.planetControlThreshold - ? CharacterTeam.neutral - : this.ownership < 0.5 - ? CharacterTeam.blue - : CharacterTeam.red; - } - - private timeSinceLastPointGeneration = 0; - private getPoints(game: CommandReceiver) { - if (this.timeSinceLastPointGeneration > settings.planetPointGenerationInterval) { - this.timeSinceLastPointGeneration = 0; - - const value = Math.round( - settings.planetPointGenerationValue * this.sizePointMultiplier, - ); - game.handleCommand( - new GeneratePointsCommand( - this.team === CharacterTeam.blue ? value : 0, - this.team === CharacterTeam.red ? value : 0, - ), - ); - } - } - - private step({ deltaTimeInSeconds, game }: StepCommand) { - this.rotation += deltaTimeInSeconds * this.rotationSpeed; + public step(deltaTimeInSeconds: number) { + this.advanceRotation(deltaTimeInSeconds); this.timeSinceLastPointGeneration += deltaTimeInSeconds; - // In reverse order, so that teams can achieve a 100% control. - this.getPoints(game); + this.generatePoints(); this.resolveCapture(deltaTimeInSeconds); - this.detectFlip(game); + this.detectFlip(); - this.presentCharacters = []; + this.presentCharacters.length = 0; + } + + private generatePoints() { + if (this.timeSinceLastPointGeneration > settings.planetPointGenerationInterval) { + this.timeSinceLastPointGeneration = 0; + this.awardToOwner(settings.planetPointGenerationValue); + } + } + + private awardToOwner(base: number) { + addPointsForTeam(this.game, this.team, Math.round(base * this.sizePointMultiplier)); } - // One capture step per tick driven by the net team head-count, so grouping up - // pays off and an equal standoff freezes the planet (contested) instead of - // both sides silently cancelling with no feedback. private resolveCapture(deltaTime: number) { let blue = 0; let red = 0; for (const c of this.presentCharacters) { + if (!c.isAlive) { + continue; + } if (c.team === CharacterTeam.blue) { blue++; } else if (c.team === CharacterTeam.red) { @@ -206,14 +114,11 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { deltaTime * lead, ); } else if (!occupied) { - // Empty planets drift back to neutral; the keystone drifts much slower so - // it lingers as a live flashpoint. this.takeControl( CharacterTeam.neutral, this.isKeystone ? deltaTime / settings.keystoneLoseControlScale : deltaTime, ); } - // occupied tie -> frozen tug-of-war: no ownership change, ring pulses. const contested = occupied && net === 0; if (contested !== this.isContested) { @@ -222,7 +127,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { } } - private detectFlip(game: CommandReceiver) { + private detectFlip() { const currentTeam = this.team; if (currentTeam === this.lastTeam) { return; @@ -230,22 +135,15 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { this.lastTeam = currentTeam; if (currentTeam !== CharacterTeam.neutral) { - const reward = Math.round( - settings.captureFlipPointReward * this.sizePointMultiplier, - ); - this.remoteCall('generatedPoints', reward); - game.handleCommand( - new GeneratePointsCommand( - currentTeam === CharacterTeam.blue ? reward : 0, - currentTeam === CharacterTeam.red ? reward : 0, - ), + this.remoteCall( + 'generatedPoints', + Math.round(settings.captureFlipPointReward * this.sizePointMultiplier), ); + this.awardToOwner(settings.captureFlipPointReward); if (this.isKeystone) { - game.handleCommand( - new AnnounceCommand( - `Team ${currentTeam} captured the Heart`, - ), + this.game.announce( + `Team ${currentTeam} captured the Heart`, ); } } @@ -253,22 +151,12 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { const control = Math.abs(this.ownership - 0.5) / 0.5; const lightness = mix(settings.lampMinLightness, settings.lampMaxLightness, control); const color = settings.palette[settings.colorIndices[currentTeam]]; - this.lamps.forEach((lamp) => lamp.queueSetLight(color, lightness)); this.remoteCall('onFlipped', currentTeam); } - public getPropertyUpdates(): PropertyUpdatesForObject { - return new PropertyUpdatesForObject(this.id, [ - new UpdatePropertyCommand('ownership', this.ownership, 0), - // Stream the spin rate as the rate-of-change so the client can keep the - // angle moving when snapshots run late (see planet-view.ts). - new UpdatePropertyCommand('rotation', this.rotation, this.rotationSpeed), - ]); - } - - public takeControl(team: CharacterTeam, deltaTime: number) { + private takeControl(team: CharacterTeam, deltaTime: number) { if (team === CharacterTeam.blue) { this.ownership -= (0.5 / settings.takeControlTimeInSeconds) * deltaTime; } else if (team === CharacterTeam.red) { @@ -279,10 +167,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { -Math.sign(this.ownership - 0.5) * (0.5 / settings.loseControlTimeInSeconds) * deltaTime; - if ( - (previous < 0.5 && this.ownership > 0.5) || - (previous > 0.5 && this.ownership < 0.5) - ) { + if ((previous - 0.5) * (this.ownership - 0.5) < 0) { this.ownership = 0.5; } } @@ -290,45 +175,14 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { this.ownership = clamp01(this.ownership); } - public get boundingBox(): ImmutableBoundingBox { - if (!this._boundingBox) { - // The polygon spins about its centre (see distance), so this static box - // has to cover every orientation, not just the spawn-time one: take the - // circumscribed circle around the rotation centre. - const maxVertexDistance = this.vertices.reduce( - (max, vertex) => Math.max(max, vec2.distance(this.center, vertex)), - 0, - ); - - this._boundingBox = new ImmutableBoundingBox( - this.center.x - maxVertexDistance, - this.center.x + maxVertexDistance, - this.center.y - maxVertexDistance, - this.center.y + maxVertexDistance, - ); - } - - return this._boundingBox; - } - - public getForce(position: vec2): vec2 { - const diff = vec2.subtract(vec2.create(), this.center, position); - const dist = Math.max(settings.minGravityDistance, vec2.length(diff) - this.radius); - vec2.normalize(diff, diff); - const scale = clamp( - settings.maxGravityQ * ((settings.maxGravityDistance / dist) ** 1.5 - 1), - 0, - settings.maxGravityStrength, - ); - return vec2.scale(diff, diff, scale); - } - - // GroundSurface alias the shared movement simulation calls for gravity. - public gravityAt(position: vec2): vec2 { - return this.getForce(position); - } - - public get gameObject(): this { - return this; + public getPropertyUpdates(timeScale = 1): PropertyUpdatesForObject { + return new PropertyUpdatesForObject(this.id, [ + new UpdatePropertyCommand('ownership', this.ownership, 0), + new UpdatePropertyCommand( + 'rotation', + this.rotation, + this.angularVelocity * timeScale, + ), + ]); } } diff --git a/backend/src/objects/projectile-physical.ts b/backend/src/objects/projectile-physical.ts index 8a967f1..6ced842 100644 --- a/backend/src/objects/projectile-physical.ts +++ b/backend/src/objects/projectile-physical.ts @@ -2,57 +2,42 @@ import { vec2 } from 'gl-matrix'; import { id, settings, - serializesTo, ProjectileBase, CharacterTeam, PropertyUpdatesForObject, UpdatePropertyCommand, - CommandExecutors, - Circle, - marchCircle, + GameObject, + evaluateSdf, + sumGravity, } from 'shared'; -import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box'; +import { BoundingBox } from '../physics/bounding-box'; import { CirclePhysical } from './circle-physical'; -import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; -import { PhysicalContainer } from '../physics/containers/physical-container'; +import { Physical } from '../physics/physical'; +import { PhysicalContainer } from '../physics/physical-container'; import { CharacterPhysical } from './character-physical'; -import { forceAtPosition } from '../physics/functions/force-at-position'; -import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; -import { StepCommand } from '../commands/step'; -import { ReactToCollisionCommand } from '../commands/react-to-collision'; +import { planetsIn } from './planet-physical'; -@serializesTo(ProjectileBase) -export class ProjectilePhysical extends ProjectileBase implements DynamicPhysical { +export class ProjectilePhysical extends ProjectileBase implements Physical { public readonly canCollide = true; - public readonly canMove = true; + public readonly object: CirclePhysical; private isDestroyed = false; private bounceCount = 0; - private _boundingBox?: ImmutableBoundingBox; - - public object: CirclePhysical; - - protected commandExecutors: CommandExecutors = { - [StepCommand.type]: this.handleStep.bind(this), - [ReactToCollisionCommand.type]: this.onCollision.bind(this), - }; constructor( center: vec2, radius: number, public strength: number, team: CharacterTeam, - private velocity: vec2, + velocity: vec2, public readonly originator: CharacterPhysical, - readonly container: PhysicalContainer, - // Normalised charge (0..1) of the shot that fired this, used by the victim - // to scale hit/kill feedback and the death fling. + private readonly container: PhysicalContainer, public readonly charge: number = 0, ) { super(id(), center, radius, team, strength); - this.object = new CirclePhysical(center, radius, this, container, 0.9); - - this.moveOutsideOfObject(); + this.object = new CirclePhysical(this.center, radius, this, container, 0.9); + vec2.copy(this.object.velocity, velocity); + this.moveOutsideFriendlyBodies(); } public get isAlive(): boolean { @@ -60,36 +45,14 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica } public get direction(): vec2 { - const direction = vec2.clone(this.velocity); + const direction = vec2.clone(this.object.velocity); return vec2.length(direction) > 0 ? vec2.normalize(direction, direction) : vec2.fromValues(0, -1); } - private moveOutsideOfObject() { - let wasCollision = true; - const delta = vec2.scale( - vec2.create(), - vec2.normalize(vec2.create(), this.velocity), - 10, - ); - while (wasCollision) { - const intersecting = this.container - .findIntersecting(this.boundingBox) - .filter((g) => g instanceof CharacterPhysical && g.team === this.team); - const { hitSurface } = marchCircle(this.object, delta, intersecting, true); - wasCollision = hitSurface; - } - vec2.add(this.center, this.center, delta); - vec2.add(this.center, this.center, delta); - } - - public get boundingBox(): ImmutableBoundingBox { - if (!this._boundingBox) { - this._boundingBox = (this.object as CirclePhysical).boundingBox; - } - - return this._boundingBox; + public get boundingBox(): BoundingBox { + return this.object.boundingBox; } public get gameObject(): this { @@ -100,6 +63,19 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica return this.object.distance(target); } + private moveOutsideFriendlyBodies() { + const step = vec2.scale(vec2.create(), this.direction, 10); + for (let pass = 0; pass < 32; pass++) { + vec2.add(this.center, this.center, step); + const friendly = this.container + .findIntersecting(this.boundingBox) + .filter((o) => o instanceof CharacterPhysical && o.team === this.team); + if (evaluateSdf(this.center, friendly) >= this.radius) { + return; + } + } + } + public destroy() { if (!this.isDestroyed) { this.isDestroyed = true; @@ -107,22 +83,35 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica } } - public onCollision({ other }: ReactToCollisionCommand) { - if ( - !(other instanceof CharacterPhysical && other.team === this.team) && - this.bounceCount++ === settings.projectileMaxBounceCount - ) { + public onCollision(other: GameObject) { + if (other instanceof CharacterPhysical && other.team === this.team) { + return; + } + this.bounceCount++; + if (this.bounceCount > settings.projectileMaxBounceCount) { this.destroy(); } } - public getPropertyUpdates(): PropertyUpdatesForObject { + public getPropertyUpdates(timeScale = 1): PropertyUpdatesForObject { return new PropertyUpdatesForObject(this.id, [ - new UpdatePropertyCommand('center', this.center, this.velocity), + new UpdatePropertyCommand( + 'center', + this.center, + vec2.scale(vec2.create(), this.object.velocity, timeScale), + ), + new UpdatePropertyCommand( + 'strength', + this.strength, + -settings.projectileFadeSpeed * timeScale, + ), ]); } - private handleStep({ deltaTimeInSeconds }: StepCommand) { + public step(deltaTimeInSeconds: number) { + if (!this.isAlive) { + return; + } super.step(deltaTimeInSeconds); if (this.strength <= 0) { @@ -130,31 +119,17 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica return; } - // Curveball: the same planetary gravity that pulls on a free-falling - // character bends the shot, so slower (charged) shots arc and can be lobbed - // over a planet's horizon. Scale is tiny — near-surface gravity is huge. - // This single broadphase is reused for the step below: the gravity radius - // (maxGravityDistance) dwarfs one tick's travel, so the set is a superset of - // the swept-collision box and the step needn't query the container again. - const intersecting = settings.projectileGravityEnabled - ? this.container.findIntersecting( - getBoundingBoxOfCircle( - new Circle(this.center, this.object.radius + settings.maxGravityDistance), - ), - ) - : undefined; + const intersecting = this.container.findIntersecting( + BoundingBox.ofCircle(this.center, this.radius + settings.maxGravityDistance), + ); - if (intersecting) { - vec2.scaleAndAdd( - this.velocity, - this.velocity, - forceAtPosition(this.center, intersecting), - settings.projectileGravityScale * deltaTimeInSeconds, - ); - } + vec2.scaleAndAdd( + this.object.velocity, + this.object.velocity, + sumGravity(planetsIn(intersecting), this.center), + settings.projectileGravityScale * deltaTimeInSeconds, + ); - vec2.copy(this.object.velocity, this.velocity); - const { velocity } = this.object.stepManually(deltaTimeInSeconds, intersecting); - vec2.copy(this.velocity, velocity); + this.object.stepManually(deltaTimeInSeconds, intersecting); } } diff --git a/backend/src/options.ts b/backend/src/options.ts index ba28c16..e3b2dab 100644 --- a/backend/src/options.ts +++ b/backend/src/options.ts @@ -6,3 +6,14 @@ export interface Options { npcCount: number; seed: number; } + +const isDevelopment = process.env.NODE_ENV !== 'production'; + +export const defaultOptions: Options = { + port: 3000, + name: isDevelopment ? 'Dev server' : 'Doppler server', + playerLimit: 16, + npcCount: 8, + seed: (Math.random() * 0x100000000) >>> 0, + scoreLimit: 2500, +}; diff --git a/backend/src/physics/bounding-box.ts b/backend/src/physics/bounding-box.ts new file mode 100644 index 0000000..feb0d7a --- /dev/null +++ b/backend/src/physics/bounding-box.ts @@ -0,0 +1,36 @@ +import { vec2 } from 'gl-matrix'; + +export class BoundingBox { + constructor( + public xMin = 0, + public xMax = 0, + public yMin = 0, + public yMax = 0, + ) {} + + public static ofCircle(center: vec2, radius: number): BoundingBox { + return new BoundingBox( + center[0] - radius, + center[0] + radius, + center[1] - radius, + center[1] + radius, + ); + } + + public setCircle(center: vec2, radius: number): BoundingBox { + this.xMin = center[0] - radius; + this.xMax = center[0] + radius; + this.yMin = center[1] - radius; + this.yMax = center[1] + radius; + return this; + } + + public intersects(other: BoundingBox): boolean { + return ( + this.xMin < other.xMax && + this.xMax > other.xMin && + this.yMin < other.yMax && + this.yMax > other.yMin + ); + } +} diff --git a/backend/src/physics/bounding-boxes/bounding-box-base.ts b/backend/src/physics/bounding-boxes/bounding-box-base.ts deleted file mode 100644 index 91946c9..0000000 --- a/backend/src/physics/bounding-boxes/bounding-box-base.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { vec2 } from 'gl-matrix'; - -// axis-aligned -export abstract class BoundingBoxBase { - constructor( - protected _xMin: number = 0, - protected _xMax: number = 0, - protected _yMin: number = 0, - protected _yMax: number = 0, - ) {} - - [key: number]: number | undefined; - - public get 0(): number { - return this._xMin; - } - - public get 1(): number { - return this._xMax; - } - - public get 2(): number { - return this._yMin; - } - - public get 3(): number { - return this._yMax; - } - - public get xMin(): number { - return this._xMin; - } - - public get xMax(): number { - return this._xMax; - } - - public get yMin(): number { - return this._yMin; - } - - public get yMax(): number { - return this._yMax; - } - - public get topLeft(): vec2 { - return vec2.fromValues(this._xMin, this._yMax); - } - - public get size(): vec2 { - return vec2.fromValues(this._xMax - this._xMin, this._yMax - this._yMin); - } - - public intersects(other: BoundingBoxBase): boolean { - return ( - this._xMin < other._xMax && - this._xMax > other._xMin && - this._yMin < other._yMax && - this._yMax > other._yMin - ); - } -} diff --git a/backend/src/physics/bounding-boxes/bounding-box.ts b/backend/src/physics/bounding-boxes/bounding-box.ts deleted file mode 100644 index 562794f..0000000 --- a/backend/src/physics/bounding-boxes/bounding-box.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { BoundingBoxBase } from './bounding-box-base'; -import { ImmutableBoundingBox } from './immutable-bounding-box'; - -export class BoundingBox extends BoundingBoxBase { - public get xMin(): number { - return this._xMin; - } - - public set xMin(value: number) { - this._xMin = value; - } - - public set xMax(value: number) { - this._xMax = value; - } - - public get xMax(): number { - return this._xMax; - } - - public set yMin(value: number) { - this._yMin = value; - } - - public get yMin(): number { - return this._yMin; - } - - public set yMax(value: number) { - this._yMax = value; - } - - public get yMax(): number { - return this._yMax; - } - - public get topLeft(): vec2 { - return vec2.fromValues(this._xMin, this._yMax); - } - - public set topLeft(value: vec2) { - this._xMin = value.x; - this._yMax = value.y; - } - - public set size(value: vec2) { - this._xMax = this.xMin + value.x; - this._yMin = this.yMax - value.y; - } - - public get size(): vec2 { - return vec2.fromValues(this._xMax - this._xMin, this._yMax - this._yMin); - } - - public cloneAsImmutable(): ImmutableBoundingBox { - return new ImmutableBoundingBox(this.xMin, this.xMax, this.yMin, this.yMax); - } -} diff --git a/backend/src/physics/bounding-boxes/immutable-bounding-box.ts b/backend/src/physics/bounding-boxes/immutable-bounding-box.ts deleted file mode 100644 index 43b87e1..0000000 --- a/backend/src/physics/bounding-boxes/immutable-bounding-box.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { BoundingBoxBase } from './bounding-box-base'; - -export class ImmutableBoundingBox extends BoundingBoxBase {} diff --git a/backend/src/physics/containers/bounding-box-list.ts b/backend/src/physics/containers/bounding-box-list.ts deleted file mode 100644 index 4311f62..0000000 --- a/backend/src/physics/containers/bounding-box-list.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { DynamicPhysical } from '../physicals/dynamic-physical'; -import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base'; - -export class BoundingBoxList { - constructor(private objects: Array = []) {} - - public insert(object: DynamicPhysical) { - this.objects.push(object); - } - - public remove(object: DynamicPhysical) { - this.objects.splice( - this.objects.findIndex((i) => i === object), - 1, - ); - } - - public forEach(func: (object: DynamicPhysical) => unknown) { - this.objects.forEach(func); - } - - public findIntersecting(boundingBox: BoundingBoxBase): Array { - return this.objects.filter((b) => b.boundingBox.intersects(boundingBox)); - } -} diff --git a/backend/src/physics/containers/bounding-box-tree.ts b/backend/src/physics/containers/bounding-box-tree.ts deleted file mode 100644 index 39981ff..0000000 --- a/backend/src/physics/containers/bounding-box-tree.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base'; -import { StaticPhysical } from '../physicals/static-physical'; - -// source: https://github.com/ubilabs/kd-tree-javascript/blob/master/kdTree.js -class Node { - public left: Node | null = null; - public right: Node | null = null; - constructor( - public object: StaticPhysical, - public parent: Node | null, - ) {} -} - -export class BoundingBoxTree { - root?: Node | null; - - constructor(objects: Array = []) { - this.build(objects); - } - - public build(objects: Array) { - this.root = this.buildRecursive(objects, 0, null); - } - - private buildRecursive( - objects: Array, - depth: number, - parent: Node | null, - ): Node | null { - if (objects.length === 0) { - return null; - } - - if (objects.length === 1) { - return new Node(objects[0], parent); - } - - const dimension = depth % 4; - - objects.sort((a, b) => a.boundingBox[dimension]! - b.boundingBox[dimension]!); - - const median = Math.floor(objects.length / 2); - - const node = new Node(objects[median], parent); - node.left = this.buildRecursive(objects.slice(0, median), depth + 1, node); - node.right = this.buildRecursive(objects.slice(median + 1), depth + 1, node); - - return node; - } - - public insert(object: StaticPhysical) { - const [insertPosition, depth] = this.findParent(object, this.root, 0, null); - - if (insertPosition === null) { - this.root = new Node(object, null); - } else { - const node = new Node(object, insertPosition); - const dimension = depth % 4; - - if ( - object.boundingBox[dimension]! < insertPosition.object.boundingBox[dimension]! - ) { - insertPosition.left = node; - } else { - insertPosition.right = node; - } - } - } - - public findIntersecting(boundingBox: BoundingBoxBase): Array { - const maybeResults = this.findMaybeIntersecting(boundingBox, this.root!, 0); - const results = maybeResults.filter((b) => b.boundingBox.intersects(boundingBox)); - return results; - } - - private findMaybeIntersecting( - boundingBox: BoundingBoxBase, - node: Node | null, - depth: number, - ): Array { - if (node === null) { - return []; - } - - if (depth % 4 == 0 && boundingBox.xMax < node.object.boundingBox.xMin) { - return this.findMaybeIntersecting(boundingBox, node.left, depth + 1); - } - - if (depth % 4 == 1 && boundingBox.xMin > node.object.boundingBox.xMax) { - return this.findMaybeIntersecting(boundingBox, node.right, depth + 1); - } - - if (depth % 4 == 2 && boundingBox.yMax < node.object.boundingBox.yMin) { - return this.findMaybeIntersecting(boundingBox, node.left, depth + 1); - } - - if (depth % 4 == 3 && boundingBox.yMin > node.object.boundingBox.yMax) { - return this.findMaybeIntersecting(boundingBox, node.right, depth + 1); - } - - return [ - node.object, - ...this.findMaybeIntersecting(boundingBox, node.left, depth + 1), - ...this.findMaybeIntersecting(boundingBox, node.right, depth + 1), - ]; - } - - private findParent( - object: StaticPhysical, - node: Node | null | undefined, - depth: number, - parent: Node | null, - ): [Node | null, number] { - if (!node) { - return [parent, depth - 1]; - } - - const dimension = depth % 4; - - if (object.boundingBox[dimension]! < node.object.boundingBox[dimension]!) { - return this.findParent(object, node.left, depth + 1, node); - } - - return this.findParent(object, node.right, depth + 1, node); - } -} diff --git a/backend/src/physics/containers/physical-container.ts b/backend/src/physics/containers/physical-container.ts deleted file mode 100644 index 604672e..0000000 --- a/backend/src/physics/containers/physical-container.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base'; -import { BoundingBoxList } from './bounding-box-list'; -import { BoundingBoxTree } from './bounding-box-tree'; -import { Physical } from '../physicals/physical'; -import { StaticPhysical } from '../physicals/static-physical'; -import { DynamicPhysical } from '../physicals/dynamic-physical'; -import { Command, CommandReceiver } from 'shared'; - -export class PhysicalContainer extends CommandReceiver { - private isTreeInitialized = false; - private staticBoundingBoxesWaitList: Array = []; - private staticBoundingBoxes = new BoundingBoxTree(); - private dynamicBoundingBoxes = new BoundingBoxList(); - private objects: Array = []; - - protected defaultCommandExecutor(c: Command) { - this.objects.forEach((o) => o.handleCommand(c)); - } - - public initialize() { - this.staticBoundingBoxes.build(this.staticBoundingBoxesWaitList); - this.isTreeInitialized = true; - } - - public addObject(physical: Physical) { - this.objects.push(physical); - - if (physical.canMove) { - this.dynamicBoundingBoxes.insert(physical); - } else { - if (!this.isTreeInitialized) { - this.staticBoundingBoxesWaitList.push(physical); - } else { - this.staticBoundingBoxes.insert(physical); - } - } - } - - public removeObject(object: DynamicPhysical) { - this.objects = this.objects.filter((p) => p !== object); - this.dynamicBoundingBoxes.remove(object); - } - - public resetRemoteCalls() { - this.objects.forEach((o) => o.gameObject.resetRemoteCalls()); - } - - public findIntersecting(box: BoundingBoxBase): Array { - return [ - ...this.staticBoundingBoxes.findIntersecting(box), - ...this.dynamicBoundingBoxes.findIntersecting(box), - ]; - } -} diff --git a/backend/src/physics/functions/force-at-position.ts b/backend/src/physics/functions/force-at-position.ts deleted file mode 100644 index 85bf2e3..0000000 --- a/backend/src/physics/functions/force-at-position.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { PlanetPhysical } from '../../objects/planet-physical'; -import { Physical } from '../physicals/physical'; - -export const forceAtPosition = (position: vec2, objects: Array) => - objects - .filter((o) => o instanceof PlanetPhysical) - .reduce( - (sum: vec2, o: Physical) => - vec2.add(sum, sum, (o as PlanetPhysical).getForce(position)), - vec2.create(), - ); diff --git a/backend/src/physics/functions/get-bounding-box-of-circle.ts b/backend/src/physics/functions/get-bounding-box-of-circle.ts deleted file mode 100644 index 9c5b2eb..0000000 --- a/backend/src/physics/functions/get-bounding-box-of-circle.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Circle } from 'shared'; -import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base'; -import { ImmutableBoundingBox } from '../bounding-boxes/immutable-bounding-box'; - -export const getBoundingBoxOfCircle = (circle: Circle): BoundingBoxBase => - new ImmutableBoundingBox( - circle.center.x - circle.radius, - circle.center.x + circle.radius, - circle.center.y - circle.radius, - circle.center.y + circle.radius, - ); diff --git a/backend/src/physics/functions/is-circle-intersecting.ts b/backend/src/physics/functions/is-circle-intersecting.ts deleted file mode 100644 index 203eb1d..0000000 --- a/backend/src/physics/functions/is-circle-intersecting.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Circle, evaluateSdf } from 'shared'; -import { PhysicalBase } from '../physicals/physical-base'; - -export const isCircleIntersecting = ( - circle: Circle, - intersectors: Array, -): boolean => evaluateSdf(circle.center, intersectors) < circle.radius; diff --git a/backend/src/physics/physical-container.ts b/backend/src/physics/physical-container.ts new file mode 100644 index 0000000..a3ac6c2 --- /dev/null +++ b/backend/src/physics/physical-container.ts @@ -0,0 +1,29 @@ +import { GameEvents } from '../game-events'; +import { BoundingBox } from './bounding-box'; +import { Physical } from './physical'; + +export class PhysicalContainer { + private objects: Array = []; + + constructor(public readonly game: GameEvents) {} + + public addObject(physical: Physical) { + this.objects.push(physical); + } + + public removeObject(physical: Physical) { + this.objects = this.objects.filter((o) => o !== physical); + } + + public step(deltaTimeInSeconds: number) { + this.objects.forEach((o) => o.step?.(deltaTimeInSeconds)); + } + + public resetRemoteCalls() { + this.objects.forEach((o) => o.gameObject.resetRemoteCalls()); + } + + public findIntersecting(box: BoundingBox): Array { + return this.objects.filter((o) => o.boundingBox.intersects(box)); + } +} diff --git a/backend/src/physics/physical.ts b/backend/src/physics/physical.ts new file mode 100644 index 0000000..65d6cb2 --- /dev/null +++ b/backend/src/physics/physical.ts @@ -0,0 +1,10 @@ +import { GameObject, Sdf } from 'shared'; +import { BoundingBox } from './bounding-box'; + +export interface Physical extends Sdf { + readonly boundingBox: BoundingBox; + readonly gameObject: GameObject; + + step?(deltaTimeInSeconds: number): void; + onCollision?(other: GameObject): void; +} diff --git a/backend/src/physics/physicals/dynamic-physical.ts b/backend/src/physics/physicals/dynamic-physical.ts deleted file mode 100644 index d72cd9d..0000000 --- a/backend/src/physics/physicals/dynamic-physical.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { PhysicalBase } from './physical-base'; - -export interface DynamicPhysical extends PhysicalBase { - readonly canMove: true; -} diff --git a/backend/src/physics/physicals/physical-base.ts b/backend/src/physics/physicals/physical-base.ts deleted file mode 100644 index ab41eac..0000000 --- a/backend/src/physics/physicals/physical-base.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { CommandReceiver, GameObject } from 'shared'; -import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base'; - -export interface PhysicalBase extends CommandReceiver { - readonly canCollide: boolean; - readonly canMove: boolean; - readonly boundingBox: BoundingBoxBase; - readonly gameObject: GameObject; - - distance(target: vec2): number; -} diff --git a/backend/src/physics/physicals/physical.ts b/backend/src/physics/physicals/physical.ts deleted file mode 100644 index 2e3189b..0000000 --- a/backend/src/physics/physicals/physical.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { DynamicPhysical } from './dynamic-physical'; -import { StaticPhysical } from './static-physical'; - -export type Physical = StaticPhysical | DynamicPhysical; diff --git a/backend/src/physics/physicals/static-physical.ts b/backend/src/physics/physicals/static-physical.ts deleted file mode 100644 index eef168b..0000000 --- a/backend/src/physics/physicals/static-physical.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { PhysicalBase } from './physical-base'; -import { ImmutableBoundingBox } from '../bounding-boxes/immutable-bounding-box'; - -export interface StaticPhysical extends PhysicalBase { - readonly canMove: false; - readonly boundingBox: ImmutableBoundingBox; -} diff --git a/backend/src/players/npc.ts b/backend/src/players/npc.ts index 655ade7..d5ee0b8 100644 --- a/backend/src/players/npc.ts +++ b/backend/src/players/npc.ts @@ -2,22 +2,21 @@ import { vec2 } from 'gl-matrix'; import { PlayerInformation, settings, - Circle, Random, - MoveActionCommand, CharacterTeam, Id, + evaluateSdf, } from 'shared'; -import { PhysicalContainer } from '../physics/containers/physical-container'; +import { PhysicalContainer } from '../physics/physical-container'; +import { BoundingBox } from '../physics/bounding-box'; import { PlayerContainer } from './player-container'; import { PlayerBase } from './player-base'; -import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; import { CharacterPhysical } from '../objects/character-physical'; -import { PlanetPhysical } from '../objects/planet-physical'; +import { PlanetPhysical, planetsIn } from '../objects/planet-physical'; import { ProjectilePhysical } from '../objects/projectile-physical'; -import { Physical } from '../physics/physicals/physical'; +import { Physical } from '../physics/physical'; -const npcTuning = { +const tuning = { planIntervalSeconds: 1, shootIntervalSeconds: 1.5, reactionIntervalSeconds: 1 / 10, @@ -40,7 +39,6 @@ const npcTuning = { dodgeThreatRange: 450, dodgeApproachDot: 0.6, - dodgeBaseChance: 0.25, dodgeAggressionChance: 0.35, dodgeCommitSeconds: 0.35, @@ -61,32 +59,36 @@ const npcTuning = { spreadPerDistance: 0.08, spreadAggressionFalloff: 1.3, - // Per-second chance to hop while grounded and on the move, so bots use the - // leap verb too instead of being grounded targets. leapChancePerSecond: 0.35, }; +interface Enemy { + character: CharacterPhysical; + distance: number; +} + export class NPC extends PlayerBase { private direction = vec2.fromValues(Random.getRandom() - 0.5, Random.getRandom() - 0.5); - private timeSinceLastPlan = 10000; - private timeSinceLastShoot = 10000; - private isWandering = false; + private movement = vec2.create(); + private timeSinceLastPlan = Infinity; + private timeSinceLastShoot = Infinity; + private timeSinceObserve = Infinity; private timeSinceLastWanderingConsideration = 0; + private isWandering = false; private isComingBack = false; private readonly aggression = Random.getRandomInRange( - npcTuning.aggressionMin, - npcTuning.aggressionMax, + tuning.aggressionMin, + tuning.aggressionMax, ); - private aimTargetId: Id = null; + private aimTargetId: Id | null = null; private readonly aimTargetLastPosition = vec2.create(); private readonly dodgeDirection = vec2.create(); private dodgeCommitRemaining = 0; private dodgeCooldownRemaining = 0; - private timeSinceObserve = npcTuning.reactionIntervalSeconds; private nearObjects: Array = []; constructor( @@ -97,69 +99,50 @@ export class NPC extends PlayerBase { ) { super(playerInfo, playerContainer, objectContainer, team); this.createCharacter(); - this.step(0); } - private timeUntilRespawn = 0; public step(deltaTimeInSeconds: number) { - if (this.character) { - this.center = this.character.center; - - if (!this.character.isAlive) { - this.sumDeaths++; - this.sumKills = this.character.killCount; - this.character = null; - this.timeUntilRespawn = settings.playerDiedTimeout; - return; - } - } else { - if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) { - this.createCharacter(); - this.center = this.character!.center; - } + const character = this.stepLifecycle(deltaTimeInSeconds); + if (!character) { return; } if ( (this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) > - npcTuning.wanderReconsiderSeconds + tuning.wanderReconsiderSeconds ) { this.timeSinceLastWanderingConsideration = 0; - this.isWandering = Random.getRandom() < npcTuning.wanderProbability; + this.isWandering = Random.getRandom() < tuning.wanderProbability; } - if ((this.timeSinceLastPlan += deltaTimeInSeconds) > npcTuning.planIntervalSeconds) { + if ((this.timeSinceLastPlan += deltaTimeInSeconds) > tuning.planIntervalSeconds) { this.timeSinceLastPlan = 0; this.plan(); } - if ( - (this.timeSinceObserve += deltaTimeInSeconds) > npcTuning.reactionIntervalSeconds - ) { - this.timeSinceObserve = 0; - this.nearObjects = this.observe(npcTuning.reactionObserveRadius); - } - this.dodgeCommitRemaining -= deltaTimeInSeconds; this.dodgeCooldownRemaining -= deltaTimeInSeconds; - const movement = this.decideMovement(this.nearObjects); - this.character.handleMovementAction(new MoveActionCommand(movement)); + + if ((this.timeSinceObserve += deltaTimeInSeconds) > tuning.reactionIntervalSeconds) { + this.timeSinceObserve = 0; + this.nearObjects = this.observe(tuning.reactionObserveRadius); + this.movement = this.decideMovement(character, this.nearObjects); + character.setMoveDirection(this.movement); + } if ( !this.isComingBack && - this.character.groundPlanet && - vec2.length(movement) > 0 && + character.groundPlanet && + vec2.length(this.movement) > 0 && Random.getRandom() < - npcTuning.leapChancePerSecond * this.aggression * deltaTimeInSeconds + tuning.leapChancePerSecond * this.aggression * deltaTimeInSeconds ) { - this.character.leap(); + character.leap(); } - if ( - (this.timeSinceLastShoot += deltaTimeInSeconds) > npcTuning.shootIntervalSeconds - ) { + if ((this.timeSinceLastShoot += deltaTimeInSeconds) > tuning.shootIntervalSeconds) { this.timeSinceLastShoot = 0; - this.tryShoot(this.nearObjects); + this.tryShoot(character, this.nearObjects); } } @@ -175,23 +158,18 @@ export class NPC extends PlayerBase { } this.isComingBack = false; - const nearObjects = this.observe(npcTuning.planScanRadius); - const enemies = this.enemiesByDistance(nearObjects); - - if (enemies.length > 0) { - const nearest = enemies[0]; + const nearObjects = this.observe(tuning.planScanRadius); + const nearest = this.enemiesByDistance(nearObjects)[0]; + if (nearest) { const fleeRange = - npcTuning.fleeBaseRange * (npcTuning.fleeAggressionFalloff - this.aggression); + tuning.fleeBaseRange * (tuning.fleeAggressionFalloff - this.aggression); if (nearest.distance < fleeRange) { vec2.subtract(this.direction, this.center, nearest.character.center); return; } - } - if (enemies.length > 0) { - const nearest = enemies[0]; const chaseRange = - npcTuning.chaseBaseRange + npcTuning.chaseAggressionRange * this.aggression; + tuning.chaseBaseRange + tuning.chaseAggressionRange * this.aggression; if (nearest.distance < chaseRange) { vec2.subtract(this.direction, nearest.character.center, this.center); return; @@ -201,7 +179,7 @@ export class NPC extends PlayerBase { if (!this.isWandering) { const planet = this.capturablePlanetsByDistance(nearObjects)[0]; if (planet) { - vec2.subtract(this.direction, planet.planet.center, this.center); + vec2.subtract(this.direction, planet.center, this.center); return; } } @@ -210,11 +188,14 @@ export class NPC extends PlayerBase { this.direction, this.direction, vec2.create(), - Random.getRandomInRange(-npcTuning.wanderTurn, npcTuning.wanderTurn), + Random.getRandomInRange(-tuning.wanderTurn, tuning.wanderTurn), ); } - private decideMovement(nearObjects: Array): vec2 { + private decideMovement( + character: CharacterPhysical, + nearObjects: Array, + ): vec2 { if (this.dodgeCommitRemaining > 0) { return vec2.clone(this.dodgeDirection); } @@ -223,23 +204,20 @@ export class NPC extends PlayerBase { if (dodge) { if ( Random.getRandom() < - npcTuning.dodgeBaseChance + npcTuning.dodgeAggressionChance * this.aggression + tuning.dodgeBaseChance + tuning.dodgeAggressionChance * this.aggression ) { vec2.copy(this.dodgeDirection, dodge); - this.dodgeCommitRemaining = npcTuning.dodgeCommitSeconds; + this.dodgeCommitRemaining = tuning.dodgeCommitSeconds; return vec2.clone(this.dodgeDirection); } - this.dodgeCooldownRemaining = npcTuning.dodgeCooldownSeconds; + this.dodgeCooldownRemaining = tuning.dodgeCooldownSeconds; } } - const planet = this.character!.groundPlanet; + const planet = character.groundPlanet; if (planet && planet.team !== this.team) { const enemies = this.enemiesByDistance(nearObjects); - if ( - enemies.length === 0 || - enemies[0].distance > npcTuning.captureHoldEnemyDistance - ) { + if (enemies.length === 0 || enemies[0].distance > tuning.captureHoldEnemyDistance) { return vec2.create(); } } @@ -258,12 +236,12 @@ export class NPC extends PlayerBase { } const toMe = vec2.subtract(vec2.create(), this.center, p.center); const distance = vec2.length(toMe); - if (distance > npcTuning.dodgeThreatRange || distance === 0) { + if (distance > tuning.dodgeThreatRange || distance === 0) { continue; } vec2.normalize(toMe, toMe); if ( - vec2.dot(p.direction, toMe) > npcTuning.dodgeApproachDot && + vec2.dot(p.direction, toMe) > tuning.dodgeApproachDot && distance < threatDistance ) { threatDistance = distance; @@ -275,19 +253,16 @@ export class NPC extends PlayerBase { return undefined; } - const perpendicular = vec2.fromValues(-threat.direction.y, threat.direction.x); + const perpendicular = vec2.fromValues(-threat.direction[1], threat.direction[0]); const toMe = vec2.subtract(vec2.create(), this.center, threat.center); if (vec2.dot(perpendicular, toMe) < 0) { vec2.negate(perpendicular, perpendicular); } - vec2.normalize(perpendicular, perpendicular); - - return perpendicular; + return vec2.normalize(perpendicular, perpendicular); } - private tryShoot(nearObjects: Array) { - const enemies = this.enemiesByDistance(nearObjects); - const visible = enemies.find((e) => + private tryShoot(character: CharacterPhysical, nearObjects: Array) { + const visible = this.enemiesByDistance(nearObjects).find((e) => this.hasLineOfSightTo(e.character.center, nearObjects), ); if (!visible) { @@ -295,71 +270,66 @@ export class NPC extends PlayerBase { return; } - const target = visible.character; - const distance = visible.distance; + const { character: target, distance } = visible; const velocity = vec2.create(); if (this.aimTargetId === target.id) { vec2.subtract(velocity, target.center, this.aimTargetLastPosition); - vec2.scale(velocity, velocity, 1 / npcTuning.shootIntervalSeconds); + vec2.scale(velocity, velocity, 1 / tuning.shootIntervalSeconds); } this.aimTargetId = target.id; vec2.copy(this.aimTargetLastPosition, target.center); if ( Random.getRandom() > - npcTuning.fireBaseChance + npcTuning.fireAggressionChance * this.aggression + tuning.fireBaseChance + tuning.fireAggressionChance * this.aggression ) { return; } const charge = - distance > npcTuning.chargeRangeThreshold && + distance > tuning.chargeRangeThreshold && Random.getRandom() < - npcTuning.chargeBaseChance + npcTuning.chargeAggressionChance * this.aggression - ? Random.getRandomInRange(npcTuning.chargeMin, 1) + tuning.chargeBaseChance + tuning.chargeAggressionChance * this.aggression + ? Random.getRandomInRange(tuning.chargeMin, 1) : 0; const projectileSpeed = charge > 0 ? settings.chargeShotSpeedMax : settings.chargeShotSpeedMin; - const leadTime = distance / projectileSpeed; - const aim = vec2.scaleAndAdd(vec2.create(), target.center, velocity, leadTime); + const aim = vec2.scaleAndAdd( + vec2.create(), + target.center, + velocity, + distance / projectileSpeed, + ); const spread = - (npcTuning.spreadBase + distance * npcTuning.spreadPerDistance) * - (npcTuning.spreadAggressionFalloff - this.aggression); - aim.x += Random.getRandomInRange(-spread, spread); - aim.y += Random.getRandomInRange(-spread, spread); + (tuning.spreadBase + distance * tuning.spreadPerDistance) * + (tuning.spreadAggressionFalloff - this.aggression); + aim[0] += Random.getRandomInRange(-spread, spread); + aim[1] += Random.getRandomInRange(-spread, spread); - this.character!.shootTowards(aim, charge); + character.shootTowards(aim, charge); } private hasLineOfSightTo(target: vec2, nearObjects: Array): boolean { - const planets: Array = []; - for (const o of nearObjects) { - if (o.gameObject instanceof PlanetPhysical) { - planets.push(o.gameObject); - } - } + const planets = planetsIn(nearObjects); if (planets.length === 0) { return true; } const direction = vec2.subtract(vec2.create(), target, this.center); const totalDistance = vec2.length(direction); - if (totalDistance <= npcTuning.lineOfSightStartOffset) { + if (totalDistance <= tuning.lineOfSightStartOffset) { return true; } vec2.normalize(direction, direction); - let traveled = npcTuning.lineOfSightStartOffset; + let traveled = tuning.lineOfSightStartOffset; const position = vec2.scaleAndAdd(vec2.create(), this.center, direction, traveled); while (traveled < totalDistance) { - let sdf = Infinity; - for (const planet of planets) { - sdf = Math.min(sdf, planet.distance(position)); - } - if (sdf < npcTuning.lineOfSightClearance) { + const sdf = evaluateSdf(position, planets); + if (sdf < tuning.lineOfSightClearance) { return false; } traveled += sdf; @@ -370,45 +340,34 @@ export class NPC extends PlayerBase { private observe(radius: number): Array { return this.objectContainer.findIntersecting( - getBoundingBoxOfCircle(new Circle(this.center, radius)), + BoundingBox.ofCircle(this.center, radius), ); } - private enemiesByDistance( - nearObjects: Array, - ): Array<{ character: CharacterPhysical; distance: number }> { - const seen = new Set(); - const enemies: Array<{ character: CharacterPhysical; distance: number }> = []; + private enemiesByDistance(nearObjects: Array): Array { + const enemies: Array = []; for (const o of nearObjects) { const c = o.gameObject; if ( c instanceof CharacterPhysical && c !== this.character && c.isAlive && - c.team !== this.team && - !seen.has(c) + c.team !== this.team ) { - seen.add(c); enemies.push({ character: c, distance: vec2.distance(this.center, c.center) }); } } - enemies.sort((a, b) => a.distance - b.distance); - return enemies; + return enemies.sort((a, b) => a.distance - b.distance); } private capturablePlanetsByDistance( nearObjects: Array, - ): Array<{ planet: PlanetPhysical; distance: number }> { - const planets = nearObjects - .filter( - (o): o is Physical => - o.gameObject instanceof PlanetPhysical && o.gameObject.team !== this.team, - ) - .map((o) => ({ - planet: o.gameObject as PlanetPhysical, - distance: vec2.distance(this.center, (o.gameObject as PlanetPhysical).center), - })); - planets.sort((a, b) => a.distance - b.distance); - return planets; + ): Array { + return planetsIn(nearObjects) + .filter((p) => p.team !== this.team) + .sort( + (a, b) => + vec2.distance(this.center, a.center) - vec2.distance(this.center, b.center), + ); } } diff --git a/backend/src/players/player-base.ts b/backend/src/players/player-base.ts index 6de34cd..ff3312d 100644 --- a/backend/src/players/player-base.ts +++ b/backend/src/players/player-base.ts @@ -1,56 +1,101 @@ import { vec2 } from 'gl-matrix'; import { CommandReceiver, - Circle, PlayerInformation, CharacterTeam, Random, settings, + sanitizeName, + boundRadius, + evaluateSdf, } from 'shared'; -import { PhysicalContainer } from '../physics/containers/physical-container'; -import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; -import { isCircleIntersecting } from '../physics/functions/is-circle-intersecting'; +import { PhysicalContainer } from '../physics/physical-container'; +import { BoundingBox } from '../physics/bounding-box'; import { CharacterPhysical } from '../objects/character-physical'; -import { PlanetPhysical } from '../objects/planet-physical'; +import { PlanetPhysical, planetsNear } from '../objects/planet-physical'; import { PlayerContainer } from './player-container'; +const maximumNameLength = 40; + +export interface Score { + kills: number; + deaths: number; +} + export abstract class PlayerBase extends CommandReceiver { - public character?: CharacterPhysical | null; + public character: CharacterPhysical | null = null; public center: vec2 = vec2.create(); - protected sumKills = 0; - protected sumDeaths = 0; + protected lastCharacter?: CharacterPhysical; + protected timeUntilRespawn = 0; + private kills: number; + private deaths: number; constructor( protected readonly playerInfo: PlayerInformation, protected readonly playerContainer: PlayerContainer, protected readonly objectContainer: PhysicalContainer, public readonly team: CharacterTeam, + score: Score = { kills: 0, deaths: 0 }, ) { super(); + this.kills = score.kills; + this.deaths = score.deaths; } - protected createCharacter() { + public get score(): Score { + return { + kills: (this.character ?? this.lastCharacter)?.killCount ?? this.kills, + deaths: this.deaths, + }; + } + + protected createCharacter(): CharacterPhysical { + this.kills = this.lastCharacter?.killCount ?? this.kills; this.character = new CharacterPhysical( - this.playerInfo.name.slice(0, 20), - this.sumKills, - this.sumDeaths, + sanitizeName(this.playerInfo.name, maximumNameLength), + this.kills, + this.deaths, this.team, this.objectContainer, this.findEmptyPositionForPlayer(this.findSpawnCenter()), ); - this.objectContainer.addObject(this.character); + this.center = this.character.center; + return this.character; } - public abstract step(deltaTimeInSeconds: number): void; + public step(deltaTimeInSeconds: number) { + this.stepLifecycle(deltaTimeInSeconds); + } + + protected stepLifecycle(deltaTimeInSeconds: number): CharacterPhysical | undefined { + if (this.character) { + this.center = this.character.center; + + if (this.character.isAlive) { + return this.character; + } + + this.deaths++; + this.lastCharacter = this.character; + this.character = null; + this.timeUntilRespawn = settings.playerDiedTimeout; + return undefined; + } + + if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) { + this.createCharacter(); + } + return undefined; + } private findSpawnCenter(): vec2 { - const planets = this.objectContainer - .findIntersecting( - getBoundingBoxOfCircle(new Circle(vec2.create(), settings.worldRadius * 2)), - ) - .filter((o): o is PlanetPhysical => o instanceof PlanetPhysical); + const planets = planetsNear( + this.objectContainer, + vec2.create(), + settings.worldRadius * 2, + ); const friendly = planets.filter((p) => p.team === this.team); const neutral = planets.filter((p) => p.team === CharacterTeam.neutral); @@ -68,34 +113,39 @@ export abstract class PlayerBase extends CommandReceiver { ); const safe = candidates.filter((p) => !isContested(p)); - // candidates is non-empty here, so choose() always returns a planet. return vec2.clone(Random.choose(safe.length ? safe : candidates)!.center); } - protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 { + private findEmptyPositionForPlayer(preferredCenter: vec2): vec2 { let rotation = 0; let radius = 0; - for (;;) { - const playerPosition = vec2.fromValues( - radius * Math.cos(rotation) + preferredCenter.x, - radius * Math.sin(rotation) + preferredCenter.y, + let roomiestPosition = vec2.clone(preferredCenter); + let roomiestClearance = -Infinity; + for (let attempt = 0; attempt < 512; attempt++) { + const position = vec2.fromValues( + radius * Math.cos(rotation) + preferredCenter[0], + radius * Math.sin(rotation) + preferredCenter[1], ); - const playerBoundingCircle = new Circle( - playerPosition, - CharacterPhysical.boundRadius, + const clearance = evaluateSdf( + position, + this.objectContainer.findIntersecting( + BoundingBox.ofCircle(position, boundRadius), + ), ); - - const playerBoundingBox = getBoundingBoxOfCircle(playerBoundingCircle); - const possibleIntersectors = - this.objectContainer.findIntersecting(playerBoundingBox); - if (!isCircleIntersecting(playerBoundingCircle, possibleIntersectors)) { - return playerPosition; + if (clearance >= boundRadius) { + return position; + } + if (clearance > roomiestClearance) { + roomiestClearance = clearance; + roomiestPosition = position; } rotation += Math.PI / 8; radius += 30; } + + return roomiestPosition; } public destroy() { diff --git a/backend/src/players/player-container.ts b/backend/src/players/player-container.ts index 01a1c96..0232e9f 100644 --- a/backend/src/players/player-container.ts +++ b/backend/src/players/player-container.ts @@ -1,13 +1,25 @@ -import { CharacterTeam, PlayerInformation, Random, settings, Command } from 'shared'; +import { + CharacterTeam, + PlayerInformation, + Random, + settings, + Command, + GameObject, + PropertyUpdatesForObject, +} from 'shared'; import { Socket } from 'socket.io'; -import { PhysicalContainer } from '../physics/containers/physical-container'; +import { PhysicalContainer } from '../physics/physical-container'; import { NPC } from './npc'; import { Player } from './player'; -import { PlayerBase } from './player-base'; +import { PlayerBase, Score } from './player-base'; + +export interface CarriedScore extends Score { + team: CharacterTeam; +} export class PlayerContainer { private _players: Array = []; - private _npcs: Array = []; + private npcs: Array = []; constructor( private readonly objects: PhysicalContainer, @@ -17,58 +29,65 @@ export class PlayerContainer { this.createNPCs(); } - public createNPCs() { - const newNpcCount = Math.min( - this.playerMaxCount - this._players.length - this._npcs.length, - this.npcMaxCount - this._npcs.length, - ); + private createNPCs() { + const newNpcCount = + Math.min(this.playerMaxCount, this.npcMaxCount) - + this._players.length - + this.npcs.length; for (let i = 0; i < newNpcCount; i++) { const name = `🤖 ${Random.choose(settings.npcNames)}`; - this._npcs.push( - new NPC({ name }, this, this.objects, this.getTeamOfNextPlayer(true)), + this.npcs.push( + new NPC({ name }, this, this.objects, this.teamOfNext(this.players)), ); } } - public createPlayer(playerInfo: PlayerInformation, socket: Socket): Player { - if (this._players.length === this.playerMaxCount) { - throw new Error('Too many players'); - } + public createPlayer( + playerInfo: PlayerInformation, + socket: Socket, + carried?: CarriedScore, + ): Player { + const team = carried ? carried.team : this.teamOfNext(this._players); - const team = this.getTeamOfNextPlayer(); - let npcToReplace = this._npcs.find((n) => n.team === team); - if (!npcToReplace) { - npcToReplace = this._npcs.find((n) => n.team !== team); - } + const npcToReplace = + this.npcs.find((n) => n.team === team) ?? this.npcs.find((n) => n.team !== team); npcToReplace?.destroy(); - this._npcs = this._npcs.filter((n) => n !== npcToReplace); + this.npcs = this.npcs.filter((n) => n !== npcToReplace); - const player = new Player(playerInfo, this, this.objects, team, socket); + const player = new Player(playerInfo, this, this.objects, team, socket, carried); this._players.push(player); - return player; } public get players(): Array { - return [...this._players, ...this._npcs]; + return [...this._players, ...this.npcs]; } public get count(): number { return this._players.length; } - // Measured round-trip times (ms) of the real connected players, for - // server-side latency stats. NPCs have no socket and are excluded. + public get isFull(): boolean { + return this._players.length >= this.playerMaxCount; + } + public get connectedPlayerRttsMs(): Array { return this._players.map((p) => p.rttMs); } public step(deltaTimeInSeconds: number) { - this.players.forEach((p) => p.step(deltaTimeInSeconds)); + this._players.forEach((p) => p.step(deltaTimeInSeconds)); + this.npcs.forEach((p) => p.step(deltaTimeInSeconds)); } - public stepCommunication(deltaTimeInSeconds: number) { - this._players.forEach((p) => p.stepCommunications(deltaTimeInSeconds)); + public stepCommunication( + deltaTimeInSeconds: number, + simulatedThroughMs: number, + propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined, + ) { + this._players.forEach((p) => + p.stepCommunications(deltaTimeInSeconds, simulatedThroughMs, propertyUpdatesOf), + ); } public endGame(winner: CharacterTeam) { @@ -83,8 +102,7 @@ export class PlayerContainer { this._players.forEach((p) => p.sendQueuedCommandsToClient()); } - private getTeamOfNextPlayer(isNpc = false): CharacterTeam { - const players = isNpc ? this.players : this._players; + private teamOfNext(players: Array): CharacterTeam { const blueCount = players.filter((p) => p.team === CharacterTeam.blue).length; const redCount = players.filter((p) => p.team === CharacterTeam.red).length; @@ -96,7 +114,9 @@ export class PlayerContainer { } public deletePlayer(player: Player) { - this._players = this._players.filter((p) => p !== player); - this.createNPCs(); + if (this._players.includes(player)) { + this._players = this._players.filter((p) => p !== player); + this.createNPCs(); + } } } diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts index 923be65..20912ca 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -25,49 +25,85 @@ import { PrimaryActionCommand, LeapActionCommand, InputAcknowledgement, + ClientHeartbeatCommand, + finiteInRange, + finiteVec2, + isFiniteNumber, + boundRadius, } from 'shared'; import { Socket } from 'socket.io'; -import { BoundingBox } from '../physics/bounding-boxes/bounding-box'; -import { PhysicalContainer } from '../physics/containers/physical-container'; +import { BoundingBox } from '../physics/bounding-box'; +import { PhysicalContainer } from '../physics/physical-container'; import { PlayerContainer } from './player-container'; -import { PlayerBase } from './player-base'; +import { PlayerBase, Score } from './player-base'; +import { CharacterPhysical } from '../objects/character-physical'; +import { planetsNear } from '../objects/planet-physical'; -// How often the server pings each client to measure round-trip time. const pingIntervalSeconds = 1; +const minimumAspectRatio = 0.2; +const maximumAspectRatio = 8; + +const sheddableWhenBackedUp: ReadonlyArray = [ + PropertyUpdatesForObjects.name, + UpdateMinimap.name, + InputAcknowledgement.name, +]; export class Player extends PlayerBase { - // default, until the clients sends its real value - private aspectRatio: number = 16 / 9; - private timeUntilRespawn = 0; - private timeSinceLastMessage = 0; - private objectsPreviouslyInViewArea: Array = []; - private lastInputClientTimeMs = 0; - private lastLeapClientTimeMs = 0; - - // Measured round-trip time to this client (ms) — the latency primitive that - // lag compensation, a latency HUD, and adaptive interpolation build on. + public reconnectToken = ''; public rttMs = 0; + + private aspectRatio = 16 / 9; + private timeSinceLastMessage = 0; + private objectsInViewArea = new Set(); + private commandsToBeSent: Array = []; + private winnerTeam?: CharacterTeam; + + private lastInputClientTimeMs = 0; + private lastInputReceiptMs = 0; + private lastLeapClientTimeMs = -1; + private timeSinceLastPing = 0; private lastPingSentMs = 0; + private pendingPingNonce = 0; + private nextPingNonce = 1; + // Input is untrusted: one non-finite value reaching the simulation leaves a character that never dies. protected commandExecutors: CommandExecutors = { - [SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) => - (this.aspectRatio = v.aspectRatio), - [MoveActionCommand.type]: (c: MoveActionCommand) => { - // Remember how far into this client's input timeline we've consumed, to - // echo back for client-side prediction reconciliation. - this.lastInputClientTimeMs = c.clientTimeMs; - this.character?.handleMovementAction(c); + [SetAspectRatioActionCommand.name]: (v: SetAspectRatioActionCommand) => { + this.aspectRatio = finiteInRange( + v.aspectRatio, + minimumAspectRatio, + maximumAspectRatio, + this.aspectRatio, + ); }, - [PrimaryActionCommand.type]: (c: PrimaryActionCommand) => - this.character?.shootTowards(c.position, c.charge), - [LeapActionCommand.type]: (c: LeapActionCommand) => { - // Record receipt (whether or not leap() accepts it): either way its effect - // on bodyVelocity is now reflected in the streamed launch momentum, so the - // predictor must stop replaying this leap. + [MoveActionCommand.name]: (c: MoveActionCommand) => { + const direction = finiteVec2(c.direction, 1); + if (!direction) { + return; + } + this.observeClientTime(c.clientTimeMs); + this.character?.setMoveDirection(direction); + }, + [PrimaryActionCommand.name]: (c: PrimaryActionCommand) => { + const position = finiteVec2(c.position, settings.maxClientPositionMagnitude); + if (!position) { + return; + } + this.observeClientTime(c.clientTimeMs); + this.character?.shootTowards(position, finiteInRange(c.charge, 0, 1, 0)); + }, + [LeapActionCommand.name]: (c: LeapActionCommand) => { + if (!isFiniteNumber(c.clientTimeMs)) { + return; + } this.lastLeapClientTimeMs = c.clientTimeMs; + this.observeClientTime(c.clientTimeMs); this.character?.leap(); }, + [ClientHeartbeatCommand.name]: (c: ClientHeartbeatCommand) => + this.observeClientTime(c.clientTimeMs), }; constructor( @@ -76,116 +112,152 @@ export class Player extends PlayerBase { objectContainer: PhysicalContainer, team: CharacterTeam, private readonly socket: Socket, + score?: Score, ) { - super(playerInfo, playerContainer, objectContainer, team); + super(playerInfo, playerContainer, objectContainer, team, score); this.createCharacter(); - this.step(0); - - // The client already echoes a Pong for every Ping (see game.ts). Only one - // ping is ever in flight, so RTT is simply now − send-time; no payload - // needed and no client change required. - this.socket.on(TransportEvents.Pong, () => { - if (this.lastPingSentMs > 0) { - this.rttMs = performance.now() - this.lastPingSentMs; - } - }); + this.socket.on(TransportEvents.Pong, this.onPong); } - protected createCharacter() { - super.createCharacter(); + private observeClientTime(clientTimeMs: number) { + if (isFiniteNumber(clientTimeMs) && clientTimeMs > this.lastInputClientTimeMs) { + this.lastInputClientTimeMs = clientTimeMs; + this.lastInputReceiptMs = performance.now(); + } + } - this.objectsPreviouslyInViewArea.push(this.character!); - this.queueCommandSend(new CreatePlayerCommand(this.character!)); + private readonly onPong = (nonce: unknown) => { + if (this.pendingPingNonce === 0 || nonce !== this.pendingPingNonce) { + return; + } + this.pendingPingNonce = 0; + this.rttMs = Math.min( + performance.now() - this.lastPingSentMs, + settings.maxMeasuredRttMs, + ); + }; + + public detachFromSocket() { + this.socket.off(TransportEvents.Pong, this.onPong); + } + + protected createCharacter(): CharacterPhysical { + const character = super.createCharacter(); + this.objectsInViewArea.add(character); + this.queueCommandSend(new CreatePlayerCommand(character)); + return character; } - private winnerTeam?: CharacterTeam; public onGameEnded(winnerTeam: CharacterTeam) { this.winnerTeam = winnerTeam; } - public step(deltaTimeInSeconds: number) { - if (this.character) { - this.center = this.character?.center; + public queueCommandSend(command: Command) { + this.commandsToBeSent.push(command); + } - if (!this.character.isAlive) { - this.sumDeaths++; - this.sumKills = this.character.killCount; + public stepCommunications( + deltaTime: number, + simulatedThroughMs: number, + propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined, + ) { + const remoteCalls: Array = []; + for (const object of this.objectsInViewArea) { + const calls = object.getRemoteCalls(); + if (calls.length > 0) { + remoteCalls.push(new RemoteCallsForObject(object.id, calls)); + } + } + if (remoteCalls.length > 0) { + this.queueCommandSend(new RemoteCallsForObjects(remoteCalls)); + } - this.character = null; - this.timeUntilRespawn = settings.playerDiedTimeout; - } - } else { - if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) { - this.createCharacter(); - this.center = this.character!.center; - } + if ((this.timeSinceLastPing += deltaTime) > pingIntervalSeconds) { + this.timeSinceLastPing = 0; + this.lastPingSentMs = performance.now(); + this.pendingPingNonce = this.nextPingNonce++; + this.socket.emit(TransportEvents.Ping, this.pendingPingNonce); + } + + // Subtracted, not zeroed: zeroing would stretch every interval to the next physics frame. + if ((this.timeSinceLastMessage += deltaTime) >= settings.updateMessageInterval) { + this.timeSinceLastMessage = Math.min( + this.timeSinceLastMessage - settings.updateMessageInterval, + settings.updateMessageInterval, + ); + this.queueAnnouncement(); + this.queueSnapshot(simulatedThroughMs, propertyUpdatesOf); + this.sendQueuedCommandsToClient(); } } - private handleViewAreaUpdate() { - const viewArea = calculateViewArea(this.center, this.aspectRatio, 1.2); - const bb = new BoundingBox(); - bb.topLeft = viewArea.topLeft; - bb.size = viewArea.size; - - const objectsInViewArea = Array.from( - new Set(this.objectContainer.findIntersecting(bb).map((o) => o.gameObject)), + private queueSnapshot( + simulatedThroughMs: number, + propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined, + ) { + const { topLeft, size } = calculateViewArea(this.center, this.aspectRatio, 1.2); + const viewBox = new BoundingBox( + topLeft[0], + topLeft[0] + size[0], + topLeft[1] - size[1], + topLeft[1], ); - // The owning character must always be in its own snapshot, regardless of the - // view-area query, so the client predictor never loses its authoritative - // anchor (the body can ride a fast spinner to the very edge of the box). - if (this.character && !objectsInViewArea.includes(this.character)) { - objectsInViewArea.push(this.character); + const inViewArea = new Set( + this.objectContainer.findIntersecting(viewBox).map((o) => o.gameObject), + ); + if (this.character) { + inViewArea.add(this.character); + // Prediction needs every gravity source, including planets outside a narrow viewport. + for (const planet of planetsNear( + this.objectContainer, + this.character.center, + boundRadius + settings.maxGravityDistance, + )) { + inViewArea.add(planet); + } } - const newlyIntersecting = objectsInViewArea.filter( - (o) => !this.objectsPreviouslyInViewArea.includes(o), - ); + const created = [...inViewArea].filter((o) => !this.objectsInViewArea.has(o)); + const deleted = [...this.objectsInViewArea].filter((o) => !inViewArea.has(o)); + this.objectsInViewArea = inViewArea; - const noLongerIntersecting = this.objectsPreviouslyInViewArea.filter( - (o) => !objectsInViewArea.includes(o), - ); - - this.objectsPreviouslyInViewArea = objectsInViewArea; - - if (noLongerIntersecting.length > 0) { - this.queueCommandSend( - new DeleteObjectsCommand(noLongerIntersecting.map((g) => g.id)), - ); + if (deleted.length > 0) { + this.queueCommandSend(new DeleteObjectsCommand(deleted.map((g) => g.id))); } - - if (newlyIntersecting.length > 0) { - this.queueCommandSend(new CreateObjectsCommand(newlyIntersecting)); + if (created.length > 0) { + this.queueCommandSend(new CreateObjectsCommand(created)); } this.queueCommandSend(new UpdateMinimap(this.getMinimapPlayers())); + const propertyUpdates: Array = []; + for (const object of this.objectsInViewArea) { + const update = propertyUpdatesOf(object); + if (update) { + propertyUpdates.push(update); + } + } this.queueCommandSend( - new PropertyUpdatesForObjects( - this.objectsPreviouslyInViewArea - .map((o) => o.getPropertyUpdates()) - .filter((u) => u) as Array, - performance.now() / 1000, - ), + new PropertyUpdatesForObjects(propertyUpdates, simulatedThroughMs / 1000), ); - // Tell the client how much of its own input is reflected in the snapshot it - // just received, so its predictor can replay the rest. Only while alive — - // a dead player isn't predicting. if (this.character) { this.queueCommandSend( new InputAcknowledgement( this.lastInputClientTimeMs, - this.character.launchMomentum, + this.character.movementSnapshot, this.lastLeapClientTimeMs, + // Aged against the instant the pose is from, not the send time: the difference is + // the un-simulated remainder, and it would land straight in the client's replay. + this.lastInputReceiptMs > 0 + ? Math.max(0, simulatedThroughMs - this.lastInputReceiptMs) + : 0, ), ); } } - // Every living player except this one, reported by absolute world position so - // the client can plot the whole circular arena on its minimap. private getMinimapPlayers(): Array { return this.playerContainer.players .filter((p) => p !== this && p.character?.isAlive) @@ -195,49 +267,66 @@ export class Player extends PlayerBase { ); } - private commandsToBeSent: Array = []; - public queueCommandSend(command: Command) { - this.commandsToBeSent.push(command); - } - - public stepCommunications(deltaTime: number) { - const remoteCalls = this.objectsPreviouslyInViewArea - .map((g) => new RemoteCallsForObject(g.id, g.getRemoteCalls())) - .filter((c) => c.calls.length > 0); - - if (remoteCalls.length > 0) { - this.queueCommandSend(new RemoteCallsForObjects(remoteCalls)); - } - - if ((this.timeSinceLastPing += deltaTime) > pingIntervalSeconds) { - this.timeSinceLastPing = 0; - this.lastPingSentMs = performance.now(); - this.socket.emit(TransportEvents.Ping); - } - - if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) { - this.handleAnnouncements(); - this.handleViewAreaUpdate(); - this.sendQueuedCommandsToClient(); - this.timeSinceLastMessage = 0; + private get bufferedBytes(): number { + const conn = this.socket.conn as unknown as + | { + transport?: { socket?: { bufferedAmount?: unknown } }; + writeBuffer?: Array<{ data?: unknown }>; + } + | undefined; + const buffered = conn?.transport?.socket?.bufferedAmount; + let bytes = typeof buffered === 'number' ? buffered : 0; + // Engine.IO queues packets here while the transport is waiting for its write callback. + for (const { data } of conn?.writeBuffer ?? []) { + if (typeof data === 'string') { + bytes += Buffer.byteLength(data); + } else if (ArrayBuffer.isView(data) || data instanceof ArrayBuffer) { + bytes += data.byteLength; + } } + return bytes; } public sendQueuedCommandsToClient() { + // Only state the next snapshot regenerates may be dropped; create/delete are one-shot. + if (this.bufferedBytes > settings.maxBufferedBytesPerClient) { + const hasReliableCommands = this.commandsToBeSent.some( + (c) => !sheddableWhenBackedUp.includes(c.constructor.name), + ); + this.commandsToBeSent = hasReliableCommands + ? this.commandsToBeSent.flatMap((c): Array => { + // Lifecycle events still need their snapshot time to enter the render timeline. + if (c.constructor.name === PropertyUpdatesForObjects.name) { + return [ + new PropertyUpdatesForObjects( + [], + (c as PropertyUpdatesForObjects).timestamp, + ), + ]; + } + return sheddableWhenBackedUp.includes(c.constructor.name) ? [] : [c]; + }) + : []; + } + if (this.commandsToBeSent.length === 0) { + return; + } + this.socket.emit(TransportEvents.ServerToPlayer, serialize(this.commandsToBeSent)); this.commandsToBeSent = []; } - private handleAnnouncements() { - let announcement = ''; + private queueAnnouncement() { if (this.winnerTeam) { - announcement = `Team ${this.winnerTeam} won 🎉`; + this.queueCommandSend( + new ServerAnnouncement( + `Team ${this.winnerTeam} won 🎉`, + ), + ); } else if (!this.character) { - announcement = `Reviving in ${Math.round(this.timeUntilRespawn)}…`; - } - - if (announcement) { - this.queueCommandSend(new ServerAnnouncement(announcement)); + this.queueCommandSend( + new ServerAnnouncement(`Reviving in ${Math.round(this.timeUntilRespawn)}…`), + ); } } } diff --git a/backend/tsconfig.json b/backend/tsconfig.json index e9e0884..1bd6a6c 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -10,8 +10,7 @@ "moduleResolution": "node", "ignoreDeprecations": "6.0", "module": "commonjs", - "lib": ["dom", "es2017"], - "typeRoots": ["./types", "./node_modules/@types"] + "lib": ["es2017"] }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "typings"] diff --git a/backend/types/socket.io-msgpack-parser/index.d.ts b/backend/types/socket.io-msgpack-parser/index.d.ts deleted file mode 100644 index 4065a1e..0000000 --- a/backend/types/socket.io-msgpack-parser/index.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module 'socket.io-msgpack-parser'; diff --git a/backend/webpack.config.js b/backend/webpack.config.js index 20e8a57..0d1a0e3 100644 --- a/backend/webpack.config.js +++ b/backend/webpack.config.js @@ -33,8 +33,7 @@ module.exports = (env, argv) => ({ minimizer: [ new TerserJSPlugin({ exclude: /node_modules/, - // The custom serialization protocol keys on class names, so they must - // survive minification (see shared/src/serialization). + // The serialization protocol keys on class names. terserOptions: { keep_classnames: true, }, @@ -43,10 +42,7 @@ module.exports = (env, argv) => ({ }, plugins: [ new webpack.BannerPlugin({ banner: '#!/usr/bin/env node', raw: true }), - new CleanWebpackPlugin({ - protectWebpackAssets: false, - cleanAfterEveryBuildPatterns: [], - }), + new CleanWebpackPlugin(), ], module: { rules: [ diff --git a/eslint.config.js b/eslint.config.js index 84a6892..20da74d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,4 +1,3 @@ -// Flat config (ESLint 9+/10). Ported from the former .eslintrc.json. const tseslint = require('typescript-eslint'); const prettierRecommended = require('eslint-plugin-prettier/recommended'); const unusedImports = require('eslint-plugin-unused-imports'); @@ -25,7 +24,6 @@ module.exports = tseslint.config( '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/no-empty-function': 'off', - // Renamed from the former no-var-requires; keep require() allowed. '@typescript-eslint/no-require-imports': 'off', }, }, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bfa9f49..6c253d6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,7 +23,7 @@ "resize-observer-polyfill": "^1.5.1", "sass": "^1.100.0", "sass-loader": "^17.0.0", - "sdf-2d": "file:../../sdf-2d", + "sdf-2d": "^0.8.0", "shared": "file:../shared", "socket.io-client": "^4.8.3", "socket.io-msgpack-parser": "^3.0.2", @@ -36,40 +36,6 @@ "webpack-dev-server": "^5.2.4" } }, - "../../sdf-2d": { - "version": "0.7.6", - "dev": true, - "license": "ISC", - "dependencies": { - "gl-matrix": "^3.4.4", - "resize-observer-polyfill": "^1.5.1" - }, - "devDependencies": { - "@eslint/js": "^10.0.1", - "@typescript-eslint/eslint-plugin": "^8.60.1", - "@typescript-eslint/parser": "^8.60.1", - "eslint": "^10.4.1", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-prettier": "^5.5.6", - "eslint-plugin-unused-imports": "^4.4.1", - "globals": "^17.6.0", - "prettier": "^3.8.3", - "raw-loader": "^4.0.2", - "terser-webpack-plugin": "^5.6.1", - "ts-loader": "^9.6.0", - "typedoc": "^0.28.19", - "typedoc-plugin-extras": "^4.0.1", - "typescript": "^6.0.3", - "webpack": "^5.107.2", - "webpack-cli": "^7.0.3" - } - }, - "../../sdf-2d/dist": { - "extraneous": true - }, - "../../sdf-2d/lib": { - "extraneous": true - }, "../shared": { "version": "0.0.0", "dev": true, @@ -5443,8 +5409,22 @@ } }, "node_modules/sdf-2d": { - "resolved": "../../sdf-2d", - "link": true + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/sdf-2d/-/sdf-2d-0.8.0.tgz", + "integrity": "sha512-rqM3RMm71ytlLmlzC9lwsUFOv9uvodgtgRRZrv69IV5nh9OlGKk25RJRe2sRLK3u3LDvt0h+4KwthQu7GVRyJg==", + "dev": true, + "license": "ISC", + "dependencies": { + "gl-matrix": "^3.4.4", + "resize-observer-polyfill": "^1.5.1" + } + }, + "node_modules/sdf-2d/node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "dev": true, + "license": "MIT" }, "node_modules/select-hose": { "version": "2.0.0", diff --git a/frontend/src/custom.d.ts b/frontend/src/custom.d.ts index f053a20..5c3fbac 100644 --- a/frontend/src/custom.d.ts +++ b/frontend/src/custom.d.ts @@ -3,13 +3,11 @@ declare module '*.mp3' { export default content; } -// Asset imports handled by webpack loaders. TypeScript 5.x+ requires ambient -// declarations for side-effect imports of these, so declare them here. +// TypeScript 5.x+ requires ambient declarations for side-effect asset imports. declare module '*.png'; declare module '*.ico'; declare module '*.svg'; declare module '*.scss'; -// webpack's `mode` inlines `process.env.NODE_ENV` via DefinePlugin. Declare just -// that one global so browser code can read it without pulling in all of @types/node. +// Declare just NODE_ENV (DefinePlugin inlines it) so browser code need not pull in all of @types/node. declare const process: { env: { NODE_ENV?: string } }; diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 00f04c0..4c7780f 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -13,20 +13,19 @@ import '../static/favicons/apple-touch-icon.png'; import '../static/favicons/favicon-16x16.png'; import '../static/favicons/favicon-32x32.png'; import '../static/favicons/favicon.ico'; +import ResizeObserver from 'resize-observer-polyfill'; import { LandingPageBackground } from './scripts/landing-page-background'; -import { JoinFormHandler } from './scripts/join-form-handler'; +import { JoinFormHandler, PlayerDecision } from './scripts/join-form-handler'; import { handleFullScreen } from './scripts/helper/handle-full-screen'; import { Game } from './scripts/game'; -import ResizeObserver from 'resize-observer-polyfill'; -import { OptionsHandler } from './scripts/options-handler'; -import { hide } from './scripts/helper/hide'; -import { show } from './scripts/helper/show'; +import { initializeOptions } from './scripts/options-handler'; import { SoundHandler, Sounds } from './scripts/sound-handler'; import { VibrationHandler } from './scripts/vibration-handler'; import { CharacterView } from './scripts/objects/types/character-view'; import { LampView } from './scripts/objects/types/lamp-view'; import { PlanetView } from './scripts/objects/types/planet-view'; import { ProjectileView } from './scripts/objects/types/projectile-view'; +import { loadStoredValue, saveStoredValue } from './scripts/helper/storage'; glMatrix.setMatrixArrayType(Array); @@ -35,23 +34,36 @@ overrideDeserialization(PlanetBase, PlanetView); overrideDeserialization(LampBase, LampView); overrideDeserialization(ProjectileBase, ProjectileView); -const landingUI = document.querySelector('#landing-ui') as HTMLElement; -const nameInput = document.querySelector('#name') as HTMLInputElement; -const joinGameForm = document.querySelector('#join-game-form') as HTMLFormElement; -const serverContainer = document.querySelector('#server-container') as HTMLElement; -const canvas = document.querySelector('canvas') as HTMLCanvasElement; -const overlay = document.querySelector('#overlay') as HTMLElement; -const settings = document.querySelector('#settings') as HTMLElement; -const toggleSettingsButton = document.querySelector( - '#toggle-settings-container', -) as HTMLElement; -const minimize = document.querySelector('#minimize') as HTMLElement; -const maximize = document.querySelector('#maximize') as HTMLElement; -const logoutButton = document.querySelector('#logout') as HTMLElement; -const enableSounds = document.querySelector('#enable-sounds') as HTMLInputElement; -const enableMusic = document.querySelector('#enable-music') as HTMLInputElement; -const enableVibration = document.querySelector('#enable-vibration') as HTMLInputElement; -const spinner = document.querySelector('#spinner-container') as HTMLElement; +const query = (selector: string) => + document.querySelector(selector) as T; + +const landingUI = query('#landing-ui'); +const nameInput = query('#name'); +const joinGameForm = query('#join-game-form'); +const serverContainer = query('#server-container'); +const canvas = query('canvas'); +const overlay = query('#overlay'); +const settings = query('#settings'); +const toggleSettingsButton = query('#toggle-settings-container'); +const minimize = query('#minimize'); +const maximize = query('#maximize'); +const logoutButton = query('#logout'); +const enableSounds = query('#enable-sounds'); +const enableMusic = query('#enable-music'); +const enableVibration = query('#enable-vibration'); +const spinner = query('#spinner-container'); + +const joinNotice = document.createElement('p'); +joinNotice.className = 'join-notice'; +joinNotice.style.display = 'none'; +joinGameForm.prepend(joinNotice); + +const setVisible = (e: HTMLElement, visible: boolean) => { + e.style.visibility = visible ? 'inherit' : 'hidden'; +}; +const setDisplayed = (e: HTMLElement, display: string | null) => { + e.style.display = display ?? 'none'; +}; const toggleSettings = () => { settings.className = settings.className === 'open' ? '' : 'open'; @@ -63,98 +75,105 @@ const applyServerContainerShadows = () => { const bottomShadow = 'inset 0 8px 8px -8px rgba(0, 0, 0, 0.4)'; const { scrollHeight, clientHeight, scrollTop } = serverContainer; - if (scrollHeight > clientHeight) { - if (scrollTop <= 0) { - serverContainer.style.boxShadow = topShadow; - } else if (scrollTop + clientHeight >= scrollHeight) { - serverContainer.style.boxShadow = bottomShadow; - } else { - serverContainer.style.boxShadow = topShadow + ',' + bottomShadow; - } - } else { + if (scrollHeight <= clientHeight) { serverContainer.style.boxShadow = ''; + } else if (scrollTop <= 0) { + serverContainer.style.boxShadow = topShadow; + } else if (scrollTop + clientHeight >= scrollHeight) { + serverContainer.style.boxShadow = bottomShadow; + } else { + serverContainer.style.boxShadow = topShadow + ',' + bottomShadow; } }; const main = async () => { - try { - let game: Game; + let game: Game | undefined; - const storedUserName = localStorage?.getItem('userName'); - if (storedUserName) { - nameInput.value = JSON.parse(storedUserName); - } + const storedUserName = loadStoredValue('userName'); + if (typeof storedUserName === 'string') { + nameInput.value = storedUserName; + } - const firstClickListener = () => { - SoundHandler.initialize( - () => { - enableMusic.checked = true; - enableMusic.dispatchEvent(new Event('change')); - }, - () => { - enableMusic.checked = false; - enableMusic.dispatchEvent(new Event('change')); - }, - ); - document.removeEventListener('click', firstClickListener); - }; - document.addEventListener('click', firstClickListener); + const firstClickListener = () => { + SoundHandler.initialize( + () => { + enableMusic.checked = true; + enableMusic.dispatchEvent(new Event('change')); + }, + () => { + enableMusic.checked = false; + enableMusic.dispatchEvent(new Event('change')); + }, + ); + document.removeEventListener('click', firstClickListener); + }; + document.addEventListener('click', firstClickListener); - if (!VibrationHandler.isVibrationEnabledHeuristics) { - hide(document.querySelector("label[for='enable-vibration']") as HTMLElement, true); - } + if (!VibrationHandler.isVibrationEnabledHeuristics) { + setDisplayed(query("label[for='enable-vibration']"), null); + } - handleFullScreen(minimize, maximize); - toggleSettingsButton.addEventListener('click', toggleSettings); + handleFullScreen(minimize, maximize); + toggleSettingsButton.addEventListener('click', toggleSettings); - new ResizeObserver(applyServerContainerShadows).observe(serverContainer); - serverContainer.addEventListener('scroll', applyServerContainerShadows); + new ResizeObserver(applyServerContainerShadows).observe(serverContainer); + serverContainer.addEventListener('scroll', applyServerContainerShadows); - OptionsHandler.initialize({ - soundsEnabled: enableSounds, - vibrationEnabled: enableVibration, - musicEnabled: enableMusic, - }); + initializeOptions({ + soundsEnabled: enableSounds, + vibrationEnabled: enableVibration, + musicEnabled: enableMusic, + }); - logoutButton.addEventListener('click', () => { - game.destroy(); - toggleSettings(); - }); - window.onpopstate = () => game.destroy(); + logoutButton.addEventListener('click', () => { + game?.destroy(); + toggleSettings(); + }); + window.onpopstate = () => game?.destroy(); - for (;;) { - show(spinner); - hide(logoutButton, true); - show(landingUI, true, 'flex'); + for (;;) { + setVisible(spinner, true); + setDisplayed(logoutButton, null); + setDisplayed(landingUI, 'flex'); - const background = new LandingPageBackground(canvas); - const joinHandler = new JoinFormHandler(joinGameForm, serverContainer); + const background = new LandingPageBackground(canvas); + const joinHandler = new JoinFormHandler(joinGameForm, serverContainer); + let playerDecision: PlayerDecision; + try { await background.renderer; - hide(spinner); - - const playerDecision = await joinHandler.getPlayerDecision(); - - localStorage?.setItem('userName', JSON.stringify(playerDecision.name)); - - if (!history.state) { - history.pushState(true, ''); - } - - hide(landingUI, true); - show(spinner); + setVisible(spinner, false); + playerDecision = await joinHandler.getPlayerDecision(); + } finally { background.destroy(); - game = new Game(playerDecision, canvas, overlay); - const gameOver = game.start(); - await game.started; - hide(spinner); - show(logoutButton, true, 'block'); - await gameOver; + joinHandler.destroy(); } - } catch (e) { - console.error(e); - alert(e); + + saveStoredValue('userName', playerDecision.name); + + if (!history.state) { + history.pushState(true, ''); + } + + setDisplayed(landingUI, null); + setVisible(spinner, true); + game = new Game(playerDecision, canvas, overlay); + const gameOver = game.start(); + await game.started; + setVisible(spinner, false); + setDisplayed(logoutButton, 'block'); + await gameOver; + + const reason = game.rejectionReason; + joinNotice.innerText = reason ? Game.rejectionText(reason) : ''; + setDisplayed(joinNotice, reason ? 'block' : null); } }; -main(); +main().catch((error) => { + console.error(error); + setVisible(spinner, false); + setDisplayed(landingUI, 'flex'); + joinNotice.innerText = 'Something went wrong. Please reload the page.'; + setDisplayed(joinNotice, 'block'); +}); diff --git a/frontend/src/main.scss b/frontend/src/main.scss index 3ab46d2..b9c6a92 100644 --- a/frontend/src/main.scss +++ b/frontend/src/main.scss @@ -127,7 +127,6 @@ body { .player-tag { border-radius: 1000px; - transition: transform 200ms; &.blue { color: $bright-blue; @@ -231,8 +230,6 @@ body { } } - // Top-down radar of the whole arena (see Minimap). The circular border is - // the world boundary; dots are painted onto the canvas. .minimap { top: $small-padding; left: $small-padding; @@ -247,16 +244,13 @@ body { border-radius: 1000px; background-color: rgba(0, 0, 0, 0.35); - box-shadow: - inset 0 0 3px 0 rgba(0, 0, 0, 0.4), - 0 0 10px rgba(0, 0, 0, 0.3); + box-shadow: inset 0 0 3px 0 rgba(0, 0, 0, 0.4), + 0 0 10px rgba(0, 0, 0, 0.3); border: $border-width solid rgba(255, 255, 255, 0.25); z-index: 100; } - // Off-screen pointer to the keystone "Heart", tinted by its current owner. .keystone-arrow { - transition: transform 150ms; opacity: 0.9; @include square($large-icon); @@ -333,8 +327,6 @@ body { content: '\25C9'; } - // Twin-stick aim indicator: drawn from the button centre toward the - // current drag direction while aiming a shot (see TouchListener). .aim-line { position: absolute; left: 50%; @@ -738,8 +730,6 @@ body { } } - // Centred "Eliminated" overlay shown while the local player is dead; the - // respawn countdown is the server-driven "Reviving in N…" announcement. .elimination { position: absolute; top: 42%; @@ -937,4 +927,29 @@ body { opacity: 1; transform: translate(-50%, -50%) scale(1); } -} \ No newline at end of file +} + +.connection-banner { + position: absolute; + top: 12px; + left: 50%; + transform: translateX(-50%); + padding: 6px 14px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.62); + color: #fff; + font-size: 0.9rem; + letter-spacing: 0.02em; + pointer-events: none; + z-index: 20; +} + +.join-notice { + margin: 0 0 0.75rem; + padding: 0.5rem 0.75rem; + border-radius: 4px; + background: rgba(209, 86, 82, 0.18); + color: #f2c9c7; + font-size: 0.85rem; + text-align: center; +} diff --git a/frontend/src/scripts/analytics.ts b/frontend/src/scripts/analytics.ts index e732cc4..76e258f 100644 --- a/frontend/src/scripts/analytics.ts +++ b/frontend/src/scripts/analytics.ts @@ -1,16 +1,9 @@ -import { init as plausibleInit } from '@plausible-analytics/tracker'; - -const ANALYTICS_AUTO_CAPTURE_PAGEVIEWS = true; -const ANALYTICS_DOMAIN = 'doppler.schmelczer.dev'; -const ANALYTICS_ENDPOINT = 'https://stats.schmelczer.dev/status'; -const ANALYTICS_LOGGING = process.env.NODE_ENV !== 'production'; +import { init } from '@plausible-analytics/tracker'; try { - plausibleInit({ - domain: ANALYTICS_DOMAIN, - endpoint: ANALYTICS_ENDPOINT, - autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS, - logging: ANALYTICS_LOGGING, + init({ + domain: 'doppler.schmelczer.dev', + endpoint: 'https://stats.schmelczer.dev/status', }); } catch (error) { console.warn('Could not initialize analytics.', error); diff --git a/frontend/src/scripts/charge-indicator.ts b/frontend/src/scripts/charge-indicator.ts index 239e33b..e068eb6 100644 --- a/frontend/src/scripts/charge-indicator.ts +++ b/frontend/src/scripts/charge-indicator.ts @@ -1,58 +1,58 @@ -import { holdDurationToCharge } from 'shared'; -import { Pointer } from './helper/pointer'; +import { chargeHeldSince } from 'shared'; +import { getDisplayPosition } from './helper/pointer'; -export abstract class ChargeIndicator { - private static element?: HTMLElement; - private static heldSince = 0; - private static raf = 0; - private static followPointer = false; +let element: HTMLElement | undefined; +let heldSince = 0; +let raf = 0; +let isFollowingPointer = false; - public static begin(x: number, y: number, followPointer = false) { - this.end(); +function beginChargeIndicator(x: number, y: number, followPointer = false) { + endChargeIndicator(); - const element = document.createElement('div'); - element.className = 'charge-ring'; - element.style.left = `${x}px`; - element.style.top = `${y}px`; - document.body.appendChild(element); + const el = document.createElement('div'); + el.className = 'charge-ring'; + el.style.left = `${x}px`; + el.style.top = `${y}px`; + document.body.appendChild(el); - this.element = element; - this.heldSince = performance.now(); - this.followPointer = followPointer; - this.raf = requestAnimationFrame(this.update); - } - - public static end() { - if (this.element) { - cancelAnimationFrame(this.raf); - this.element.parentElement?.removeChild(this.element); - this.element = undefined; - } - } - - private static update = () => { - const element = ChargeIndicator.element; - if (!element) { - return; - } - - const charge = holdDurationToCharge( - (performance.now() - ChargeIndicator.heldSince) / 1000, - ); - element.style.opacity = charge < 0.12 ? '0' : '1'; - element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${ - charge * 360 - }deg, rgba(255, 255, 255, 0.15) 0deg)`; - element.classList.toggle('full', charge >= 1); - - if (ChargeIndicator.followPointer) { - const cursor = Pointer.getDisplayPosition(); - if (cursor) { - element.style.left = `${cursor.x}px`; - element.style.top = `${cursor.y}px`; - } - } - - ChargeIndicator.raf = requestAnimationFrame(ChargeIndicator.update); - }; + element = el; + heldSince = performance.now(); + isFollowingPointer = followPointer; + raf = requestAnimationFrame(updateChargeIndicator); } + +function endChargeIndicator() { + if (element) { + cancelAnimationFrame(raf); + element.remove(); + element = undefined; + } +} + +function updateChargeIndicator() { + if (!element) { + return; + } + + const charge = chargeHeldSince(heldSince); + element.style.opacity = charge < 0.12 ? '0' : '1'; + element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${ + charge * 360 + }deg, rgba(255, 255, 255, 0.15) 0deg)`; + element.classList.toggle('full', charge >= 1); + + if (isFollowingPointer) { + const cursor = getDisplayPosition(); + if (cursor) { + element.style.left = `${cursor[0]}px`; + element.style.top = `${cursor[1]}px`; + } + } + + raf = requestAnimationFrame(updateChargeIndicator); +} + +export const ChargeIndicator = { + begin: beginChargeIndicator, + end: endChargeIndicator, +}; diff --git a/frontend/src/scripts/commands/command-socket.ts b/frontend/src/scripts/commands/command-socket.ts index 830b19b..aa94639 100644 --- a/frontend/src/scripts/commands/command-socket.ts +++ b/frontend/src/scripts/commands/command-socket.ts @@ -1,20 +1,50 @@ -import { Command, CommandReceiver, serialize, TransportEvents } from 'shared'; +import { + ClientHeartbeatCommand, + Command, + serialize, + settings, + TransportEvents, +} from 'shared'; import { Socket } from 'socket.io-client'; +import { clientTimeMs } from '../helper/prediction/local-character-predictor'; -export class CommandSocket extends CommandReceiver { - constructor(private readonly socket: Socket) { - super(); - } +const heartbeatIntervalMs = settings.clientSendInterval * 1000; +// At an exact frame-rate/interval ratio (60 Hz vs 30 Hz) float noise would otherwise drop every third beat. +const dueToleranceMs = 1; +export class CommandSocket { private commandQueue: Array = []; - protected defaultCommandExecutor(command: Command) { + private lastSendMs = -Infinity; + + constructor(private readonly socket: Socket) {} + + public queue(command: Command) { this.commandQueue.push(command); } + public reset() { + this.commandQueue = []; + this.lastSendMs = -Infinity; + } + public sendQueuedCommands() { - if (this.commandQueue.length > 0) { - this.socket.emit(TransportEvents.PlayerToServer, serialize(this.commandQueue)); + // socket.io-client would buffer emits made while disconnected and flush them before the re-join. + if (!this.socket.connected) { this.commandQueue = []; + return; } + + const nowMs = clientTimeMs(); + if ( + this.commandQueue.length === 0 && + nowMs - this.lastSendMs < heartbeatIntervalMs - dueToleranceMs + ) { + return; + } + this.lastSendMs = nowMs; + + this.commandQueue.push(new ClientHeartbeatCommand(nowMs)); + this.socket.emit(TransportEvents.PlayerToServer, serialize(this.commandQueue)); + this.commandQueue = []; } } diff --git a/frontend/src/scripts/commands/input-generator.ts b/frontend/src/scripts/commands/input-generator.ts new file mode 100644 index 0000000..39c7759 --- /dev/null +++ b/frontend/src/scripts/commands/input-generator.ts @@ -0,0 +1,29 @@ +import { vec2 } from 'gl-matrix'; +import { + Command, + LeapActionCommand, + MoveActionCommand, + PrimaryActionCommand, +} from 'shared'; +import { + clientTimeMs, + localCharacterPredictor, +} from '../helper/prediction/local-character-predictor'; + +export class InputGenerator { + constructor(protected readonly onCommand: (command: Command) => void) {} + + protected sendMove(direction: vec2) { + this.onCommand( + new MoveActionCommand(direction, localCharacterPredictor.recordInput(direction)), + ); + } + + protected sendLeap() { + this.onCommand(new LeapActionCommand(localCharacterPredictor.recordLeap())); + } + + protected sendPrimary(position: vec2, charge: number) { + this.onCommand(new PrimaryActionCommand(position, charge, clientTimeMs())); + } +} diff --git a/frontend/src/scripts/commands/keyboard-listener.ts b/frontend/src/scripts/commands/keyboard-listener.ts index 342d851..b6b0938 100644 --- a/frontend/src/scripts/commands/keyboard-listener.ts +++ b/frontend/src/scripts/commands/keyboard-listener.ts @@ -1,53 +1,71 @@ import { vec2 } from 'gl-matrix'; -import { CommandGenerator, LeapActionCommand, MoveActionCommand } from 'shared'; -import { localCharacterPredictor } from '../helper/prediction/local-character-predictor'; +import type { Command } from 'shared'; +import { InputGenerator } from './input-generator'; -export class KeyboardListener extends CommandGenerator { +const movementKeys = new Set([ + 'KeyW', + 'KeyA', + 'KeyS', + 'KeyD', + 'ArrowUp', + 'ArrowLeft', + 'ArrowDown', + 'ArrowRight', +]); +const leapKeys = new Set(['Space', 'ShiftLeft', 'ShiftRight']); + +export class KeyboardListener extends InputGenerator { private keysDown: Set = new Set(); - constructor() { - super(); - + constructor(onCommand: (command: Command) => void) { + super(onCommand); addEventListener('keydown', this.keyDownListener); addEventListener('keyup', this.keyUpListener); addEventListener('blur', this.blurListener); } private keyDownListener = (event: KeyboardEvent) => { - const key = event.key.toLowerCase(); - // Space leaps (W / ArrowUp already cover walking up). Edge-triggered so a - // held key's auto-repeat doesn't spam leaps. - if ((key === ' ' || key === 'shift') && !this.keysDown.has(key)) { - const clientTimeMs = localCharacterPredictor.recordLeap(); - this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs)); + const key = event.code; + if (!movementKeys.has(key) && !leapKeys.has(key)) { + return; + } + event.preventDefault(); + if (this.keysDown.has(key)) { + return; } this.keysDown.add(key); - this.generateCommands(); + if (leapKeys.has(key)) { + this.sendLeap(); + } else { + this.sendMovement(); + } }; private keyUpListener = (event: KeyboardEvent) => { - this.keysDown.delete(event.key.toLowerCase()); - this.generateCommands(); + if (this.keysDown.delete(event.code) && movementKeys.has(event.code)) { + this.sendMovement(); + } }; private blurListener = () => { this.keysDown.clear(); - this.generateCommands(); + this.sendMovement(); }; - private generateCommands() { - const up = ~~(this.keysDown.has('w') || this.keysDown.has('arrowup')); - const down = ~~(this.keysDown.has('s') || this.keysDown.has('arrowdown')); - const left = ~~(this.keysDown.has('a') || this.keysDown.has('arrowleft')); - const right = ~~(this.keysDown.has('d') || this.keysDown.has('arrowright')); - - const movement = vec2.fromValues(right - left, up - down); - if (vec2.squaredLength(movement) > 0) { - vec2.normalize(movement, movement); + public resendMovement() { + if ([...this.keysDown].some((key) => movementKeys.has(key))) { + this.sendMovement(); } + } - const clientTimeMs = localCharacterPredictor.recordInput(movement); - this.sendCommandToSubscribers(new MoveActionCommand(movement, clientTimeMs)); + private sendMovement() { + const down = (...codes: Array) => + codes.some((c) => this.keysDown.has(c)) ? 1 : 0; + const movement = vec2.fromValues( + down('KeyD', 'ArrowRight') - down('KeyA', 'ArrowLeft'), + down('KeyW', 'ArrowUp') - down('KeyS', 'ArrowDown'), + ); + this.sendMove(vec2.normalize(movement, movement)); } public destroy() { diff --git a/frontend/src/scripts/commands/mouse-listener.ts b/frontend/src/scripts/commands/mouse-listener.ts index c4bd30d..611c9e9 100644 --- a/frontend/src/scripts/commands/mouse-listener.ts +++ b/frontend/src/scripts/commands/mouse-listener.ts @@ -1,38 +1,35 @@ import { vec2 } from 'gl-matrix'; -import { CommandGenerator, PrimaryActionCommand, holdDurationToCharge } from 'shared'; -import { Game } from '../game'; +import { chargeHeldSince } from 'shared'; +import type { Command } from 'shared'; +import type { Game } from '../game'; import { ChargeIndicator } from '../charge-indicator'; -import { Pointer } from '../helper/pointer'; +import { setDisplayPosition } from '../helper/pointer'; +import { InputGenerator } from './input-generator'; -export class MouseListener extends CommandGenerator { - // Timestamp (ms) of the primary press, or null when not held. On release the - // held duration is mapped to the charge scalar; a quick tap reads as ~0. +export class MouseListener extends InputGenerator { private primaryDownAt: number | null = null; constructor( - private target: HTMLElement, + private readonly target: HTMLElement, private readonly game: Game, + onCommand: (command: Command) => void, ) { - super(); - + super(onCommand); target.addEventListener('mousedown', this.mouseDownListener); - target.addEventListener('mouseup', this.mouseUpListener); target.addEventListener('mousemove', this.mouseMoveListener); target.addEventListener('contextmenu', this.contextMenuListener); + window.addEventListener('mouseup', this.mouseUpListener); + window.addEventListener('blur', this.cancelPrimary); } - // Only the screen position is stored; it is reprojected to world space each - // frame so the gaze stays correct even while the camera pans under a still - // cursor. private mouseMoveListener = (event: MouseEvent) => { - Pointer.setDisplayPosition(event.clientX, event.clientY); + setDisplayPosition(vec2.fromValues(event.clientX, event.clientY)); }; private mouseDownListener = (event: MouseEvent) => { if (event.button === 0) { + this.mouseMoveListener(event); this.primaryDownAt = performance.now(); - // The ring follows the cursor and only fades in once this press has - // clearly become a hold. ChargeIndicator.begin(event.clientX, event.clientY, true); } }; @@ -41,31 +38,30 @@ export class MouseListener extends CommandGenerator { if (event.button !== 0 || this.primaryDownAt === null) { return; } - ChargeIndicator.end(); - const charge = holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000); + const charge = chargeHeldSince(this.primaryDownAt); this.primaryDownAt = null; - this.sendCommandToSubscribers( - new PrimaryActionCommand(this.positionFromEvent(event), charge), + this.sendPrimary( + this.game.displayToWorldCoordinates(vec2.fromValues(event.clientX, event.clientY)), + charge, ); }; - // Suppress the browser context menu on the canvas; right-click has no action. + private cancelPrimary = () => { + this.primaryDownAt = null; + ChargeIndicator.end(); + }; + private contextMenuListener = (event: MouseEvent) => { event.preventDefault(); }; - private positionFromEvent(event: MouseEvent): vec2 { - return this.game.displayToWorldCoordinates( - vec2.fromValues(event.clientX, event.clientY), - ); - } - public destroy() { ChargeIndicator.end(); this.target.removeEventListener('mousedown', this.mouseDownListener); - this.target.removeEventListener('mouseup', this.mouseUpListener); this.target.removeEventListener('mousemove', this.mouseMoveListener); this.target.removeEventListener('contextmenu', this.contextMenuListener); + window.removeEventListener('mouseup', this.mouseUpListener); + window.removeEventListener('blur', this.cancelPrimary); } } diff --git a/frontend/src/scripts/commands/touch-listener.ts b/frontend/src/scripts/commands/touch-listener.ts index ab91ce8..3af1895 100644 --- a/frontend/src/scripts/commands/touch-listener.ts +++ b/frontend/src/scripts/commands/touch-listener.ts @@ -1,254 +1,292 @@ import { vec2 } from 'gl-matrix'; -import { - CommandGenerator, - MoveActionCommand, - last, - PrimaryActionCommand, - LeapActionCommand, - holdDurationToCharge, - settings, -} from 'shared'; -import { Game } from '../game'; +import { chargeHeldSince, settings } from 'shared'; +import type { Command } from 'shared'; +import type { Game } from '../game'; import { ChargeIndicator } from '../charge-indicator'; -import { localCharacterPredictor } from '../helper/prediction/local-character-predictor'; +import { centeredTransform } from '../helper/centered-transform'; +import { InputGenerator } from './input-generator'; -export class TouchListener extends CommandGenerator { - private static readonly deadZone = 8; - private static readonly deltaScaling = 0.4; - // Min screen drag (px) from the fire button before a shot is aimed by the - // drag direction instead of firing straight ahead. - private static readonly aimDeadZone = 18; +const deadZone = 8; +const deltaScaling = 0.4; +const aimDeadZone = 18; +const joystickMaxLength = 20; + +export class TouchListener extends InputGenerator { + private readonly joystick = document.createElement('div'); + private readonly joystickButton = document.createElement('div'); + private readonly fireButton = document.createElement('div'); + private readonly fireStrengthRing = document.createElement('div'); + private readonly fireAimLine = document.createElement('div'); + private readonly leapButton = document.createElement('div'); - private joystick: HTMLElement; - private joystickButton: HTMLElement; private isJoystickActive = false; - private touchStartPosition!: vec2; + private touchStartPosition = vec2.create(); + private movement = vec2.create(); private primaryDownAt: number | null = null; - - private fireButton: HTMLElement; - private fireStrengthRing: HTMLElement; - private fireAimLine!: HTMLElement; - private leapButton: HTMLElement; + private gestureTouchId: number | null = null; private fireDownAt: number | null = null; - private fireButtonCenter: vec2 | null = null; - private fireAimScreen: vec2 | null = null; + private fireTouchId: number | null = null; + private fireButtonCenter = vec2.create(); constructor( - private target: HTMLElement, - private overlay: HTMLElement, + private readonly target: HTMLElement, + private readonly overlay: HTMLElement, private readonly game: Game, + onCommand: (command: Command) => void, ) { - super(); + super(onCommand); - this.joystick = document.createElement('div'); this.joystick.className = 'joystick'; - this.joystickButton = document.createElement('div'); this.joystick.appendChild(this.joystickButton); - this.fireButton = document.createElement('div'); this.fireButton.className = 'touch-button fire'; - this.fireStrengthRing = document.createElement('div'); this.fireStrengthRing.className = 'strength-ring'; - this.fireButton.appendChild(this.fireStrengthRing); - this.fireAimLine = document.createElement('div'); this.fireAimLine.className = 'aim-line'; - this.fireButton.appendChild(this.fireAimLine); - + this.fireButton.append(this.fireStrengthRing, this.fireAimLine); this.fireButton.addEventListener('touchstart', this.fireButtonDownListener); this.fireButton.addEventListener('touchmove', this.fireButtonMoveListener); this.fireButton.addEventListener('touchend', this.fireButtonUpListener); + this.fireButton.addEventListener('touchcancel', this.fireButtonCancelListener); - this.leapButton = document.createElement('div'); this.leapButton.className = 'touch-button leap'; this.leapButton.addEventListener('touchstart', this.leapButtonListener); - this.overlay.appendChild(this.fireButton); - this.overlay.appendChild(this.leapButton); - target.addEventListener('touchstart', this.touchStartListener); target.addEventListener('touchmove', this.touchMoveListener); target.addEventListener('touchend', this.touchEndListener); + target.addEventListener('touchcancel', this.touchCancelListener); + window.addEventListener('blur', this.cancelTouches); + } + + private findTouch(list: TouchList, id: number | null): Touch | undefined { + for (let i = 0; i < list.length; i++) { + if (list[i].identifier === id) { + return list[i]; + } + } + return undefined; } private touchStartListener = (event: TouchEvent) => { event.preventDefault(); + const touch = event.changedTouches[event.changedTouches.length - 1]; + if (!touch) { + return; + } if (this.isJoystickActive) { - const center = vec2.fromValues( - last(event.touches)!.clientX, - last(event.touches)!.clientY, - ); - this.sendCommandToSubscribers( - new PrimaryActionCommand(this.game.displayToWorldCoordinates(center)), - ); - } else { - this.touchStartPosition = vec2.fromValues( - event.touches[0].clientX, - event.touches[0].clientY, + this.sendPrimary( + this.game.displayToWorldCoordinates( + vec2.fromValues(touch.clientX, touch.clientY), + ), + 0, ); + } else if (this.gestureTouchId === null) { + this.gestureTouchId = touch.identifier; + vec2.set(this.touchStartPosition, touch.clientX, touch.clientY); this.primaryDownAt = performance.now(); - ChargeIndicator.begin(this.touchStartPosition.x, this.touchStartPosition.y); + ChargeIndicator.begin(touch.clientX, touch.clientY); } }; private touchMoveListener = (event: TouchEvent) => { event.preventDefault(); - const touchPosition = vec2.fromValues( - event.touches[0].clientX, - event.touches[0].clientY, - ); + const touch = this.findTouch(event.touches, this.gestureTouchId); + if (!touch) { + return; + } - const delta = vec2.subtract(vec2.create(), touchPosition, this.touchStartPosition); - vec2.scale(delta, delta, TouchListener.deltaScaling); + const delta = vec2.fromValues( + touch.clientX - this.touchStartPosition[0], + touch.clientY - this.touchStartPosition[1], + ); + vec2.scale(delta, delta, deltaScaling); const deltaLength = vec2.length(delta); - if (!this.isJoystickActive && deltaLength > TouchListener.deadZone) { + if (!this.isJoystickActive && deltaLength > deadZone) { this.isJoystickActive = true; this.primaryDownAt = null; ChargeIndicator.end(); this.overlay.appendChild(this.joystick); - this.joystickButton.style.transform = `translateX(-50%) translateY(-50%)`; - this.joystick.style.transform = `translateX(${this.touchStartPosition.x}px) translateY(${this.touchStartPosition.y}px) translateX(-50%) translateY(-50%)`; + this.joystick.style.transform = centeredTransform( + this.touchStartPosition[0], + this.touchStartPosition[1], + ); } - const maxLength = 20; - vec2.scale(delta, delta, Math.min(1, maxLength / deltaLength)); - this.joystickButton.style.transform = `translateX(${delta.x}px) translateY(${delta.y}px) translateX(-50%) translateY(-50%)`; + vec2.scale(delta, delta, Math.min(1, joystickMaxLength / deltaLength)); + this.joystickButton.style.transform = centeredTransform(delta[0], delta[1]); - vec2.set(delta, delta.x, -delta.y); - if (deltaLength > TouchListener.deadZone) { - const direction = vec2.normalize(delta, delta); - this.sendMove(direction); + if (deltaLength > deadZone) { + vec2.set(delta, delta[0], -delta[1]); + vec2.normalize(this.movement, delta); } else { - this.sendMove(vec2.create()); + vec2.zero(this.movement); } + this.sendMove(vec2.clone(this.movement)); }; - private sendMove(direction: vec2) { - const clientTimeMs = localCharacterPredictor.recordInput(direction); - this.sendCommandToSubscribers(new MoveActionCommand(direction, clientTimeMs)); - } - private touchEndListener = (event: TouchEvent) => { event.preventDefault(); - if (!this.isJoystickActive) { - ChargeIndicator.end(); - const charge = - this.primaryDownAt === null - ? 0 - : holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000); - this.primaryDownAt = null; - const center = vec2.fromValues( - event.changedTouches[0].clientX, - event.changedTouches[0].clientY, - ); - this.sendCommandToSubscribers( - new PrimaryActionCommand(this.game.displayToWorldCoordinates(center), charge), - ); - } else if (event.touches.length === 0) { - this.isJoystickActive = false; - this.joystick.parentElement?.removeChild(this.joystick); - this.sendMove(vec2.create()); + const touch = this.findTouch(event.changedTouches, this.gestureTouchId); + if (!touch) { + return; + } + this.gestureTouchId = null; + + if (this.isJoystickActive) { + this.releaseJoystick(); + return; + } + + ChargeIndicator.end(); + const charge = this.primaryDownAt === null ? 0 : chargeHeldSince(this.primaryDownAt); + this.primaryDownAt = null; + this.sendPrimary( + this.game.displayToWorldCoordinates(vec2.fromValues(touch.clientX, touch.clientY)), + charge, + ); + }; + + private touchCancelListener = (event: TouchEvent) => { + if (!this.findTouch(event.changedTouches, this.gestureTouchId)) { + return; + } + this.gestureTouchId = null; + this.primaryDownAt = null; + ChargeIndicator.end(); + if (this.isJoystickActive) { + this.releaseJoystick(); } }; - private swallowTouch = (event: TouchEvent) => { + private releaseJoystick() { + this.isJoystickActive = false; + this.joystick.remove(); + vec2.zero(this.movement); + this.sendMove(vec2.clone(this.movement)); + } + + public resendMovement() { + if (this.isJoystickActive) { + this.sendMove(vec2.clone(this.movement)); + } + } + + private cancelTouches = () => { + this.gestureTouchId = null; + this.primaryDownAt = null; + this.cancelFire(); + if (this.isJoystickActive) { + this.releaseJoystick(); + } + }; + + private cancelFire() { + this.fireTouchId = null; + this.fireDownAt = null; + this.fireAimLine.style.opacity = '0'; + ChargeIndicator.end(); + } + + private swallowTouch(event: TouchEvent) { event.preventDefault(); event.stopPropagation(); - }; + } private leapButtonListener = (event: TouchEvent) => { this.swallowTouch(event); - const clientTimeMs = localCharacterPredictor.recordLeap(); - this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs)); + this.sendLeap(); }; private fireButtonDownListener = (event: TouchEvent) => { this.swallowTouch(event); + const touch = event.changedTouches[0]; + if (this.fireTouchId !== null || !touch) { + return; + } + this.fireTouchId = touch.identifier; this.fireDownAt = performance.now(); const rect = this.fireButton.getBoundingClientRect(); - this.fireButtonCenter = vec2.fromValues( + vec2.set( + this.fireButtonCenter, rect.left + rect.width / 2, rect.top + rect.height / 2, ); - this.fireAimScreen = null; ChargeIndicator.begin(this.fireButtonCenter[0], this.fireButtonCenter[1]); }; - // Dragging from the fire button aims the shot: the drag vector sets the - // direction, decoupling aim from movement so a touch player can fire one way - // while walking another. A tap with no meaningful drag fires straight ahead. private fireButtonMoveListener = (event: TouchEvent) => { this.swallowTouch(event); - if (this.fireDownAt === null || !this.fireButtonCenter) { + const touch = this.findTouch(event.touches, this.fireTouchId); + if (this.fireDownAt === null || !touch) { return; } - const touch = event.targetTouches[0] ?? event.changedTouches[0]; - if (!touch) { - return; - } - this.fireAimScreen = vec2.fromValues(touch.clientX, touch.clientY); - const dx = this.fireAimScreen[0] - this.fireButtonCenter[0]; - const dy = this.fireAimScreen[1] - this.fireButtonCenter[1]; - if (dx * dx + dy * dy > TouchListener.aimDeadZone * TouchListener.aimDeadZone) { + const aim = this.aimFromTouch(touch); + if (aim) { this.fireAimLine.style.opacity = '1'; - this.fireAimLine.style.transform = `translateY(-50%) rotate(${Math.atan2(dy, dx)}rad)`; + this.fireAimLine.style.transform = `translateY(-50%) rotate(${Math.atan2(-aim[1], aim[0])}rad)`; } else { this.fireAimLine.style.opacity = '0'; } }; + private aimFromTouch(touch: Touch): vec2 | null { + const dx = touch.clientX - this.fireButtonCenter[0]; + const dy = touch.clientY - this.fireButtonCenter[1]; + if (dx * dx + dy * dy <= aimDeadZone * aimDeadZone) { + return null; + } + const aim = vec2.fromValues(dx, -dy); + return vec2.normalize(aim, aim); + } + private fireButtonUpListener = (event: TouchEvent) => { this.swallowTouch(event); - ChargeIndicator.end(); - this.fireAimLine.style.opacity = '0'; - if (this.fireDownAt === null) { + const touch = this.findTouch(event.changedTouches, this.fireTouchId); + if (this.fireDownAt === null || !touch) { return; } - const charge = holdDurationToCharge((performance.now() - this.fireDownAt) / 1000); - this.fireDownAt = null; + const charge = chargeHeldSince(this.fireDownAt); + const aim = this.aimFromTouch(touch); + this.cancelFire(); - const character = this.game.gameObjects.player; + const character = this.game.gameObjects.localPlayer; if (!character) { - this.fireButtonCenter = null; - this.fireAimScreen = null; return; } - // Screen drag → world aim direction (flip Y: screen +y is down). Below the - // dead-zone it's a tap, so fall back to firing along the facing direction. - let direction = character.facingDirection; - if (this.fireButtonCenter && this.fireAimScreen) { - const dx = this.fireAimScreen[0] - this.fireButtonCenter[0]; - const dy = this.fireAimScreen[1] - this.fireButtonCenter[1]; - if (dx * dx + dy * dy > TouchListener.aimDeadZone * TouchListener.aimDeadZone) { - direction = vec2.normalize(vec2.create(), vec2.fromValues(dx, -dy)); - } - } - this.fireButtonCenter = null; - this.fireAimScreen = null; - - const aim = vec2.scaleAndAdd( - vec2.create(), - character.bodyCenter, - direction, - settings.touchAimRange, + const direction = aim ?? character.facingDirection; + this.sendPrimary( + vec2.scaleAndAdd( + vec2.create(), + character.bodyCenter, + direction, + settings.touchAimRange, + ), + charge, ); - this.sendCommandToSubscribers(new PrimaryActionCommand(aim, charge)); }; - public update(_deltaTimeInSeconds: number) { - if (!this.fireButton.parentElement) { - this.overlay.appendChild(this.fireButton); + private fireButtonCancelListener = (event: TouchEvent) => { + this.swallowTouch(event); + if (this.findTouch(event.changedTouches, this.fireTouchId)) { + this.cancelFire(); } - if (!this.leapButton.parentElement) { - this.overlay.appendChild(this.leapButton); + }; + + public update() { + if (!this.fireButton.parentElement) { + this.overlay.append(this.fireButton, this.leapButton); + } + if (this.isJoystickActive && !this.joystick.parentElement) { + this.overlay.appendChild(this.joystick); } - const character = this.game.gameObjects.player; + const character = this.game.gameObjects.localPlayer; if (character) { this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${ character.strengthFraction * 360 @@ -261,13 +299,17 @@ export class TouchListener extends CommandGenerator { this.target.removeEventListener('touchstart', this.touchStartListener); this.target.removeEventListener('touchmove', this.touchMoveListener); this.target.removeEventListener('touchend', this.touchEndListener); + this.target.removeEventListener('touchcancel', this.touchCancelListener); + window.removeEventListener('blur', this.cancelTouches); this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener); this.fireButton.removeEventListener('touchmove', this.fireButtonMoveListener); this.fireButton.removeEventListener('touchend', this.fireButtonUpListener); + this.fireButton.removeEventListener('touchcancel', this.fireButtonCancelListener); this.leapButton.removeEventListener('touchstart', this.leapButtonListener); - this.fireButton.parentElement?.removeChild(this.fireButton); - this.leapButton.parentElement?.removeChild(this.leapButton); + this.fireButton.remove(); + this.leapButton.remove(); + this.joystick.remove(); } } diff --git a/frontend/src/scripts/commands/types/before-destroy.ts b/frontend/src/scripts/commands/types/before-destroy.ts deleted file mode 100644 index 3020623..0000000 --- a/frontend/src/scripts/commands/types/before-destroy.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Command } from 'shared'; - -export class BeforeDestroyCommand extends Command { - public constructor() { - super(); - } -} diff --git a/frontend/src/scripts/commands/types/render.ts b/frontend/src/scripts/commands/types/render.ts deleted file mode 100644 index c83f5a1..0000000 --- a/frontend/src/scripts/commands/types/render.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Renderer } from 'sdf-2d'; -import { Command } from 'shared'; - -export class RenderCommand extends Command { - public constructor( - public readonly renderer: Renderer, - public readonly overlay: HTMLElement, - public readonly shouldChangeLayout: boolean, - ) { - super(); - } -} diff --git a/frontend/src/scripts/commands/types/step.ts b/frontend/src/scripts/commands/types/step.ts deleted file mode 100644 index ff0d3cc..0000000 --- a/frontend/src/scripts/commands/types/step.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Command } from 'shared'; - -export class StepCommand extends Command { - public constructor(public readonly deltaTimeInSeconds: number) { - super(); - } -} diff --git a/frontend/src/scripts/configuration.ts b/frontend/src/scripts/configuration.ts index 9ab3fb6..d8d2dd0 100644 --- a/frontend/src/scripts/configuration.ts +++ b/frontend/src/scripts/configuration.ts @@ -1,35 +1,9 @@ -/** - * Hardcoded list of game servers the landing page offers to players. - * - * Each entry is the public origin of a dockerized `doppler-server` instance. - * The join screen polls `/state` (see `serverInformationEndpoint`) and - * only shows a server once it responds, so listing an offline origin here is - * harmless. Add or remove origins as you deploy more server containers. - */ -// This origin predates the rebrand; update it once the game server is -// redeployed under a doppler subdomain. const productionServers: Array = ['https://declared.schmelczer.dev']; -/** - * When the page is served from localhost (i.e. the webpack-dev-server), also - * offer the local backend so a `npm start` server can be joined during - * development. The backend's default port is 3000 (see backend/src/options.ts). - * In production the page is served from its own hostname, so this never leaks. - */ const isDevelopment = typeof location !== 'undefined' && (location.hostname === 'localhost' || location.hostname === '127.0.0.1'); -const servers: Array = isDevelopment +export const servers: Array = isDevelopment ? [`http://${location.hostname}:3000`, ...productionServers] : productionServers; - -export abstract class Configuration { - public static async initialize(): Promise { - // Kept async for call-site compatibility; the server list is static now. - } - - public static get servers(): Array { - return servers; - } -} diff --git a/frontend/src/scripts/feedback-hud.ts b/frontend/src/scripts/feedback-hud.ts index adbf068..4d59359 100644 --- a/frontend/src/scripts/feedback-hud.ts +++ b/frontend/src/scripts/feedback-hud.ts @@ -1,120 +1,128 @@ import { settings } from 'shared'; -import { Pointer } from './helper/pointer'; +import { getDisplayPosition } from './helper/pointer'; -export abstract class FeedbackHud { - private static root?: HTMLElement; - private static killfeed?: HTMLElement; - private static elimination?: HTMLElement; +let root: HTMLElement | undefined; +let killfeed: HTMLElement | undefined; +let elimination: HTMLElement | undefined; - private static ensureRoot(): { root: HTMLElement; killfeed: HTMLElement } { - if (!this.root || !this.killfeed) { - this.root = document.createElement('div'); - this.root.className = 'feedback-hud'; +function ensureRoot(): { root: HTMLElement; killfeed: HTMLElement } { + if (!root || !killfeed) { + root = document.createElement('div'); + root.className = 'feedback-hud'; - this.killfeed = document.createElement('div'); - this.killfeed.className = 'killfeed'; - this.root.appendChild(this.killfeed); + killfeed = document.createElement('div'); + killfeed.className = 'killfeed'; + root.appendChild(killfeed); - document.body.appendChild(this.root); - } - return { root: this.root, killfeed: this.killfeed }; + document.body.appendChild(root); } + return { root, killfeed }; +} - private static focusPoint(): { x: number; y: number } { - const cursor = Pointer.getDisplayPosition(); - if (cursor) { - return { x: cursor.x, y: cursor.y }; - } - return { x: window.innerWidth / 2, y: window.innerHeight / 2 }; +function focusPoint(): { x: number; y: number } { + const cursor = getDisplayPosition(); + if (cursor) { + return { x: cursor[0], y: cursor[1] }; } + return { x: window.innerWidth / 2, y: window.innerHeight / 2 }; +} - private static addTransient(element: HTMLElement, lifetimeMs: number) { - const { root } = this.ensureRoot(); - root.appendChild(element); - setTimeout(() => element.parentElement?.removeChild(element), lifetimeMs); - } +function addTransient(el: HTMLElement, lifetimeMs: number) { + const { root: r } = ensureRoot(); + r.appendChild(el); + setTimeout(() => el.remove(), lifetimeMs); +} - public static hitMarker(charge = 0) { - const { x, y } = this.focusPoint(); - const marker = document.createElement('div'); - marker.className = - 'hitmarker' + (charge >= settings.chargedHitThreshold ? ' charged' : ''); - marker.style.left = `${x}px`; - marker.style.top = `${y}px`; - this.addTransient(marker, 250); - } +function hitMarker(charge = 0) { + const { x, y } = focusPoint(); + const marker = document.createElement('div'); + marker.className = + 'hitmarker' + (charge >= settings.chargedHitThreshold ? ' charged' : ''); + marker.style.left = `${x}px`; + marker.style.top = `${y}px`; + addTransient(marker, 250); +} - public static killConfirmed(victimName?: string, streak = 1, charge = 0) { - const { killfeed } = this.ensureRoot(); - const charged = charge >= settings.chargedHitThreshold; +function killConfirmed(victimName?: string, streak = 1, charge = 0) { + const { killfeed: kf } = ensureRoot(); + const charged = charge >= settings.chargedHitThreshold; - // A quick crimson vignette pulse around the whole frame to punctuate the kill. - const flash = document.createElement('div'); - flash.className = 'kill-flash' + (charged ? ' charged' : ''); - this.addTransient(flash, 420); + const flash = document.createElement('div'); + flash.className = 'kill-flash' + (charged ? ' charged' : ''); + addTransient(flash, 420); - const entry = document.createElement('div'); - entry.className = 'kill-entry'; - entry.innerHTML = `Eliminated ${this.escape(victimName ?? 'enemy')}`; - killfeed.insertBefore(entry, killfeed.firstChild); - setTimeout(() => entry.parentElement?.removeChild(entry), 4500); + const entry = document.createElement('div'); + entry.className = 'kill-entry'; + entry.innerHTML = `Eliminated ${escapeHtml(victimName ?? 'enemy')}`; + kf.insertBefore(entry, kf.firstChild); + setTimeout(() => entry.remove(), 4500); - const { x, y } = this.focusPoint(); - const popup = document.createElement('div'); - popup.className = 'kill-popup' + (charged ? ' charged' : ''); - popup.innerHTML = `+${settings.playerKillPoint} +${settings.playerKillHealthReward}❤`; - popup.style.left = `${x}px`; - popup.style.top = `${y}px`; - this.addTransient(popup, 1200); + const { x, y } = focusPoint(); + const popup = document.createElement('div'); + popup.className = 'kill-popup' + (charged ? ' charged' : ''); + popup.innerHTML = `+${settings.playerKillPoint} +${settings.playerKillHealthReward}❤`; + popup.style.left = `${x}px`; + popup.style.top = `${y}px`; + addTransient(popup, 1200); - const callout = this.streakName(streak) ?? (charged ? 'Charged Kill!' : undefined); - if (callout) { - const el = document.createElement('div'); - el.className = 'streak-callout'; - el.innerText = callout; - this.addTransient(el, 1400); - } - } - - // Persistent centred overlay shown while the local player is dead and waiting - // to respawn. The countdown itself is the server-driven "Reviving in N…" - // announcement; this makes the death state unmistakable and stays up until - // hideElimination() is called on respawn. - public static showElimination(): void { - if (this.elimination) { - return; - } - const { root } = this.ensureRoot(); + const callout = streakName(streak) ?? (charged ? 'Charged Kill!' : undefined); + if (callout) { const el = document.createElement('div'); - el.className = 'elimination'; - el.innerHTML = - '
Eliminated
' + - '
Respawning…
'; - root.appendChild(el); - this.elimination = el; - } - - public static hideElimination(): void { - this.elimination?.parentElement?.removeChild(this.elimination); - this.elimination = undefined; - } - - private static streakName(streak: number): string | undefined { - switch (streak) { - case 2: - return 'Double Kill!'; - case 3: - return 'Triple Kill!'; - case 4: - return 'Quad Kill!'; - default: - return streak >= 5 ? 'Rampage!' : undefined; - } - } - - private static escape(text: string): string { - const div = document.createElement('div'); - div.innerText = text; - return div.innerHTML; + el.className = 'streak-callout'; + el.innerText = callout; + addTransient(el, 1400); } } + +function showElimination(): void { + if (elimination) { + return; + } + const { root: r } = ensureRoot(); + const el = document.createElement('div'); + el.className = 'elimination'; + el.innerHTML = + '
Eliminated
' + + '
Respawning…
'; + r.appendChild(el); + elimination = el; +} + +function hideElimination(): void { + elimination?.remove(); + elimination = undefined; +} + +function resetFeedbackHud(): void { + hideElimination(); + root?.remove(); + root = undefined; + killfeed = undefined; +} + +function streakName(streak: number): string | undefined { + switch (streak) { + case 2: + return 'Double Kill!'; + case 3: + return 'Triple Kill!'; + case 4: + return 'Quad Kill!'; + default: + return streak >= 5 ? 'Rampage!' : undefined; + } +} + +function escapeHtml(text: string): string { + const div = document.createElement('div'); + div.innerText = text; + return div.innerHTML; +} + +export const FeedbackHud = { + hitMarker, + killConfirmed, + showElimination, + hideElimination, + reset: resetFeedbackHud, +}; diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index 4b8678f..e76fd80 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -21,6 +21,7 @@ import { Command, settings, InputAcknowledgement, + JoinRejectionReason, } from 'shared'; import { io, Socket } from 'socket.io-client'; import { KeyboardListener } from './commands/keyboard-listener'; @@ -32,34 +33,67 @@ import { GameObjectContainer } from './objects/game-object-container'; import parser from 'socket.io-msgpack-parser'; import { CharacterShape } from './shapes/character-shape'; import { PlanetShape } from './shapes/planet-shape'; -import { RenderCommand } from './commands/types/render'; -import { StepCommand } from './commands/types/step'; import { serverTimeline } from './helper/server-timeline'; -import { localCharacterPredictor } from './helper/prediction/local-character-predictor'; +import { centeredTransform } from './helper/centered-transform'; +import { + localCharacterPredictor, + setFrameTimeMs, +} from './helper/prediction/local-character-predictor'; import { Tutorial } from './tutorial'; import { Scoreboard } from './scoreboard'; import { Minimap } from './minimap'; import { ScreenShake } from './screen-shake'; +import { FeedbackHud } from './feedback-hud'; + +const maximumReconnectionAttempts = 10; export class Game extends CommandReceiver { public gameObjects = new GameObjectContainer(this); public renderer?: Renderer; + public rejectionReason?: JoinRejectionReason; + public readonly started: Promise; + private socket!: Socket; - private isBetweenGames = false; - - public started: Promise; - private resolveStarted!: () => unknown; - - private keyboardListener: KeyboardListener; - private mouseListener: MouseListener; - private touchListener: TouchListener; - - private scoreboard = new Scoreboard(); - private minimap = new Minimap(); - private announcementText = document.createElement('h2'); - private keystoneArrow?: HTMLElement; private socketReceiver!: CommandSocket; private tutorial!: Tutorial; + private resolveStarted!: () => void; + private isBetweenGames = false; + private isActive = true; + + private readonly keyboardListener: KeyboardListener; + private readonly mouseListener: MouseListener; + private readonly touchListener: TouchListener; + + private readonly scoreboard = new Scoreboard(); + private readonly minimap = new Minimap(); + private readonly announcementText = document.createElement('h2'); + private keystoneArrow?: HTMLElement; + private connectionBanner?: HTMLElement; + private reconnectToken?: string; + private lastAspectRatio?: number; + private lastGameState?: UpdateGameState; + private lastMinimap?: UpdateMinimap; + private lastAnnouncementText = ''; + private timeSinceLastAnnouncement = 0; + private framesSinceLastLayoutUpdate = 0; + + protected commandExecutors: CommandExecutors = { + [ServerAnnouncement.name]: (c: ServerAnnouncement) => { + this.lastAnnouncementText = c.text; + this.timeSinceLastAnnouncement = 0; + }, + [UpdateGameState.name]: (c: UpdateGameState) => (this.lastGameState = c), + [InputAcknowledgement.name]: (c: InputAcknowledgement) => + localCharacterPredictor.acknowledge( + c.clientTimeMs, + c.movement, + c.lastLeapClientTimeMs, + c.ackAgeMs, + ), + [GameEndCommand.name]: () => (localCharacterPredictor.enabled = false), + [UpdateMinimap.name]: (c: UpdateMinimap) => (this.lastMinimap = c), + [GameStartCommand.name]: () => this.initialize(), + }; constructor( private readonly playerDecision: PlayerDecision, @@ -70,9 +104,17 @@ export class Game extends CommandReceiver { this.started = new Promise((r) => (this.resolveStarted = r)); this.announcementText.className = 'announcement'; - this.keyboardListener = new KeyboardListener(); - this.mouseListener = new MouseListener(this.canvas, this); - this.touchListener = new TouchListener(this.canvas, this.overlay, this); + const onInput = (c: Command) => { + this.socketReceiver.queue(c); + this.tutorial.handleCommand(c); + }; + this.keyboardListener = new KeyboardListener(onInput); + this.mouseListener = new MouseListener(this.canvas, this, onInput); + this.touchListener = new TouchListener(this.canvas, this.overlay, this, onInput); + } + + protected defaultCommandExecutor(c: Command) { + this.gameObjects.handleCommand(c); } private initialize() { @@ -81,136 +123,188 @@ export class Game extends CommandReceiver { this.socket?.close(); serverTimeline.reset(); localCharacterPredictor.reset(); - // Clear any leftover shake/zoom so a kill at the end of one match can't bleed - // its camera impact into the next. + localCharacterPredictor.enabled = true; ScreenShake.reset(); + this.gameObjects.reset(); this.gameObjects = new GameObjectContainer(this); this.overlay.innerHTML = ''; this.keystoneArrow = undefined; + this.connectionBanner = undefined; this.lastMinimap = undefined; - this.isEnding = false; this.lastAnnouncementText = ''; - this.overlay.appendChild(this.scoreboard.element); - this.overlay.appendChild(this.minimap.element); this.announcementText.innerText = ''; - this.timeScaling = 1; - this.overlay.appendChild(this.announcementText); + this.overlay.append( + this.scoreboard.element, + this.minimap.element, + this.announcementText, + ); this.tutorial = new Tutorial(this.overlay); this.socket = io(this.playerDecision.server, { reconnectionDelayMax: 10000, + // Must be finite, otherwise `reconnect_failed` never fires. + reconnectionAttempts: maximumReconnectionAttempts, transports: ['websocket'], forceNew: true, parser, } as any); + this.socketReceiver = new CommandSocket(this.socket); - // In socket.io-client v4 reconnection events are emitted by the Manager - // (`socket.io`), not the Socket itself. this.socket.io.on('reconnect_attempt', () => { this.socket.io.opts.transports = ['polling', 'websocket']; }); this.socket.on('disconnect', () => { if (!this.isBetweenGames) { - this.destroy(); + this.showConnectionBanner('Reconnecting…'); } }); - this.socket.on(TransportEvents.Ping, () => { - this.socket.emit(TransportEvents.Pong); + // Fires for every (re)connection: a reconnect is a brand-new server-side player. + this.socket.on('connect', () => { + if (this.isBetweenGames) { + return; + } + this.hideConnectionBanner(); + serverTimeline.reset(); + localCharacterPredictor.reset(); + this.gameObjects.reset(); + this.socketReceiver.reset(); + this.socket.emit(TransportEvents.PlayerJoining, { + ...this.playerDecision, + reconnectToken: this.reconnectToken, + }); + if (this.lastAspectRatio !== undefined) { + this.socketReceiver.queue(new SetAspectRatioActionCommand(this.lastAspectRatio)); + } + }); + + this.socket.io.on('reconnect_failed', () => { + this.showConnectionBanner('Connection lost'); + this.destroy(); + }); + + this.socket.on(TransportEvents.PlayerJoined, (token: string) => { + this.reconnectToken = typeof token === 'string' ? token : undefined; + this.hideConnectionBanner(); + }); + + this.socket.on(TransportEvents.JoinRejected, (reason: JoinRejectionReason) => { + this.rejectionReason = reason; + this.showConnectionBanner(Game.rejectionText(reason)); + this.destroy(); + }); + + this.socket.on(TransportEvents.Ping, (nonce: unknown) => { + this.socket.emit(TransportEvents.Pong, nonce); }); this.socket.on(TransportEvents.ServerToPlayer, (serializedCommands: string) => { - const commands: Array = deserialize(serializedCommands); - commands.forEach((c) => this.handleCommand(c)); + try { + const commands: Array = deserialize(serializedCommands); + commands.forEach((c) => { + try { + this.handleCommand(c); + } catch (e) { + console.error('Failed to apply a server command', e); + } + }); + } catch (e) { + console.error('Dropped an undecodable server message', e); + } }); - this.socketReceiver = new CommandSocket(this.socket); - // The tutorial listens to the same input streams as the socket, so its - // stages clear off the player's own commands without any server involvement. - this.keyboardListener.clearSubscribers(); - this.keyboardListener.subscribe(this.socketReceiver); - this.keyboardListener.subscribe(this.tutorial); - this.mouseListener.clearSubscribers(); - this.mouseListener.subscribe(this.socketReceiver); - this.mouseListener.subscribe(this.tutorial); - this.touchListener.clearSubscribers(); - this.touchListener.subscribe(this.socketReceiver); - this.touchListener.subscribe(this.tutorial); - this.isBetweenGames = false; - - this.socket.emit(TransportEvents.PlayerJoining, this.playerDecision); } - protected defaultCommandExecutor(c: Command) { - this.gameObjects.handleCommand(c); - } - - private lastGameState?: UpdateGameState; - private isEnding = false; - private timeScaling = 1; - - private lastAnnouncementText = ''; - protected commandExecutors: CommandExecutors = { - [ServerAnnouncement.type]: (c: ServerAnnouncement) => { - this.lastAnnouncementText = c.text; - this.timeSinceLastAnnouncement = 0; - }, - [UpdateGameState.type]: (c: UpdateGameState) => (this.lastGameState = c), - [InputAcknowledgement.type]: (c: InputAcknowledgement) => - localCharacterPredictor.acknowledge( - c.clientTimeMs, - c.bodyVelocity, - c.lastLeapClientTimeMs, - ), - [GameEndCommand.type]: () => (this.isEnding = true), - [UpdateMinimap.type]: (c: UpdateMinimap) => (this.lastMinimap = c), - [GameStartCommand.type]: this.initialize.bind(this), - }; - - private lastMinimap?: UpdateMinimap; - public async start(): Promise { - const noiseTexture = await renderNoise([256, 256], 2, 1); + setFrameTimeMs(0); + try { + this.initialize(); + this.resolveStarted(); + let renderError: unknown; + const noiseTexture = await renderNoise([256, 256], 2, 1); - this.initialize(); - - await runAnimation( - this.canvas, - [ - PlanetShape.descriptor, - CharacterShape.descriptor, - { - ...CircleLight.descriptor, - shaderCombinationSteps: [0, 1, 2, 4, 8, 16], + await runAnimation( + this.canvas, + [ + PlanetShape.descriptor, + CharacterShape.descriptor, + { + ...CircleLight.descriptor, + shaderCombinationSteps: [0, 1, 2, 4, 8, 16], + }, + ], + (renderer, currentTime, deltaTime) => { + try { + return this.gameLoop(renderer, currentTime, deltaTime); + } catch (error) { + renderError = error; + return false; + } }, - ], - this.gameLoop.bind(this), - { - shadowTraceCount: 16, - paletteSize: settings.paletteDim.length, - colorPalette: settings.paletteDim, - enableHighDpiRendering: true, - lightCutoffDistance: settings.lightCutoffDistance, - lightOverlapReduction: settings.lightOverlapReduction, - textures: { - noiseTexture: { - source: noiseTexture, - overrides: { - maxFilter: FilteringOptions.LINEAR, - wrapS: WrapOptions.MIRRORED_REPEAT, - wrapT: WrapOptions.MIRRORED_REPEAT, + { + shadowTraceCount: 16, + paletteSize: settings.paletteDim.length, + colorPalette: settings.paletteDim, + enableHighDpiRendering: true, + lightCutoffDistance: settings.lightCutoffDistance, + lightOverlapReduction: settings.lightOverlapReduction, + textures: { + noiseTexture: { + source: noiseTexture, + overrides: { + maxFilter: FilteringOptions.LINEAR, + wrapS: WrapOptions.MIRRORED_REPEAT, + wrapT: WrapOptions.MIRRORED_REPEAT, + }, }, }, }, - }, - ); - this.socket.close(); - this.overlay.innerHTML = ''; - this.keyboardListener.destroy(); - this.mouseListener.destroy(); - this.touchListener.destroy(); + ); + if (renderError) { + throw renderError; + } + } finally { + this.resolveStarted(); + this.socket?.close(); + this.gameObjects.reset(); + this.overlay.innerHTML = ''; + this.hideConnectionBanner(); + FeedbackHud.reset(); + this.keyboardListener.destroy(); + this.mouseListener.destroy(); + this.touchListener.destroy(); + } + } + + public static rejectionText(reason: JoinRejectionReason): string { + switch (reason) { + case JoinRejectionReason.ServerFull: + return 'That server is full — pick another'; + case JoinRejectionReason.RoundEnding: + return 'That round is just finishing — try again in a moment'; + case JoinRejectionReason.AlreadyJoined: + return 'Already joined on this connection'; + default: + return 'The server refused the connection'; + } + } + + private showConnectionBanner(text: string) { + if (!this.connectionBanner) { + this.connectionBanner = document.createElement('div'); + this.connectionBanner.className = 'connection-banner'; + this.overlay.appendChild(this.connectionBanner); + } + this.connectionBanner.innerText = text; + this.connectionBanner.style.display = 'block'; + } + + private hideConnectionBanner() { + if (this.connectionBanner) { + this.connectionBanner.style.display = 'none'; + } } public displayToWorldCoordinates(p: vec2): vec2 { @@ -218,37 +312,35 @@ export class Game extends CommandReceiver { } public aspectRatioChanged(aspectRatio: number) { - this.socketReceiver.handleCommand(new SetAspectRatioActionCommand(aspectRatio)); + this.lastAspectRatio = aspectRatio; + this.socketReceiver.queue(new SetAspectRatioActionCommand(aspectRatio)); } - private isActive = true; public destroy() { this.isActive = false; } - private timeSinceLastAnnouncement = 0; - private framesSinceLastLayoutUpdate = 0; + public resendMovement() { + this.keyboardListener.resendMovement(); + this.touchListener.resendMovement(); + } + private gameLoop( renderer: Renderer, - _: DOMHighResTimeStamp, + currentTime: DOMHighResTimeStamp, deltaTime: DOMHighResTimeStamp, ): boolean { - this.resolveStarted(); + this.renderer = renderer; + // The frame timestamp, not performance.now(): dispatch jitter would land in the replay window. + setFrameTimeMs(currentTime); deltaTime /= 1000; - // Decay the camera impact effects on raw wall-clock time, before any of the - // end-game slow-motion scaling below. These only adjust the rendered view, - // never the simulation, so they stay decoupled from prediction and netcode. + // Both run on wall-clock time: the end-game slow motion is already baked into the snapshots. ScreenShake.step(deltaTime); - - // Stepped before the end-game time scaling on purpose: the slow motion is - // already baked into the snapshots the server sends, so the playback - // cursor itself must keep running on wall-clock time. serverTimeline.step(deltaTime); - let shouldChangeLayout = false; - if (++this.framesSinceLastLayoutUpdate > 1) { - shouldChangeLayout = true; + const shouldChangeLayout = ++this.framesSinceLastLayoutUpdate > 1; + if (shouldChangeLayout) { this.framesSinceLastLayoutUpdate = 0; this.draw(); } @@ -259,51 +351,38 @@ export class Game extends CommandReceiver { this.lastAnnouncementText = ''; } - if (this.isEnding) { - this.timeScaling *= Math.pow(settings.endGameDeltaScaling, deltaTime); - deltaTime /= this.timeScaling; - } - - this.renderer = renderer; - - this.gameObjects.handleCommand(new StepCommand(deltaTime)); - this.gameObjects.handleCommand( - new RenderCommand(this.renderer, this.overlay, shouldChangeLayout), - ); - - this.touchListener.update(deltaTime); - + this.gameObjects.step(deltaTime); + this.gameObjects.render(renderer, this.overlay, shouldChangeLayout); + this.touchListener.update(); this.tutorial.step(this.gameObjects); - this.socketReceiver.sendQueuedCommands(); return this.isActive; } private draw() { + // Before any style writes this frame, so the read cannot force a synchronous layout. + this.minimap.measure(); + if (this.lastGameState) { - // The local player's team is read off the main character once it exists. - this.scoreboard.update(this.lastGameState, this.gameObjects.player?.team); + this.scoreboard.update(this.lastGameState, this.gameObjects.localPlayer?.team); } this.minimap.update( - this.gameObjects.localPlayerPosition, + this.gameObjects.localPlayer?.position, this.lastMinimap?.players ?? [], ); this.handleKeystoneArrow(); - this.announcementText.innerHTML = this.lastAnnouncementText; + if (this.announcementText.innerHTML !== this.lastAnnouncementText) { + this.announcementText.innerHTML = this.lastAnnouncementText; + } } - // Points an off-screen chevron toward the keystone "Heart" planet, tinted by - // who currently holds it, so the match's focal objective is always findable. private handleKeystoneArrow() { - if (!this.renderer) { - return; - } const keystone = this.gameObjects.planets.find((p) => p.isKeystone); - if (!keystone) { + if (!this.renderer || !keystone) { if (this.keystoneArrow) { this.keystoneArrow.style.display = 'none'; } @@ -315,47 +394,38 @@ export class Game extends CommandReceiver { this.overlay.appendChild(this.keystoneArrow); } - const width = this.renderer.canvasSize.x; - const height = this.renderer.canvasSize.y; + const [width, height] = this.renderer.canvasSize; const display = this.renderer.worldToDisplayCoordinates(keystone.center); const margin = 48; const onScreen = - display.x >= margin && - display.x <= width - margin && - display.y >= margin && - display.y <= height - margin; - - const control = Math.abs(keystone.ownership - 0.5); - const team = - control < settings.planetControlThreshold - ? 'neutral' - : keystone.ownership < 0.5 - ? 'blue' - : 'red'; - this.keystoneArrow.className = 'keystone-arrow ' + team; + display[0] >= margin && + display[0] <= width - margin && + display[1] >= margin && + display[1] <= height - margin; + this.keystoneArrow.className = 'keystone-arrow ' + keystone.team; + this.keystoneArrow.style.display = onScreen ? 'none' : 'block'; if (onScreen) { - this.keystoneArrow.style.display = 'none'; return; } - this.keystoneArrow.style.display = 'block'; - const center = vec2.fromValues(width / 2, height / 2); - const dir = vec2.fromValues(display.x - center.x, display.y - center.y); - const angle = Math.atan2(dir.y, dir.x); + const dx = display[0] - width / 2; + const dy = display[1] - height / 2; + const angle = Math.atan2(dy, dx); - const aspectRatio = width / height; - const directionRatio = dir.x / dir.y; let deltaX: number, deltaY: number; - if (aspectRatio < Math.abs(directionRatio)) { - deltaX = (width / 2 - margin) * Math.sign(dir.x); - deltaY = deltaX / directionRatio; + if (width / height < Math.abs(dx / dy)) { + deltaX = (width / 2 - margin) * Math.sign(dx); + deltaY = (deltaX * dy) / dx; } else { - deltaY = (height / 2 - margin) * Math.sign(dir.y); - deltaX = deltaY * directionRatio; + deltaY = (height / 2 - margin) * Math.sign(dy); + deltaX = (deltaY * dx) / dy; } - const p = vec2.add(center, center, vec2.fromValues(deltaX, deltaY)); - this.keystoneArrow.style.transform = `translateX(${p.x}px) translateY(${p.y}px) translateX(-50%) translateY(-50%) rotate(${angle + Math.PI / 2}rad)`; + this.keystoneArrow.style.transform = centeredTransform( + width / 2 + deltaX, + height / 2 + deltaY, + ` rotate(${angle + Math.PI / 2}rad)`, + ); } } diff --git a/frontend/src/scripts/helper/centered-transform.ts b/frontend/src/scripts/helper/centered-transform.ts new file mode 100644 index 0000000..9496fc2 --- /dev/null +++ b/frontend/src/scripts/helper/centered-transform.ts @@ -0,0 +1,2 @@ +export const centeredTransform = (x: number, y: number, extra = ''): string => + `translateX(${x}px) translateY(${y}px) translateX(-50%) translateY(-50%)${extra}`; diff --git a/frontend/src/scripts/helper/handle-full-screen.ts b/frontend/src/scripts/helper/handle-full-screen.ts index c5b414c..e0ab2b4 100644 --- a/frontend/src/scripts/helper/handle-full-screen.ts +++ b/frontend/src/scripts/helper/handle-full-screen.ts @@ -18,32 +18,25 @@ export const handleFullScreen = ( }; showButtons(); - - let currentWindowHeight = innerHeight; - - const followToggle = () => { - SoundHandler.play(Sounds.click); - showButtons(); - currentWindowHeight = innerHeight; - }; + document.addEventListener('fullscreenchange', showButtons); const triggerToggle = async () => { - await (isInFullScreen() - ? document.exitFullscreen() - : document.body.requestFullscreen()); - followToggle(); + try { + await (isInFullScreen() + ? document.exitFullscreen() + : document.body.requestFullscreen()); + SoundHandler.play(Sounds.click); + } catch { + // The browser may deny fullscreen (for example, when embedded). + } }; addEventListener('keydown', (e) => { if (e.key === 'F11') { - triggerToggle(); e.preventDefault(); - } - }); - - addEventListener('resize', () => { - if (isInFullScreen() && currentWindowHeight > innerHeight) { - followToggle(); + if (!e.repeat) { + void triggerToggle(); + } } }); diff --git a/frontend/src/scripts/helper/hide.ts b/frontend/src/scripts/helper/hide.ts deleted file mode 100644 index 8283913..0000000 --- a/frontend/src/scripts/helper/hide.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const hide = (e: HTMLElement, useDisplay = false) => { - if (useDisplay) { - e.style.display = 'none'; - } else { - e.style.visibility = 'hidden'; - } -}; diff --git a/frontend/src/scripts/helper/interpolators/linear-interpolator.ts b/frontend/src/scripts/helper/interpolators/linear-interpolator.ts index bcc23b5..6a43cd3 100644 --- a/frontend/src/scripts/helper/interpolators/linear-interpolator.ts +++ b/frontend/src/scripts/helper/interpolators/linear-interpolator.ts @@ -1,4 +1,4 @@ -import { last, mix } from 'shared'; +import { mix } from 'shared'; import { serverTimeline } from '../server-timeline'; interface Frame { @@ -7,26 +7,12 @@ interface Frame { velocity: number; } -// How far past the newest snapshot the value may coast on its last known -// velocity (network hiccup) before freezing in place. const maxCoastSeconds = 0.06; -// When fresh snapshots arrive after a coast, they usually disagree with where -// the coast ended up; the difference decays away with this time constant -// instead of being shown as a jump. const blendSeconds = 0.12; -// At 25 snapshots per second this spans over a second of state, far more than -// rendering ever needs; it only bounds memory while the tab is hidden and -// snapshots keep arriving without being consumed. const maxFrames = 32; -/** - * Replays a server-streamed scalar by interpolating between timestamped - * snapshots at the shared timeline's render time, which trails the newest - * snapshot. The streamed rate of change is only used to coast briefly when - * the buffer runs dry. - */ export class LinearInterpolator { private frames: Array = []; private blendOffset = 0; @@ -39,10 +25,13 @@ export class LinearInterpolator { public addFrame(value: number, rateOfChange: number) { const time = serverTimeline.snapshotTime; - const newest = last(this.frames); + const newest = this.frames.length > 0 ? this.frames[this.frames.length - 1] : null; const wasCoasting = this.isCoasting; - if (newest && time <= newest.time) { + if (newest && time < newest.time) { + return; + } + if (newest && time === newest.time) { this.frames[this.frames.length - 1] = { time: newest.time, value, @@ -56,8 +45,6 @@ export class LinearInterpolator { } if (wasCoasting) { - // Continue from where the coast left off and let the offset decay, - // instead of jumping onto the freshly revealed trajectory. this.blendOffset = this.lastValue - this.sample(serverTimeline.renderTime); } } diff --git a/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts b/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts index ae6fb52..ef67ad8 100644 --- a/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts +++ b/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts @@ -6,13 +6,13 @@ export class Vec2Interpolator { private y: LinearInterpolator; constructor(currentValue: vec2) { - this.x = new LinearInterpolator(currentValue.x); - this.y = new LinearInterpolator(currentValue.y); + this.x = new LinearInterpolator(currentValue[0]); + this.y = new LinearInterpolator(currentValue[1]); } public addFrame(value: vec2, rateOfChange: vec2) { - this.x.addFrame(value.x, rateOfChange.x); - this.y.addFrame(value.y, rateOfChange.y); + this.x.addFrame(value[0], rateOfChange[0]); + this.y.addFrame(value[1], rateOfChange[1]); } public getValue(deltaTime: number): vec2 { diff --git a/frontend/src/scripts/helper/pointer.ts b/frontend/src/scripts/helper/pointer.ts index ab8886d..e126c41 100644 --- a/frontend/src/scripts/helper/pointer.ts +++ b/frontend/src/scripts/helper/pointer.ts @@ -1,13 +1,9 @@ import { vec2 } from 'gl-matrix'; -export class Pointer { - private static displayPosition: vec2 | null = null; +let displayPosition: vec2 | null = null; - public static setDisplayPosition(x: number, y: number): void { - Pointer.displayPosition = vec2.fromValues(x, y); - } +export const setDisplayPosition = (position: vec2): void => { + displayPosition = position; +}; - public static getDisplayPosition(): vec2 | null { - return Pointer.displayPosition; - } -} +export const getDisplayPosition = (): vec2 | null => displayPosition; diff --git a/frontend/src/scripts/helper/prediction/client-character-world.ts b/frontend/src/scripts/helper/prediction/client-character-world.ts index a08dbc5..1fb19b3 100644 --- a/frontend/src/scripts/helper/prediction/client-character-world.ts +++ b/frontend/src/scripts/helper/prediction/client-character-world.ts @@ -4,135 +4,48 @@ import { GroundSurface, Id, PhysicsBody, - planetDistance, - planetGravity, + PlanetBase, resolveCircleMovement, } from 'shared'; +import { PlanetView } from '../../objects/types/planet-view'; -// What the predictor needs to know about a planet to collide and be pulled by -// it. The client reads this off its PlanetViews. -export interface PredictablePlanet { - id: Id; - vertices: Array; - center: vec2; - radius: number; - rotation: number; - rotationSpeed: number; -} +export type PlanetSnapshot = Pick< + PlanetView, + 'id' | 'vertices' | 'snapshotRotation' | 'snapshotRotationSpeed' +>; -// A planet collision/gravity surface whose rotation can be advanced during -// replay, so its outline turns in lockstep with the body the carry term moves — -// exactly as the server steps the planet before the character each tick. -class PlanetSurface implements GroundSurface { - public readonly canCollide = true; - public readonly isGround = true; - public readonly id: Id; - public center: vec2; - public angularVelocity: number; - private vertices: Array; - private radius: number; - private rotation = 0; - private cos = 1; - private sin = 0; - - constructor(planet: PredictablePlanet) { - this.id = planet.id; - this.center = planet.center; - this.vertices = planet.vertices; - this.radius = planet.radius; - this.angularVelocity = planet.rotationSpeed; - this.setRotation(planet.rotation); - } - - public sync(planet: PredictablePlanet) { - this.center = planet.center; - this.vertices = planet.vertices; - this.radius = planet.radius; - this.angularVelocity = planet.rotationSpeed; - this.setRotation(planet.rotation); - } - - private setRotation(rotation: number) { - this.rotation = rotation; - this.cos = Math.cos(rotation); - this.sin = Math.sin(rotation); - } - - public advance(deltaTimeInSeconds: number) { - this.setRotation(this.rotation + this.angularVelocity * deltaTimeInSeconds); - } - - public distance(target: vec2): number { - return planetDistance(target, this.vertices, this.center, this.cos, this.sin); - } - - public gravityAt(target: vec2): vec2 { - return planetGravity(this.center, this.radius, target); - } -} - -// The planets-only collision world the local predictor runs against. It holds -// persistent surfaces keyed by planet id (so a `currentPlanet` reference stays -// valid across frames) and never dispatches collision reactions — damage, -// scoring and the like are server-authoritative. +// Planets only, re-seeded from the newest snapshot every frame; never dispatches collisions. export class ClientCharacterWorld implements CharacterWorld { - private surfaces = new Map(); - private ordered: Array = []; + private surfaces: Array = []; - // Refresh from the current PlanetViews. Far planets contribute zero gravity - // (the falloff clamps to 0 past maxGravityDistance) and never collide, so the - // whole set can be handed to every query without a range filter. Ordered by - // id so the (rare) two-surface contact picks a stable surface. - public sync(planets: Array) { - const seen = new Set(); - for (const planet of planets) { - seen.add(planet.id); - const existing = this.surfaces.get(planet.id); - if (existing) { - existing.sync(planet); - } else { - this.surfaces.set(planet.id, new PlanetSurface(planet)); - } - } - for (const id of [...this.surfaces.keys()]) { - if (!seen.has(id)) { - this.surfaces.delete(id); - } - } - this.ordered = [...this.surfaces.entries()] - .sort((a, b) => Number(a[0]) - Number(b[0])) - .map((e) => e[1]); + public sync(planets: Array) { + this.surfaces = planets + .map((planet) => { + const surface = new PlanetBase(planet.id, planet.vertices); + surface.rotation = planet.snapshotRotation; + surface.angularVelocity = planet.snapshotRotationSpeed; + return surface; + }) + .sort((a, b) => a.id - b.id); } - // Advance every planet's collision frame by one replay substep, so surfaces - // and the carried body rotate together. The surfaces are re-synced to the - // newest snapshot rotation each frame (see sync), so the replay only ever - // steps forward from there. public advance(deltaTimeInSeconds: number) { - for (const surface of this.ordered) { - surface.advance(deltaTimeInSeconds); - } + this.surfaces.forEach((s) => s.advanceRotation(deltaTimeInSeconds)); } - public surfaceById(id: Id | undefined): GroundSurface | undefined { - return id == null ? undefined : this.surfaces.get(id); + public surfaceById(id: Id | null): GroundSurface | undefined { + return this.surfaces.find((s) => s.id === id); } - public idOf(surface: GroundSurface | undefined): Id | undefined { - return surface instanceof PlanetSurface ? surface.id : undefined; - } - - public groundsNear(): Array { - return this.ordered; + public groundsNear(_center: vec2, _radius: number): Array { + return this.surfaces; } public stepBody( body: PhysicsBody, deltaTimeInSeconds: number, ): GroundSurface | undefined { - const { hitObject } = resolveCircleMovement(body, deltaTimeInSeconds, this.ordered); - return hitObject && (hitObject as GroundSurface).isGround - ? (hitObject as GroundSurface) - : undefined; + const hit = resolveCircleMovement(body, deltaTimeInSeconds, this.surfaces); + return hit instanceof PlanetBase ? hit : undefined; } } diff --git a/frontend/src/scripts/helper/prediction/input-history.ts b/frontend/src/scripts/helper/prediction/input-history.ts index 3ec2604..fab8f7a 100644 --- a/frontend/src/scripts/helper/prediction/input-history.ts +++ b/frontend/src/scripts/helper/prediction/input-history.ts @@ -5,16 +5,8 @@ interface InputSample { direction: vec2; } -// Keep a little over a second of input — far more than any sane reconciliation -// window — so a brief stall (or a backgrounded tab catching up) can still be -// replayed, while old samples are pruned to bound memory. const retainMs = 1500; -// A timeline of the local player's movement directions in client-clock time. -// Each generated MoveActionCommand is stamped with the time this hands out, and -// the same sample is recorded here so the predictor replays exactly the input -// the server will eventually receive. Direction is piecewise-constant: the -// active direction at any instant is the most recent sample at or before it. export class InputHistory { private samples: Array = []; @@ -27,8 +19,6 @@ export class InputHistory { } } - // The held direction at `timeMs`: the latest sample at or before it, or zero - // before any input exists. public directionAt(timeMs: number): vec2 { let direction = vec2.create(); for (const sample of this.samples) { diff --git a/frontend/src/scripts/helper/prediction/local-character-predictor.ts b/frontend/src/scripts/helper/prediction/local-character-predictor.ts index 5d23c1c..ccf248b 100644 --- a/frontend/src/scripts/helper/prediction/local-character-predictor.ts +++ b/frontend/src/scripts/helper/prediction/local-character-predictor.ts @@ -1,8 +1,8 @@ import { vec2 } from 'gl-matrix'; import { Circle, + CharacterMovementSnapshot, CharacterMovementState, - Id, PhysicsBody, settings, stepCharacterMovement, @@ -11,95 +11,63 @@ import { feetRadius, headRadius, } from 'shared'; -import { ClientCharacterWorld, PredictablePlanet } from './client-character-world'; +import { ClientCharacterWorld, PlanetSnapshot } from './client-character-world'; +import type { PlanetView } from '../../objects/types/planet-view'; import { InputHistory } from './input-history'; -const stepMs = 1000 / 200; // match the server's 200 Hz fixed tick -const stepSeconds = 1 / 200; +const stepSeconds = settings.targetPhysicsDeltaTimeInSeconds; +const stepMs = stepSeconds * 1000; -// Clock source for the predictor. Injectable so deterministic reconciliation -// tests can drive prediction without real time passing; defaults to the -// browser wall clock in production. -let nowMs: () => number = () => performance.now(); -export const setPredictorClockForTesting = (clock: () => number): void => { - nowMs = clock; +let frameTimeMs = 0; +export const setFrameTimeMs = (timeMs: number): void => { + frameTimeMs = timeMs; }; +export const clientTimeMs = (): number => frameTimeMs; -// Don't replay more than this far back: if the last acknowledged input is older -// (a stall, or a backgrounded tab catching up) snap to the authoritative pose -// instead of grinding through hundreds of steps. -const maxReplayMs = 300; +const maxReplayMs = 400; +const correctionSeconds = 0.08; +const snapDistance = 1500; -// Render-side easing of the correction the reconciliation produces each frame, -// so a snapshot that disagrees with the prediction is smoothed out instead of -// popping. Short, so the local player still feels immediate. -const smoothSeconds = 0.06; +interface ReplaySnapshot { + pose: { head: Circle; leftFoot: Circle; rightFoot: Circle }; + movement: CharacterMovementSnapshot; + anchorMs: number; + leapAckMs: number; + strength: number; + planets: Array; + horizonState?: CharacterMovementState; +} -// A correction bigger than this isn't prediction error — it's a respawn, -// teleport, or a server-side impulse (leap / slingshot / recoil / death throw) -// the predictor doesn't model. Snap to it rather than gliding across the gap. -const snapDistance = 250; +const upwards = vec2.fromValues(0, 1); -const makeBody = (center: vec2, radius: number): PhysicsBody => ({ +const makeBody = (center: vec2, radius: number, normal: vec2): PhysicsBody => ({ center: vec2.clone(center), radius, velocity: vec2.create(), - lastNormal: vec2.fromValues(0, 1), + lastNormal: vec2.clone(normal), restitution: 0, }); -// Predicts the local player's character so it responds to input immediately, -// instead of lagging ~100 ms + RTT behind like the interpolated remote objects. -// Each frame it resets to the latest authoritative snapshot and replays the -// player's own un-acknowledged input through the SAME movement simulation the -// server runs (shared/stepCharacterMovement), then eases the rendered pose -// toward the result. Discrete server-side impulses it can't model show up as a -// large correction and snap rather than rubber-band. export class LocalCharacterPredictor { + public enabled = true; private readonly inputHistory = new InputHistory(); private readonly world = new ClientCharacterWorld(); private authoritative?: { head: Circle; leftFoot: Circle; rightFoot: Circle }; - private lastAckClientTimeMs?: number; - // Wall-clock (client) time the latest authoritative snapshot was received. The - // replay predicts forward from HERE by the snapshot's age, so the local pose - // advances smoothly with real time between the 25 Hz snapshots. Anchoring to - // the last *acked input* time instead breaks when input is sent only on change - // (a held key sends nothing): that time freezes, the window pins to the - // maxReplayMs clamp, the replay displacement goes constant, and the pose - // stair-steps at the snapshot rate. - private authReceiptMs = 0; - // Authoritative launch momentum at the last snapshot — seeds each replay so a - // leap/slingshot/recoil flight is reproduced and continuously corrected. - private authoritativeBodyVelocity = vec2.create(); - // Wall-clock times the player issued a leap, replayed (with the impulse - // applied locally) so the launch is felt immediately, not after a round trip. + private replayAnchorMs?: number; + private movement?: CharacterMovementSnapshot; private leapHistory: Array = []; - // clientTimeMs of the last leap the server has folded into the streamed - // momentum. Leaps at or before this are already in authoritativeBodyVelocity; - // only newer ones are replayed, so a leap is never applied twice. private lastLeapAckMs = -Infinity; - // Latest streamed shooting-strength, to gate predicted leaps as the server does. private currentStrength = settings.playerMaxStrength; - // Whether the local body is alive on the server. While dead (awaiting respawn) - // prediction is suppressed so the corpse/ghost can't keep walking in response - // to input — the server ignores a dead player's movement, so a predicted body - // that still moved would be a pure client-side desync. private alive = true; + private previousSnapshot?: ReplaySnapshot; + private lastUpdateMs?: number; + private readonly correction = vec2.create(); - // Continuous state carried between replays (the snapshot carries only poses). - // The facing direction is NOT carried — it is re-derived from the pose each - // frame (see directionFromPose / simulate); only the latched planet and the - // time-since-surface persist. - private carriedPlanetId?: Id; - private carriedSecondsSinceSurface = 1; - - // The eased, rendered pose handed to the view. private renderHead = new Circle(vec2.create(), headRadius); private renderLeftFoot = new Circle(vec2.create(), feetRadius); private renderRightFoot = new Circle(vec2.create(), feetRadius); - private hasRender = false; public get head(): Circle { return this.renderHead; @@ -111,42 +79,29 @@ export class LocalCharacterPredictor { return this.renderRightFoot; } - // Stamp a movement command and record it for replay. Returns the wall-clock - // time the command should carry so the server can echo it back. public recordInput(direction: vec2): number { - const timeMs = Math.round(nowMs()); + const timeMs = clientTimeMs(); this.inputHistory.record(direction, timeMs); return timeMs; } public acknowledge( clientTimeMs: number, - bodyVelocity: vec2, + movement: CharacterMovementSnapshot, lastLeapClientTimeMs: number, + ackAgeMs = 0, ): void { - // Inputs only advance the acknowledgement forward; the launch momentum and - // leap boundary always adopt the latest authoritative values. - if ( - this.lastAckClientTimeMs === undefined || - clientTimeMs > this.lastAckClientTimeMs - ) { - this.lastAckClientTimeMs = clientTimeMs; - } - vec2.set(this.authoritativeBodyVelocity, bodyVelocity[0], bodyVelocity[1]); + this.replayAnchorMs = clientTimeMs + Math.max(0, ackAgeMs); + this.movement = movement; this.lastLeapAckMs = lastLeapClientTimeMs; } public setAuthoritative(head: Circle, leftFoot: Circle, rightFoot: Circle): void { this.authoritative = { head, leftFoot, rightFoot }; - this.authReceiptMs = Math.round(nowMs()); } - // The player pressed leap; remember when, so the replay applies the same - // impulse the server will. Recorded regardless of whether the server accepts - // it — a rejected leap (no strength/cooldown) self-corrects via the streamed - // authoritative momentum. public recordLeap(): number { - const timeMs = Math.round(nowMs()); + const timeMs = clientTimeMs(); this.leapHistory.push(timeMs); const cutoff = timeMs - 1500; while (this.leapHistory.length > 0 && this.leapHistory[0] <= cutoff) { @@ -168,24 +123,23 @@ export class LocalCharacterPredictor { this.leapHistory = []; this.lastLeapAckMs = -Infinity; this.authoritative = undefined; - this.lastAckClientTimeMs = undefined; - this.authReceiptMs = 0; - vec2.zero(this.authoritativeBodyVelocity); + this.replayAnchorMs = undefined; + this.movement = undefined; this.currentStrength = settings.playerMaxStrength; - this.carriedPlanetId = undefined; - this.carriedSecondsSinceSurface = 1; - this.hasRender = false; this.alive = true; + this.previousSnapshot = undefined; + this.lastUpdateMs = undefined; + vec2.zero(this.correction); } public get canPredict(): boolean { - return this.authoritative !== undefined && this.lastAckClientTimeMs !== undefined; + return ( + this.authoritative !== undefined && + this.movement !== undefined && + this.replayAnchorMs !== undefined + ); } - // During spawn-in and death the server freezes walking and only scales the - // body (CharacterPhysical.step returns early), so its head radius is below - // nominal. Predicting then would walk the body off a position the server is - // holding still — let interpolation show the animation instead. private get isAnimatingInOrOut(): boolean { return ( this.authoritative !== undefined && @@ -193,158 +147,151 @@ export class LocalCharacterPredictor { ); } - // Run one frame of prediction. Returns true if it produced a rendered pose the - // caller should use (otherwise fall back to interpolation). `planets` is the - // current collision world; `frameSeconds` is the render delta. - public update(planets: Array, frameSeconds: number): boolean { - if (!this.alive || !this.canPredict || this.isAnimatingInOrOut) { - // Resume from the authoritative pose with a snap rather than gliding from - // a stale rendered one. - this.hasRender = false; + public update(planets: Array): boolean { + if (!this.enabled || !this.alive || !this.canPredict || this.isAnimatingInOrOut) { + this.previousSnapshot = undefined; + this.lastUpdateMs = undefined; + vec2.zero(this.correction); return false; } - this.world.sync(planets); - const predicted = this.simulate(); + const now = clientTimeMs(); + const deltaSeconds = + this.lastUpdateMs === undefined ? 0 : (now - this.lastUpdateMs) / 1000; + vec2.scale( + this.correction, + this.correction, + Math.exp(-deltaSeconds / correctionSeconds), + ); + const snapshot: ReplaySnapshot = + this.previousSnapshot && this.previousSnapshot.pose === this.authoritative + ? this.previousSnapshot + : { + pose: this.authoritative!, + movement: this.movement!, + anchorMs: this.replayAnchorMs!, + leapAckMs: this.lastLeapAckMs, + strength: this.currentStrength, + planets: planets.map( + ({ id, vertices, snapshotRotation, snapshotRotationSpeed }) => ({ + id, + vertices, + snapshotRotation, + snapshotRotationSpeed, + }), + ), + }; + const predicted = this.simulate(snapshot); - if (!this.hasRender) { - this.snapRenderTo(predicted); - this.hasRender = true; - } else { - this.easeRenderTo(predicted, frameSeconds); + if (this.previousSnapshot && this.previousSnapshot.pose !== snapshot.pose) { + // Compare both snapshots at the same instant: smooth only the correction, + // leaving fresh input and ordinary movement immediate. + const previous = this.simulate(this.previousSnapshot); + vec2.add( + this.correction, + this.correction, + vec2.subtract(vec2.create(), previous.head.center, predicted.head.center), + ); + if (vec2.length(this.correction) > snapDistance) { + vec2.zero(this.correction); + } } + for (const [render, body] of [ + [this.renderHead, predicted.head], + [this.renderLeftFoot, predicted.leftFoot], + [this.renderRightFoot, predicted.rightFoot], + ]) { + vec2.add(render.center, body.center, this.correction); + render.radius = body.radius; + } + this.previousSnapshot = snapshot; + this.lastUpdateMs = now; + return true; } - // The body's facing angle is encoded in the pose: each part is sprung toward - // center + R(direction)*offset and the head's offset points +y, so - // direction = atan2(head - center) - PI/2. Re-deriving it from the snapshot - // each frame (rather than carrying the previous frame's evolved value onto - // this past pose, re-evolved by a variable substep count) keeps the posture - // seed a pure function of the snapshot — carrying it fed a frame-rate-dependent - // loop that wobbled the rendered limbs. - private directionFromPose(head: Circle, leftFoot: Circle, rightFoot: Circle): number { - const cx = (head.center[0] + leftFoot.center[0] + rightFoot.center[0]) / 3; - const cy = (head.center[1] + leftFoot.center[1] + rightFoot.center[1]) / 3; - return Math.atan2(head.center[1] - cy, head.center[0] - cx) - Math.PI / 2; - } - - private simulate(): CharacterMovementState { - const auth = this.authoritative!; - const now = Math.round(nowMs()); - // Predict forward from the latest snapshot by its age, clamped so a stall - // (or a backgrounded tab catching up) snaps instead of grinding hundreds of - // steps. This advances with wall-clock time even while a held key sends no - // fresh input, so the pose no longer pins to the 25 Hz snapshot cadence. - const startMs = Math.max(this.authReceiptMs, now - maxReplayMs); - const windowMs = Math.max(0, now - startMs); + private simulate(snapshot: ReplaySnapshot): CharacterMovementState { + if (snapshot.horizonState) { + return snapshot.horizonState; + } + this.world.sync(snapshot.planets); + const { pose: auth, movement } = snapshot; + const now = clientTimeMs(); + const startMs = snapshot.anchorMs; + const windowMs = Math.min(maxReplayMs, Math.max(0, now - startMs)); const steps = Math.floor(windowMs / stepMs); - const remainderSeconds = (windowMs - steps * stepMs) / 1000; + const partialStep = (windowMs - steps * stepMs) / stepMs; const state: CharacterMovementState = { - head: makeBody(auth.head.center, auth.head.radius), - leftFoot: makeBody(auth.leftFoot.center, auth.leftFoot.radius), - rightFoot: makeBody(auth.rightFoot.center, auth.rightFoot.radius), - direction: this.directionFromPose(auth.head, auth.leftFoot, auth.rightFoot), - currentPlanet: this.world.surfaceById(this.carriedPlanetId), - secondsSinceOnSurface: this.carriedSecondsSinceSurface, - // Leaps before the replay window are already baked into this; leaps inside - // the window are re-applied below, so neither is double-counted. - bodyVelocity: vec2.clone(this.authoritativeBodyVelocity), + head: makeBody(auth.head.center, auth.head.radius, upwards), + leftFoot: makeBody( + auth.leftFoot.center, + auth.leftFoot.radius, + movement.leftFootNormal, + ), + rightFoot: makeBody( + auth.rightFoot.center, + auth.rightFoot.radius, + movement.rightFootNormal, + ), + direction: movement.direction, + currentPlanet: this.world.surfaceById(movement.groundPlanetId), + secondsSinceOnSurface: movement.secondsSinceOnSurface, + bodyVelocity: vec2.fromValues(movement.bodyVelocity[0], movement.bodyVelocity[1]), }; - // The planet collision frames were synced (in update(), just before this) - // to the NEWEST snapshot's rotation — the same instant the authoritative - // body pose is from — so the body and the surface start the replay at the - // same phase. The loop below advances the surfaces FORWARD in lockstep with - // the body's carry from that shared phase, so no rewind is needed (and the - // persistent surfaces are re-synced next frame, so this never accumulates). - - const cooldownMs = settings.leapCooldownSeconds * 1000; - // Mirror the server: each accepted leap spends leapStrengthCost, so a burst - // of leaps in one replay window is gated by the running strength rather than - // a single up-front affordability check. - let availableStrength = this.currentStrength; - let lastLeapMs = -Infinity; + let availableStrength = snapshot.strength; + let cooldown = movement.leapCooldownRemaining ?? 0; let t = startMs; - for (let i = 0; i < steps; i++) { - const input = this.inputHistory.directionAt(t); - tickPlanetDetachment(state, stepSeconds); - stepCharacterMovement(state, this.world, input, stepSeconds); - this.world.advance(stepSeconds); - - // A leap issued during this step launches now (the next step injects the - // momentum), gated like the server: on a surface, off cooldown, with - // strength. applyLeapImpulse is a no-op off-surface. + for (let i = 0; i < steps + (partialStep > 0 ? 1 : 0); i++) { + const fraction = i < steps ? 1 : partialStep; + const from = + fraction < 1 + ? [state.head, state.leftFoot, state.rightFoot].map((body) => + vec2.clone(body.center), + ) + : undefined; for (const leapMs of this.leapHistory) { if ( leapMs >= t && - leapMs < t + stepMs && - // Only leaps the server hasn't yet folded into the seed, so a leap - // is never both seeded and replayed. - leapMs > this.lastLeapAckMs && + leapMs < t + stepMs * fraction && + leapMs > snapshot.leapAckMs && state.currentPlanet && - leapMs - lastLeapMs >= cooldownMs && + cooldown <= 0 && availableStrength >= settings.leapStrengthCost ) { applyLeapImpulse(state, this.inputHistory.directionAt(leapMs)); availableStrength -= settings.leapStrengthCost; - lastLeapMs = leapMs; + cooldown = settings.leapCooldownSeconds; } } - - t += stepMs; - } - - // Final sub-tick of the leftover (< stepMs) so the predicted pose is a - // continuous function of the window length rather than advancing in 5 ms - // quanta — the floor() above would otherwise surface that as a per-frame - // wobble on top of the ~16.7 ms render cadence. - if (remainderSeconds > 0) { - tickPlanetDetachment(state, remainderSeconds); + this.world.advance(stepSeconds); + tickPlanetDetachment(state, stepSeconds); stepCharacterMovement( state, this.world, - this.inputHistory.directionAt(now), - remainderSeconds, + this.inputHistory.directionAt(t), + stepSeconds, ); - this.world.advance(remainderSeconds); + cooldown = Math.max(0, cooldown - stepSeconds); + availableStrength = Math.min( + settings.playerMaxStrength, + availableStrength + settings.playerStrengthRegenerationPerSeconds * stepSeconds, + ); + if (from) { + [state.head, state.leftFoot, state.rightFoot].forEach((body, index) => + vec2.lerp(body.center, from[index], body.center, fraction), + ); + } + t += stepMs; } - this.carriedPlanetId = this.world.idOf(state.currentPlanet); - this.carriedSecondsSinceSurface = state.secondsSinceOnSurface; - + if (windowMs === maxReplayMs) { + snapshot.horizonState = state; + } return state; } - - private snapRenderTo(state: CharacterMovementState): void { - this.renderHead = new Circle(vec2.clone(state.head.center), state.head.radius); - this.renderLeftFoot = new Circle( - vec2.clone(state.leftFoot.center), - state.leftFoot.radius, - ); - this.renderRightFoot = new Circle( - vec2.clone(state.rightFoot.center), - state.rightFoot.radius, - ); - } - - private easeRenderTo(state: CharacterMovementState, frameSeconds: number): void { - const q = 1 - Math.exp(-frameSeconds / smoothSeconds); - this.easePart(this.renderHead, state.head, q); - this.easePart(this.renderLeftFoot, state.leftFoot, q); - this.easePart(this.renderRightFoot, state.rightFoot, q); - } - - private easePart(render: Circle, target: PhysicsBody, q: number): void { - if (vec2.distance(render.center, target.center) > snapDistance) { - vec2.copy(render.center, target.center); - } else { - vec2.lerp(render.center, render.center, target.center, q); - } - render.radius = target.radius; - } } export const localCharacterPredictor = new LocalCharacterPredictor(); diff --git a/frontend/src/scripts/helper/server-timeline.ts b/frontend/src/scripts/helper/server-timeline.ts index 28aab75..d4b17fc 100644 --- a/frontend/src/scripts/helper/server-timeline.ts +++ b/frontend/src/scripts/helper/server-timeline.ts @@ -1,63 +1,37 @@ import { clamp, settings } from 'shared'; -// Convergence rate of the playback cursor towards its target; a deviation -// decays with a time constant of 1 / rateGain seconds. const rateGain = 2; -// Playback speed stays within [0.75, 1.25]× so corrections are invisible. const maxRateAdjustment = 0.25; -// Beyond this divergence (tab was in the background, server changed) chasing -// the target is pointless: jump straight to it. const resyncSeconds = 0.3; -/** - * The playback clock for state streamed from the server. - * - * Snapshot timestamps estimate the server's clock: the newest received - * timestamp plus the time elapsed since it arrived. Rendering happens - * settings.interpolationDelaySeconds behind that estimate, so there is - * normally a newer snapshot to interpolate towards and network jitter is - * absorbed by the buffer instead of being shown. - * - * The cursor never jumps under normal operation: it runs at a gently adjusted - * rate to stay on target and only snaps after a long divergence. - */ class ServerTimeline { private cursor?: number; private newestSnapshotTime?: number; - private sinceNewestSnapshot = 0; - private _snapshotTime = 0; + private newestReceivedAtMs = 0; + public snapshotTime = 0; - /** Timestamp of the update batch currently being applied. */ - public get snapshotTime(): number { - return this._snapshotTime; - } - - /** The point on the server's clock that should be rendered this frame. */ public get renderTime(): number { return this.cursor ?? 0; } public onSnapshot(timestamp: number) { - this._snapshotTime = timestamp; + this.snapshotTime = timestamp; if (this.newestSnapshotTime === undefined || timestamp > this.newestSnapshotTime) { this.newestSnapshotTime = timestamp; - this.sinceNewestSnapshot = 0; + this.newestReceivedAtMs = performance.now(); } } - // Must be called with unscaled wall-clock time, even during the end-game - // slow motion: the slowdown is already baked into the snapshots. public step(deltaTimeInSeconds: number) { if (this.newestSnapshotTime === undefined) { return; } - this.sinceNewestSnapshot += deltaTimeInSeconds; const target = this.newestSnapshotTime + - this.sinceNewestSnapshot - + (performance.now() - this.newestReceivedAtMs) / 1000 - settings.interpolationDelaySeconds; if (this.cursor === undefined || Math.abs(target - this.cursor) > resyncSeconds) { @@ -65,19 +39,17 @@ class ServerTimeline { return; } - const rate = clamp( - 1 + (target - this.cursor) * rateGain, - 1 - maxRateAdjustment, - 1 + maxRateAdjustment, - ); - this.cursor += deltaTimeInSeconds * rate; + this.cursor += deltaTimeInSeconds; + this.cursor += + deltaTimeInSeconds * + clamp((target - this.cursor) * rateGain, -maxRateAdjustment, maxRateAdjustment); } public reset() { this.cursor = undefined; this.newestSnapshotTime = undefined; - this.sinceNewestSnapshot = 0; - this._snapshotTime = 0; + this.newestReceivedAtMs = 0; + this.snapshotTime = 0; } } diff --git a/frontend/src/scripts/helper/show.ts b/frontend/src/scripts/helper/show.ts deleted file mode 100644 index cb43718..0000000 --- a/frontend/src/scripts/helper/show.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const show = (e: HTMLElement, useDisplay = false, normalDisplayValue?: string) => { - if (useDisplay) { - e.style.display = normalDisplayValue!; - } else { - e.style.visibility = 'inherit'; - } -}; diff --git a/frontend/src/scripts/helper/storage.ts b/frontend/src/scripts/helper/storage.ts new file mode 100644 index 0000000..b68bb01 --- /dev/null +++ b/frontend/src/scripts/helper/storage.ts @@ -0,0 +1,16 @@ +export function loadStoredValue(key: string): unknown { + try { + const stored = localStorage.getItem(key); + return stored === null ? undefined : JSON.parse(stored); + } catch { + return undefined; + } +} + +export function saveStoredValue(key: string, value: unknown): void { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // Preferences still apply for this visit when browser storage is unavailable. + } +} diff --git a/frontend/src/scripts/join-form-handler.ts b/frontend/src/scripts/join-form-handler.ts index c2bd37f..d1eff3b 100644 --- a/frontend/src/scripts/join-form-handler.ts +++ b/frontend/src/scripts/join-form-handler.ts @@ -1,6 +1,6 @@ import { ServerInformation, serverInformationEndpoint, TransportEvents } from 'shared'; import { io, Socket } from 'socket.io-client'; -import { Configuration } from './configuration'; +import { servers } from './configuration'; import parser from 'socket.io-msgpack-parser'; import { SoundHandler, Sounds } from './sound-handler'; @@ -10,140 +10,142 @@ export type PlayerDecision = { }; const pollInterval = 8000; + export class JoinFormHandler { - private joinButton: HTMLButtonElement; - private waitingForDecision: Promise; - private resolvePlayerDecision!: (d: PlayerDecision) => void; - private pollServersTimer: any; - private keyUpListener = (e: KeyboardEvent) => { - // KeyboardEvent.key for Return is 'Enter' (capital E); the old lowercase - // comparison never matched, so pressing Enter silently did nothing. - // requestSubmit() (unlike submit()) fires the form's onsubmit handler and - // runs HTML5 validation, so Enter behaves exactly like clicking Join. - if (e.key === 'Enter' && !this.joinButton.disabled) { - this.form.requestSubmit(); - } - }; + private readonly joinButton: HTMLButtonElement; + private readonly decision: Promise; + private readonly pollServersTimer: ReturnType; + private servers: Array = []; + private readonly pendingRequests = new Map(); + private destroyed = false; constructor( - private form: HTMLFormElement, + private readonly form: HTMLFormElement, private readonly container: HTMLElement, ) { this.joinButton = form.querySelector('button[type="submit"]') as HTMLButtonElement; this.joinButton.disabled = true; - this.waitingForDecision = new Promise((r) => (this.resolvePlayerDecision = r)); - new FormData(form); + this.decision = new Promise((resolve) => { + form.onsubmit = (e) => { + e.preventDefault(); + if (this.joinButton.disabled) { + return; + } + SoundHandler.play(Sounds.click); + const data = new FormData(form); + this.destroy(); + resolve({ name: String(data.get('name')), server: String(data.get('server')) }); + }; + }); - addEventListener('keyup', this.keyUpListener); - - form.onsubmit = (e) => { - SoundHandler.play(Sounds.click); - const result: PlayerDecision = ( - Array.from((new FormData(form) as any).entries()) as Array<[string, any]> - ).reduce((result, [name, value]) => { - (result as any)[name] = value; - return result; - }, {}) as any; - - this.resolvePlayerDecision(result); - - e.preventDefault(); - }; - - this.pollServersTimer = setInterval(this.loadServers.bind(this), pollInterval); + this.pollServersTimer = setInterval(() => this.loadServers(), pollInterval); this.loadServers(); - this.waitForFinish(); } - private async waitForFinish() { - await this.waitingForDecision; - this.destroy(); + public getPlayerDecision(): Promise { + return this.decision; } - private destroy() { - removeEventListener('keyup', this.keyUpListener); + public destroy() { + this.destroyed = true; + this.form.onsubmit = null; clearInterval(this.pollServersTimer); + this.pendingRequests.forEach((controller) => controller.abort()); this.servers.forEach((s) => s.destroy()); } - private servers: Array = []; - private async loadServers() { - await Configuration.initialize(); - - const serverList = Configuration.servers.filter( - (u) => !this.servers.find((s) => s.url === u), - ); - - serverList.map(async (url) => { - const controller = new AbortController(); - const signal = controller.signal; - setTimeout(() => controller.abort(), pollInterval * 0.8); - - let response: Response | undefined; - try { - response = await fetch(url + serverInformationEndpoint, { signal }); - } catch { - // it's okay + private loadServers() { + for (const url of servers) { + if (!this.pendingRequests.has(url) && !this.servers.some((s) => s.url === url)) { + void this.loadServer(url); } + } + } - if (response?.ok) { - const content: ServerInformation = await response.json(); - if (!this.servers.find((s) => s.url === url)) { - const server = new ServerChooserOption( - content, - url, - this.removeServer.bind(this), - this.servers.length === 0, - ); - this.servers.push(server); - this.joinButton.disabled = false; - this.container.appendChild(server.element); - } + private async loadServer(url: string) { + const controller = new AbortController(); + this.pendingRequests.set(url, controller); + const timeout = setTimeout(() => controller.abort(), pollInterval * 0.8); + try { + const response = await fetch(url + serverInformationEndpoint, { + signal: controller.signal, + }); + if (!response.ok) { + return; } - }); + const content: ServerInformation = await response.json(); + if (this.destroyed) { + return; + } + const server = new ServerChooserOption( + content, + url, + (s) => this.removeServer(s), + this.servers.length === 0, + ); + this.servers.push(server); + this.joinButton.disabled = false; + this.container.appendChild(server.element); + } catch { + // Unavailable servers are retried on the next poll. + } finally { + clearTimeout(timeout); + this.pendingRequests.delete(url); + } } private removeServer(server: ServerChooserOption) { this.servers = this.servers.filter((s) => s !== server); - if (!this.servers.length) { - this.joinButton.disabled = true; + this.joinButton.disabled = this.servers.length === 0; + if (this.servers.length && !this.servers.some((s) => s.input.checked)) { + this.servers[0].input.checked = true; } } - - public async getPlayerDecision(): Promise { - return this.waitingForDecision; - } } -class ServerChooserOption { - private divElement = document.createElement('div'); - private inputElement = document.createElement('input'); - private labelElement = document.createElement('label'); - private serverNameElement = document.createElement('span'); - private completionElement = document.createElement('span'); +const roundCompletionTexts = [ + 'Just started', + 'Just started', + 'Ongoing', + 'Halfway through', + 'Nearly over', + 'About to finish', + 'Game is over', +]; - private socket: Socket; +class ServerChooserOption { + public readonly element = document.createElement('div'); + public readonly input = document.createElement('input'); + private readonly serverNameElement = document.createElement('span'); + private readonly completionElement = document.createElement('span'); + private readonly socket: Socket; + private readonly reconnectFailedListener = () => this.destroy(); constructor( - private content: ServerInformation, + private readonly content: ServerInformation, public readonly url: string, - private onDestroy: (v: ServerChooserOption) => unknown, + private readonly onDestroy: (v: ServerChooserOption) => unknown, isFirst: boolean, ) { - this.inputElement.required = true; - this.inputElement.type = 'radio'; - this.inputElement.id = this.inputElement.value = url; - this.inputElement.name = 'server'; - this.inputElement.checked = isFirst; - this.labelElement.htmlFor = url; - this.labelElement.onclick = () => SoundHandler.play(Sounds.click); - this.divElement.appendChild(this.inputElement); - this.divElement.appendChild(this.labelElement); - this.labelElement.appendChild(this.serverNameElement); - this.labelElement.appendChild(document.createElement('br')); - this.labelElement.appendChild(this.completionElement); + const input = this.input; + input.required = true; + input.type = 'radio'; + input.id = input.value = url; + input.name = 'server'; + input.checked = isFirst; + + const label = document.createElement('label'); + label.htmlFor = url; + label.onclick = () => SoundHandler.play(Sounds.click); + label.append( + this.serverNameElement, + document.createElement('br'), + this.completionElement, + ); this.completionElement.className = 'completion'; + + this.element.append(input, label); this.setServerInfoLabelText(); this.socket = io(url, { @@ -153,8 +155,7 @@ class ServerChooserOption { parser, } as any); - this.socket.io.on('reconnect_failed', this.destroy.bind(this)); - + this.socket.io.on('reconnect_failed', this.reconnectFailedListener); this.socket.on('connect', () => this.socket.emit(TransportEvents.SubscribeForServerInfoUpdates), ); @@ -169,33 +170,18 @@ class ServerChooserOption { } public destroy() { + this.socket.io.off('reconnect_failed', this.reconnectFailedListener); this.socket.close(); - this.divElement.parentElement?.removeChild(this.divElement); + this.element.remove(); this.onDestroy(this); } - private getRoundCompletionText(percent: number): string { - const texts = [ - 'Just started', - 'Just started', - 'Ongoing', - 'Halfway through', - 'Nearly over', - 'About to finish', - 'Game is over', - ]; - - return texts[Math.floor((percent / 100) * (texts.length - 1))]; - } - private setServerInfoLabelText() { - this.serverNameElement.innerText = `${this.content.serverName} - ${this.content.playerCount}/${this.content.playerLimit} players`; - this.completionElement.innerText = this.getRoundCompletionText( - this.content.gameStatePercent, - ); - } - - public get element(): HTMLElement { - return this.divElement; + const { serverName, playerCount, playerLimit, gameStatePercent } = this.content; + this.serverNameElement.innerText = `${serverName} - ${playerCount}/${playerLimit} players`; + this.completionElement.innerText = + roundCompletionTexts[ + Math.floor((gameStatePercent / 100) * (roundCompletionTexts.length - 1)) + ]; } } diff --git a/frontend/src/scripts/landing-page-background.ts b/frontend/src/scripts/landing-page-background.ts index bafe559..68941f0 100644 --- a/frontend/src/scripts/landing-page-background.ts +++ b/frontend/src/scripts/landing-page-background.ts @@ -1,52 +1,52 @@ -import { vec2 } from 'gl-matrix'; +import { ReadonlyVec2, vec2, vec3 } from 'gl-matrix'; import { CircleLight, FilteringOptions, - hsl, Renderer, renderNoise, runAnimation, WrapOptions, } from 'sdf-2d'; -import { settings, rgb, PlanetBase, Random } from 'shared'; +import { settings, PlanetBase, Random, hsl } from 'shared'; import { PlanetShape } from './shapes/planet-shape'; -// PlanetShape colours by mixing blue (0) -> red (1). The two backdrop planets -// read as the two in-game teams; the red planet is pulled a little off the -// pure-red end so it shows as a more muted, less saturated red. const bluePlanet = 0; const redPlanet = 0.85; export class LandingPageBackground { + public readonly renderer: Promise; + private isActive = true; - public renderer: Promise; private resolveRenderer!: (r: Renderer) => unknown; + private scene?: { + canvasSize: vec2; + topPlanet: PlanetShape; + bottomPlanet: PlanetShape; + planetAngle: number; + planetDistance: number; + }; constructor(canvas: HTMLCanvasElement) { - this.start(canvas); - this.renderer = new Promise((r) => (this.resolveRenderer = r)); + this.renderer = new Promise((resolve, reject) => { + this.resolveRenderer = resolve; + void this.start(canvas).catch(reject); + }); } private async start(canvas: HTMLCanvasElement): Promise { const noiseTexture = await renderNoise([256, 256], 1.2, 2); - runAnimation( + await runAnimation( canvas, [ - { - ...PlanetShape.descriptor, - shaderCombinationSteps: [0, 1, 2], - }, - { - ...CircleLight.descriptor, - shaderCombinationSteps: [0, 2], - }, + { ...PlanetShape.descriptor, shaderCombinationSteps: [0, 1, 2] }, + { ...CircleLight.descriptor, shaderCombinationSteps: [0, 2] }, ], this.gameLoop.bind(this), { shadowTraceCount: 16, paletteSize: 1, - ambientLight: rgb(0, 0, 0), + ambientLight: vec3.fromValues(0, 0, 0), lightCutoffDistance: settings.lightCutoffDistance, textures: { noiseTexture: { @@ -62,84 +62,77 @@ export class LandingPageBackground { ); } - private gameLoop(renderer: Renderer, time: DOMHighResTimeStamp, _: number): boolean { + private buildScene(canvasSize: ReadonlyVec2) { Random.seed = 42; - this.resolveRenderer(renderer); - - renderer.setViewArea(vec2.fromValues(0, renderer.canvasSize.y), renderer.canvasSize); - - const topPlanetPosition = vec2.fromValues( - 0.7 * renderer.canvasSize.x, - 0.7 * renderer.canvasSize.y, - ); - + const topPosition = vec2.scale(vec2.create(), canvasSize, 0.7); const topPlanet = new PlanetShape( PlanetBase.createPlanetVertices( - topPlanetPosition, + topPosition, Random.getRandomInRange(150, 400), Random.getRandomInRange(150, 400), Random.getRandomInRange(10, 20), ), redPlanet, ); - - // Fixed terrain phase (no longer animated -> no pulsing); the planet spins - // instead, the same way in-game planets do: PlanetShape's rotation uniform - // turns the whole body, terrain and outline together, in its own frame. - // Speeds sit in the game's per-planet range (~0.05-0.12 rad/s) and - // counter-rotate so the two planets don't drift in lockstep. topPlanet.randomOffset = Random.getRandom(); - topPlanet.rotation = (time / 1000) * 0.09; - - const bottomPlanetPosition = vec2.fromValues( - 0.3 * renderer.canvasSize.x, - 0.3 * renderer.canvasSize.y, - ); + const bottomPosition = vec2.scale(vec2.create(), canvasSize, 0.3); const bottomPlanet = new PlanetShape( PlanetBase.createPlanetVertices( - bottomPlanetPosition, + bottomPosition, Random.getRandomInRange(150, 800), Random.getRandomInRange(150, 400), Random.getRandomInRange(10, 40), ), bluePlanet, ); - bottomPlanet.randomOffset = Random.getRandom(); - bottomPlanet.rotation = (time / 1000) * -0.06; - const planetDistance = vec2.subtract( - vec2.create(), - topPlanetPosition, - bottomPlanetPosition, - ); - const planetDistanceLength = vec2.length(planetDistance); - const planetDirection = vec2.normalize(planetDistance, planetDistance); - const planetAngle = Math.atan2(planetDirection.y, planetDirection.x); + const between = vec2.subtract(vec2.create(), topPosition, bottomPosition); + this.scene = { + canvasSize: vec2.clone(canvasSize), + topPlanet, + bottomPlanet, + planetAngle: Math.atan2(between[1], between[0]), + planetDistance: vec2.length(between), + }; + return this.scene; + } - renderer.addDrawable(topPlanet); - renderer.addDrawable(bottomPlanet); + private gameLoop(renderer: Renderer, time: DOMHighResTimeStamp): boolean { + this.resolveRenderer(renderer); + renderer.setViewArea(vec2.fromValues(0, renderer.canvasSize[1]), renderer.canvasSize); + + const scene = + this.scene && vec2.exactEquals(this.scene.canvasSize, renderer.canvasSize) + ? this.scene + : this.buildScene(renderer.canvasSize); + + scene.topPlanet.rotation = (time / 1000) * 0.09; + scene.bottomPlanet.rotation = (time / 1000) * -0.06; + renderer.addDrawable(scene.topPlanet); + renderer.addDrawable(scene.bottomPlanet); + + const lightDistance = scene.planetDistance * 1.2; renderer.addDrawable( new CircleLight( this.calculateLightPosition( renderer, - planetAngle, - planetDistanceLength * 1.2, + scene.planetAngle, + lightDistance, -time / 3000, ), hsl(25, 75, 60), 0.75, ), ); - renderer.addDrawable( new CircleLight( this.calculateLightPosition( renderer, - planetAngle, - planetDistanceLength * 1.2, + scene.planetAngle, + lightDistance, time / 2000 + Math.PI, ), hsl(249, 79, 70), @@ -160,12 +153,9 @@ export class LandingPageBackground { length * Math.sin(t), length * Math.sin(t) * Math.cos(t), ); - const canvasCenter = vec2.scale(vec2.create(), renderer.canvasSize, 0.5); - vec2.add(lightPosition, lightPosition, canvasCenter); - vec2.rotate(lightPosition, lightPosition, canvasCenter, angle); - return lightPosition; + return vec2.rotate(lightPosition, lightPosition, canvasCenter, angle); } public destroy() { diff --git a/frontend/src/scripts/minimap.ts b/frontend/src/scripts/minimap.ts index 64267e8..a9187b2 100644 --- a/frontend/src/scripts/minimap.ts +++ b/frontend/src/scripts/minimap.ts @@ -1,27 +1,14 @@ import { vec2 } from 'gl-matrix'; -import { CharacterTeam, Id, settings } from 'shared'; +import { CharacterTeam, Id, MinimapPlayer, settings } from 'shared'; -export interface MinimapBlip { - id: Id; - position: vec2; - team: CharacterTeam; -} - -// Top-down radar of the whole circular arena, pinned to the top-left. The map's -// circular border is the world boundary (its radius maps to worldRadius), so the -// local player's bright dot reads as a true position in the arena and every other -// living player shows as a team-coloured dot. Owns its own , so the Game -// just appends `element` to the overlay and feeds it `update()` each frame. -// Replaces the off-screen chevrons that used to point at other players. export class Minimap { public readonly element = document.createElement('canvas'); private readonly ctx: CanvasRenderingContext2D; private readonly colors: Record; - // World-space positions, smoothed per player so the 25 Hz snapshots glide - // rather than step between frames. private readonly smoothed = new Map(); private bufferSize = 0; + private size = 0; constructor() { this.element.className = 'minimap'; @@ -37,10 +24,16 @@ export class Minimap { }; } - public update(localPosition: vec2 | undefined, players: Array) { - const size = this.element.clientWidth; + // Reading clientWidth after this frame's style writes would force a synchronous layout of + // the whole overlay inside the animation frame; the size only changes when the window does. + public measure() { + this.size = this.element.clientWidth; + } + + public update(localPosition: vec2 | undefined, players: Array) { + const size = this.size; if (size === 0) { - return; // not laid out yet + return; } this.syncBufferSize(size); @@ -52,8 +45,8 @@ export class Minimap { const scale = innerRadius / settings.worldRadius; const toMap = (world: vec2): { x: number; y: number } => { - let dx = world.x * scale; - let dy = -world.y * scale; // world Y points up, canvas Y points down + let dx = world[0] * scale; + let dy = -world[1] * scale; const distance = Math.hypot(dx, dy); if (distance > innerRadius) { dx = (dx / distance) * innerRadius; @@ -62,7 +55,6 @@ export class Minimap { return { x: center + dx, y: center + dy }; }; - // Faint marker at the world's centre to aid orientation. ctx.fillStyle = 'rgba(255, 255, 255, 0.12)'; ctx.beginPath(); ctx.arc(center, center, 1.5, 0, Math.PI * 2); @@ -87,8 +79,6 @@ export class Minimap { } } - // The local player rides on top, drawn in white with a ring so "you" is - // unmistakable amongst the team-coloured dots. if (localPosition) { const { x, y } = toMap(localPosition); this.drawDot(x, y, 3.5, '#ffffff'); @@ -119,8 +109,6 @@ export class Minimap { this.element.width = target; this.element.height = target; } - // Setting the buffer size resets the transform, so (re)establish it every - // frame and draw in CSS pixels regardless of the device pixel ratio. this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } } diff --git a/frontend/src/scripts/objects/game-object-container.ts b/frontend/src/scripts/objects/game-object-container.ts index 2b03836..a469ff1 100644 --- a/frontend/src/scripts/objects/game-object-container.ts +++ b/frontend/src/scripts/objects/game-object-container.ts @@ -1,172 +1,250 @@ -import { vec2 } from 'gl-matrix'; +import { Renderer } from 'sdf-2d'; import { Circle, - Command, + CharacterBase, CommandExecutors, CommandReceiver, CreateObjectsCommand, CreatePlayerCommand, DeleteObjectsCommand, - GameObject, Id, PropertyUpdatesForObjects, + RemoteCall, RemoteCallsForObjects, - settings, UpdatePropertyCommand, } from 'shared'; -import { BeforeDestroyCommand } from '../commands/types/before-destroy'; -import { StepCommand } from '../commands/types/step'; import { FeedbackHud } from '../feedback-hud'; -import { Game } from '../game'; +import type { Game } from '../game'; import { serverTimeline } from '../helper/server-timeline'; -import { PredictablePlanet } from '../helper/prediction/client-character-world'; import { localCharacterPredictor } from '../helper/prediction/local-character-predictor'; import { Camera } from './types/camera'; import { CharacterView } from './types/character-view'; import { PlanetView } from './types/planet-view'; +import { View } from './view'; + +// Keep enough history for interpolation, without retaining a background tab's entire session. +const deferredHistorySeconds = 0.5; +const persistentRemoteCalls = new Set(['setKillCount', 'setLight', 'setContested']); export class GameObjectContainer extends CommandReceiver { - protected objects: Map = new Map(); - public player!: CharacterView; - public camera: Camera = new Camera(this.game); + public player?: CharacterView; + public readonly camera: Camera; + public planets: Array = []; + + private objects: Map = new Map(); private wasLocalPlayerAlive = false; + private lastStepAtMs = performance.now(); + + // Creates and deletes take effect on the render timeline, not on arrival. + private visibleFrom = new Map(); + private deleteAt = new Map(); + private awaitingCreateStamp: Array = []; + private awaitingDeleteStamp: Array = []; + private remoteCalls: Array<{ object: View; calls: Array; at?: number }> = + []; protected commandExecutors: CommandExecutors = { - [CreatePlayerCommand.type]: (c: CreatePlayerCommand) => { + [CreatePlayerCommand.name]: (c: CreatePlayerCommand) => { this.player = c.character as CharacterView; this.player.isMainCharacter = true; this.addObject(this.player); - // Fresh character (first spawn or respawn at a far planet): drop any - // prediction state so it snaps to the new body instead of gliding across. localCharacterPredictor.reset(); - // Respawned — clear the elimination overlay. + this.game.resendMovement(); FeedbackHud.hideElimination(); this.wasLocalPlayerAlive = true; }, - [CreateObjectsCommand.type]: (c: CreateObjectsCommand) => - c.objects.forEach((o) => this.addObject(o as GameObject)), + [CreateObjectsCommand.name]: (c: CreateObjectsCommand) => + c.objects.forEach((o) => { + this.addObject(o as View); + this.awaitingCreateStamp.push(o.id); + }), - [StepCommand.type]: (c: StepCommand) => { - this.defaultCommandExecutor(c); - - // The local body is alive only while its object still exists (the server - // deletes it on death) and its health is above zero — `player` keeps - // pointing at the now-stale view after death, so both checks are needed. - const bodyPresent = !!this.player && this.objects.has(this.player.id); - const alive = bodyPresent && this.player.health > 0; - - // Show the elimination overlay on the alive→dead edge; CreatePlayerCommand - // clears it on respawn. - if (this.wasLocalPlayerAlive && !alive) { - FeedbackHud.showElimination(); - } - this.wasLocalPlayerAlive = alive; - - if (bodyPresent) { - // Override the interpolated pose of the local player with the predicted - // one so it responds to input immediately. Suppressed while dead so the - // corpse can't be walked around (the server ignores a dead player's - // input — a moving predicted body would be a pure client-side desync). - // A large correction (respawn / death) snaps inside the predictor. - localCharacterPredictor.setAlive(alive); - localCharacterPredictor.setStrength( - this.player.strengthFraction * settings.playerMaxStrength, - ); - if ( - localCharacterPredictor.update(this.predictablePlanets(), c.deltaTimeInSeconds) - ) { - this.player.head = localCharacterPredictor.head; - this.player.leftFoot = localCharacterPredictor.leftFoot; - this.player.rightFoot = localCharacterPredictor.rightFoot; + [RemoteCallsForObjects.name]: (c: RemoteCallsForObjects) => + c.callsForObjects.forEach(({ id, calls }) => { + const object = this.objects.get(id); + if (object === this.player) { + object?.processRemoteCalls(calls); + } else if (object) { + this.remoteCalls.push({ object, calls }); } - this.camera.follow(this.player.position, c.deltaTimeInSeconds); - } - }, + }), - [RemoteCallsForObjects.type]: (c: RemoteCallsForObjects) => - c.callsForObjects.forEach((c) => - this.objects.get(c.id)?.processRemoteCalls(c.calls), - ), - - [PropertyUpdatesForObjects.type]: (c: PropertyUpdatesForObjects) => { + [PropertyUpdatesForObjects.name]: (c: PropertyUpdatesForObjects) => { serverTimeline.onSnapshot(c.timestamp); + this.stampPending(c.timestamp); + this.pruneDeferred(c.timestamp - deferredHistorySeconds); c.updates.forEach((u) => { - u.updates.forEach((au) => this.objects.get(u.id)?.handleCommand(au)); - if (this.player && u.id === this.player.id) { + const object = this.objects.get(u.id); + u.updates.forEach((au) => object?.updateProperty?.(au)); + if (object && object === this.player) { this.feedPredictor(u.updates); } }); }, - [DeleteObjectsCommand.type]: (c: DeleteObjectsCommand) => - c.ids.forEach((id: Id) => this.deleteObject(id)), + [DeleteObjectsCommand.name]: (c: DeleteObjectsCommand) => + c.ids.forEach((id: Id) => this.awaitingDeleteStamp.push(id)), }; - constructor(private game: Game) { + constructor(private readonly game: Game) { super(); + this.camera = new Camera(game); } - // The local player's world position, but only while the body is alive. On - // death the server deletes the character object (yet `player` keeps pointing - // at the now-stale view), so gate on the object still being present — otherwise - // the minimap would pin the "you" dot at the death spot for the whole respawn. - public get localPlayerPosition(): vec2 | undefined { - return this.player && this.objects.has(this.player.id) - ? this.player.position - : undefined; + public reset() { + this.objects.forEach((o) => o.beforeDestroy?.()); + this.objects.clear(); + this.player = undefined; + this.planets = []; + this.visibleFrom.clear(); + this.deleteAt.clear(); + this.awaitingCreateStamp = []; + this.awaitingDeleteStamp = []; + this.remoteCalls = []; + this.wasLocalPlayerAlive = false; + this.lastStepAtMs = performance.now(); } - public get planets(): Array { - const planets: Array = []; + public get localPlayer(): CharacterView | undefined { + return this.player && this.objects.has(this.player.id) ? this.player : undefined; + } + + public step(deltaTimeInSeconds: number) { + this.stampPending(serverTimeline.snapshotTime); + const nowMs = performance.now(); + const resumed = nowMs - this.lastStepAtMs > deferredHistorySeconds * 1000; + this.lastStepAtMs = nowMs; + this.pruneDeferred( + serverTimeline.renderTime - (resumed ? 0 : deferredHistorySeconds), + ); + this.retireDueObjects(); + this.remoteCalls = this.remoteCalls.filter(({ object, calls, at }) => { + if (this.objects.get(object.id) !== object) { + return false; + } + if (at !== undefined && at <= serverTimeline.renderTime) { + object.processRemoteCalls(calls); + return false; + } + return true; + }); + this.objects.forEach((o) => o.step(deltaTimeInSeconds)); + + const player = this.localPlayer; + const alive = !!player && player.health > 0; + if (this.wasLocalPlayerAlive && !alive) { + FeedbackHud.showElimination(); + } + this.wasLocalPlayerAlive = alive; + + if (player) { + localCharacterPredictor.setAlive(alive); + localCharacterPredictor.setStrength(player.snapshotStrength); + if (localCharacterPredictor.update(this.planets)) { + player.head = localCharacterPredictor.head; + player.leftFoot = localCharacterPredictor.leftFoot; + player.rightFoot = localCharacterPredictor.rightFoot; + } + this.camera.follow(player.position); + } + } + + public render(renderer: Renderer, overlay: HTMLElement, shouldChangeLayout: boolean) { + // First: everything below projects world coordinates through this frame's view area. + this.camera.draw(renderer); this.objects.forEach((o) => { - if (o instanceof PlanetView) { - planets.push(o); + if (this.isVisible(o.id)) { + o.render(renderer, overlay, shouldChangeLayout); } }); - return planets; } - protected defaultCommandExecutor(c: Command) { - this.objects.forEach((o) => o.handleCommand(c)); - this.camera.handleCommand(c); + private isVisible(id: Id): boolean { + const from = this.visibleFrom.get(id); + return from === undefined || serverTimeline.renderTime >= from; + } + + private stampPending(timestamp: number) { + for (const id of this.awaitingCreateStamp) { + this.visibleFrom.set(id, timestamp); + } + this.awaitingCreateStamp = []; + + for (const id of this.awaitingDeleteStamp) { + this.deleteAt.set(id, timestamp); + } + this.awaitingDeleteStamp = []; + for (const call of this.remoteCalls) { + call.at ??= timestamp; + } + } + + private pruneDeferred(cutoff: number) { + this.retireDueObjects(cutoff); + this.remoteCalls = this.remoteCalls.filter(({ object, calls, at }) => { + if (at === undefined || at > cutoff) { + return true; + } + for (const call of calls) { + if (call.functionName === 'setHealth' && object instanceof CharacterBase) { + // The view's health setter also plays hit feedback; expired damage is state only. + object.health = call.args[0]; + } else if (persistentRemoteCalls.has(call.functionName)) { + object.processRemoteCalls([call]); + } + } + return false; + }); + } + + private retireDueObjects(renderTime = serverTimeline.renderTime) { + for (const [id, at] of this.deleteAt) { + if (renderTime >= at) { + this.deleteObject(id); + } + } } - // Hand the local player's raw authoritative pose to the predictor (the - // interpolated pose would already be ~100 ms stale). The three body parts - // arrive together in one snapshot. private feedPredictor(updates: Array) { - let head: Circle | undefined; - let leftFoot: Circle | undefined; - let rightFoot: Circle | undefined; + const pose: Partial> = {}; for (const u of updates) { - if (u.propertyKey === 'head') head = u.propertyValue as Circle; - else if (u.propertyKey === 'leftFoot') leftFoot = u.propertyValue as Circle; - else if (u.propertyKey === 'rightFoot') rightFoot = u.propertyValue as Circle; + if ( + u.propertyKey === 'head' || + u.propertyKey === 'leftFoot' || + u.propertyKey === 'rightFoot' + ) { + pose[u.propertyKey] = u.propertyValue; + } } - if (head && leftFoot && rightFoot) { - localCharacterPredictor.setAuthoritative(head, leftFoot, rightFoot); + if (pose.head && pose.leftFoot && pose.rightFoot) { + localCharacterPredictor.setAuthoritative(pose.head, pose.leftFoot, pose.rightFoot); } } - private predictablePlanets(): Array { - return this.planets.map((p) => ({ - id: p.id, - vertices: p.vertices, - center: p.center, - radius: p.radius, - rotation: p.predictionRotation, - rotationSpeed: p.predictionRotationSpeed, - })); - } - - private addObject(object: GameObject) { + private addObject(object: View) { + // An object can re-enter the interest area before its delayed deletion is drawn. + // Retire the old view and its pending deletion before installing the replacement. + if (this.objects.has(object.id)) { + this.deleteObject(object.id); + } + this.awaitingDeleteStamp = this.awaitingDeleteStamp.filter((id) => id !== object.id); this.objects.set(object.id, object); + if (object instanceof PlanetView) { + this.planets.push(object); + } } private deleteObject(id: Id) { const object = this.objects.get(id); - object?.handleCommand(new BeforeDestroyCommand()); + object?.beforeDestroy?.(); this.objects.delete(id); + this.remoteCalls = this.remoteCalls.filter((call) => call.object !== object); + if (this.player === object) { + this.player = undefined; + } + this.planets = this.planets.filter((p) => p.id !== id); + this.visibleFrom.delete(id); + this.deleteAt.delete(id); } } diff --git a/frontend/src/scripts/objects/types/camera.ts b/frontend/src/scripts/objects/types/camera.ts index c607c38..cf95754 100644 --- a/frontend/src/scripts/objects/types/camera.ts +++ b/frontend/src/scripts/objects/types/camera.ts @@ -1,56 +1,26 @@ import { vec2 } from 'gl-matrix'; -import { - calculateViewArea, - CommandExecutors, - CommandReceiver, - mixRgb, - settings, -} from 'shared'; -import { RenderCommand } from '../../commands/types/render'; -import { Game } from '../../game'; +import { Renderer } from 'sdf-2d'; +import { calculateViewArea, mixRgb, settings } from 'shared'; +import type { Game } from '../../game'; import { ScreenShake } from '../../screen-shake'; -export class Camera extends CommandReceiver { - public center: vec2 = vec2.create(); +export class Camera { + public readonly center: vec2 = vec2.create(); private aspectRatio?: number; - // A short exponential lag masks any residual stepping in the followed - // position without the camera noticeably trailing during normal movement. - private static readonly followSeconds = 0.08; + constructor(private readonly game: Game) {} - // Swooshing across half the map after a respawn would be disorienting; - // beyond this distance the camera cuts instead. - private static readonly snapDistance = 1500; - - constructor(private game: Game) { - super(); + public follow(target: vec2) { + vec2.copy(this.center, target); } - public follow(target: vec2, deltaTimeInSeconds: number) { - if (vec2.distance(target, this.center) > Camera.snapDistance) { - vec2.copy(this.center, target); - return; - } - - const q = 1 - Math.exp(-deltaTimeInSeconds / Camera.followSeconds); - vec2.lerp(this.center, this.center, target, q); - } - - protected commandExecutors: CommandExecutors = { - [RenderCommand.type]: this.draw.bind(this), - }; - - private draw({ renderer }: RenderCommand) { - const canvasAspectRatio = renderer.canvasSize.x / renderer.canvasSize.y; + public draw(renderer: Renderer) { + const canvasAspectRatio = renderer.canvasSize[0] / renderer.canvasSize[1]; if (canvasAspectRatio !== this.aspectRatio) { this.aspectRatio = canvasAspectRatio; this.game.aspectRatioChanged(canvasAspectRatio); } - // Shake displaces only the rendered view centre and the zoom-punch shrinks - // only the rendered view area — neither touches the followed position, so - // impacts jolt the frame without nudging the camera off the player. Passing - // the zoom as oversizeRatio scales the area about the (shaken) centre. const shakenCenter = vec2.fromValues( this.center[0] + ScreenShake.offsetX, this.center[1] + ScreenShake.offsetY, @@ -59,12 +29,9 @@ export class Camera extends CommandReceiver { const viewArea = calculateViewArea(shakenCenter, canvasAspectRatio, scale * scale); renderer.setViewArea(viewArea.topLeft, viewArea.size); + const [near, far] = settings.backgroundGradient; renderer.setRuntimeSettings({ - ambientLight: mixRgb( - settings.backgroundGradient[0], - settings.backgroundGradient[1], - vec2.length(this.center) / settings.worldRadius, - ), + ambientLight: mixRgb(near, far, vec2.length(this.center) / settings.worldRadius), }); } } diff --git a/frontend/src/scripts/objects/types/character-view.ts b/frontend/src/scripts/objects/types/character-view.ts index 5c0d751..4169696 100644 --- a/frontend/src/scripts/objects/types/character-view.ts +++ b/frontend/src/scripts/objects/types/character-view.ts @@ -1,7 +1,7 @@ import { vec2, vec3 } from 'gl-matrix'; import { CircleLight, Renderer } from 'sdf-2d'; - import { + characterCenter, Circle, Id, CharacterBase, @@ -10,27 +10,22 @@ import { clamp, clamp01, mix, - CommandExecutors, + strengthToCharge, UpdatePropertyCommand, } from 'shared'; -import { BeforeDestroyCommand } from '../../commands/types/before-destroy'; -import { RenderCommand } from '../../commands/types/render'; -import { StepCommand } from '../../commands/types/step'; +import { centeredTransform } from '../../helper/centered-transform'; import { CircleInterpolator } from '../../helper/interpolators/circle-interpolator'; import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator'; -import { Pointer } from '../../helper/pointer'; +import { getDisplayPosition } from '../../helper/pointer'; import { CharacterShape } from '../../shapes/character-shape'; import { SoundHandler, Sounds } from '../../sound-handler'; import { VibrationHandler } from '../../vibration-handler'; import { FeedbackHud } from '../../feedback-hud'; import { ScreenShake } from '../../screen-shake'; +import { View } from '../view'; const muzzleFlashDecaySeconds = 0.12; const hitFlashDecaySeconds = 0.15; - -// A white-hot pop of light thrown at the spot a character dies, seen by everyone -// who can see the body. No radius knob on a CircleLight, so the burst is sold by -// a bright (HDR) colour with a fast-decaying intensity envelope. const deathBurstDecaySeconds = 0.42; const deathBurstMaxIntensity = 1.7; const deathBurstColor = vec3.fromValues(2.5, 2.3, 2.1); @@ -42,34 +37,30 @@ const deathIcon = ''; -export class CharacterView extends CharacterBase { - private shape: CharacterShape; - private muzzleFlash: CircleLight; +export class CharacterView extends CharacterBase implements View { + public isMainCharacter = false; + public snapshotStrength = settings.playerMaxStrength; + + private readonly shape: CharacterShape; + private readonly muzzleFlash: CircleLight; + private readonly deathBurst: CircleLight; private muzzleFlashIntensity = 0; private hitFlashIntensity = 0; - private deathBurst: CircleLight; private deathBurstIntensity = 0; private strength = settings.playerMaxStrength; - private strengthInterpolator = new LinearInterpolator(settings.playerMaxStrength); - private nameElement: HTMLElement = document.createElement('div'); - private statsElement: HTMLElement = document.createElement('div'); - private killCountElement: HTMLElement = document.createElement('span'); - private deathCountElement: HTMLElement = document.createElement('span'); - private healthElement: HTMLElement = document.createElement('div'); - private chargeElement: HTMLElement = document.createElement('div'); - public isMainCharacter = false; + private readonly headInterpolator: CircleInterpolator; + private readonly leftFootInterpolator: CircleInterpolator; + private readonly rightFootInterpolator: CircleInterpolator; + private readonly strengthInterpolator = new LinearInterpolator( + settings.playerMaxStrength, + ); - private leftFootInterpolator: CircleInterpolator; - private rightFootInterpolator: CircleInterpolator; - private headInterpolator: CircleInterpolator; - - protected commandExecutors: CommandExecutors = { - [RenderCommand.type]: this.draw.bind(this), - [StepCommand.type]: this.step.bind(this), - [BeforeDestroyCommand.type]: this.beforeDestroy.bind(this), - [UpdatePropertyCommand.type]: this.updateProperty.bind(this), - }; + private readonly nameElement = document.createElement('div'); + private readonly healthElement = document.createElement('div'); + private readonly chargeElement = document.createElement('div'); + private readonly killCountElement = document.createElement('span'); + private readonly deathCountElement = document.createElement('span'); constructor( id: Id, @@ -78,29 +69,30 @@ export class CharacterView extends CharacterBase { deathCount: number, team: CharacterTeam, health: number, - head?: Circle, - leftFoot?: Circle, - rightFoot?: Circle, + head: Circle, + leftFoot: Circle, + rightFoot: Circle, ) { super(id, name, killCount, deathCount, team, health, head, leftFoot, rightFoot); this.shape = new CharacterShape(settings.colorIndices[team]); this.muzzleFlash = new CircleLight( - vec2.clone(this.head!.center), + vec2.clone(head.center), settings.paletteDim[settings.colorIndices[team]], 0, ); - this.deathBurst = new CircleLight(vec2.clone(this.head!.center), deathBurstColor, 0); + this.deathBurst = new CircleLight(vec2.clone(head.center), deathBurstColor, 0); - this.leftFootInterpolator = new CircleInterpolator(this.leftFoot!); - this.rightFootInterpolator = new CircleInterpolator(this.rightFoot!); - this.headInterpolator = new CircleInterpolator(this.head!); + this.headInterpolator = new CircleInterpolator(head); + this.leftFootInterpolator = new CircleInterpolator(leftFoot); + this.rightFootInterpolator = new CircleInterpolator(rightFoot); this.nameElement.className = 'player-tag ' + this.team; this.nameElement.innerText = this.name; this.healthElement.className = 'health'; this.chargeElement.className = 'charge'; - this.statsElement.className = 'stats'; + const stats = document.createElement('div'); + stats.className = 'stats'; this.killCountElement.className = 'value'; this.deathCountElement.className = 'value'; const killStat = document.createElement('span'); @@ -111,31 +103,27 @@ export class CharacterView extends CharacterBase { deathStat.className = 'stat deaths'; deathStat.innerHTML = deathIcon; deathStat.appendChild(this.deathCountElement); - this.statsElement.append(killStat, deathStat); + stats.append(killStat, deathStat); - this.nameElement.appendChild(this.healthElement); - this.nameElement.appendChild(this.chargeElement); - this.nameElement.appendChild(this.statsElement); + this.nameElement.append(this.healthElement, this.chargeElement, stats); } public get position(): vec2 { - return this.head!.center; + return this.head.center; } public get bodyCenter(): vec2 { - const center = vec2.add(vec2.create(), this.head!.center, this.leftFoot!.center); - vec2.add(center, center, this.rightFoot!.center); - return vec2.scale(center, center, 1 / 3); + return characterCenter(this.head, this.leftFoot, this.rightFoot); } public get facingDirection(): vec2 { const footAverage = vec2.add( vec2.create(), - this.leftFoot!.center, - this.rightFoot!.center, + this.leftFoot.center, + this.rightFoot.center, ); vec2.scale(footAverage, footAverage, 0.5); - const forward = vec2.subtract(footAverage, this.head!.center, footAverage); + const forward = vec2.subtract(footAverage, this.head.center, footAverage); return vec2.length(forward) > 0 ? vec2.normalize(forward, forward) : vec2.fromValues(0, 1); @@ -145,21 +133,19 @@ export class CharacterView extends CharacterBase { return clamp01(this.strength / settings.playerMaxStrength); } - private updateProperty({ + public updateProperty({ propertyKey, propertyValue, rateOfChange, }: UpdatePropertyCommand) { if (propertyKey === 'head') { this.headInterpolator.addFrame(propertyValue, rateOfChange); - } - if (propertyKey === 'leftFoot') { + } else if (propertyKey === 'leftFoot') { this.leftFootInterpolator.addFrame(propertyValue, rateOfChange); - } - if (propertyKey === 'rightFoot') { + } else if (propertyKey === 'rightFoot') { this.rightFootInterpolator.addFrame(propertyValue, rateOfChange); - } - if (propertyKey === 'strength') { + } else if (propertyKey === 'strength') { + this.snapshotStrength = propertyValue; this.strengthInterpolator.addFrame(propertyValue, rateOfChange); } } @@ -177,8 +163,6 @@ export class CharacterView extends CharacterBase { if (this.isMainCharacter) { VibrationHandler.vibrate(Math.min(200, damage * 4)); - // Getting hit jolts the frame too, so taking fire has weight, not just - // dealing it. ScreenShake.add(clamp01(0.12 + (0.5 * damage) / settings.playerMaxStrength)); } } @@ -188,9 +172,6 @@ export class CharacterView extends CharacterBase { if (this.isMainCharacter) { VibrationHandler.vibrate(150); } - // Visible to everyone who can see the body: a white-hot flash plus a - // full-body whiteout, so a kill reads as a violent burst rather than the - // character quietly blinking out. this.deathBurstIntensity = 1; this.hitFlashIntensity = 1; } @@ -199,8 +180,6 @@ export class CharacterView extends CharacterBase { if (!this.isMainCharacter) { return; } - // Layer a meaty thud under the crisp confirmation tick; a charged hit lands - // lower and harder than a panic tap. SoundHandler.play(Sounds.hit, mix(0.35, 0.7, charge), mix(1.3, 0.95, charge)); SoundHandler.play(Sounds.click, mix(0.4, 0.75, charge), mix(1.7, 1.1, charge)); ScreenShake.add(mix(0.22, 0.5, charge)); @@ -212,9 +191,6 @@ export class CharacterView extends CharacterBase { if (!this.isMainCharacter) { return; } - // A heavy low thud for the kill with a brighter confirmation over the top, - // a hard frame jolt, a zoom-punch toward the action for weight, and a - // double-thump rumble. SoundHandler.play(Sounds.hit, 1, mix(0.62, 0.5, charge)); SoundHandler.play(Sounds.click, 0.9, mix(0.6, 0.45, charge)); ScreenShake.add(mix(0.75, 1, charge)); @@ -224,16 +200,21 @@ export class CharacterView extends CharacterBase { } public onLeap() { - if (!this.isMainCharacter) { - return; + if (this.isMainCharacter) { + SoundHandler.play(Sounds.shoot, 0.3, 1.5); } - SoundHandler.play(Sounds.shoot, 0.3, 1.5); } - private step({ deltaTimeInSeconds }: StepCommand): void { - this.head! = this.headInterpolator.getValue(deltaTimeInSeconds); - this.leftFoot! = this.leftFootInterpolator.getValue(deltaTimeInSeconds); - this.rightFoot! = this.rightFootInterpolator.getValue(deltaTimeInSeconds); + public onShoot(strength: number) { + const q = strengthToCharge(strength); + SoundHandler.play(Sounds.shoot, mix(0.55, 1, q), mix(1.15, 0.8, q)); + this.muzzleFlashIntensity = mix(0.35, 1, q); + } + + public step(deltaTimeInSeconds: number) { + this.head = this.headInterpolator.getValue(deltaTimeInSeconds); + this.leftFoot = this.leftFootInterpolator.getValue(deltaTimeInSeconds); + this.rightFoot = this.rightFootInterpolator.getValue(deltaTimeInSeconds); this.strength = clamp( this.strengthInterpolator.getValue(deltaTimeInSeconds), @@ -246,7 +227,6 @@ export class CharacterView extends CharacterBase { 0, this.muzzleFlashIntensity - deltaTimeInSeconds / muzzleFlashDecaySeconds, ); - this.muzzleFlash.center = this.head!.center; this.muzzleFlash.intensity = this.muzzleFlashIntensity; } @@ -262,39 +242,34 @@ export class CharacterView extends CharacterBase { 0, this.deathBurstIntensity - deltaTimeInSeconds / deathBurstDecaySeconds, ); - this.deathBurst.center = this.bodyCenter; - // Square the envelope so the flash blooms then drops off sharply rather - // than fading out in a flat ramp. this.deathBurst.intensity = deathBurstMaxIntensity * this.deathBurstIntensity * this.deathBurstIntensity; } } - public onShoot(strength: number) { - const q = clamp01( - (strength - settings.chargeShotStrengthMin) / - (settings.chargeShotStrengthMax - settings.chargeShotStrengthMin), - ); - SoundHandler.play(Sounds.shoot, mix(0.55, 1, q), mix(1.15, 0.8, q)); - this.muzzleFlashIntensity = mix(0.35, 1, q); + public beforeDestroy() { + this.nameElement.remove(); } - private beforeDestroy(): void { - this.nameElement.parentElement?.removeChild(this.nameElement); - } - - private draw({ renderer, overlay, shouldChangeLayout }: RenderCommand): void { + public render(renderer: Renderer, overlay: HTMLElement, shouldChangeLayout: boolean) { if (shouldChangeLayout) { if (!this.nameElement.parentElement) { overlay.appendChild(this.nameElement); } const screenPosition = renderer.worldToDisplayCoordinates( - this.calculateTextPosition(), + vec2.scaleAndAdd( + vec2.create(), + this.head.center, + this.facingDirection, + this.head.radius + 80, + ), + ); + this.nameElement.style.transform = centeredTransform( + screenPosition[0], + screenPosition[1], + ' rotate(-15deg)', ); - - this.nameElement.style.transform = `translateX(${screenPosition.x}px) translateY(${screenPosition.y}px) translateX(-50%) translateY(-50%) rotate(-15deg)`; - this.healthElement.style.width = (50 * this.health) / settings.playerMaxHealth + 'px'; this.chargeElement.style.width = @@ -305,43 +280,29 @@ export class CharacterView extends CharacterBase { this.shape.hitFlash = this.hitFlashIntensity; this.shape.gazeTarget = this.calculateGazeTarget(renderer); - this.shape.setCircles([this.head!, this.leftFoot!, this.rightFoot!]); + this.shape.setCircles([this.head, this.leftFoot, this.rightFoot]); renderer.addDrawable(this.shape); if (this.muzzleFlashIntensity > 0) { + this.muzzleFlash.center = this.head.center; renderer.addDrawable(this.muzzleFlash); } - if (this.deathBurstIntensity > 0) { + this.deathBurst.center = this.bodyCenter; renderer.addDrawable(this.deathBurst); } } private calculateGazeTarget(renderer: Renderer): vec2 { - const cursor = Pointer.getDisplayPosition(); + const cursor = getDisplayPosition(); if (this.isMainCharacter && cursor) { return renderer.displayToWorldCoordinates(cursor); } - return vec2.scaleAndAdd( vec2.create(), - this.head!.center, + this.head.center, this.facingDirection, - this.head!.radius * 8, + this.head.radius * 8, ); } - - private calculateTextPosition(): vec2 { - const footAverage = vec2.add( - vec2.create(), - this.leftFoot!.center, - this.rightFoot!.center, - ); - vec2.scale(footAverage, footAverage, 0.5); - - const headFeetDelta = vec2.subtract(footAverage, this.head!.center, footAverage); - vec2.normalize(headFeetDelta, headFeetDelta); - const textOffset = vec2.scale(headFeetDelta, headFeetDelta, this.head!.radius + 80); - return vec2.add(textOffset, this.head!.center, textOffset); - } } diff --git a/frontend/src/scripts/objects/types/lamp-view.ts b/frontend/src/scripts/objects/types/lamp-view.ts index e4db4f6..e4f32ce 100644 --- a/frontend/src/scripts/objects/types/lamp-view.ts +++ b/frontend/src/scripts/objects/types/lamp-view.ts @@ -1,20 +1,13 @@ import { vec2, vec3 } from 'gl-matrix'; -import { CircleLight } from 'sdf-2d'; -import { CommandExecutors, Id, LampBase, mixRgb, settings } from 'shared'; -import { RenderCommand } from '../../commands/types/render'; -import { StepCommand } from '../../commands/types/step'; - -export class LampView extends LampBase { - private light: CircleLight; +import { CircleLight, Renderer } from 'sdf-2d'; +import { Id, LampBase, mix, mixRgb, settings, smoothing } from 'shared'; +import { View } from '../view'; +export class LampView extends LampBase implements View { + private readonly light: CircleLight; private targetColor: vec3; private targetLightness: number; - protected commandExecutors: CommandExecutors = { - [RenderCommand.type]: this.draw.bind(this), - [StepCommand.type]: this.step.bind(this), - }; - constructor(id: Id, center: vec2, color: vec3, lightness: number) { super(id, center, color, lightness); this.light = new CircleLight(vec2.clone(center), vec3.clone(color), lightness); @@ -27,13 +20,13 @@ export class LampView extends LampBase { this.targetLightness = lightness; } - private step({ deltaTimeInSeconds }: StepCommand): void { - const t = 1 - Math.exp(-deltaTimeInSeconds / settings.lampLerpSeconds); + public step(deltaTimeInSeconds: number) { + const t = smoothing(deltaTimeInSeconds, settings.lampLerpSeconds); this.light.color = mixRgb(this.light.color, this.targetColor, t); - this.light.intensity += (this.targetLightness - this.light.intensity) * t; + this.light.intensity = mix(this.light.intensity, this.targetLightness, t); } - private draw({ renderer }: RenderCommand): void { + public render(renderer: Renderer) { renderer.addDrawable(this.light); } } diff --git a/frontend/src/scripts/objects/types/planet-view.ts b/frontend/src/scripts/objects/types/planet-view.ts index 06e6880..a0ded36 100644 --- a/frontend/src/scripts/objects/types/planet-view.ts +++ b/frontend/src/scripts/objects/types/planet-view.ts @@ -1,67 +1,41 @@ import { vec2, vec3 } from 'gl-matrix'; -import { CircleLight } from 'sdf-2d'; +import { CircleLight, Renderer } from 'sdf-2d'; import { Id, Random, PlanetBase, UpdatePropertyCommand, - CommandExecutors, CharacterTeam, settings, } from 'shared'; -import { BeforeDestroyCommand } from '../../commands/types/before-destroy'; -import { RenderCommand } from '../../commands/types/render'; -import { StepCommand } from '../../commands/types/step'; +import { centeredTransform } from '../../helper/centered-transform'; import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator'; import { PlanetShape } from '../../shapes/planet-shape'; +import { View } from '../view'; const fallingPointLifetimeMs = 2000; -// Global budget for simultaneously-lit capture flares, so a clustered wave of -// captures can never white out the SDF exposure — excess flips still pulse the -// ring and toast, they just skip the extra light. The acquire/release pair keeps -// the count in one place instead of being hand-maintained at every call site. -abstract class FlareBudget { - private static active = 0; +let activeFlares = 0; - public static tryAcquire(): boolean { - if (FlareBudget.active >= settings.maxConcurrentFlipFlares) { - return false; - } - FlareBudget.active++; - return true; - } +export class PlanetView extends PlanetBase implements View { + // Rotation as of the newest snapshot: the predictor collides at this phase, not the drawn one. + public snapshotRotation = 0; + public snapshotRotationSpeed = 0; - public static release(): void { - FlareBudget.active = Math.max(0, FlareBudget.active - 1); - } -} - -export class PlanetView extends PlanetBase { - private shape: PlanetShape; - private ownershipProgress: HTMLElement; - // Rotation is owned by the backend (it drives the collision polygon too) and - // streamed in; the interpolator replays the angle on the shared snapshot - // timeline, in sync with the characters standing on the surface. + private readonly shape: PlanetShape; + private readonly ownershipProgress = document.createElement('div'); private readonly rotationInterpolator = new LinearInterpolator(0); private flareLight?: CircleLight; private flareIntensity = 0; private holdsFlareSlot = false; - - protected commandExecutors: CommandExecutors = { - [RenderCommand.type]: this.draw.bind(this), - [StepCommand.type]: this.step.bind(this), - [BeforeDestroyCommand.type]: this.beforeDestroy.bind(this), - [UpdatePropertyCommand.type]: this.updateProperty.bind(this), - }; + private lastGeneratedPoint?: number; + private lastGradient?: string; constructor(id: Id, vertices: Array, ownership = 0.5, isKeystone = false) { super(id, vertices, ownership, isKeystone); this.shape = new PlanetShape(vertices, ownership); this.shape.randomOffset = Random.getRandom(); - - this.ownershipProgress = document.createElement('div'); this.ownershipProgress.className = 'ownership' + (isKeystone ? ' keystone' : ''); } @@ -69,58 +43,11 @@ export class PlanetView extends PlanetBase { this.ownershipProgress.classList.toggle('contested', contested); } - private renderedRotation = 0; - private rotationSpeed = 0; - // Newest streamed rotation VALUE — the server-current angle at the latest - // snapshot — as opposed to renderedRotation, which the interpolator holds - // ~interpolationDelaySeconds in the PAST for drawing. The predictor seeds the - // local body from the same snapshot's pose, so it must collide against the - // planet at THIS (newest) phase and advance forward from it; using the drawn - // lagged angle biases the body off the surface by omega*delay*radius and - // wobbles it whenever the spin or the interpolator's rate cursor varies. - private latestRotation = 0; - public get predictionRotation(): number { - return this.latestRotation; - } - public get predictionRotationSpeed(): number { - return this.rotationSpeed; - } - - private step({ deltaTimeInSeconds }: StepCommand): void { - this.renderedRotation = this.rotationInterpolator.getValue(deltaTimeInSeconds); - this.shape.rotation = this.renderedRotation; - this.shape.colorMixQ = this.ownership; - - if (this.flareIntensity > 0) { - this.flareIntensity = Math.max( - 0, - this.flareIntensity - deltaTimeInSeconds / settings.lampFlareDecaySeconds, - ); - - if (this.flareLight) { - this.flareLight.intensity = - settings.lampFlareIntensity * this.flareIntensity * this.flareIntensity; - } - - if (this.flareIntensity === 0) { - this.releaseFlareSlot(); - } - } - } - - private releaseFlareSlot(): void { - if (this.holdsFlareSlot) { - this.holdsFlareSlot = false; - FlareBudget.release(); - } - } - - private lastGeneratedPoint?: number; public generatedPoints(value: number) { this.lastGeneratedPoint = value; } - public onFlipped(team: CharacterTeam): void { + public onFlipped(team: CharacterTeam) { const color = settings.palette[settings.colorIndices[team]]; if (!this.flareLight) { @@ -130,57 +57,87 @@ export class PlanetView extends PlanetBase { } if (!this.holdsFlareSlot) { - if (!FlareBudget.tryAcquire()) { + if (activeFlares >= settings.maxConcurrentFlipFlares) { return; } + activeFlares++; this.holdsFlareSlot = true; } this.flareIntensity = 1; } - private beforeDestroy(): void { - this.ownershipProgress.parentElement?.removeChild(this.ownershipProgress); - this.releaseFlareSlot(); - } - - private updateProperty({ + public updateProperty({ propertyKey, propertyValue, rateOfChange, - }: UpdatePropertyCommand): void { + }: UpdatePropertyCommand) { if (propertyKey === 'rotation') { this.rotationInterpolator.addFrame(propertyValue, rateOfChange); - this.latestRotation = propertyValue; - this.rotationSpeed = rateOfChange; + this.snapshotRotation = propertyValue; + this.snapshotRotationSpeed = rateOfChange; } else { this.ownership = propertyValue; } } - private draw({ renderer, overlay, shouldChangeLayout }: RenderCommand): void { + public step(deltaTimeInSeconds: number) { + this.shape.rotation = this.rotationInterpolator.getValue(deltaTimeInSeconds); + this.shape.colorMixQ = this.ownership; + + if (this.flareIntensity > 0) { + this.flareIntensity = Math.max( + 0, + this.flareIntensity - deltaTimeInSeconds / settings.lampFlareDecaySeconds, + ); + if (this.flareLight) { + this.flareLight.intensity = + settings.lampFlareIntensity * this.flareIntensity * this.flareIntensity; + } + if (this.flareIntensity === 0) { + this.releaseFlareSlot(); + } + } + } + + public beforeDestroy() { + this.ownershipProgress.remove(); + this.releaseFlareSlot(); + } + + private releaseFlareSlot() { + if (this.holdsFlareSlot) { + this.holdsFlareSlot = false; + activeFlares--; + } + } + + public render(renderer: Renderer, overlay: HTMLElement, shouldChangeLayout: boolean) { if (shouldChangeLayout) { if (!this.ownershipProgress.parentElement) { overlay.appendChild(this.ownershipProgress); } const screenPosition = renderer.worldToDisplayCoordinates(this.center); + this.ownershipProgress.style.transform = centeredTransform( + screenPosition[0], + screenPosition[1], + ); - this.ownershipProgress.style.transform = `translateX(${screenPosition.x}px) translateY(${screenPosition.y}px) translateX(-50%) translateY(-50%)`; - this.ownershipProgress.style.background = this.getGradient(); + const gradient = this.getGradient(); + if (gradient !== this.lastGradient) { + this.lastGradient = gradient; + this.ownershipProgress.style.background = gradient; + } if (this.lastGeneratedPoint !== undefined) { const element = document.createElement('div'); element.className = 'falling-point ' + (this.ownership < 0.5 ? 'blue' : 'red'); element.innerText = '+' + this.lastGeneratedPoint; - element.style.left = `${screenPosition.x}px`; - element.style.top = `${screenPosition.y}px`; + element.style.left = `${screenPosition[0]}px`; + element.style.top = `${screenPosition[1]}px`; overlay.appendChild(element); - setTimeout( - () => element.parentElement?.removeChild(element), - fallingPointLifetimeMs, - ); - + setTimeout(() => element.remove(), fallingPointLifetimeMs); this.lastGeneratedPoint = undefined; } } @@ -193,14 +150,10 @@ export class PlanetView extends PlanetBase { } private getGradient(): string { - const sideBlue = this.ownership < 0.5; - // Keep the ring neutral through the same dead-band that gates scoring - // (settings.planetControlThreshold), so "the ring fills" and "this planet - // pays my team" happen together rather than disagreeing. const control = Math.abs(this.ownership - 0.5); const t = settings.planetControlThreshold; const sidePercent = control <= t ? 0 : ((control - t) / (0.5 - t)) * 100; - return sideBlue + return this.ownership < 0.5 ? `conic-gradient( var(--bright-blue) ${sidePercent}%, var(--bright-blue) ${sidePercent}%, diff --git a/frontend/src/scripts/objects/types/projectile-view.ts b/frontend/src/scripts/objects/types/projectile-view.ts index 5843e59..a615adb 100644 --- a/frontend/src/scripts/objects/types/projectile-view.ts +++ b/frontend/src/scripts/objects/types/projectile-view.ts @@ -1,27 +1,20 @@ import { vec2 } from 'gl-matrix'; -import { CircleLight } from 'sdf-2d'; +import { CircleLight, Renderer } from 'sdf-2d'; import { CharacterTeam, - CommandExecutors, Id, ProjectileBase, settings, UpdatePropertyCommand, } from 'shared'; -import { RenderCommand } from '../../commands/types/render'; -import { StepCommand } from '../../commands/types/step'; import { Vec2Interpolator } from '../../helper/interpolators/vec2-interpolator'; +import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator'; +import { View } from '../view'; -export class ProjectileView extends ProjectileBase { - private light: CircleLight; - - private centerInterpolator: Vec2Interpolator; - - protected commandExecutors: CommandExecutors = { - [RenderCommand.type]: this.draw.bind(this), - [StepCommand.type]: this.handleStep.bind(this), - [UpdatePropertyCommand.type]: this.updateProperty.bind(this), - }; +export class ProjectileView extends ProjectileBase implements View { + private readonly light: CircleLight; + private readonly centerInterpolator: Vec2Interpolator; + private readonly strengthInterpolator: LinearInterpolator; constructor( id: Id, @@ -37,15 +30,23 @@ export class ProjectileView extends ProjectileBase { 0, ); this.centerInterpolator = new Vec2Interpolator(center); + this.strengthInterpolator = new LinearInterpolator(strength); } - private updateProperty({ propertyValue, rateOfChange }: UpdatePropertyCommand): void { - this.centerInterpolator.addFrame(propertyValue, rateOfChange); + public updateProperty({ + propertyKey, + propertyValue, + rateOfChange, + }: UpdatePropertyCommand) { + if (propertyKey === 'center') { + this.centerInterpolator.addFrame(propertyValue, rateOfChange); + } else if (propertyKey === 'strength') { + this.strengthInterpolator.addFrame(propertyValue, rateOfChange); + } } - private handleStep({ deltaTimeInSeconds }: StepCommand): void { - this.step(deltaTimeInSeconds); - + public step(deltaTimeInSeconds: number) { + this.strength = Math.max(0, this.strengthInterpolator.getValue(deltaTimeInSeconds)); this.center = this.centerInterpolator.getValue(deltaTimeInSeconds); this.light.center = this.center; this.light.intensity = Math.min( @@ -54,7 +55,7 @@ export class ProjectileView extends ProjectileBase { ); } - private draw({ renderer }: RenderCommand): void { + public render(renderer: Renderer) { renderer.addDrawable(this.light); } } diff --git a/frontend/src/scripts/objects/view.ts b/frontend/src/scripts/objects/view.ts new file mode 100644 index 0000000..af4be1c --- /dev/null +++ b/frontend/src/scripts/objects/view.ts @@ -0,0 +1,9 @@ +import { Renderer } from 'sdf-2d'; +import { GameObject, UpdatePropertyCommand } from 'shared'; + +export interface View extends GameObject { + step(deltaTimeInSeconds: number): void; + render(renderer: Renderer, overlay: HTMLElement, shouldChangeLayout: boolean): void; + beforeDestroy?(): void; + updateProperty?(update: UpdatePropertyCommand): void; +} diff --git a/frontend/src/scripts/options-handler.ts b/frontend/src/scripts/options-handler.ts index 8479fb4..fe64eff 100644 --- a/frontend/src/scripts/options-handler.ts +++ b/frontend/src/scripts/options-handler.ts @@ -1,77 +1,61 @@ import { SoundHandler, Sounds } from './sound-handler'; import { VibrationHandler } from './vibration-handler'; +import { loadStoredValue, saveStoredValue } from './helper/storage'; -interface Options { +export interface Options { vibrationEnabled: boolean; soundsEnabled: boolean; musicEnabled: boolean; } -export abstract class OptionsHandler { - private static initialized = false; - private static _options: Options = { - vibrationEnabled: true, - soundsEnabled: true, - musicEnabled: true, - }; +export const options: Options = { + vibrationEnabled: true, + soundsEnabled: true, + musicEnabled: true, +}; - public static initialize(inputElements: { - [k in Extract]: HTMLInputElement; - }) { - if (localStorage.getItem('options')) { - const stored: Partial | null = JSON.parse( - localStorage.getItem('options')!, - ); - this._options = { - ...this._options, - ...stored, - }; +export const initializeOptions = ( + inputElements: Record, +) => { + const stored = loadStoredValue('options'); + if (stored && typeof stored === 'object') { + for (const key of Object.keys(inputElements) as Array) { + const value = (stored as Partial)[key]; + if (typeof value === 'boolean') { + options[key] = value; + } } + } - if (this._options.musicEnabled) { - SoundHandler.playAmbient(); - } + if (options.musicEnabled) { + SoundHandler.playAmbient(); + } - for (const k in inputElements) { - const element = inputElements[k as keyof Options]; - element.checked = OptionsHandler._options[k as keyof Options]; - element.addEventListener('change', function () { - OptionsHandler._options[k as keyof Options] = this.checked; - if (!this.checked && k === 'soundsEnabled') { - OptionsHandler._options.musicEnabled = false; - inputElements.musicEnabled.checked = false; + for (const key of Object.keys(inputElements) as Array) { + const element = inputElements[key]; + element.checked = options[key]; + element.addEventListener('change', () => { + options[key] = element.checked; + if (!element.checked && key === 'soundsEnabled') { + options.musicEnabled = false; + inputElements.musicEnabled.checked = false; + SoundHandler.stopAmbient(); + } + + if (key === 'musicEnabled') { + if (element.checked) { + SoundHandler.playAmbient(); + } else { SoundHandler.stopAmbient(); } + } - if (k === 'musicEnabled') { - if (this.checked) { - SoundHandler.playAmbient(); - } else { - SoundHandler.stopAmbient(); - } - } + if (element.checked && key === 'vibrationEnabled') { + VibrationHandler.vibrate(100); + } - if (this.checked && k === 'vibrationEnabled') { - VibrationHandler.vibrate(100); - } - - SoundHandler.play(Sounds.click); - OptionsHandler.save(); - }); - } - - this.initialized = true; + SoundHandler.play(Sounds.click); + saveStoredValue('options', options); + }); } - - private static save() { - localStorage.setItem('options', JSON.stringify(this._options)); - } - - public static get options(): Options { - if (!this.initialized) { - throw new Error('Uninitialized'); - } - - return this._options; - } -} +}; diff --git a/frontend/src/scripts/scoreboard.ts b/frontend/src/scripts/scoreboard.ts index 41b02ba..3b2190f 100644 --- a/frontend/src/scripts/scoreboard.ts +++ b/frontend/src/scripts/scoreboard.ts @@ -1,10 +1,5 @@ import { CharacterTeam, UpdateGameState, clamp, settings } from 'shared'; -// Centred tug-of-war territory bar. The two fills meet at the bar's centre and -// grow outward by each team's share of the score limit, so the meeting point -// reads as the lead at a glance. Numeric scores are overlaid (the leader's is -// emphasised) and a "YOU" tick marks the local team's half. Owns its own DOM so -// the Game just appends `element` to the overlay and feeds it `update()`. export class Scoreboard { public readonly element = document.createElement('div'); diff --git a/frontend/src/scripts/screen-shake.ts b/frontend/src/scripts/screen-shake.ts index 75c5bce..9b606da 100644 --- a/frontend/src/scripts/screen-shake.ts +++ b/frontend/src/scripts/screen-shake.ts @@ -1,84 +1,63 @@ -// Camera impact effects fed by combat hits, sampled by the camera at render -// time. Kept as a process-wide singleton so any view can feed it without -// threading a camera reference through the deserialized object graph. Both -// effects touch only the *rendered* view (centre offset + view-area zoom), never -// the followed position or the simulation clock — so they can't drift the camera -// off the player, and they're completely decoupled from prediction/netcode. -// -// Two effects: -// - shake: a trauma model (Eiserloh) — the felt shake is trauma SQUARED so -// small taps stay subtle while a kill punches hard; it bleeds off linearly. -// - punch: a brief zoom-in on a kill that snaps back fast, for "weight". -// -// Honours prefers-reduced-motion: callers can fire freely and it simply no-ops -// for players who have asked the OS to minimise motion. -export abstract class ScreenShake { - private static trauma = 0; - private static punch = 0; +let trauma = 0; +let punch = 0; - // Peak translation, in world units, at full trauma. The visible world is - // ~3800 units wide, so this tops out at a few percent of the screen. - private static readonly maxTranslation = 150; - // Fraction the view zooms in at full punch (smaller view area = zoomed in). - private static readonly maxZoom = 0.07; - private static readonly traumaDecayPerSecond = 1.7; - // Snaps back quickly so the punch reads as a sharp bite, not a slow drift. - private static readonly punchDecayPerSecond = 6; +const maxTranslation = 150; +const maxZoom = 0.07; +const traumaDecayPerSecond = 1.7; +const punchDecayPerSecond = 6; - private static offsetXValue = 0; - private static offsetYValue = 0; +let offsetXValue = 0; +let offsetYValue = 0; - private static get reducedMotion(): boolean { - return ( - typeof window !== 'undefined' && - typeof window.matchMedia === 'function' && - window.matchMedia('(prefers-reduced-motion: reduce)').matches - ); - } - - public static add(trauma: number): void { - if (this.reducedMotion) { - return; - } - this.trauma = Math.min(1, this.trauma + trauma); - } - - public static addPunch(amount: number): void { - if (this.reducedMotion) { - return; - } - this.punch = Math.min(1, this.punch + amount); - } - - public static step(deltaTimeInSeconds: number): void { - this.trauma = Math.max( - 0, - this.trauma - this.traumaDecayPerSecond * deltaTimeInSeconds, - ); - this.punch = Math.max(0, this.punch - this.punchDecayPerSecond * deltaTimeInSeconds); - const shake = this.trauma * this.trauma; - this.offsetXValue = this.maxTranslation * shake * (Math.random() * 2 - 1); - this.offsetYValue = this.maxTranslation * shake * (Math.random() * 2 - 1); - } - - public static get offsetX(): number { - return this.offsetXValue; - } - - public static get offsetY(): number { - return this.offsetYValue; - } - - // Multiplier for the view-area size: <1 zooms in. Squared so the punch bites - // sharply near its peak rather than ramping linearly. - public static get viewScale(): number { - return 1 - this.maxZoom * this.punch * this.punch; - } - - public static reset(): void { - this.trauma = 0; - this.punch = 0; - this.offsetXValue = 0; - this.offsetYValue = 0; - } +function reducedMotion(): boolean { + return ( + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ); } + +function addTrauma(t: number): void { + if (reducedMotion()) { + return; + } + trauma = Math.min(1, trauma + t); +} + +function addPunch(amount: number): void { + if (reducedMotion()) { + return; + } + punch = Math.min(1, punch + amount); +} + +function stepScreenShake(deltaTimeInSeconds: number): void { + trauma = Math.max(0, trauma - traumaDecayPerSecond * deltaTimeInSeconds); + punch = Math.max(0, punch - punchDecayPerSecond * deltaTimeInSeconds); + const shake = trauma * trauma; + offsetXValue = maxTranslation * shake * (Math.random() * 2 - 1); + offsetYValue = maxTranslation * shake * (Math.random() * 2 - 1); +} + +function resetScreenShake(): void { + trauma = 0; + punch = 0; + offsetXValue = 0; + offsetYValue = 0; +} + +export const ScreenShake = { + add: addTrauma, + addPunch, + step: stepScreenShake, + get offsetX() { + return offsetXValue; + }, + get offsetY() { + return offsetYValue; + }, + get viewScale() { + return 1 - maxZoom * punch * punch; + }, + reset: resetScreenShake, +}; diff --git a/frontend/src/scripts/shapes/character-shape.ts b/frontend/src/scripts/shapes/character-shape.ts index 711f70a..f8422a9 100644 --- a/frontend/src/scripts/shapes/character-shape.ts +++ b/frontend/src/scripts/shapes/character-shape.ts @@ -2,9 +2,6 @@ import { mat2d, vec2 } from 'gl-matrix'; import { Drawable, DrawableDescriptor } from 'sdf-2d'; import { Circle } from 'shared'; -// A single character silhouette shared by every team (teams differ only by -// colour): a circular head sitting on two thin line-legs that run down to the -// two feet. export class CharacterShape extends Drawable { public static descriptor: DrawableDescriptor = { sdf: { @@ -22,8 +19,6 @@ export class CharacterShape extends Drawable { return distance(target, circleCenter) - radius; } - // Distance to the segment a->b; subtract a radius to get a capsule (a - // rounded thick line). float segmentDistance(vec2 a, vec2 b, vec2 target) { vec2 pa = target - a; vec2 ba = b - a; @@ -41,29 +36,19 @@ export class CharacterShape extends Drawable { float headRadius = characterHeadRadii[i]; float footRadius = characterFootRadii[i]; - // The server rotates the whole posture toward travel, so the head - // leads: forward is the facing direction, perp its 90deg rotation, so - // the face sits on the leading side and reads at any rotation. vec2 footAverage = (leftFoot + rightFoot) * 0.5; vec2 toHead = head - footAverage; float toHeadLength = length(toHead); vec2 forward = toHeadLength > 0.001 ? toHead / toHeadLength : vec2(0.0, 1.0); vec2 perp = vec2(-forward.y, forward.x); - // A circular head, drawn a bit smaller than the head->feet gap so the - // legs below it read as two distinct LINES rather than being swallowed - // by the head. float renderRadius = headRadius * 0.75; float headDistance = circleDistance(head, renderRadius, target); - // Legs as thin line-segments (capsules) from the head down to each - // foot, hard-min'd onto the head so the head stays a crisp circle. float legThickness = footRadius * 0.35; float leftLeg = segmentDistance(head, leftFoot, target) - legThickness; float rightLeg = segmentDistance(head, rightFoot, target) - legThickness; - // Rounded feet at the ends of the lines, kept large enough to stay - // visible even when the leg is short and the head nearly reaches them. float footRender = footRadius * 0.7; float leftFootDistance = circleDistance(leftFoot, footRender, target); float rightFootDistance = circleDistance(rightFoot, footRender, target); @@ -73,23 +58,12 @@ export class CharacterShape extends Drawable { min(min(leftLeg, rightLeg), min(leftFootDistance, rightFootDistance)) ); - // Real eyes painted on the leading face — a bright white sclera with a - // dark pupil — rather than carved holes, so the character reads as - // alive instead of hollow-socketed. - // Big white sclera, small pupil: the eye must read as mostly white so - // it looks like an eye, not a dark socket. (An 8-bit albedo caps the - // sclera at pure white, so its area — not its colour — is what makes - // the eye read brighter against the body.) vec2 eyeBase = head + forward * (renderRadius * 0.26); vec2 leftEyeCenter = eyeBase + perp * (renderRadius * 0.38); vec2 rightEyeCenter = eyeBase - perp * (renderRadius * 0.38); float scleraRadius = renderRadius * 0.26; float pupilRadius = renderRadius * 0.11; - // The pupil slides toward the gaze target (the cursor for the local - // player, otherwise the travel direction), kept well inside the rim so - // a generous ring of white always frames it. The normalize is guarded - // so a cursor resting exactly on an eye can't produce NaNs. vec2 gazeTarget = characterGazeTargets[i]; float pupilReach = (scleraRadius - pupilRadius) * 0.6; vec2 toLeftGaze = gazeTarget - leftEyeCenter; @@ -106,14 +80,7 @@ export class CharacterShape extends Drawable { circleDistance(rightEyeCenter + rightGaze * pupilReach, pupilRadius, target) ); - // Soften each eye edge by at least one screen texel so the colour - // boundary antialiases instead of staircasing when the character is - // small on screen. The fixed world-space term sets a floor on the - // softness when zoomed in; fwidth widens the band to a texel as the - // head shrinks. Computed here in uniform control flow — NOT inside the - // body branch below — so the screen-space derivatives stay defined. - // fwidth needs WebGL2 derivatives (core in GLSL ES 3.00), so the - // WebGL1 fallback keeps the plain world-space band. + // Uniform control flow on purpose: fwidth needs defined derivatives. float eyeAaBase = renderRadius * 0.025; #ifdef WEBGL2_IS_AVAILABLE float scleraAa = max(eyeAaBase, fwidth(sclera)); @@ -125,21 +92,12 @@ export class CharacterShape extends Drawable { if (body < minDistance) { minDistance = body; - // Brief white punch when taking a hit. color = mix( readFromPalette(characterColors[i]), vec4(1.0), clamp(characterFlash[i], 0.0, 1.0) ); - // Paint the eyes over the body colour. The sclera albedo is HDR - // (>> 1): the shading pass multiplies it by the (often dim, reddish) - // scene light before clamping to the screen, so an over-bright white - // reads as a clean white eye everywhere instead of dimming into a - // dark socket. Needs the float colour buffer in sdf-2d; on 8-bit - // fallback it simply clamps back to plain white. The smoothstep - // widths (scleraAa / pupilAa, computed above) antialias the colour - // boundary in a zoom-aware way. color = mix(color, vec4(10.0, 10.0, 10.0, 1.0), 1.0 - smoothstep(-scleraAa, scleraAa, sclera)); color = mix(color, vec4(0.04, 0.04, 0.07, 1.0), 1.0 - smoothstep(-pupilAa, pupilAa, pupil)); } @@ -169,10 +127,7 @@ export class CharacterShape extends Drawable { protected leftFoot!: Circle; protected rightFoot!: Circle; - // 0..1 transient white flash on taking a hit. Set per frame. public hitFlash = 0; - // World point the eyes look at; the pupils slide toward it. Set per frame - // (the cursor for the local player, the travel direction for everyone else). public gazeTarget = vec2.create(); public constructor(private readonly color: number) { diff --git a/frontend/src/scripts/shapes/planet-shape.ts b/frontend/src/scripts/shapes/planet-shape.ts index ceb2e35..492b122 100644 --- a/frontend/src/scripts/shapes/planet-shape.ts +++ b/frontend/src/scripts/shapes/planet-shape.ts @@ -2,7 +2,7 @@ import { mat2d, vec2, vec3, vec4 } from 'gl-matrix'; import { PolygonFactory, DrawableDescriptor, Drawable } from 'sdf-2d'; import { settings } from 'shared'; -export const colorToString = (v: vec3 | vec4): string => +const colorToString = (v: vec3 | vec4): string => `vec4(${v[0]}, ${v[1]}, ${v[2]}, ${v.length > 3 ? v[3] : 1})`; export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { @@ -58,9 +58,6 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { float cr = cos(rotation); float sr = sin(rotation); - // Spin the whole planet: evaluate the SDF in the planet's own - // rotating frame so the polygon outline turns together with its - // terrain, instead of the terrain sliding over a fixed outline. vec2 targetCenterDelta = target - center; float targetDistance = length(targetCenterDelta); vec2 localTarget = center + vec2( @@ -129,9 +126,6 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { public randomOffset = 0; public rotation = 0; - // Circle about the rotation centre (the vertex centroid, which is what the - // shader spins around — see planetMinDistance above). The vertices never - // change after construction, so cache it once. private readonly cullCenter: vec2; private readonly cullRadius: number; diff --git a/frontend/src/scripts/sound-handler.ts b/frontend/src/scripts/sound-handler.ts index 81607ad..cdab0d6 100644 --- a/frontend/src/scripts/sound-handler.ts +++ b/frontend/src/scripts/sound-handler.ts @@ -1,82 +1,87 @@ +import { clamp01 } from 'shared'; import hitSound from '../../static/hit.mp3'; import shootSound from '../../static/shoot.mp3'; import clickSound from '../../static/click.mp3'; -import ambientSound from '../../static/ambient.mp3'; -import { OptionsHandler } from './options-handler'; +import ambientAudio from '../../static/ambient.mp3'; +import { options } from './options-handler'; -export enum Sounds { - hit = 'hit', - shoot = 'shoot', - click = 'click', +export const Sounds = { hit: 'hit', shoot: 'shoot', click: 'click' } as const; +export type Sound = (typeof Sounds)[keyof typeof Sounds]; + +let sounds: Record; +let isAmbientPlaying = false; +const ambient = new Audio(ambientAudio); +let initialized = false; + +async function initializeSound(src: string): Promise { + const snd = new Audio(src); + snd.muted = true; + await snd.play().catch(() => undefined); + snd.pause(); + snd.muted = false; + snd.currentTime = 0; + return snd; } -export abstract class SoundHandler { - private static sounds: { [key in Sounds]: HTMLAudioElement }; - private static isAmbientPlaying = false; +async function initialize( + onPlayKeypress: () => unknown = () => null, + onPauseKeypress: () => unknown = () => null, +) { + ambient.muted = true; + ambient.volume = 0.5; + ambient.loop = true; + // Unlock every audio element during the initiating click, before awaiting any one. + const ambientReady = ambient.play().catch(() => undefined); + const [hit, shoot, click] = await Promise.all( + [hitSound, shootSound, clickSound].map(initializeSound), + ); + sounds = { hit, shoot, click }; - private static ambientSound = new Audio(ambientSound); + await ambientReady; + initialized = true; + ambient.onpause = onPauseKeypress; + ambient.onplay = onPlayKeypress; - private static initialized = false; - public static async initialize( - onPlayKeypress: () => unknown = () => null, - onPauseKeypress: () => unknown = () => null, - ) { - this.sounds = { - [Sounds.hit]: await this.initializeSound(hitSound), - [Sounds.shoot]: await this.initializeSound(shootSound), - [Sounds.click]: await this.initializeSound(clickSound), - }; + ambient.muted = false; - await this.ambientSound.play(); - this.ambientSound.muted = true; - this.initialized = true; - this.ambientSound.onpause = onPauseKeypress; - this.ambientSound.onplay = onPlayKeypress; - - this.ambientSound.muted = false; - this.ambientSound.volume = 0.5; - this.ambientSound.loop = true; - - if (!this.isAmbientPlaying) { - this.ambientSound.pause(); - } - } - - private static async initializeSound(hitSound: string): Promise { - const sound = new Audio(hitSound); - sound.muted = true; - await sound.play(); - sound.pause(); - sound.muted = false; - sound.currentTime = 0; - return sound; - } - - public static play(sound: Sounds, volume = 1, playbackRate = 1) { - if (!this.initialized || !OptionsHandler.options.soundsEnabled) { - return; - } - - const audio = - this.sounds[sound].currentTime > 0 - ? (this.sounds[sound].cloneNode(true) as HTMLAudioElement) - : this.sounds[sound]; - audio.volume = Math.max(0, Math.min(1, volume)); - audio.playbackRate = playbackRate; - audio.play(); - } - - public static playAmbient() { - this.isAmbientPlaying = true; - if (this.initialized) { - this.ambientSound.play(); - } - } - - public static stopAmbient() { - this.isAmbientPlaying = false; - if (this.initialized) { - this.ambientSound.pause(); - } + if (!isAmbientPlaying) { + ambient.pause(); } } + +function play(snd: Sound, volume = 1, playbackRate = 1) { + if (!initialized || !options.soundsEnabled) { + return; + } + + const pooled = sounds[snd]; + const isBusy = !pooled.paused && !pooled.ended; + const audio = isBusy ? (pooled.cloneNode(true) as HTMLAudioElement) : pooled; + if (!isBusy) { + audio.currentTime = 0; + } + audio.volume = clamp01(volume); + audio.playbackRate = playbackRate; + void audio.play().catch(() => undefined); +} + +function playAmbient() { + isAmbientPlaying = true; + if (initialized) { + void ambient.play().catch(() => undefined); + } +} + +function stopAmbient() { + isAmbientPlaying = false; + if (initialized) { + ambient.pause(); + } +} + +export const SoundHandler = { + initialize, + play, + playAmbient, + stopAmbient, +}; diff --git a/frontend/src/scripts/tutorial.ts b/frontend/src/scripts/tutorial.ts index c0d1155..e227c71 100644 --- a/frontend/src/scripts/tutorial.ts +++ b/frontend/src/scripts/tutorial.ts @@ -34,17 +34,17 @@ export class Tutorial extends CommandReceiver { private standingBaseline = 0; protected commandExecutors: CommandExecutors = { - [MoveActionCommand.type]: (c: MoveActionCommand) => { + [MoveActionCommand.name]: (c: MoveActionCommand) => { if (this.clearsOn() === 'move' && vec2.length(c.direction) > 0) { this.advance(); } }, - [PrimaryActionCommand.type]: () => { + [PrimaryActionCommand.name]: () => { if (this.clearsOn() === 'shoot') { this.advance(); } }, - [LeapActionCommand.type]: () => { + [LeapActionCommand.name]: () => { if (this.clearsOn() === 'leap') { this.advance(); } @@ -71,7 +71,7 @@ export class Tutorial extends CommandReceiver { return; } - const player = gameObjects.player; + const player = gameObjects.localPlayer; if (!player) { return; } diff --git a/frontend/src/scripts/vibration-handler.ts b/frontend/src/scripts/vibration-handler.ts index 3ae8420..7d56f59 100644 --- a/frontend/src/scripts/vibration-handler.ts +++ b/frontend/src/scripts/vibration-handler.ts @@ -1,19 +1,12 @@ -import { OptionsHandler } from './options-handler'; +import { options } from './options-handler'; -export abstract class VibrationHandler { - // Accepts either a single duration or an on/off pattern (e.g. a double-thump - // [40, 30, 90] for a kill). - public static vibrate(pattern: number | number[]): void { - if (OptionsHandler.options.vibrationEnabled && this.isVibrationEnabled) { +export const VibrationHandler = { + vibrate(pattern: number | number[]) { + if (options.vibrationEnabled && 'vibrate' in navigator) { navigator.vibrate(pattern); } - } - - public static get isVibrationEnabled(): boolean { - return 'vibrate' in navigator; - } - - public static get isVibrationEnabledHeuristics(): boolean { - return this.isVibrationEnabled && 'ontouchstart' in window; - } -} + }, + get isVibrationEnabledHeuristics() { + return 'vibrate' in navigator && 'ontouchstart' in window; + }, +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index b12fbd2..e404baf 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -10,9 +10,8 @@ "ignoreDeprecations": "6.0", "skipLibCheck": true, "module": "commonjs", - "lib": ["dom", "es2017"], - "typeRoots": ["./types", "./node_modules/@types"] + "lib": ["dom", "es2017"] }, - "include": ["src/**/*.ts", "src/*.ts"], + "include": ["src/**/*.ts"], "exclude": ["node_modules", "typings"] } diff --git a/frontend/types/socket.io-msgpack-parser/index.d.ts b/frontend/types/socket.io-msgpack-parser/index.d.ts deleted file mode 100644 index 4065a1e..0000000 --- a/frontend/types/socket.io-msgpack-parser/index.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module 'socket.io-msgpack-parser'; diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index 97f6f6b..4bbc556 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -5,7 +5,6 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const TerserJSPlugin = require('terser-webpack-plugin'); const CopyWebpackPlugin = require('copy-webpack-plugin'); const HTMLInlineCSSWebpackPlugin = require('html-inline-css-webpack-plugin').default; -// const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; const Sass = require('sass'); module.exports = (env, argv) => { @@ -23,35 +22,25 @@ module.exports = (env, argv) => { host: '0.0.0.0', allowedHosts: 'all', }, - // webpack-dev-server 5 no longer accepts `watchOptions` under `devServer`; - // polling is configured on the compiler instead (needed for some FS mounts). watchOptions: { poll: true, }, plugins: [ new CleanWebpackPlugin(), new MiniCssExtractPlugin(), - //new BundleAnalyzerPlugin(), new HtmlWebpackPlugin({ template: './src/index.html', }), - // The SVG UI icons used to be inlined into the HTML by the (abandoned, - // webpack-4-only) html-webpack-inline-svg-plugin. They are now emitted as - // static files and referenced via `static/.svg` from index.html. new CopyWebpackPlugin({ patterns: [{ from: 'static/*.svg', to: 'static/[name][ext]' }], }), - // Inline the extracted CSS into the HTML for production builds (replaces - // the abandoned html-webpack-inline-source-plugin). In development the CSS - // stays a separate, linked file for faster rebuilds. ...(isProduction ? [new HTMLInlineCSSWebpackPlugin()] : []), ], optimization: { minimizer: [ new TerserJSPlugin({ exclude: /node_modules/, - // The custom serialization protocol keys on class names, so they must - // survive minification (see shared/src/serialization). + // The serialization protocol keys on class names. terserOptions: { keep_classnames: true, }, @@ -86,23 +75,11 @@ module.exports = (env, argv) => { ], }, { - // SVGs referenced from CSS (`mask-image`/`background-image`) and JS - // must be inlined as real `data:` URIs. svg-url-loader emits a CJS - // module (`module.exports = "data:..."`) which webpack 5 + css-loader 7 - // write out verbatim as a `.svg` asset file, so the browser fetches JS - // instead of an image and the mask/background silently fails. webpack 5's - // built-in `asset/inline` produces a proper data URI instead. - // (HTML icons are unaffected: they are copied by CopyWebpackPlugin - // and referenced by literal `static/*.svg` paths, not through this rule.) test: /\.svg$/, type: 'asset/inline', }, { - // Use oneOf so each asset matches exactly one rule (og-image.png would - // otherwise match both the generic png rule and its own rule). - // The directory is encoded in `name` (not `outputPath`) and has no - // leading slash: with the default `publicPath: 'auto'` a leading slash - // produces a doubled slash in the emitted URL (e.g. `//static/x.mp3`). + // No leading slash in `name`: with publicPath 'auto' it doubles the slash. oneOf: [ { test: /og-image\.png$/, @@ -136,6 +113,8 @@ module.exports = (env, argv) => { ], }, resolve: { + // Shared physics and rendering must use the same matrix array type as index.ts. + alias: { 'gl-matrix': path.resolve(__dirname, 'node_modules/gl-matrix') }, extensions: ['.ts', '.js', '.json'], }, }; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..65637fd --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2805 @@ +{ + "name": "root", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "root", + "devDependencies": { + "concurrently": "^10.0.3", + "eslint": "^10.4.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", + "eslint-plugin-unused-imports": "^4.4.1", + "prettier": "^3.8.3", + "typescript": "^6.0.3", + "typescript-eslint": "^8.60.1", + "vitest": "^4.1.9" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pkgr/core": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", + "integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/type-utils": "8.60.1", + "@typescript-eslint/utils": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.60.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz", + "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz", + "integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.60.1", + "@typescript-eslint/types": "^8.60.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz", + "integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz", + "integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz", + "integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/utils": "8.60.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz", + "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz", + "integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.60.1", + "@typescript-eslint/tsconfig-utils": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz", + "integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz", + "integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/concurrently": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", + "integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.4", + "supports-color": "10.2.2", + "tree-kill": "1.2.2", + "yargs": "18.0.0" + }, + "bin": { + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz", + "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz", + "integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.13" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-unused-imports": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz", + "integrity": "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", + "eslint": "^10.0.0 || ^9.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/synckit": { + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.3.6" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.1.tgz", + "integrity": "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.60.1", + "@typescript-eslint/parser": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/utils": "8.60.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json index 3e123bd..285a535 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,11 @@ "build": "cd shared && npm run build && cd ../frontend && npm run build && cd ../backend && npm run build", "lint": "eslint ./**/src/**/*.{js,ts} --fix && prettier --write ./**/src/**/*.{js,ts,json}", "lint:check": "eslint ./**/src/**/*.{js,ts} && prettier --check ./**/src/**/*.{js,ts,json}", + "pretest": "npm --prefix shared run build", "test": "vitest run", + "pretest:watch": "npm --prefix shared run build", "test:watch": "vitest", "init": "cd shared && npm install && cd ../frontend && npm install && cd ../backend && npm install", - "dev": "concurrently --kill-others-on-fail \"cd shared && npm dev\" \"cd backend && npm dev\" \"cd frontend && npm dev\"" + "dev": "concurrently --kill-others-on-fail --names shared,backend,frontend \"cd shared && npm run dev\" \"cd backend && npm run dev\" \"cd frontend && npm run dev\"" } } diff --git a/shared/package-lock.json b/shared/package-lock.json new file mode 100644 index 0000000..5e024b9 --- /dev/null +++ b/shared/package-lock.json @@ -0,0 +1,2196 @@ +{ + "name": "shared", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "shared", + "version": "0.0.0", + "devDependencies": { + "clean-webpack-plugin": "^4.0.0", + "file-loader": "^6.2.0", + "gl-matrix": "^3.3.0", + "prettier": "^3.8.3", + "terser-webpack-plugin": "^5.6.1", + "ts-loader": "^9.6.0", + "typescript": "^6.0.3", + "webpack": "^5.107.2", + "webpack-cli": "^7.0.3" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-1.1.0.tgz", + "integrity": "sha512-Xc3VhU02wqZ1HvHRJUwL09HkZSTvidqY5Ya0NXBSYOxAp+Ln9dcJr9fySI+CkONzP3PekQo9WdzCv0PGER/mOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.17.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimatch": "*", + "@types/node": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/minimatch": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-uniq": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/clean-webpack-plugin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/clean-webpack-plugin/-/clean-webpack-plugin-4.0.0.tgz", + "integrity": "sha512-WuWE1nyTNAyW5T7oNyys2EN0cfP2fdRxhxnIQWiAp0bMabPdHhoGxM8A6YL2GhqwgrPnnaemVE7nv5XJ2Fhh2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "del": "^4.1.1" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "webpack": ">=4.0.0 <6.0.0" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/del": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/del/-/del-4.1.1.tgz", + "integrity": "sha512-QwGuEUouP2kVwQenAsOof5Fv8K9t3D8Ca8NxcXKrIpEHjTXK5J2nXLdP+ALI1cgv8wj7KuwBhTwBkOZSJKM5XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/glob": "^7.1.1", + "globby": "^6.1.0", + "is-path-cwd": "^2.0.0", + "is-path-in-cwd": "^2.0.0", + "p-map": "^2.0.0", + "pify": "^4.0.1", + "rimraf": "^2.6.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.366", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.366.tgz", + "integrity": "sha512-OlRuhb688YTCzzU3gXPLn6nGyd+F+53INE1qaKKlu6kETErE8FYsyDh0XqXEU+uBRn0MpCzz2vfNwORhkap8qg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.22.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.2.tgz", + "integrity": "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/envinfo": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.21.0.tgz", + "integrity": "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==", + "dev": true, + "license": "MIT", + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/file-loader": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", + "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gl-matrix": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.3.0.tgz", + "integrity": "sha512-COb7LDz+SXaHtl/h4LeaFcNdJdAQSDeVqjiIihSXNrkWObZLhDI4hIkZC11Aeqp7bcE72clzB0BnDXr2SmslRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/globby": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-6.1.0.tgz", + "integrity": "sha512-KVbFv2TQtbzCoxAnfD6JcHZTYCzyliEaaeM/gH8qQdkKr5s0OP9scEgvdcngyk7AVdY6YVW/TJHd+lQ/Df3Daw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^1.0.1", + "glob": "^7.0.3", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/globby/node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-cwd": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", + "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-path-in-cwd": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-2.1.0.tgz", + "integrity": "sha512-rNocXHgipO+rvnP6dk3zI20RpOtrAM/kzbB258Uw5BWr3TpXi861yzjo16Dn4hUox07iw5AyeMLHWsujkjzvRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-path-inside": "^2.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-path-inside": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-2.1.0.tgz", + "integrity": "sha512-wiyhTzfDWsvwAW53OBWF5zuvaOGlZ6PwYxAbPVDhpm+gM09xKQGjBq/8uYN12aDvMxnAnq3dxTyoSoRNmg5YFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-is-inside": "^1.0.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pinkie": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-loader": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.6.0.tgz", + "integrity": "sha512-dsJO0S+T7grTDWTc4a0nTygXGjKncVUpx8Y+af8EvI/D5WgTJby5UEk5eoMCB9EcLQmnvitqh99MqtjtHgAwFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "loader-utils": "*", + "typescript": "*", + "webpack": "^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "loader-utils": { + "optional": true + } + } + }, + "node_modules/ts-loader/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.107.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.107.2.tgz", + "integrity": "sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.22.0", + "es-module-lexer": "^2.1.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "loader-runner": "^4.3.2", + "mime-db": "^1.54.0", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.5.0", + "watchpack": "^2.5.1", + "webpack-sources": "^3.5.0" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-7.0.3.tgz", + "integrity": "sha512-2E2C6A1e2El7791zQgTH7LPIuwLjRliow9OHS/qlJc9pwhZlCoL/uiwqd/1WSlXT83wJfmfDbkcqHXuXoPJZ3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@discoveryjs/json-ext": "^1.1.0", + "commander": "^14.0.3", + "cross-spawn": "^7.0.6", + "envinfo": "^7.14.0", + "import-local": "^3.0.2", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", + "webpack-merge": "^6.0.1" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.101.0", + "webpack-bundle-analyzer": "^4.0.0 || ^5.0.0", + "webpack-dev-server": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/webpack-merge": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", + "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz", + "integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/shared/src/commands/command-executors.ts b/shared/src/commands/command-executors.ts deleted file mode 100644 index a5271f5..0000000 --- a/shared/src/commands/command-executors.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type CommandExecutors = { - [type: string]: (command: any) => unknown; -}; diff --git a/shared/src/commands/command-generator.ts b/shared/src/commands/command-generator.ts deleted file mode 100644 index 27db5f3..0000000 --- a/shared/src/commands/command-generator.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { CommandReceiver } from './command-receiver'; -import { Command } from './command'; - -export abstract class CommandGenerator { - private subscribers: Array = []; - - public subscribe(subscriber: CommandReceiver): void { - this.subscribers.push(subscriber); - } - - public clearSubscribers(): void { - this.subscribers = []; - } - - protected sendCommandToSubscribers(command: Command): void { - this.subscribers.forEach((s) => s.handleCommand(command)); - } -} diff --git a/shared/src/commands/command-receiver.ts b/shared/src/commands/command-receiver.ts index 0eff7c4..33f489a 100644 --- a/shared/src/commands/command-receiver.ts +++ b/shared/src/commands/command-receiver.ts @@ -1,13 +1,16 @@ -import { CommandExecutors } from './command-executors'; import { Command } from './command'; +export type CommandExecutors = { + [type: string]: (command: any) => unknown; +}; + export abstract class CommandReceiver { protected commandExecutors: CommandExecutors = {}; protected defaultCommandExecutor(_: Command) {} public handleCommand(command: Command) { - const commandType = command.type; + const commandType = command.constructor.name; if (Object.prototype.hasOwnProperty.call(this.commandExecutors, commandType)) { this.commandExecutors[commandType]!(command); diff --git a/shared/src/commands/command.ts b/shared/src/commands/command.ts index 1142b7a..9074445 100644 --- a/shared/src/commands/command.ts +++ b/shared/src/commands/command.ts @@ -1,9 +1 @@ -export abstract class Command { - public static get type(): string { - return (this as any).name; - } - - public get type(): string { - return (this as any).constructor.name; - } -} +export abstract class Command {} diff --git a/shared/src/commands/types/actions/client-heartbeat.ts b/shared/src/commands/types/actions/client-heartbeat.ts new file mode 100644 index 0000000..af4ef01 --- /dev/null +++ b/shared/src/commands/types/actions/client-heartbeat.ts @@ -0,0 +1,13 @@ +import { serializable } from '../../../serialization/serializable'; +import { Command } from '../../command'; + +@serializable +export class ClientHeartbeatCommand extends Command { + public constructor(public readonly clientTimeMs: number) { + super(); + } + + public toArray(): Array { + return [this.clientTimeMs]; + } +} diff --git a/shared/src/commands/types/actions/leap-action.ts b/shared/src/commands/types/actions/leap-action.ts index 0115e73..1cc1f54 100644 --- a/shared/src/commands/types/actions/leap-action.ts +++ b/shared/src/commands/types/actions/leap-action.ts @@ -1,15 +1,9 @@ import { serializable } from '../../../serialization/serializable'; import { Command } from '../../command'; -// Sent client -> server when the player triggers a leap (Space / leap button). -// The launch direction is derived server-side from the character's surface -// normal and current movement input. clientTimeMs is the client's wall-clock at -// the press, echoed back via InputAcknowledgement so the predictor knows which -// leaps the server has already folded into the streamed launch momentum and -// must not replay again. @serializable export class LeapActionCommand extends Command { - public constructor(public readonly clientTimeMs: number = 0) { + public constructor(public readonly clientTimeMs: number) { super(); } diff --git a/shared/src/commands/types/actions/move-action.ts b/shared/src/commands/types/actions/move-action.ts index 117d3e3..ea82c43 100644 --- a/shared/src/commands/types/actions/move-action.ts +++ b/shared/src/commands/types/actions/move-action.ts @@ -4,14 +4,9 @@ import { Command } from '../../command'; @serializable export class MoveActionCommand extends Command { - // clientTimeMs is the client's wall-clock (integer ms, survives the - // serializer's toFixed(3)) when the input was generated. The server echoes - // the latest one back via InputAcknowledgement so the client knows how much - // of its input timeline is already baked into a snapshot and can replay the - // rest for prediction. Defaults to 0 for inputs the server itself synthesises. public constructor( public readonly direction: vec2, - public readonly clientTimeMs: number = 0, + public readonly clientTimeMs: number, ) { super(); } diff --git a/shared/src/commands/types/actions/primary-action.ts b/shared/src/commands/types/actions/primary-action.ts index 54b92c9..ea78ae7 100644 --- a/shared/src/commands/types/actions/primary-action.ts +++ b/shared/src/commands/types/actions/primary-action.ts @@ -6,12 +6,13 @@ import { Command } from '../../command'; export class PrimaryActionCommand extends Command { public constructor( public readonly position: vec2, - public readonly charge: number = 0, + public readonly charge: number, + public readonly clientTimeMs: number, ) { super(); } public toArray(): Array { - return [this.position, this.charge]; + return [this.position, this.charge, this.clientTimeMs]; } } diff --git a/shared/src/commands/types/delete-objects.ts b/shared/src/commands/types/delete-objects.ts index 6bf9ecf..698b59c 100644 --- a/shared/src/commands/types/delete-objects.ts +++ b/shared/src/commands/types/delete-objects.ts @@ -1,4 +1,4 @@ -import { Id } from '../../communication/id'; +import { Id } from '../../communication/communication'; import { serializable } from '../../serialization/serializable'; import { Command } from '../command'; diff --git a/shared/src/commands/types/game-end.ts b/shared/src/commands/types/game-end.ts index 32bb84c..a4551e4 100644 --- a/shared/src/commands/types/game-end.ts +++ b/shared/src/commands/types/game-end.ts @@ -1,18 +1,9 @@ -import { CharacterTeam } from '../../objects/types/character-base'; import { serializable } from '../../serialization/serializable'; import { Command } from '../command'; @serializable export class GameEndCommand extends Command { - constructor( - public readonly winningTeam: CharacterTeam, - public readonly endCardLengthInSeconds: number, - public readonly shouldReconnect: boolean, - ) { - super(); - } - public toArray(): Array { - return [this.winningTeam, this.endCardLengthInSeconds, this.shouldReconnect]; + return []; } } diff --git a/shared/src/commands/types/game-start.ts b/shared/src/commands/types/game-start.ts index 15f14c8..4e56570 100644 --- a/shared/src/commands/types/game-start.ts +++ b/shared/src/commands/types/game-start.ts @@ -3,10 +3,6 @@ import { Command } from '../command'; @serializable export class GameStartCommand extends Command { - constructor() { - super(); - } - public toArray(): Array { return []; } diff --git a/shared/src/commands/types/input-acknowledgement.ts b/shared/src/commands/types/input-acknowledgement.ts index 3f488e7..181977f 100644 --- a/shared/src/commands/types/input-acknowledgement.ts +++ b/shared/src/commands/types/input-acknowledgement.ts @@ -1,28 +1,46 @@ import { vec2 } from 'gl-matrix'; +import { Id } from '../../communication/communication'; import { serializable } from '../../serialization/serializable'; import { Command } from '../command'; -// Sent server -> owning client only, alongside that client's own character -// snapshot. Carries the clientTimeMs of the most recent movement input the -// server had received when the snapshot was taken (so the client's predictor -// can reset to the snapshot and replay just the inputs the server hasn't seen -// yet) and the authoritative launch momentum (so the predictor reproduces a -// leap/slingshot/recoil flight rather than only snapping to it). See -// LocalCharacterPredictor. +// `direction` is not recoverable from the pose: the body lags the posture it is sprung towards. +@serializable +export class CharacterMovementSnapshot { + public constructor( + public readonly direction: number, + public readonly bodyVelocity: vec2, + public readonly leftFootNormal: vec2, + public readonly rightFootNormal: vec2, + public readonly groundPlanetId: Id | null, + public readonly secondsSinceOnSurface: number, + public readonly leapCooldownRemaining: number = 0, + ) {} + + public toArray(): Array { + return [ + this.direction, + this.bodyVelocity, + this.leftFootNormal, + this.rightFootNormal, + this.groundPlanetId, + this.secondsSinceOnSurface, + this.leapCooldownRemaining, + ]; + } +} + @serializable export class InputAcknowledgement extends Command { public constructor( public readonly clientTimeMs: number, - public readonly bodyVelocity: vec2, - // clientTimeMs of the most recent leap the server has received: any leap at - // or before it is already reflected in bodyVelocity, so the predictor must - // not replay it. + public readonly movement: CharacterMovementSnapshot, public readonly lastLeapClientTimeMs: number, + public readonly ackAgeMs: number = 0, ) { super(); } public toArray(): Array { - return [this.clientTimeMs, this.bodyVelocity, this.lastLeapClientTimeMs]; + return [this.clientTimeMs, this.movement, this.lastLeapClientTimeMs, this.ackAgeMs]; } } diff --git a/shared/src/commands/types/property-updates-for-objects.ts b/shared/src/commands/types/property-updates-for-objects.ts index bef2d6b..aa4ec34 100644 --- a/shared/src/commands/types/property-updates-for-objects.ts +++ b/shared/src/commands/types/property-updates-for-objects.ts @@ -6,8 +6,6 @@ import { Command } from '../command'; export class PropertyUpdatesForObjects extends Command { constructor( public readonly updates: Array, - // Seconds on the server's monotonic clock at the moment this state was - // captured. Only differences between timestamps are meaningful. public readonly timestamp: number, ) { super(); diff --git a/shared/src/commands/types/remote-calls-for-objects.ts b/shared/src/commands/types/remote-calls-for-objects.ts index 3a99962..f1d14fc 100644 --- a/shared/src/commands/types/remote-calls-for-objects.ts +++ b/shared/src/commands/types/remote-calls-for-objects.ts @@ -1,4 +1,5 @@ -import { Id, RemoteCall } from '../../main'; +import { Id } from '../../communication/communication'; +import { RemoteCall } from '../../objects/game-object'; import { serializable } from '../../serialization/serializable'; import { Command } from '../command'; diff --git a/shared/src/commands/types/update-minimap.ts b/shared/src/commands/types/update-minimap.ts index a24e371..7650d0e 100644 --- a/shared/src/commands/types/update-minimap.ts +++ b/shared/src/commands/types/update-minimap.ts @@ -1,5 +1,5 @@ import { vec2 } from 'gl-matrix'; -import { Id } from '../../communication/id'; +import { Id } from '../../communication/communication'; import { CharacterTeam } from '../../objects/types/character-base'; import { serializable } from '../../serialization/serializable'; import { Command } from '../command'; diff --git a/shared/src/communication/server-information.ts b/shared/src/communication/communication.ts similarity index 52% rename from shared/src/communication/server-information.ts rename to shared/src/communication/communication.ts index 425ef2a..51a9cd6 100644 --- a/shared/src/communication/server-information.ts +++ b/shared/src/communication/communication.ts @@ -1,3 +1,13 @@ +export type Id = number; + +let currentId = 0; +export const id = (): Id => currentId++; + +export interface PlayerInformation { + name: string; + reconnectToken?: string; +} + export interface ServerInformation { playerLimit: number; playerCount: number; diff --git a/shared/src/communication/id.ts b/shared/src/communication/id.ts deleted file mode 100644 index c6c1e4d..0000000 --- a/shared/src/communication/id.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type Id = number | null; - -let currentId = 0; - -export const id = (): number => { - return currentId++; -}; diff --git a/shared/src/communication/player-information.ts b/shared/src/communication/player-information.ts deleted file mode 100644 index 1130b7b..0000000 --- a/shared/src/communication/player-information.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface PlayerInformation { - name: string; -} diff --git a/shared/src/communication/transport-events.ts b/shared/src/communication/transport-events.ts index 395a564..da2b01d 100644 --- a/shared/src/communication/transport-events.ts +++ b/shared/src/communication/transport-events.ts @@ -6,4 +6,13 @@ export enum TransportEvents { ServerInfoUpdate = 'ServerInfoUpdate', Ping = 'Ping', Pong = 'Pong', + PlayerJoined = 'PlayerJoined', + JoinRejected = 'JoinRejected', +} + +export enum JoinRejectionReason { + ServerFull = 'ServerFull', + RoundEnding = 'RoundEnding', + AlreadyJoined = 'AlreadyJoined', + InvalidRequest = 'InvalidRequest', } diff --git a/shared/src/helper/array.ts b/shared/src/helper/array.ts deleted file mode 100644 index 8c86ffa..0000000 --- a/shared/src/helper/array.ts +++ /dev/null @@ -1,34 +0,0 @@ -declare global { - // `T` must stay to merge with the global `Array` (otherwise TS2428), but it - // is unused by the x/y aliases below. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - interface Array { - x: number; - y: number; - } - - interface Float32Array { - x: number; - y: number; - } -} - -const setIndexAlias = (name: string, index: number, type: any) => { - if (!Object.prototype.hasOwnProperty.call(type.prototype, name)) { - Object.defineProperty(type.prototype, name, { - get() { - return this[index]; - }, - set(value) { - this[index] = value; - }, - }); - } -}; - -export const applyArrayPlugins = () => { - setIndexAlias('x', 0, Array); - setIndexAlias('y', 1, Array); - setIndexAlias('x', 0, Float32Array); - setIndexAlias('y', 1, Float32Array); -}; diff --git a/shared/src/helper/calculate-view-area.ts b/shared/src/helper/calculate-view-area.ts index 00ebd50..5c36b56 100644 --- a/shared/src/helper/calculate-view-area.ts +++ b/shared/src/helper/calculate-view-area.ts @@ -1,22 +1,17 @@ import { vec2 } from 'gl-matrix'; -import { Rectangle, settings } from '../main'; +import { settings } from '../settings'; export const calculateViewArea = ( center: vec2, aspectRatio: number, oversizeRatio = 1, -): Rectangle => { - const viewArea = new Rectangle(); - - viewArea.size = vec2.fromValues( +) => { + const size = vec2.fromValues( Math.sqrt(settings.inViewAreaSize * oversizeRatio * aspectRatio), Math.sqrt((settings.inViewAreaSize * oversizeRatio) / aspectRatio), ); - viewArea.topLeft = vec2.fromValues( - center[0] - viewArea.size[0] / 2, - center[1] + viewArea.size[1] / 2, - ); + const topLeft = vec2.fromValues(center[0] - size[0] / 2, center[1] + size[1] / 2); - return viewArea; + return { topLeft, size }; }; diff --git a/shared/src/helper/charge.ts b/shared/src/helper/charge.ts index 1ab6ece..9842cc9 100644 --- a/shared/src/helper/charge.ts +++ b/shared/src/helper/charge.ts @@ -1,5 +1,11 @@ import { clamp01 } from './clamp'; import { settings } from '../settings'; -export const holdDurationToCharge = (heldSeconds: number): number => - clamp01(heldSeconds / settings.chargeShotFullHoldSeconds); +export const chargeHeldSince = (heldSinceMs: number): number => + clamp01((performance.now() - heldSinceMs) / 1000 / settings.chargeShotFullHoldSeconds); + +export const strengthToCharge = (strength: number): number => + clamp01( + (strength - settings.chargeShotStrengthMin) / + (settings.chargeShotStrengthMax - settings.chargeShotStrengthMin), + ); diff --git a/shared/src/helper/ease.ts b/shared/src/helper/ease.ts new file mode 100644 index 0000000..5349aae --- /dev/null +++ b/shared/src/helper/ease.ts @@ -0,0 +1,2 @@ +export const smoothing = (deltaTimeInSeconds: number, seconds: number): number => + 1 - Math.exp(-deltaTimeInSeconds / seconds); diff --git a/shared/src/helper/finite.ts b/shared/src/helper/finite.ts new file mode 100644 index 0000000..8eb7bab --- /dev/null +++ b/shared/src/helper/finite.ts @@ -0,0 +1,46 @@ +import { vec2 } from 'gl-matrix'; +import { clamp } from './clamp'; + +export const isFiniteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +export const finiteInRange = ( + value: unknown, + min: number, + max: number, + fallback: number, +): number => { + if (!isFiniteNumber(value)) { + return fallback; + } + return clamp(value, min, max); +}; + +export const finiteVec2 = (value: unknown, maxMagnitude: number): vec2 | undefined => { + if (!value || typeof value !== 'object') { + return undefined; + } + + const candidate = value as ArrayLike; + if (candidate.length < 2) { + return undefined; + } + + const x = candidate[0]; + const y = candidate[1]; + if (!isFiniteNumber(x) || !isFiniteNumber(y)) { + return undefined; + } + + const length = Math.hypot(x, y); + if (length > maxMagnitude) { + return vec2.fromValues((x / length) * maxMagnitude, (y / length) * maxMagnitude); + } + + return vec2.fromValues(x, y); +}; + +export const sanitizeName = (value: unknown, maxLength: number): string => { + const text = typeof value === 'string' ? value : ''; + return text.slice(0, maxLength); +}; diff --git a/shared/src/helper/hsl.ts b/shared/src/helper/hsl.ts index 5b4aaf0..4b29cda 100644 --- a/shared/src/helper/hsl.ts +++ b/shared/src/helper/hsl.ts @@ -1,7 +1,5 @@ import { vec3 } from 'gl-matrix'; -import { rgb } from './rgb'; -// source: https://stackoverflow.com/questions/2353211/hsl-to-rgb-color-conversion export const hsl = (hue: number, saturation: number, lightness: number): vec3 => { hue /= 360; saturation /= 100; @@ -31,5 +29,5 @@ export const hsl = (hue: number, saturation: number, lightness: number): vec3 => b = hue2rgb(p, q, hue - 1 / 3); } - return rgb(r, g, b); + return vec3.fromValues(r, g, b); }; diff --git a/shared/src/helper/last.ts b/shared/src/helper/last.ts deleted file mode 100644 index 39a5c1f..0000000 --- a/shared/src/helper/last.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function last(a: ArrayLike): T | null { - return a.length > 0 ? a[a.length - 1] : null; -} diff --git a/shared/src/helper/random.ts b/shared/src/helper/random.ts index d7a0de3..654988c 100644 --- a/shared/src/helper/random.ts +++ b/shared/src/helper/random.ts @@ -1,31 +1,23 @@ -// source -// https://stackoverflow.com/questions/521295/seeding-the-random-number-generator-in-javascript -// Mulberry32 +let seed = (Math.random() * 0x100000000) >>> 0; -export abstract class Random { - private static _seed = Math.random(); - - public static set seed(value: number) { - Random._seed = value; - } - - public static choose(values: Array): T | undefined { - const to = values.length; - if (to === 0) { - return undefined; - } - - return values[Math.floor(this.getRandomInRange(0, to))]; - } - - public static getRandomInRange(from: number, to: number): number { - return from + this.getRandom() * (to - from); - } - - public static getRandom(): number { - let t = (Random._seed += 0x6d2b79f5); - t = Math.imul(t ^ (t >>> 15), t | 1); - t ^= t + Math.imul(t ^ (t >>> 7), t | 61); - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - } +function getRandom(): number { + let t = (seed = (seed + 0x6d2b79f5) | 0); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; } + +export const Random = { + set seed(value: number) { + seed = value | 0; + }, + getRandom, + getRandomInRange(from: number, to: number): number { + return from + getRandom() * (to - from); + }, + choose(values: Array): T | undefined { + return values.length === 0 + ? undefined + : values[Math.floor(getRandom() * values.length)]; + }, +}; diff --git a/shared/src/helper/rectangle.ts b/shared/src/helper/rectangle.ts deleted file mode 100644 index f1aa8f3..0000000 --- a/shared/src/helper/rectangle.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { serializable } from '../serialization/serializable'; - -@serializable -export class Rectangle { - constructor( - public topLeft = vec2.create(), - public size = vec2.create(), - ) {} - - public toArray(): Array { - return [this.topLeft, this.size]; - } -} diff --git a/shared/src/helper/rgb.ts b/shared/src/helper/rgb.ts deleted file mode 100644 index a6ef20a..0000000 --- a/shared/src/helper/rgb.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { vec3 } from 'gl-matrix'; - -export const rgb = (r: number, g: number, b: number): vec3 => vec3.fromValues(r, g, b); diff --git a/shared/src/helper/rotate-90-deg.ts b/shared/src/helper/rotate-90-deg.ts deleted file mode 100644 index 2196fa8..0000000 --- a/shared/src/helper/rotate-90-deg.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { vec2 } from 'gl-matrix'; - -export const rotate90Deg = (vec: vec2): vec2 => vec2.fromValues(-vec[1], vec[0]); diff --git a/shared/src/helper/rotate-minus-90-deg.ts b/shared/src/helper/rotate-minus-90-deg.ts deleted file mode 100644 index 3f4773f..0000000 --- a/shared/src/helper/rotate-minus-90-deg.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { vec2 } from 'gl-matrix'; - -export const rotateMinus90Deg = (vec: vec2): vec2 => vec2.fromValues(vec[1], -vec[0]); diff --git a/shared/src/helper/wait.ts b/shared/src/helper/wait.ts deleted file mode 100644 index 73dd2c2..0000000 --- a/shared/src/helper/wait.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const wait = (ms: number): Promise => { - return new Promise((resolve, _) => setTimeout(resolve, ms)); -}; diff --git a/shared/src/main.ts b/shared/src/main.ts index 7adfcf6..befadfc 100644 --- a/shared/src/main.ts +++ b/shared/src/main.ts @@ -10,34 +10,26 @@ export * from './commands/types/game-start'; export * from './commands/types/update-minimap'; export * from './commands/types/update-game-state'; export * from './commands/command-receiver'; -export * from './commands/command-executors'; -export * from './commands/command-generator'; export * from './commands/types/actions/move-action'; export * from './commands/types/actions/primary-action'; export * from './commands/types/actions/leap-action'; +export * from './commands/types/actions/client-heartbeat'; export * from './commands/types/actions/set-aspect-ratio-action'; -export * from './helper/array'; -export * from './helper/last'; -export * from './helper/rgb'; export * from './helper/hsl'; export * from './helper/rgb255'; -export * from './helper/mix-rgb'; export * from './helper/clamp'; +export * from './helper/finite'; export * from './helper/charge'; export * from './helper/calculate-view-area'; export * from './helper/circle'; -export * from './helper/rectangle'; export * from './helper/mix'; +export * from './helper/ease'; +export * from './helper/mix-rgb'; export * from './helper/random'; -export * from './communication/id'; -export * from './communication/server-information'; -export * from './communication/player-information'; -export * from './helper/rotate-90-deg'; -export * from './helper/rotate-minus-90-deg'; +export * from './communication/communication'; export * from './objects/game-object'; export * from './serialization/deserialize'; export * from './serialization/serialize'; -export * from './serialization/serializes-to'; export * from './serialization/serializable'; export * from './serialization/override-deserialization'; export * from './objects/types/character-base'; diff --git a/shared/src/objects/game-object.ts b/shared/src/objects/game-object.ts index f851137..44fba62 100644 --- a/shared/src/objects/game-object.ts +++ b/shared/src/objects/game-object.ts @@ -1,6 +1,5 @@ import { Command } from '../commands/command'; -import { CommandReceiver } from '../commands/command-receiver'; -import { Id } from '../communication/id'; +import { Id } from '../communication/communication'; import { serializable } from '../serialization/serializable'; @serializable @@ -15,11 +14,6 @@ export class RemoteCall { } } -// Every object property streamed via UpdatePropertyCommand. A single union means -// a typo or rename on the producing (server *-physical) or consuming (client -// *-view) side is a compile error instead of a silently dropped update that -// just stops a body interpolating. The wire format is unchanged — these remain -// the same strings, only now compiler-checked at both ends. export type SyncPropertyKey = | 'head' | 'leftFoot' @@ -56,46 +50,42 @@ export class PropertyUpdatesForObject { } } -export abstract class GameObject extends CommandReceiver { +// Security: remote calls are dispatched by a raw string off the network. +const allowedRemoteCalls: ReadonlySet = new Set([ + 'onShoot', + 'onLeap', + 'onDie', + 'onHitConfirmed', + 'onKillConfirmed', + 'setHealth', + 'setKillCount', + 'onFlipped', + 'setContested', + 'generatedPoints', + 'setLight', +]); + +export abstract class GameObject { private remoteCalls: Array = []; - // The only methods a peer may invoke over the wire. processRemoteCalls - // dispatches by a raw string taken straight off the network, so without this - // gate a malformed or hostile packet could call ANY method on the object - // (toArray, resetRemoteCalls, even prototype methods). Keep in sync with the - // remoteCall() emitters on the *-physical classes. - private static readonly allowedRemoteCalls: ReadonlySet = new Set([ - 'onShoot', - 'onLeap', - 'onDie', - 'onHitConfirmed', - 'onKillConfirmed', - 'setHealth', - 'setKillCount', - 'onFlipped', - 'setContested', - 'generatedPoints', - 'setLight', - ]); - - constructor(public readonly id: Id) { - super(); - } + constructor(public readonly id: Id) {} public processRemoteCalls(remoteCalls: Array) { - remoteCalls.forEach((r) => { - if (!GameObject.allowedRemoteCalls.has(r.functionName)) { - console.warn(`Dropped disallowed remote call: ${r.functionName}`); - return; + for (const { functionName, args } of remoteCalls) { + if (!allowedRemoteCalls.has(functionName)) { + console.warn(`Dropped disallowed remote call: ${functionName}`); + continue; } - const fn = this[r.functionName as keyof this]; + const fn = this[functionName as keyof this]; if (typeof fn === 'function') { - (fn as (...args: Array) => unknown).apply(this, r.args); + (fn as (...args: Array) => unknown).apply(this, args); } - }); + } } - public getPropertyUpdates(): PropertyUpdatesForObject | void {} + public getPropertyUpdates(_timeScale = 1): PropertyUpdatesForObject | undefined { + return undefined; + } public getRemoteCalls(): Array { return this.remoteCalls; diff --git a/shared/src/objects/types/character-base.ts b/shared/src/objects/types/character-base.ts index f6cad5c..d860730 100644 --- a/shared/src/objects/types/character-base.ts +++ b/shared/src/objects/types/character-base.ts @@ -1,7 +1,6 @@ -import { Id } from '../../communication/id'; +import { Id } from '../../communication/communication'; import { Circle } from '../../helper/circle'; import { serializable } from '../../serialization/serializable'; -import { toArrayFromFields } from '../../serialization/serialized-fields'; import { GameObject } from '../game-object'; export enum CharacterTeam { @@ -12,18 +11,6 @@ export enum CharacterTeam { @serializable export class CharacterBase extends GameObject { - private static readonly serializedFields = [ - 'id', - 'name', - 'killCount', - 'deathCount', - 'team', - 'health', - 'head', - 'leftFoot', - 'rightFoot', - ] as const; - constructor( id: Id, public name: string, @@ -31,23 +18,20 @@ export class CharacterBase extends GameObject { public deathCount: number, public team: CharacterTeam, public health: number, - public head?: Circle, - public leftFoot?: Circle, - public rightFoot?: Circle, + public head: Circle, + public leftFoot: Circle, + public rightFoot: Circle, ) { super(id); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public onShoot(strength: number) {} + public onShoot(_strength: number) {} public onLeap() {} - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public onHitConfirmed(charge?: number) {} + public onHitConfirmed(_charge?: number) {} - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public onKillConfirmed(victimName?: string, streak?: number, charge?: number) {} + public onKillConfirmed(_victimName?: string, _streak?: number, _charge?: number) {} public setHealth(health: number) { this.health = health; @@ -60,6 +44,16 @@ export class CharacterBase extends GameObject { } public toArray(): Array { - return toArrayFromFields(this, CharacterBase.serializedFields); + return [ + this.id, + this.name, + this.killCount, + this.deathCount, + this.team, + this.health, + this.head, + this.leftFoot, + this.rightFoot, + ]; } } diff --git a/shared/src/objects/types/lamp-base.ts b/shared/src/objects/types/lamp-base.ts index 9f86ca6..aba7d83 100644 --- a/shared/src/objects/types/lamp-base.ts +++ b/shared/src/objects/types/lamp-base.ts @@ -1,17 +1,10 @@ import { vec2, vec3 } from 'gl-matrix'; -import { Id, serializable } from '../../main'; -import { toArrayFromFields } from '../../serialization/serialized-fields'; +import { Id } from '../../communication/communication'; +import { serializable } from '../../serialization/serializable'; import { GameObject } from '../game-object'; @serializable export class LampBase extends GameObject { - private static readonly serializedFields = [ - 'id', - 'center', - 'color', - 'lightness', - ] as const; - constructor( id: Id, public center: vec2, @@ -21,12 +14,9 @@ export class LampBase extends GameObject { super(id); } - // Overridden by LampView (which lerps toward the new colour/brightness); a - // no-op on the wire base. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public setLight(color: vec3, lightness: number) {} + public setLight(_color: vec3, _lightness: number) {} public toArray(): Array { - return toArrayFromFields(this, LampBase.serializedFields); + return [this.id, this.center, this.color, this.lightness]; } } diff --git a/shared/src/objects/types/planet-base.ts b/shared/src/objects/types/planet-base.ts index 2f90eb1..f2ef614 100644 --- a/shared/src/objects/types/planet-base.ts +++ b/shared/src/objects/types/planet-base.ts @@ -2,24 +2,23 @@ import { vec2 } from 'gl-matrix'; import { Random } from '../../helper/random'; import { settings } from '../../settings'; import { serializable } from '../../serialization/serializable'; -import { toArrayFromFields } from '../../serialization/serialized-fields'; import { GameObject } from '../game-object'; -import { Id } from '../../communication/id'; +import { Id } from '../../communication/communication'; import { CharacterTeam } from './character-base'; +import { GroundSurface } from '../../physics/sdf'; +import { planetDistance, planetGravity } from '../../physics/planet-sdf'; @serializable -export class PlanetBase extends GameObject { - // centre/radius are derived from vertices in the constructor, so they are not - // serialized — only the constructor parameters are. - private static readonly serializedFields = [ - 'id', - 'vertices', - 'ownership', - 'isKeystone', - ] as const; - +export class PlanetBase extends GameObject implements GroundSurface { + public readonly canCollide = true; + public readonly isGround = true; public readonly center: vec2; public readonly radius: number; + public angularVelocity = 0; + + private _rotation = 0; + private cosRotation = 1; + private sinRotation = 0; constructor( id: Id, @@ -28,19 +27,57 @@ export class PlanetBase extends GameObject { public readonly isKeystone: boolean = false, ) { super(id); - this.center = vertices.reduce((sum, v) => vec2.add(sum, sum, v), vec2.create()); + this.center = vec2.create(); + for (const vertex of vertices) { + vec2.add(this.center, this.center, vertex); + } vec2.scale(this.center, this.center, 1 / vertices.length); - this.radius = - vertices.reduce((sum, v) => sum + vec2.distance(this.center, v), 0) / - this.vertices.length; + let totalRadius = 0; + for (const vertex of vertices) { + totalRadius += vec2.distance(this.center, vertex); + } + this.radius = totalRadius / vertices.length; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public generatedPoints(value: number) {} - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public onFlipped(team: CharacterTeam) {} - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public setContested(contested: boolean) {} + public get team(): CharacterTeam { + return Math.abs(this.ownership - 0.5) < settings.planetControlThreshold + ? CharacterTeam.neutral + : this.ownership < 0.5 + ? CharacterTeam.blue + : CharacterTeam.red; + } + + public get rotation(): number { + return this._rotation; + } + + public set rotation(value: number) { + this._rotation = value; + this.cosRotation = Math.cos(value); + this.sinRotation = Math.sin(value); + } + + public advanceRotation(deltaTimeInSeconds: number) { + this.rotation += this.angularVelocity * deltaTimeInSeconds; + } + + public distance(target: vec2): number { + return planetDistance( + target, + this.vertices, + this.center, + this.cosRotation, + this.sinRotation, + ); + } + + public gravityAt(position: vec2): vec2 { + return planetGravity(this.center, this.radius, position); + } + + public generatedPoints(_value: number) {} + public onFlipped(_team: CharacterTeam) {} + public setContested(_contested: boolean) {} public static createPlanetVertices( center: vec2, @@ -67,6 +104,6 @@ export class PlanetBase extends GameObject { } public toArray(): Array { - return toArrayFromFields(this, PlanetBase.serializedFields); + return [this.id, this.vertices, this.ownership, this.isKeystone]; } } diff --git a/shared/src/objects/types/projectile-base.ts b/shared/src/objects/types/projectile-base.ts index f96fd94..2450398 100644 --- a/shared/src/objects/types/projectile-base.ts +++ b/shared/src/objects/types/projectile-base.ts @@ -1,21 +1,12 @@ import { vec2 } from 'gl-matrix'; import { settings } from '../../settings'; import { serializable } from '../../serialization/serializable'; -import { toArrayFromFields } from '../../serialization/serialized-fields'; import { GameObject } from '../game-object'; -import { Id } from '../../communication/id'; +import { Id } from '../../communication/communication'; import { CharacterTeam } from './character-base'; @serializable export class ProjectileBase extends GameObject { - private static readonly serializedFields = [ - 'id', - 'center', - 'radius', - 'team', - 'strength', - ] as const; - constructor( id: Id, public center: vec2, @@ -32,6 +23,6 @@ export class ProjectileBase extends GameObject { } public toArray(): Array { - return toArrayFromFields(this, ProjectileBase.serializedFields); + return [this.id, this.center, this.radius, this.team, this.strength]; } } diff --git a/shared/src/physics/character-movement.ts b/shared/src/physics/character-movement.ts index 21355a5..b314ad3 100644 --- a/shared/src/physics/character-movement.ts +++ b/shared/src/physics/character-movement.ts @@ -2,41 +2,18 @@ import { vec2 } from 'gl-matrix'; import { settings } from '../settings'; import { GroundSurface, PhysicsBody } from './sdf'; import { interpolateAngles } from './interpolate-angles'; +import { smoothing } from '../helper/ease'; -// Body layout, copied verbatim from CharacterPhysical so the predicted body -// matches the authoritative one to the bit. The head sits this far above the -// feet; offsets are measured from the centre of mass. export const headRadius = 50; export const feetRadius = 20; -const desiredHeadOffset = vec2.fromValues(0, 55); -const desiredLeftFootOffset = vec2.fromValues(-20, 0); -const desiredRightFootOffset = vec2.fromValues(20, 0); -const centerOfMass = vec2.scale( - vec2.create(), - vec2.add( - vec2.create(), - vec2.add(vec2.create(), desiredHeadOffset, desiredLeftFootOffset), - desiredRightFootOffset, - ), - 1 / 3, -); -export const headOffset = vec2.subtract(vec2.create(), desiredHeadOffset, centerOfMass); -export const leftFootOffset = vec2.subtract( - vec2.create(), - desiredLeftFootOffset, - centerOfMass, -); -export const rightFootOffset = vec2.subtract( - vec2.create(), - desiredRightFootOffset, - centerOfMass, -); +// Imports run before setMatrixArrayType(Array): keep these fractional constants in f64. +const centerHeight = 55 * (1 / 3); +export const headOffset: vec2 = [0, 55 - centerHeight]; +export const leftFootOffset: vec2 = [-20, -centerHeight]; +export const rightFootOffset: vec2 = [20, -centerHeight]; export const boundRadius = (headRadius + feetRadius * 2) * 2; -// The character's movement state: the three body parts plus the small amount of -// carried state the step reads and writes. Backend CharacterPhysical and the -// client predictor both expose this shape. export interface CharacterMovementState { readonly head: PhysicsBody; readonly leftFoot: PhysicsBody; @@ -44,47 +21,47 @@ export interface CharacterMovementState { direction: number; currentPlanet: GroundSurface | undefined; secondsSinceOnSurface: number; - // Persistent launch momentum (leap / slingshot / recoil / death throw). - // Walking rebuilds and zeroes each body part's velocity every tick, - // so anything that should carry accumulates here, is injected into the parts - // before they step, and decays by friction. Stays zero for ordinary walking. - // The client predictor leaves this zero and relies on the reconciliation snap - // to follow server-side impulses, but the field is here so the server can - // delegate its full movement to stepCharacterMovement unchanged. bodyVelocity: vec2; } -// The collision/gravity world the movement queries. The server backs this with -// its spatial container (planets + dynamics, dispatching collision reactions); -// the client backs it with the planets it knows about, dispatching nothing. export interface CharacterWorld { - // Planets within `radius` of `center` that exert gravity / can be stood on. groundsNear(center: vec2, radius: number): Array; - // Resolve one body part's motion this tick; returns the ground it landed on. stepBody(body: PhysicsBody, deltaTimeInSeconds: number): GroundSurface | undefined; } -const applyForce = (body: PhysicsBody, force: vec2, deltaTimeInSeconds: number) => { - vec2.add( - body.velocity, - body.velocity, - vec2.scale(vec2.create(), force, deltaTimeInSeconds), - ); +export const applyForce = ( + body: PhysicsBody, + force: vec2, + deltaTimeInSeconds: number, +) => { + vec2.scaleAndAdd(body.velocity, body.velocity, force, deltaTimeInSeconds); }; -// ((head + leftFoot) + rightFoot) / 3, in the exact association the server uses -// everywhere it reads the character centre — do not reassociate. -export const characterCenter = (state: CharacterMovementState): vec2 => { - const center = vec2.add(vec2.create(), state.head.center, state.leftFoot.center); - vec2.add(center, center, state.rightFoot.center); +// ((head + leftFoot) + rightFoot) / 3 — do not reassociate. +export const characterCenter = ( + head: { center: vec2 }, + leftFoot: { center: vec2 }, + rightFoot: { center: vec2 }, +): vec2 => { + const center = vec2.add(vec2.create(), head.center, leftFoot.center); + vec2.add(center, center, rightFoot.center); return vec2.scale(center, center, 1 / 3); }; -const setDirection = (state: CharacterMovementState, direction: vec2) => { +// Tuned so one physics step still turns the same 20% it always did, but a replay that splits +// or merges steps now lands on the same facing instead of one that depends on the step count. +const directionSmoothingSeconds = + settings.targetPhysicsDeltaTimeInSeconds / Math.log(1 / (1 - 0.2)); + +const setDirection = ( + state: CharacterMovementState, + direction: vec2, + deltaTimeInSeconds: number, +) => { state.direction = interpolateAngles( state.direction, Math.atan2(direction[1], direction[0]) + Math.PI / 2, - 0.2, + smoothing(deltaTimeInSeconds, directionSmoothingSeconds), ); }; @@ -98,15 +75,11 @@ const springMove = ( const desiredPosition = vec2.add(vec2.create(), center, offset); vec2.rotate(desiredPosition, desiredPosition, center, state.direction); const positionDelta = vec2.subtract(vec2.create(), desiredPosition, body.center); - // First-order velocity relaxation toward the desired posture position, added - // to the gravity/movement velocity already accumulated this tick. The dt - // arrives later when the body integrates velocity, so the per-tick - // displacement is positionDelta * stiffness * dt. vec2.scaleAndAdd(body.velocity, body.velocity, positionDelta, stiffness); }; const keepPosture = (state: CharacterMovementState) => { - const center = characterCenter(state); + const center = characterCenter(state.head, state.leftFoot, state.rightFoot); springMove( state, state.leftFoot, @@ -124,10 +97,6 @@ const keepPosture = (state: CharacterMovementState) => { springMove(state, state.head, center, headOffset, settings.postureHeadStiffness); }; -// While standing on a planet, ride its spin: rigidly rotate the whole body -// about the planet centre by the same per-tick angle the collision SDF turns -// by (negative, matching R(-rotation)), so the player is carried with the -// surface instead of sliding across it. const carryWithRotatingPlanet = ( state: CharacterMovementState, deltaTimeInSeconds: number, @@ -137,28 +106,11 @@ const carryWithRotatingPlanet = ( return; } const angle = -planet.angularVelocity * deltaTimeInSeconds; - const center = planet.center; - state.head.center = vec2.rotate(vec2.create(), state.head.center, center, angle); - state.leftFoot.center = vec2.rotate( - vec2.create(), - state.leftFoot.center, - center, - angle, - ); - state.rightFoot.center = vec2.rotate( - vec2.create(), - state.rightFoot.center, - center, - angle, - ); + for (const body of [state.head, state.leftFoot, state.rightFoot]) { + vec2.rotate(body.center, body.center, planet.center, angle); + } }; -// Launch off the current surface: directed by the foot contact normals plus -// the movement input, slingshotted by the planet's spin. Mutates bodyVelocity -// (which the next tick injects into the body) and detaches. Shared so the -// server's leap() and the client's prediction apply the exact same impulse. -// The caller does the gating (strength, cooldown, alive); this is a no-op when -// not on a surface. export const applyLeapImpulse = (state: CharacterMovementState, moveDirection: vec2) => { const planet = state.currentPlanet; if (!planet) { @@ -188,9 +140,7 @@ export const applyLeapImpulse = (state: CharacterMovementState, moveDirection: v vec2.normalize(launch, launch); vec2.scaleAndAdd(state.bodyVelocity, state.bodyVelocity, launch, settings.leapSpeed); - // Slingshot: add the tangential velocity of the spinning surface under the - // body (the same motion carryWithRotatingPlanet imparts). - const center = characterCenter(state); + const center = characterCenter(state.head, state.leftFoot, state.rightFoot); const omega = planet.angularVelocity; const surfaceVelocity = vec2.fromValues( omega * (center[1] - planet.center[1]), @@ -207,9 +157,6 @@ export const applyLeapImpulse = (state: CharacterMovementState, moveDirection: v state.secondsSinceOnSurface = settings.planetDetachmentSeconds; }; -// Time-based detachment: a grounded body that hasn't touched a surface for a -// while floats free. Kept as its own step because on the server it runs before -// the ownership/scoring blocks; the client calls it at the head of each tick. export const tickPlanetDetachment = ( state: CharacterMovementState, deltaTimeInSeconds: number, @@ -221,9 +168,6 @@ export const tickPlanetDetachment = ( } }; -// Inject the persistent launch momentum onto every body part right before they -// step, so the whole body translates rigidly without disturbing the posture -// springs (which only set up relative offsets). No-op while walking. const applyBodyMomentum = (state: CharacterMovementState) => { if (vec2.squaredLength(state.bodyVelocity) === 0) { return; @@ -233,14 +177,6 @@ const applyBodyMomentum = (state: CharacterMovementState) => { vec2.add(state.head.velocity, state.head.velocity, state.bodyVelocity); }; -// Decay one launch-momentum vector in place by one tick. Stiff on the ground -// (skid to a stop), gentle in the air so a leap or slingshot still carries -// across the gaps. The gentle exponential only asymptotes, though, so on top of -// it a constant deceleration brakes the momentum to a definite stop in a couple -// of seconds instead of leaving a 15+ second drift, and a hard cap stops stacked -// impulses (rapid recoil, a leap into a slingshot) from building without bound. -// Shared so the living body, the client predictor, and the ragdoll corpse all -// brake identically. export const decayMomentum = ( bodyVelocity: vec2, onGround: boolean, @@ -265,11 +201,7 @@ export const decayMomentum = ( } }; -const decayBodyMomentum = (state: CharacterMovementState, deltaTimeInSeconds: number) => { - decayMomentum(state.bodyVelocity, !!state.currentPlanet, deltaTimeInSeconds); -}; - -const sumGravity = (grounds: Array, position: vec2): vec2 => +export const sumGravity = (grounds: Array, position: vec2): vec2 => grounds.reduce( (sum, ground) => vec2.add(sum, sum, ground.gravityAt(position)), vec2.create(), @@ -285,23 +217,18 @@ const latchGround = ( } }; -// One tick of character movement. This is the exact movement block of the -// authoritative CharacterPhysical.step (gravity gather → movement force → -// on/off-planet branch → posture → step the three body parts), with all -// server-only concerns (scoring, health, shooting, spawn/death animation, -// ownership) left to the caller. `inputDirection` is the already-averaged, -// already-normalized movement direction for this tick and is consumed in place. export const stepCharacterMovement = ( state: CharacterMovementState, world: CharacterWorld, inputDirection: vec2, deltaTimeInSeconds: number, ) => { - const center = characterCenter(state); - const grounds = world.groundsNear(center, boundRadius + settings.maxGravityDistance); + vec2.zero(state.head.velocity); + vec2.zero(state.leftFoot.velocity); + vec2.zero(state.rightFoot.velocity); const movementForce = vec2.scale( - inputDirection, + vec2.create(), inputDirection, settings.maxAcceleration, ); @@ -309,6 +236,8 @@ export const stepCharacterMovement = ( applyForce(state.rightFoot, movementForce, deltaTimeInSeconds); if (!state.currentPlanet) { + const center = characterCenter(state.head, state.leftFoot, state.rightFoot); + const grounds = world.groundsNear(center, boundRadius + settings.maxGravityDistance); const leftFootGravity = sumGravity(grounds, state.leftFoot.center); const rightFootGravity = sumGravity(grounds, state.rightFoot.center); @@ -316,7 +245,11 @@ export const stepCharacterMovement = ( applyForce(state.rightFoot, rightFootGravity, deltaTimeInSeconds); const sumForce = vec2.subtract(vec2.create(), leftFootGravity, movementForce); - setDirection(state, vec2.length(sumForce) === 0 ? vec2.fromValues(0, -1) : sumForce); + setDirection( + state, + vec2.length(sumForce) === 0 ? vec2.fromValues(0, -1) : sumForce, + deltaTimeInSeconds, + ); } else { carryWithRotatingPlanet(state, deltaTimeInSeconds); @@ -326,9 +259,13 @@ export const stepCharacterMovement = ( vec2.add(leftFootGravity, leftFootGravity, rightFootGravity); const gravity = vec2.scale(leftFootGravity, leftFootGravity, 0.5); + const movementLength = vec2.length(movementForce); + const gravityLength = vec2.length(gravity); if ( + movementLength > 0 && + gravityLength > 0 && vec2.dot(movementForce, gravity) < - -vec2.length(movementForce) * settings.climbDotThreshold + -movementLength * gravityLength * settings.climbDotThreshold ) { vec2.scale(gravity, gravity, settings.climbGravityScale); } @@ -350,7 +287,7 @@ export const stepCharacterMovement = ( if (vec2.length(gravity) <= settings.planetDetachmentForceThreshold) { state.currentPlanet = undefined; } - setDirection(state, gravity); + setDirection(state, gravity, deltaTimeInSeconds); } keepPosture(state); @@ -359,7 +296,7 @@ export const stepCharacterMovement = ( latchGround(state, world.stepBody(state.leftFoot, deltaTimeInSeconds)); latchGround(state, world.stepBody(state.rightFoot, deltaTimeInSeconds)); - latchGround(state, world.stepBody(state.head, deltaTimeInSeconds)); + world.stepBody(state.head, deltaTimeInSeconds); - decayBodyMomentum(state, deltaTimeInSeconds); + decayMomentum(state.bodyVelocity, !!state.currentPlanet, deltaTimeInSeconds); }; diff --git a/shared/src/physics/depenetrate-circle.ts b/shared/src/physics/depenetrate-circle.ts index 7232f73..85bb04a 100644 --- a/shared/src/physics/depenetrate-circle.ts +++ b/shared/src/physics/depenetrate-circle.ts @@ -3,13 +3,6 @@ import { PhysicsBody, Sdf } from './sdf'; import { evaluateSdf } from './evaluate-sdf'; import { sdfNormal } from './sdf-normal'; -// Planet collision outlines rotate (see PlanetPhysical.distance), so a surface -// can sweep into a circle that hasn't itself moved. marchCircle assumes an -// overlap-free start — beginning inside, it registers a zero-distance hit and -// never moves again — so any overlap must be resolved here, before marching. -// Iterating handles concave spots, where leaving one face pushes into another; -// if no overlap-free position exists nearby (a crevice narrower than the -// circle), it gives up and leaves the rest to a later tick. export const depenetrateCircle = ( body: PhysicsBody, possibleIntersectors: Array, @@ -26,11 +19,6 @@ export const depenetrateCircle = ( } vec2.copy(body.lastNormal, normal); - body.center = vec2.scaleAndAdd( - vec2.create(), - body.center, - normal, - body.radius - distance + 0.01, - ); + vec2.scaleAndAdd(body.center, body.center, normal, body.radius - distance + 0.01); } }; diff --git a/shared/src/physics/evaluate-sdf.ts b/shared/src/physics/evaluate-sdf.ts index f3c60cf..3a51c65 100644 --- a/shared/src/physics/evaluate-sdf.ts +++ b/shared/src/physics/evaluate-sdf.ts @@ -1,7 +1,13 @@ import { vec2 } from 'gl-matrix'; import { Sdf } from './sdf'; -export const evaluateSdf = (target: vec2, objects: Array) => - objects - .filter((i) => i.canCollide) - .reduce((min, i) => (min = Math.min(min, i.distance(target))), 1000000); +export const evaluateSdf = (target: vec2, objects: Array): number => { + let minimum = Infinity; + for (let i = 0; i < objects.length; i++) { + const object = objects[i]; + if (object.canCollide) { + minimum = Math.min(minimum, object.distance(target)); + } + } + return minimum; +}; diff --git a/shared/src/physics/march-circle.ts b/shared/src/physics/march-circle.ts index 0a8f0c8..539d155 100644 --- a/shared/src/physics/march-circle.ts +++ b/shared/src/physics/march-circle.ts @@ -4,77 +4,65 @@ import { evaluateSdf } from './evaluate-sdf'; import { sdfNormal } from './sdf-normal'; export interface MarchResult { - hitSurface: boolean; + travelled: number; normal?: vec2; hitObject?: Sdf; } -// Raymarch a circle by `delta`, stopping at the first surface it would overlap. -// Extracted verbatim from the backend's move-circle so server and client -// resolve motion identically. The collision *reaction* is no longer dispatched -// here: on a real (non-ignored) hit, `onHit(intersecting)` is invoked at the -// exact point the backend used to dispatch its ReactToCollisionCommands, and -// the backend wrapper supplies that callback. The client passes none. +const minimumStep = 0.5; +const maximumSteps = 256; + export const marchCircle = ( body: PhysicsBody, delta: vec2, possibleIntersectors: Array, - ignoreCollision = false, onHit?: (intersecting: Sdf) => void, ): MarchResult => { - const direction = vec2.clone(delta); - - if (vec2.length(delta) > 0) { - vec2.normalize(direction, direction); + const deltaLength = vec2.length(delta); + if (!(deltaLength > 0)) { + return { travelled: 0 }; } - const deltaLength = vec2.length(delta); - let travelled = 0; + const direction = vec2.normalize(vec2.create(), delta); const rayEnd = vec2.create(); - let prevMinDistance = 0; - while (travelled < deltaLength) { - travelled += prevMinDistance; - vec2.add( - rayEnd, - body.center, - vec2.scale(vec2.create(), direction, Math.min(travelled, deltaLength)), - ); + let travelled = 0; + let lastFreeDistance = 0; + + for (let step = 0; step < maximumSteps; step++) { + vec2.scaleAndAdd(rayEnd, body.center, direction, travelled); const minDistance = evaluateSdf(rayEnd, possibleIntersectors); if (minDistance < body.radius) { const intersecting = possibleIntersectors.find( - (i) => i.distance(rayEnd) <= body.radius, + (i) => i.canCollide && i.distance(rayEnd) < body.radius, )!; - if (ignoreCollision) { - body.center = vec2.add(body.center, body.center, delta); - } else { - onHit?.(intersecting); - } - - vec2.add( - rayEnd, - body.center, - vec2.scale(vec2.create(), direction, travelled - prevMinDistance), - ); - + vec2.scaleAndAdd(rayEnd, body.center, direction, lastFreeDistance); vec2.copy(body.center, rayEnd); - const normal = sdfNormal(rayEnd, [intersecting]); + onHit?.(intersecting); + return { - hitSurface: true, + travelled: lastFreeDistance, normal, hitObject: intersecting, }; } - prevMinDistance = minDistance; + lastFreeDistance = travelled; + + if (travelled >= deltaLength) { + break; + } + + travelled = Math.min( + travelled + Math.max(minDistance - body.radius, minimumStep), + deltaLength, + ); } - vec2.add(body.center, body.center, delta); + vec2.scaleAndAdd(body.center, body.center, direction, lastFreeDistance); - return { - hitSurface: false, - }; + return { travelled: lastFreeDistance }; }; diff --git a/shared/src/physics/planet-sdf.ts b/shared/src/physics/planet-sdf.ts index 24af49d..d54183c 100644 --- a/shared/src/physics/planet-sdf.ts +++ b/shared/src/physics/planet-sdf.ts @@ -2,10 +2,8 @@ import { vec2 } from 'gl-matrix'; import { clamp, clamp01 } from '../helper/clamp'; import { settings } from '../settings'; -// Rotate a world point into the planet's own frame: R(-rotation) about the -// centre, matching the shader's localTarget = center + R(rotation)*(target-center). -// cos/sin are passed in so the server can keep memoising them per angle. export const toPlanetLocalFrame = ( + out: vec2, target: vec2, center: vec2, cos: number, @@ -13,16 +11,17 @@ export const toPlanetLocalFrame = ( ): vec2 => { const dx = target[0] - center[0]; const dy = target[1] - center[1]; - return vec2.fromValues( - center[0] + cos * dx - sin * dy, - center[1] + sin * dx + cos * dy, - ); + return vec2.set(out, center[0] + cos * dx - sin * dy, center[1] + sin * dx + cos * dy); }; -// Signed distance to the (smooth, noise-free) planet polygon, evaluated in the -// planet's rotating frame. Extracted verbatim from PlanetPhysical.distance so -// the client collides against the exact same outline the server does. Indexed -// vector access keeps it independent of the .x/.y prototype plugin. +// Plain array literals, not vec2.create(): these are allocated before +// setMatrixArrayType(Array) runs and must be f64 like the rest of the simulation. +const localPoint: vec2 = [0, 0]; +const targetFromDelta: vec2 = [0, 0]; +const toFromDelta: vec2 = [0, 0]; +const closestOnEdge: vec2 = [0, 0]; +const edgeSample: vec2 = [0, 0]; + export const planetDistance = ( target: vec2, vertices: Array, @@ -30,7 +29,7 @@ export const planetDistance = ( cos: number, sin: number, ): number => { - const local = toPlanetLocalFrame(target, center, cos, sin); + const local = toPlanetLocalFrame(localPoint, target, center, cos, sin); const startEnd = vertices[0]; let vb = startEnd; @@ -41,32 +40,31 @@ export const planetDistance = ( for (let i = 1; i <= vertices.length; i++) { const va = vb; vb = i === vertices.length ? startEnd : vertices[i]; - const targetFromDelta = vec2.subtract(vec2.create(), local, va); - const toFromDelta = vec2.subtract(vec2.create(), vb, va); + vec2.subtract(targetFromDelta, local, va); + vec2.subtract(toFromDelta, vb, va); const h = clamp01( vec2.dot(targetFromDelta, toFromDelta) / vec2.squaredLength(toFromDelta), ); - const ds = vec2.fromValues( - vec2.dist(targetFromDelta, vec2.scale(vec2.create(), toFromDelta, h)), + vec2.set( + edgeSample, + vec2.dist(targetFromDelta, vec2.scale(closestOnEdge, toFromDelta, h)), toFromDelta[0] * targetFromDelta[1] - toFromDelta[1] * targetFromDelta[0], ); if ( - (local[1] >= va[1] && local[1] < vb[1] && ds[1] > 0) || - (local[1] < va[1] && local[1] >= vb[1] && ds[1] <= 0) + (local[1] >= va[1] && local[1] < vb[1] && edgeSample[1] > 0) || + (local[1] < va[1] && local[1] >= vb[1] && edgeSample[1] <= 0) ) { sign *= -1; } - d = Math.min(d, ds[0]); + d = Math.min(d, edgeSample[0]); } return sign * d; }; -// Gravity a planet exerts at a world position. Verbatim from -// PlanetPhysical.getForce. export const planetGravity = (center: vec2, radius: number, position: vec2): vec2 => { const diff = vec2.subtract(vec2.create(), center, position); const dist = Math.max(settings.minGravityDistance, vec2.length(diff) - radius); diff --git a/shared/src/physics/resolve-circle-movement.ts b/shared/src/physics/resolve-circle-movement.ts index 47f0c1c..724c8ab 100644 --- a/shared/src/physics/resolve-circle-movement.ts +++ b/shared/src/physics/resolve-circle-movement.ts @@ -3,56 +3,44 @@ import { PhysicsBody, Sdf } from './sdf'; import { depenetrateCircle } from './depenetrate-circle'; import { marchCircle } from './march-circle'; -// The position-resolution half of the backend's CirclePhysical.stepManually, -// extracted so server and client integrate a body identically. The caller is -// responsible for the broadphase (gathering `possibleIntersectors`, including -// the swept-radius bump) because that depends on each side's spatial structure; -// everything from depenetration onward lives here. -// -// `onHit` is threaded through to marchCircle so the backend can dispatch its -// collision reactions at the same points as before (including the second, -// post-bounce slide march); the client passes none. Velocity is reset to zero -// at the end — the character re-applies all forces from zero every tick, so a -// body that retained velocity would double-integrate and diverge. export const resolveCircleMovement = ( body: PhysicsBody, deltaTimeInSeconds: number, possibleIntersectors: Array, onHit?: (intersecting: Sdf) => void, -): { hitObject?: Sdf; velocity: vec2 } => { - let delta = vec2.scale(vec2.create(), body.velocity, deltaTimeInSeconds); - +): Sdf | undefined => { depenetrateCircle(body, possibleIntersectors); + let remainingSeconds = deltaTimeInSeconds; + let firstHit: Sdf | undefined; - const { normal, hitSurface, hitObject } = marchCircle( - body, - delta, - possibleIntersectors, - false, - onHit, - ); - - if (hitSurface) { - vec2.copy(body.lastNormal, normal!); - - vec2.subtract( - body.velocity, - body.velocity, - vec2.scale( - normal!, - normal!, - (1 + body.restitution) * vec2.dot(normal!, body.velocity), - ), + for (let pass = 0; pass < 2 && remainingSeconds > 0; pass++) { + const speed = vec2.length(body.velocity); + if (speed === 0) { + break; + } + const delta = vec2.scale(vec2.create(), body.velocity, remainingSeconds); + const { normal, hitObject, travelled } = marchCircle( + body, + delta, + possibleIntersectors, + onHit, ); - - if (vec2.length(body.velocity) > 50) { - delta = vec2.scale(vec2.create(), body.velocity, deltaTimeInSeconds); - marchCircle(body, delta, possibleIntersectors, false, onHit); + if (!hitObject || !normal) { + break; + } + firstHit ??= hitObject; + vec2.copy(body.lastNormal, normal); + remainingSeconds -= travelled / speed; + const inwardSpeed = vec2.dot(normal, body.velocity); + if (inwardSpeed < 0) { + vec2.scaleAndAdd( + body.velocity, + body.velocity, + normal, + -(1 + body.restitution) * inwardSpeed, + ); } } - const lastVelocity = vec2.clone(body.velocity); - vec2.zero(body.velocity); - - return { hitObject, velocity: lastVelocity }; + return firstHit; }; diff --git a/shared/src/physics/sdf-normal.ts b/shared/src/physics/sdf-normal.ts index 507a8eb..cc5df2a 100644 --- a/shared/src/physics/sdf-normal.ts +++ b/shared/src/physics/sdf-normal.ts @@ -2,18 +2,22 @@ import { vec2 } from 'gl-matrix'; import { Sdf } from './sdf'; import { evaluateSdf } from './evaluate-sdf'; -// Central-difference gradient of the combined SDF. Can be zero where the -// samples cancel out (e.g. on the medial axis of a shape) — callers must -// handle that case. Uses indexed access so it never depends on the vec2 .x/.y -// prototype plugin (identical numerically: .x === [0]). +// Plain array literal for the same reason as planet-sdf's scratch vectors. +const probe: vec2 = [0, 0]; + +const sampleAt = (x: number, y: number, objects: Array): number => { + vec2.set(probe, x, y); + return evaluateSdf(probe, objects); +}; + export const sdfNormal = (target: vec2, objects: Array): vec2 => { const dx = - evaluateSdf(vec2.fromValues(target[0] + 0.01, target[1]), objects) - - evaluateSdf(vec2.fromValues(target[0] - 0.01, target[1]), objects); + sampleAt(target[0] + 0.01, target[1], objects) - + sampleAt(target[0] - 0.01, target[1], objects); const dy = - evaluateSdf(vec2.fromValues(target[0], target[1] + 0.01), objects) - - evaluateSdf(vec2.fromValues(target[0], target[1] - 0.01), objects); + sampleAt(target[0], target[1] + 0.01, objects) - + sampleAt(target[0], target[1] - 0.01, objects); const normal = vec2.fromValues(dx, dy); - return vec2.squaredLength(normal) > 0 ? vec2.normalize(normal, normal) : normal; + return vec2.normalize(normal, normal); }; diff --git a/shared/src/physics/sdf.ts b/shared/src/physics/sdf.ts index 38bb74c..f2202e3 100644 --- a/shared/src/physics/sdf.ts +++ b/shared/src/physics/sdf.ts @@ -1,18 +1,11 @@ import { vec2 } from 'gl-matrix'; -// Minimal signed-distance collider the geometry functions need. Backend -// Physicals and the client's planet surfaces both satisfy this structurally. export interface Sdf { readonly canCollide: boolean; - // Planets set this true so a body landing on one can latch it as ground; - // everything else leaves it falsy. Replaces the server's - // `instanceof PlanetPhysical` check with a structural flag both sides share. readonly isGround?: boolean; distance(target: vec2): number; } -// A movable circle the geometry resolves in place. Backend CirclePhysical and -// the client predictor's plain bodies both satisfy this. export interface PhysicsBody { center: vec2; radius: number; @@ -21,8 +14,6 @@ export interface PhysicsBody { readonly restitution: number; } -// A planet-like surface: collidable, exerts gravity, and spins. Drives the -// character's on-surface movement branch. export interface GroundSurface extends Sdf { readonly isGround: true; readonly center: vec2; diff --git a/shared/src/serialization/deserializable-class.ts b/shared/src/serialization/deserializable-class.ts deleted file mode 100644 index 11d0a4f..0000000 --- a/shared/src/serialization/deserializable-class.ts +++ /dev/null @@ -1 +0,0 @@ -export type DeserializableClass = { new (...args: Array): unknown; name: string }; diff --git a/shared/src/serialization/deserialize.ts b/shared/src/serialization/deserialize.ts index 6ba3a67..d0513b6 100644 --- a/shared/src/serialization/deserialize.ts +++ b/shared/src/serialization/deserialize.ts @@ -1,15 +1,12 @@ import { serializableMapping } from './serializable-mapping'; -export const deserialize = (json: string): any => { - return JSON.parse(json, (_, v) => { - if (v !== null && Object.prototype.hasOwnProperty.call(v, '0')) { - const possibleType = v[0]; - const overridableConstructor = serializableMapping.get(possibleType); - if (overridableConstructor) { - return new overridableConstructor.constructor(...v.slice(1)); +export const deserialize = (json: string): any => + JSON.parse(json, (_, value) => { + if (Array.isArray(value) && typeof value[0] === 'string') { + const Ctor = serializableMapping.get(value[0]); + if (Ctor) { + return new Ctor(...value.slice(1)); } - return v; } - return v; + return value; }); -}; diff --git a/shared/src/serialization/mangled-type-key.ts b/shared/src/serialization/mangled-type-key.ts deleted file mode 100644 index 2460725..0000000 --- a/shared/src/serialization/mangled-type-key.ts +++ /dev/null @@ -1 +0,0 @@ -export const mangledTypeKey = '__serializable_type'; diff --git a/shared/src/serialization/override-deserialization.ts b/shared/src/serialization/override-deserialization.ts index 7b8d6d9..0802f54 100644 --- a/shared/src/serialization/override-deserialization.ts +++ b/shared/src/serialization/override-deserialization.ts @@ -1,13 +1,8 @@ -import { DeserializableClass } from './deserializable-class'; -import { SerializableClass } from './serializable-class'; -import { serializableMapping } from './serializable-mapping'; +import { serializableMapping, SerializableClass } from './serializable-mapping'; export const overrideDeserialization = ( source: SerializableClass, - target: DeserializableClass, + target: SerializableClass, ) => { - serializableMapping.set(source.name, { - constructor: target, - overridden: true, - }); + serializableMapping.set(source.name, target); }; diff --git a/shared/src/serialization/serializable-class.ts b/shared/src/serialization/serializable-class.ts deleted file mode 100644 index 88eb2d4..0000000 --- a/shared/src/serialization/serializable-class.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type SerializableClass = { - new (...args: Array): { toArray(): Array }; - name: string; -}; diff --git a/shared/src/serialization/serializable-mapping.ts b/shared/src/serialization/serializable-mapping.ts index 5c9d7cb..d741b31 100644 --- a/shared/src/serialization/serializable-mapping.ts +++ b/shared/src/serialization/serializable-mapping.ts @@ -1,9 +1,5 @@ -import { DeserializableClass } from './deserializable-class'; +export type SerializableClass = { new (...args: Array): unknown; name: string }; -export const serializableMapping = new Map< - string, - { - constructor: DeserializableClass; - overridden: boolean; - } ->(); +export const MANGLE = '__serializable_type'; + +export const serializableMapping = new Map(); diff --git a/shared/src/serialization/serializable.ts b/shared/src/serialization/serializable.ts index 4605f65..71952d3 100644 --- a/shared/src/serialization/serializable.ts +++ b/shared/src/serialization/serializable.ts @@ -1,17 +1,7 @@ -import { mangledTypeKey } from './mangled-type-key'; -import { SerializableClass } from './serializable-class'; -import { serializableMapping } from './serializable-mapping'; +import { MANGLE, SerializableClass, serializableMapping } from './serializable-mapping'; export const serializable = (type: SerializableClass): any => { - if (!serializableMapping.get(type.name)) { - serializableMapping.set(type.name, { - constructor: type, - overridden: false, - }); - } - - Object.defineProperty(type, mangledTypeKey, { value: type.name }); - Object.defineProperty(type.prototype, mangledTypeKey, { value: type.name }); - + serializableMapping.set(type.name, type); + Object.defineProperty(type.prototype, MANGLE, { value: type.name }); return type; }; diff --git a/shared/src/serialization/serialize.ts b/shared/src/serialization/serialize.ts index bd72218..6772ba5 100644 --- a/shared/src/serialization/serialize.ts +++ b/shared/src/serialization/serialize.ts @@ -1,12 +1,9 @@ -import { mangledTypeKey } from './mangled-type-key'; +import { MANGLE } from './serializable-mapping'; -export const serialize = (object: any): string => { - return JSON.stringify(object, (_, value) => { - if (value && value[mangledTypeKey]) { - const props = value.toArray() as Array; - props.unshift(value[mangledTypeKey]); - return props; +export const serialize = (object: any): string => + JSON.stringify(object, (_, value) => { + if (value && value[MANGLE]) { + return [value[MANGLE], ...value.toArray()]; } - return value?.toFixed ? Number(value.toFixed(3)) : value; + return typeof value === 'number' ? Math.round(value * 1000) / 1000 : value; }); -}; diff --git a/shared/src/serialization/serialized-fields.ts b/shared/src/serialization/serialized-fields.ts deleted file mode 100644 index 48733e8..0000000 --- a/shared/src/serialization/serialized-fields.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Derive a serializable object's positional wire array from a single declared -// list of field names, so toArray() and the constructor share ONE source of -// truth for field order instead of a hand-written array that can silently drift -// out of step with the parameters. -// -// deserialize reconstructs via `new Ctor(...array.slice(1))`, so the field list -// MUST name the constructor's parameters in order. Fields a constructor -// recomputes from others (e.g. a planet's centre/radius from its vertices) are -// derived, not serialized, and so are deliberately absent from the list. -export const toArrayFromFields = ( - object: any, - fields: ReadonlyArray, -): Array => fields.map((field) => object[field]); diff --git a/shared/src/serialization/serializes-to.ts b/shared/src/serialization/serializes-to.ts deleted file mode 100644 index 47030de..0000000 --- a/shared/src/serialization/serializes-to.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { mangledTypeKey } from './mangled-type-key'; -import { SerializableClass } from './serializable-class'; -import { serializableMapping } from './serializable-mapping'; - -export const serializesTo = (target: SerializableClass) => { - return (actual: SerializableClass): any => { - if (!serializableMapping.get(target.name)) { - serializableMapping.set(target.name, { - constructor: target, - overridden: false, - }); - } - - Object.defineProperty(actual, mangledTypeKey, { value: target.name }); - Object.defineProperty(actual.prototype, mangledTypeKey, { - value: target.name, - }); - - return actual; - }; -}; diff --git a/shared/src/settings.ts b/shared/src/settings.ts index 61041a5..0227d43 100644 --- a/shared/src/settings.ts +++ b/shared/src/settings.ts @@ -7,8 +7,6 @@ const redColor = rgb255(209, 86, 82); const q = 2.5; const blueColorDim = rgb255(64 * q, 105 * q, 165 * q); const redColorDim = rgb255(209 * q, 86 * q, 82 * q); -const bluePlanetColor = blueColorDim; -const redPlanetColor = redColorDim; export const settings = { lightCutoffDistance: 600, @@ -18,9 +16,6 @@ export const settings = { worldRadius: 4000, objectsOnCircleLength: 0.002, updateMessageInterval: 1 / 25, - // How far behind the estimated server time remote state is rendered: ~2.5 - // update intervals, so a late packet rarely leaves the client without a - // newer snapshot to interpolate towards. interpolationDelaySeconds: 0.1, planetEdgeCount: 7, playerKillPoint: 25, @@ -33,10 +28,6 @@ export const settings = { maxGravityDistance: 800, minGravityDistance: 1, maxGravityQ: 5000, - // Half-width of the neutral dead-band around 50% ownership. A planet only - // counts as captured (team(), point generation, flip) once |ownership-0.5| - // exceeds this, and the rendered ownership ring stays neutral until the same - // point — so what you see matches what scores. planetControlThreshold: 0.12, playerMaxHealth: 100, maxGravityStrength: 50000, @@ -70,10 +61,8 @@ export const settings = { chargeShotRadiusMax: 32, chargeShotSpeedMin: 2500, chargeShotSpeedMax: 3400, - playerColorIndexOffset: 3, backgroundGradient: [rgb255(90, 38, 43), rgb255(43, 39, 73)], - blueColor, - bluePlanetColor, + bluePlanetColor: blueColorDim, npcNames: [ 'Adam', 'Andrew', @@ -124,8 +113,7 @@ export const settings = { 'Will', 'Wyatt', ], - redColor, - redPlanetColor, + redPlanetColor: redColorDim, colorIndices: { [CharacterTeam.blue]: 0, [CharacterTeam.neutral]: 1, @@ -148,46 +136,21 @@ export const settings = { chargedHitThreshold: 0.6, - // Projectiles fall through planetary gravity like a free-falling character, - // so slower (charged) shots arc. Scale kept tiny: near a surface gravity is - // maxGravityStrength=50000, which at full strength would corkscrew a shot into - // the planet — 0.04 gives a readable bend instead. - projectileGravityEnabled: true, projectileGravityScale: 0.04, - // Speed the corpse is flung at along the killing shot's direction, lerped by - // that shot's charge. Added to whatever momentum the victim already carried. deathImpulseMin: 280, deathImpulseMax: 1300, - // A planet's net team head-count drives a single capture step per tick; the - // lead multiplier is capped so a zerg can't flip instantly. Equal head-counts - // freeze the planet (contested) instead of silently cancelling. maxContestLeadMultiplier: 2, - // Persistent body momentum decays per second by these exponents. Airborne is - // near-frictionless so leaps and slingshots carry across the gaps; grounded is - // stiff so you skid to a stop on landing rather than sliding forever. airMomentumFriction: 0.4, groundMomentumFriction: 7, - // On top of the exponential frictions above, a constant deceleration (u/s^2) - // applied to body momentum. The exponential alone only asymptotes toward zero, - // so a fast launch keeps a slow tail for 15+ seconds — it reads as drifting - // forever with nothing slowing you. This constant brake brings the momentum to - // a definite stop in a couple of seconds, while the gentle exponential still - // lets the launch cover its distance first. - momentumStopDeceleration: 400, - // Hard ceiling (u/s) on body momentum, so stacked impulses — rapid charged-shot - // recoil, or a leap chained into a spin slingshot — can't build speed without - // bound. Kept above the overcharge fling (1700) so single launches survive. maxBodyMomentum: 2000, - // Leap: a charged-cost launch off a surface, paid from the shared shooting - // strength pool so it trades against firepower. leapStrengthCost: 32, leapSpeed: 1350, @@ -195,15 +158,23 @@ export const settings = { leapMoveBias: 0.65, leapCooldownSeconds: 0.35, - // Fraction of the planet's tangential surface velocity you keep when you leave - // it (slingshot). Leap off a fast spinner to be flung far. slingshotScale: 1, - // Recoil speed imparted opposite a shot, scaled by its charge (0 for taps). chargeShotRecoilMax: 650, - // The central giant is a named, always-contested focus. Its neutral decay is - // slowed so control lingers and teams keep fighting over it; flips are - // announced to everyone and an off-screen arrow points the way. + maxInboundMessageBytes: 16 * 1024, + + // Must not be the frame rate: dropped batches lose edge-triggered movement commands. + clientSendInterval: 1 / 30, + + maxInboundMessagesPerSecond: 240, + maxInboundMessageBurst: 480, + + maxMeasuredRttMs: 1000, + + maxBufferedBytesPerClient: 256 * 1024, + + maxClientPositionMagnitude: 1e6, + keystoneLoseControlScale: 2.5, }; diff --git a/shared/tsconfig.json b/shared/tsconfig.json index 91326be..ca43c33 100644 --- a/shared/tsconfig.json +++ b/shared/tsconfig.json @@ -15,5 +15,5 @@ "composite": true, "lib": ["dom", "es2017"] }, - "include": ["src/**/*.ts", "src/*.ts"] + "include": ["src/**/*.ts"] } diff --git a/shared/webpack.config.js b/shared/webpack.config.js index 2eebc06..f811bdc 100644 --- a/shared/webpack.config.js +++ b/shared/webpack.config.js @@ -27,7 +27,6 @@ module.exports = (env, argv) => ({ optimization: { minimize: false, }, - plugins: [], module: { rules: [ { diff --git a/test/__snapshots__/physics-determinism.test.mjs.snap b/test/__snapshots__/physics-determinism.test.mjs.snap index 50ab1c3..7972aa5 100644 --- a/test/__snapshots__/physics-determinism.test.mjs.snap +++ b/test/__snapshots__/physics-determinism.test.mjs.snap @@ -1,3 +1,5 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`shared character simulation determinism > matches the pinned reference pose (changes only with intentional physics edits) 1`] = `"{"head":[76.14,123.412],"leftFoot":[83.214,105.036],"rightFoot":[79.974,123.01],"bodyVelocity":[0,0]}"`; + +exports[`shared character simulation determinism > matches the pinned reference pose on a spinning planet 1`] = `"{"head":[321.49,-34.424],"leftFoot":[249.254,-51.093],"rightFoot":[281.714,-72.595],"direction":-0.315,"onSurface":true,"secondsSinceOnSurface":0}"`; diff --git a/test/backend-regressions.test.ts b/test/backend-regressions.test.ts new file mode 100644 index 0000000..5ff105f --- /dev/null +++ b/test/backend-regressions.test.ts @@ -0,0 +1,536 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createRequire } from 'node:module'; +import { performance } from 'node:perf_hooks'; + +import { PhysicalContainer } from '../backend/src/physics/physical-container'; +import { BoundingBox } from '../backend/src/physics/bounding-box'; +import { ProjectilePhysical } from '../backend/src/objects/projectile-physical'; +import { CharacterPhysical } from '../backend/src/objects/character-physical'; +import { PlanetPhysical } from '../backend/src/objects/planet-physical'; +import { PlayerContainer } from '../backend/src/players/player-container'; +import { defaultOptions } from '../backend/src/options'; +import { GameServer } from '../backend/src/game-server'; + +const require = createRequire(import.meta.url); +const shared = require('../shared/lib/main.js'); +const { vec2 } = require('../shared/node_modules/gl-matrix'); +const { + Random, + settings, + CharacterTeam, + SetAspectRatioActionCommand, + TransportEvents, + deserialize, + CreateObjectsCommand, + PropertyUpdatesForObjects, + InputAcknowledgement, + LeapActionCommand, + calculateViewArea, +} = shared; + +const step = settings.targetPhysicsDeltaTimeInSeconds; + +class PointsSpy { + public blue = 0; + public red = 0; + public addPoints(blue: number, red: number) { + this.blue += blue; + this.red += red; + } + public announce() {} +} + +const makeWorld = () => { + const game = new PointsSpy(); + return { game, container: new PhysicalContainer(game) }; +}; + +const spawnProjectile = (container: PhysicalContainer) => + new ProjectilePhysical( + vec2.fromValues(0, 0), + 20, + 40, + CharacterTeam.blue, + vec2.fromValues(3000, 0), + { team: CharacterTeam.blue } as never, + container, + 0, + ); + +const spawnCharacter = (container: PhysicalContainer) => { + const character = new CharacterPhysical( + 'tester', + 0, + 0, + CharacterTeam.blue, + container, + vec2.fromValues(0, 0), + ); + container.addObject(character); + return character; +}; + +const projectilesIn = (container: PhysicalContainer) => + container + .findIntersecting(BoundingBox.ofCircle([0, 0], 5000)) + .filter((o) => o.gameObject instanceof ProjectilePhysical) + .map((o) => o.gameObject as ProjectilePhysical); + +beforeEach(() => { + Random.seed = 1; +}); + +it('credits posthumous kills without making a corpse appear alive to clients', () => { + const { container } = makeWorld(); + const character = spawnCharacter(container); + character.setHealth(0); + character.onDie(); + character.addKill('late projectile victim'); + + expect(character.killCount).toBe(1); + expect(character.health).toBe(0); + expect( + character.getRemoteCalls().some((call) => call.functionName === 'setHealth'), + ).toBe(false); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const controlledServer = () => { + let nowMs = 1000; + let nextTick: () => void = () => {}; + vi.spyOn(process.hrtime, 'bigint').mockImplementation(() => BigInt(nowMs * 1e6)); + vi.spyOn(performance, 'now').mockImplementation(() => nowMs); + vi.spyOn(globalThis, 'setTimeout').mockImplementation(((callback: () => void) => { + nextTick = callback; + return 0; + }) as never); + const server = new GameServer( + { + on() {}, + to() { + return { emit() {} }; + }, + } as never, + { + ...defaultOptions, + npcCount: 0, + }, + ); + const internals = server as unknown as { + players: PlayerContainer; + objects: PhysicalContainer; + isInEndGame: boolean; + }; + server.start(); + return { + players: internals.players, + objects: internals.objects, + server, + tick(milliseconds: number) { + nowMs += milliseconds; + nextTick(); + }, + }; +}; + +describe('server snapshot cadence', () => { + it('waits for physics before acknowledging input or clearing remote calls', () => { + const { players, objects, tick } = controlledServer(); + const communicate = vi.spyOn(players, 'stepCommunication'); + const character = spawnCharacter(objects); + character.shootTowards(vec2.fromValues(1000, 0)); + + tick(1); + + expect(communicate).not.toHaveBeenCalled(); + expect( + character.getRemoteCalls().some((call) => call.functionName === 'onShoot'), + ).toBe(true); + + tick(4); + + expect(communicate).toHaveBeenCalledOnce(); + expect(character.getRemoteCalls()).toHaveLength(0); + }); + + it.each([false, true])( + 'sends 25 snapshots per simulated second (endgame: %s)', + (endGame) => { + const { players, server, tick } = controlledServer(); + (server as unknown as { isInEndGame: boolean }).isInEndGame = endGame; + const batches: unknown[] = []; + const player = players.createPlayer({ name: 'player' }, { + on() {}, + emit(event: string, batch: unknown) { + if (event === TransportEvents.ServerToPlayer) { + batches.push(batch); + } + }, + } as never); + + for (let i = 0; i < 200; i++) { + tick(5); + } + + expect(batches).toHaveLength(25); + const snapshot = deserialize(batches[24] as string).find( + (command: object) => command instanceof PropertyUpdatesForObjects, + ); + const character = snapshot.updates.find( + (object: { id: number }) => object.id === player.character!.id, + ); + const strength = character.updates.find( + (update: { propertyKey: string }) => update.propertyKey === 'strength', + ); + expect(strength.rateOfChange).toBeCloseTo( + settings.playerStrengthRegenerationPerSeconds * (endGame ? 0.5 : 1), + ); + }, + ); +}); + +describe('slow-motion extrapolation', () => { + const wallDelta = step; + const timeScale = 0.25; + + it('coasts projectiles at the same speed as the slowed server without changing momentum', () => { + const { container } = makeWorld(); + const projectile = spawnProjectile(container); + const before = vec2.clone(projectile.center); + projectile.step(wallDelta * timeScale); + const update = projectile.getPropertyUpdates(timeScale).updates[0]; + + expect( + vec2.scaleAndAdd(vec2.create(), before, update.rateOfChange, wallDelta), + ).toEqual(projectile.center); + expect(projectile.object.velocity).toEqual([3000, 0]); + const strength = projectile + .getPropertyUpdates(timeScale) + .updates.find((property) => property.propertyKey === 'strength')!; + expect(40 + strength.rateOfChange * wallDelta).toBeCloseTo(projectile.strength); + expect(strength.propertyValue).toBe(projectile.strength); + }); + + it('coasts planet rotation using wall-clock seconds', () => { + const { game } = makeWorld(); + const planet = new PlanetPhysical( + [ + [-100, -100], + [-100, 100], + [100, 100], + [100, -100], + ], + false, + game, + ); + const angularVelocity = planet.angularVelocity; + planet.step(wallDelta * timeScale); + const update = planet + .getPropertyUpdates(timeScale) + .updates.find((property) => property.propertyKey === 'rotation')!; + + expect(update.rateOfChange * wallDelta).toBeCloseTo(planet.rotation); + expect(planet.angularVelocity).toBe(angularVelocity); + }); + + it('scales character pose derivatives without mutating the stored simulation rates', () => { + const { container } = makeWorld(); + const character = spawnCharacter(container); + character.step(settings.spawnDespawnTime + step); + character.setMoveDirection(vec2.fromValues(1, 0)); + vec2.set(character.bodyVelocity, 1000, 0); + const parts = [character.head, character.leftFoot, character.rightFoot]; + const before = parts.map((part) => vec2.clone(part.center)); + character.step(wallDelta * timeScale); + const originalRates = character.getPropertyUpdates(); + const slowedRates = character.getPropertyUpdates(timeScale); + + parts.forEach((part, i) => { + const rate = slowedRates.updates[i].rateOfChange; + expect(vec2.scaleAndAdd(vec2.create(), before[i], rate.center, wallDelta)).toEqual( + part.center, + ); + }); + expect(character.getPropertyUpdates()).toEqual(originalRates); + }); + + it('coasts spawn growth using the slowed radius derivative', () => { + const { container } = makeWorld(); + const character = spawnCharacter(container); + character.step(0.1); + const before = character.head.radius; + character.step(wallDelta * timeScale); + const radiusSpeed = + character.getPropertyUpdates(timeScale).updates[0].rateOfChange.radius; + + expect(before + radiusSpeed * wallDelta).toBeCloseTo(character.head.radius); + }); +}); + +describe('projectile broadphase registration', () => { + it('follows the projectile instead of staying at the muzzle', () => { + const { container } = makeWorld(); + const projectile = spawnProjectile(container); + container.addObject(projectile); + + for (let i = 0; i < 40; i++) { + container.step(step); + } + + expect(projectile.center[0]).toBeGreaterThan(400); + expect( + container.findIntersecting(BoundingBox.ofCircle(projectile.center, 100)), + ).toContain(projectile); + expect(container.findIntersecting(BoundingBox.ofCircle([0, 0], 100))).not.toContain( + projectile, + ); + }); + + it('spawns a shot just clear of the shooter, not a jump downrange', () => { + const { container } = makeWorld(); + const projectile = spawnProjectile(container); + + expect(projectile.center[0]).toBeLessThanOrEqual(15); + }); + + it('does not advance a projectile destroyed earlier in the same world step', () => { + const { container } = makeWorld(); + const projectile = spawnProjectile(container); + const before = vec2.clone(projectile.center); + const character = spawnCharacter(container); + character.step = () => projectile.destroy(); + container.addObject(projectile); + + container.step(step); + + expect(projectile.center).toEqual(before); + expect(projectile.strength).toBe(40); + }); +}); + +describe('authoritative held movement', () => { + it('distinguishes no leap from an attempted leap timestamped at zero', () => { + const { container } = makeWorld(); + const players = new PlayerContainer(container, 1, 0); + const batches: string[] = []; + const player = players.createPlayer({ name: 'player' }, { + on() {}, + emit(event: string, payload: string) { + if (event === TransportEvents.ServerToPlayer) { + batches.push(payload); + } + }, + } as never); + const sendSnapshot = () => + player.stepCommunications(settings.updateMessageInterval, 1000, (object) => + object.getPropertyUpdates(), + ); + const leapAck = () => + deserialize(batches[batches.length - 1]).find( + (command: object) => command instanceof InputAcknowledgement, + ).lastLeapClientTimeMs; + + sendSnapshot(); + expect(leapAck()).toBe(-1); + + player.handleCommand(new LeapActionCommand(0)); + sendSnapshot(); + expect(leapAck()).toBe(0); + }); + + it('applies the latest direction when press and release arrive in the same tick', () => { + const { container } = makeWorld(); + const character = spawnCharacter(container); + character.step(settings.spawnDespawnTime + step); + const before = vec2.clone(character.center); + + character.setMoveDirection(vec2.fromValues(1, 0)); + character.setMoveDirection(vec2.fromValues(0, 0)); + character.step(step); + + expect(character.center).toEqual(before); + }); + + it('uses the latest held direction for a leap received in the same batch', () => { + const { container } = makeWorld(); + const character = spawnCharacter(container); + character.step(settings.spawnDespawnTime + step); + character.currentPlanet = { + center: vec2.fromValues(0, -100), + angularVelocity: 0, + } as never; + + character.setMoveDirection(vec2.fromValues(1, 0)); + character.leap(); + + expect(character.bodyVelocity[0]).toBeGreaterThan(0); + expect(character.movementSnapshot.leapCooldownRemaining).toBeCloseTo( + settings.leapCooldownSeconds, + ); + character.step(step); + expect(character.movementSnapshot.leapCooldownRemaining).toBeCloseTo( + settings.leapCooldownSeconds - step, + ); + }); +}); + +describe('planets', () => { + it('does not capture using a character killed after registering its presence', () => { + const { container, game } = makeWorld(); + const character = spawnCharacter(container); + const planet = new PlanetPhysical( + [ + [-100, -100], + [-100, 100], + [100, 100], + [100, -100], + ], + false, + game, + ); + planet.registerPresence(character); + character.onDie(); + + planet.step(step); + + expect(planet.ownership).toBe(0.5); + }); + + it.each([ + { aspect: 8, center: [0, 750] }, + { aspect: 0.2, center: [750, 0] }, + ])('sends offscreen gravity sources at aspect $aspect', ({ aspect, center }) => { + const { container, game } = makeWorld(); + const batches: string[] = []; + const socket = { + on() {}, + emit(event: string, payload: string) { + if (event === TransportEvents.ServerToPlayer) { + batches.push(payload); + } + }, + }; + const players = new PlayerContainer(container, 1, 0); + const player = players.createPlayer({ name: 'player' }, socket as never); + const [x, y] = center; + const planet = new PlanetPhysical( + [ + [x - 20, y - 20], + [x - 20, y + 20], + [x + 20, y + 20], + [x + 20, y - 20], + ], + false, + game, + ); + container.addObject(planet); + player.handleCommand(new SetAspectRatioActionCommand(aspect)); + const { topLeft, size } = calculateViewArea(player.center, aspect, 1.2); + expect( + planet.boundingBox.intersects( + new BoundingBox( + topLeft[0], + topLeft[0] + size[0], + topLeft[1] - size[1], + topLeft[1], + ), + ), + ).toBe(false); + + player.stepCommunications(settings.updateMessageInterval + step, 1000, (object) => + object.getPropertyUpdates(), + ); + + const created = deserialize(batches[0]).find( + (command: object) => command instanceof CreateObjectsCommand, + ); + expect(created.objects.map((object: { id: number }) => object.id)).toContain( + planet.id, + ); + }); +}); + +it('uses an integer default seed so separate server runs can generate different worlds', () => { + expect(Number.isInteger(defaultOptions.seed)).toBe(true); + expect(defaultOptions.seed).toBeGreaterThanOrEqual(0); + expect(defaultOptions.seed).toBeLessThan(0x100000000); +}); + +describe('scoring', () => { + it('awards nothing for an administrative death', () => { + const { game, container } = makeWorld(); + const character = spawnCharacter(container); + + character.onDie(); + container.step(step); + + expect(game.blue).toBe(0); + expect(game.red).toBe(0); + }); + + it('still awards a kill for a combat death', () => { + const { game, container } = makeWorld(); + const character = spawnCharacter(container); + + character.onDie(true); + container.step(step); + + expect(game.red).toBe(settings.playerKillPoint); + expect(game.blue).toBe(0); + }); + + it('awards a combat death only once', () => { + const { game, container } = makeWorld(); + const character = spawnCharacter(container); + + character.onDie(true); + character.onDie(true); + + expect(game.red).toBe(settings.playerKillPoint); + }); +}); + +describe('NPC population', () => { + it('replaces a departing human without adding extra bots for humans still playing', () => { + const { container } = makeWorld(); + const players = new PlayerContainer(container, 16, 4); + const socket = { on() {} } as never; + const first = players.createPlayer({ name: 'first' }, socket); + players.createPlayer({ name: 'second' }, socket); + expect(players.players).toHaveLength(4); + + first.destroy(); + players.deletePlayer(first); + + expect(players.count).toBe(1); + expect(players.players).toHaveLength(4); + }); +}); + +describe('charged shots', () => { + it('scales the shot by the strength actually paid, not the requested charge', () => { + const { container } = makeWorld(); + const character = spawnCharacter(container); + (character as unknown as { projectileStrength: number }).projectileStrength = + settings.chargeShotStrengthMin; + + character.shootTowards(vec2.fromValues(1000, 0), 1); + const [spawned] = projectilesIn(container); + + expect(spawned).toBeDefined(); + expect(spawned.charge).toBeCloseTo(0, 5); + expect(spawned.radius).toBeCloseTo(settings.chargeShotRadiusMin, 5); + }); + + it('ignores a shot aimed at the shooter itself', () => { + const { container } = makeWorld(); + const character = spawnCharacter(container); + + character.shootTowards(vec2.clone(character.center), 1); + + expect(projectilesIn(container)).toHaveLength(0); + }); +}); diff --git a/test/backpressure.test.ts b/test/backpressure.test.ts new file mode 100644 index 0000000..dd45cfe --- /dev/null +++ b/test/backpressure.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createRequire } from 'node:module'; + +import { PhysicalContainer } from '../backend/src/physics/physical-container'; +import { PlayerContainer } from '../backend/src/players/player-container'; +import { createWorld } from '../backend/src/create-world'; + +const require = createRequire(import.meta.url); +const shared = require('../shared/lib/main.js'); +const { + Random, + settings, + TransportEvents, + deserialize, + CreateObjectsCommand, + DeleteObjectsCommand, + PropertyUpdatesForObjects, + PropertyUpdatesForObject, + UpdatePropertyCommand, + InputAcknowledgement, + UpdateMinimap, + ServerAnnouncement, +} = shared; + +class FakeSocket { + public readonly sent: Array<{ event: string; payload: unknown }> = []; + public readonly conn = { + transport: { socket: { bufferedAmount: 0 } }, + writeBuffer: [] as Array<{ data: unknown }>, + }; + + public set bufferedAmount(bytes: number) { + this.conn.transport.socket.bufferedAmount = bytes; + } + + public on() {} + public off() {} + public emit(event: string, payload?: unknown) { + this.sent.push({ event, payload }); + } + + public get lastBatch(): Array<{ constructor: { name: string } }> { + const last = [...this.sent] + .reverse() + .find((m) => m.event === TransportEvents.ServerToPlayer); + return last ? deserialize(last.payload as string) : []; + } +} + +const makePlayer = () => { + Random.seed = 3; + const objects = new PhysicalContainer({ addPoints() {}, announce() {} }); + createWorld(objects); + const players = new PlayerContainer(objects, 4, 0); + const socket = new FakeSocket(); + const player = players.createPlayer({ name: 'player' }, socket as never); + player.sendQueuedCommandsToClient(); + socket.sent.length = 0; + return { player, socket }; +}; + +const queueOneSnapshot = (player: ReturnType['player']) => { + player.queueCommandSend(new CreateObjectsCommand([])); + player.queueCommandSend(new DeleteObjectsCommand([1234])); + player.queueCommandSend(new ServerAnnouncement('team blue wins')); + player.queueCommandSend(new UpdateMinimap([])); + player.queueCommandSend( + new PropertyUpdatesForObjects( + [ + new PropertyUpdatesForObject(1234, [ + new UpdatePropertyCommand('ownership', 1, 0), + ]), + ], + 1, + ), + ); + player.queueCommandSend( + new InputAcknowledgement(123, player.character!.movementSnapshot, 0, 0), + ); +}; + +const typesIn = (batch: Array<{ constructor: { name: string } }>) => + batch.map((c) => c.constructor.name); + +describe('slow-client backpressure', () => { + beforeEach(() => { + Random.seed = 3; + }); + + it('sends everything while the socket is draining', () => { + const { player, socket } = makePlayer(); + queueOneSnapshot(player); + player.sendQueuedCommandsToClient(); + + expect(typesIn(socket.lastBatch)).toEqual([ + CreateObjectsCommand.name, + DeleteObjectsCommand.name, + ServerAnnouncement.name, + UpdateMinimap.name, + PropertyUpdatesForObjects.name, + InputAcknowledgement.name, + ]); + }); + + it('reads the buffered byte count engine.io actually exposes', () => { + const { player, socket } = makePlayer(); + socket.bufferedAmount = settings.maxBufferedBytesPerClient + 1; + queueOneSnapshot(player); + player.sendQueuedCommandsToClient(); + + const marker = socket.lastBatch.find((c) => c instanceof PropertyUpdatesForObjects); + expect(marker).toMatchObject({ updates: [], timestamp: 1 }); + }); + + it('sheds only the state the next tick regenerates', () => { + const { player, socket } = makePlayer(); + socket.bufferedAmount = settings.maxBufferedBytesPerClient + 1; + queueOneSnapshot(player); + player.sendQueuedCommandsToClient(); + + const types = typesIn(socket.lastBatch); + expect(types).toContain(PropertyUpdatesForObjects.name); + expect(types).not.toContain(InputAcknowledgement.name); + expect(types).not.toContain(UpdateMinimap.name); + expect(types).toContain(CreateObjectsCommand.name); + expect(types).toContain(DeleteObjectsCommand.name); + expect(types).toContain(ServerAnnouncement.name); + }); + + it('stays quiet when a backed-up client has nothing but sheddable state', () => { + const { player, socket } = makePlayer(); + socket.bufferedAmount = settings.maxBufferedBytesPerClient + 1; + player.queueCommandSend(new PropertyUpdatesForObjects([], 1)); + const before = socket.sent.length; + player.sendQueuedCommandsToClient(); + + expect(socket.sent.length).toBe(before); + }); + + it('also measures packets waiting inside Engine.IO for the transport to drain', () => { + const { player, socket } = makePlayer(); + socket.conn.writeBuffer.push({ + data: Buffer.alloc(settings.maxBufferedBytesPerClient + 1), + }); + queueOneSnapshot(player); + player.sendQueuedCommandsToClient(); + + const marker = socket.lastBatch.find((c) => c instanceof PropertyUpdatesForObjects); + expect(marker).toMatchObject({ updates: [], timestamp: 1 }); + expect(typesIn(socket.lastBatch)).not.toContain(InputAcknowledgement.name); + }); + + it('sends a fresh pose and acknowledgement after the transport drains', () => { + const { player, socket } = makePlayer(); + socket.bufferedAmount = settings.maxBufferedBytesPerClient + 1; + queueOneSnapshot(player); + player.sendQueuedCommandsToClient(); + + socket.bufferedAmount = 0; + queueOneSnapshot(player); + player.sendQueuedCommandsToClient(); + + const snapshot = socket.lastBatch.find((c) => c instanceof PropertyUpdatesForObjects); + expect(snapshot).toMatchObject({ updates: [expect.anything()], timestamp: 1 }); + expect(typesIn(socket.lastBatch)).toContain(InputAcknowledgement.name); + }); +}); diff --git a/test/client-object-lifecycle.test.ts b/test/client-object-lifecycle.test.ts new file mode 100644 index 0000000..f4992c5 --- /dev/null +++ b/test/client-object-lifecycle.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRequire } from 'node:module'; +const { + CreateObjectsCommand, + DeleteObjectsCommand, + PropertyUpdatesForObjects, + RemoteCallsForObjects, + RemoteCallsForObject, + RemoteCall, + UpdatePropertyCommand, + CharacterTeam, +} = createRequire(import.meta.url)('../shared/lib/main.js'); +import { GameObjectContainer } from '../frontend/src/scripts/objects/game-object-container'; +import { PlanetView } from '../frontend/src/scripts/objects/types/planet-view'; +import { serverTimeline } from '../frontend/src/scripts/helper/server-timeline'; +import { LinearInterpolator } from '../frontend/src/scripts/helper/interpolators/linear-interpolator'; +import { ProjectileView } from '../frontend/src/scripts/objects/types/projectile-view'; + +vi.mock('../frontend/node_modules/sdf-2d', () => ({ + CircleLight: class { + constructor( + public center: number[], + public color: number[], + public intensity: number, + ) {} + }, +})); + +vi.mock('../frontend/src/scripts/objects/types/camera', () => ({ + Camera: class { + draw() {} + follow() {} + }, +})); +vi.mock('../frontend/src/scripts/objects/types/planet-view', () => ({ + PlanetView: class { + beforeDestroy = vi.fn(); + render = vi.fn(); + processRemoteCalls = vi.fn(); + step() {} + constructor(public id: number) {} + }, +})); +vi.mock('../frontend/src/scripts/feedback-hud', () => ({ FeedbackHud: {} })); + +let nowMs = 0; +beforeEach(() => { + nowMs = 0; + vi.spyOn(performance, 'now').mockImplementation(() => nowMs); + serverTimeline.reset(); +}); + +describe('remote effects', () => { + it('plays remote effects on the render timeline and local feedback immediately', () => { + const container = new GameObjectContainer({} as never); + const remote = new PlanetView(1, []); + const local = { + id: 2, + health: 100, + position: [0, 0], + snapshotStrength: 80, + step() {}, + processRemoteCalls: vi.fn(), + }; + container.handleCommand(new CreateObjectsCommand([remote, local])); + container.player = local as never; + const calls = [new RemoteCall('onShoot', [40])]; + container.handleCommand( + new RemoteCallsForObjects([ + new RemoteCallsForObject(remote.id, calls), + new RemoteCallsForObject(local.id, calls), + ]), + ); + container.handleCommand(new PropertyUpdatesForObjects([], 5)); + expect(local.processRemoteCalls).toHaveBeenCalledWith(calls); + step(0); + container.step(0); + expect(remote.processRemoteCalls).not.toHaveBeenCalled(); + step(0.11); + container.step(0); + expect(remote.processRemoteCalls).toHaveBeenCalledWith(calls); + }); + + it('discards queued effects when a view is replaced or the game resets', () => { + const container = new GameObjectContainer({} as never); + const original = new PlanetView(1, []); + container.handleCommand(new CreateObjectsCommand([original])); + container.handleCommand( + new RemoteCallsForObjects([ + new RemoteCallsForObject(1, [new RemoteCall('onFlipped', ['red'])]), + ]), + ); + container.handleCommand(new PropertyUpdatesForObjects([], 5)); + const replacement = new PlanetView(1, []); + container.handleCommand(new CreateObjectsCommand([replacement])); + container.handleCommand(new PropertyUpdatesForObjects([], 5.04)); + step(0.3); + container.step(0); + expect(original.processRemoteCalls).not.toHaveBeenCalled(); + expect(replacement.processRemoteCalls).not.toHaveBeenCalled(); + + container.handleCommand( + new RemoteCallsForObjects([ + new RemoteCallsForObject(1, [new RemoteCall('onFlipped', ['blue'])]), + ]), + ); + container.reset(); + container.step(0); + expect(replacement.processRemoteCalls).not.toHaveBeenCalled(); + }); +}); +afterEach(() => vi.restoreAllMocks()); + +const step = (deltaSeconds: number) => { + nowMs += deltaSeconds * 1000; + serverTimeline.step(deltaSeconds); +}; + +describe('snapshot object lifetime', () => { + it('keeps creation and deletion aligned with the delayed render timeline', () => { + const container = new GameObjectContainer({} as never); + const planet = new PlanetView(1, []); + container.handleCommand(new CreateObjectsCommand([planet])); + container.handleCommand(new PropertyUpdatesForObjects([], 5)); + step(0); + container.render({} as never, {} as never, true); + expect(planet.render).not.toHaveBeenCalled(); + + step(0.11); + container.render({} as never, {} as never, true); + expect(planet.render).toHaveBeenCalledOnce(); + + container.handleCommand(new DeleteObjectsCommand([1])); + container.handleCommand(new PropertyUpdatesForObjects([], 5.2)); + container.step(0); + expect(planet.beforeDestroy).not.toHaveBeenCalled(); + step(0.3); + container.step(0); + expect(planet.beforeDestroy).toHaveBeenCalledOnce(); + expect(container.planets).toHaveLength(0); + }); + + it('does not delete a replacement when an object re-enters before delayed retirement', () => { + const container = new GameObjectContainer({} as never); + const original = new PlanetView(1, []); + container.handleCommand(new CreateObjectsCommand([original])); + container.handleCommand(new PropertyUpdatesForObjects([], 5)); + step(0); + container.handleCommand(new DeleteObjectsCommand([1])); + container.handleCommand(new PropertyUpdatesForObjects([], 5.04)); + + const replacement = new PlanetView(1, []); + container.handleCommand(new CreateObjectsCommand([replacement])); + container.handleCommand(new PropertyUpdatesForObjects([], 5.08)); + step(0.3); + container.step(0); + container.render({} as never, {} as never, true); + + expect(original.beforeDestroy).toHaveBeenCalledOnce(); + expect(replacement.beforeDestroy).not.toHaveBeenCalled(); + expect(replacement.render).toHaveBeenCalledOnce(); + expect(container.planets).toEqual([replacement]); + }); +}); + +describe('remote render timeline', () => { + it('samples projectile strength on the server timeline, including slowed fading', () => { + const projectile = new ProjectileView(1, [0, 0] as never, 10, CharacterTeam.red, 100); + serverTimeline.onSnapshot(5); + projectile.updateProperty(new UpdatePropertyCommand('strength', 100, -10)); + serverTimeline.onSnapshot(5.2); + projectile.updateProperty(new UpdatePropertyCommand('strength', 98, -10)); + step(0); + projectile.step(2); + expect(projectile.strength).toBeCloseTo(99, 6); + expect([...projectile.center]).toEqual([0, 0]); + }); + + it('does not accumulate an extra frame of lead while chasing its target', () => { + serverTimeline.onSnapshot(10); + step(0); + for (let i = 0; i < 600; i++) step(1 / 60); + expect(serverTimeline.renderTime).toBeCloseTo(19.9, 8); + }); + + it('interpolates positions and bounds extrapolation while snapshots are missing', () => { + const position = new LinearInterpolator(0); + serverTimeline.onSnapshot(10); + position.addFrame(0, 100); + serverTimeline.onSnapshot(10.1); + position.addFrame(10, 100); + step(0); + step(0.05); + expect(position.getValue(0.05)).toBeCloseTo(5, 6); + step(0.4); + expect(position.getValue(0.4)).toBeCloseTo(16, 6); + }); + + it('ages a new snapshot from its arrival, not the beginning of the render frame', () => { + nowMs = 15; + serverTimeline.onSnapshot(10); + nowMs = 16; + serverTimeline.step(0.016); + expect(serverTimeline.renderTime).toBeCloseTo(9.901, 6); + }); +}); diff --git a/test/client-send-cadence.test.ts b/test/client-send-cadence.test.ts new file mode 100644 index 0000000..c199b14 --- /dev/null +++ b/test/client-send-cadence.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'node:module'; + +import { CommandSocket } from '../frontend/src/scripts/commands/command-socket'; +import { setFrameTimeMs } from '../frontend/src/scripts/helper/prediction/local-character-predictor'; + +const require = createRequire(import.meta.url); +const shared = require('../shared/lib/main.js'); +const { vec2 } = require('../shared/node_modules/gl-matrix'); +const { settings, MoveActionCommand, ClientHeartbeatCommand } = shared; + +const drive = ( + frameRate: number, + seconds: number, + onFrame?: (socket: any) => void, + connected = true, +) => { + const batches: Array = []; + const socket = { + connected, + emit: (_event: string, payload: string) => batches.push(payload), + }; + const commandSocket = new CommandSocket(socket as never); + + const frames = Math.round(frameRate * seconds); + for (let f = 0; f < frames; f++) { + setFrameTimeMs((f * 1000) / frameRate); + onFrame?.(commandSocket); + commandSocket.sendQueuedCommands(); + } + return batches; +}; + +describe('client send cadence', () => { + it('is paced by the clock, not by the frame rate', () => { + const seconds = 3; + const expected = seconds / settings.clientSendInterval; + + for (const frameRate of [60, 144, 240]) { + const batches = drive(frameRate, seconds); + expect(batches.length).toBeLessThanOrEqual(expected * 1.1); + expect(batches.length).toBeGreaterThanOrEqual(expected * 0.85); + } + }); + + it('stays under the server allowance even at an absurd frame rate', () => { + const seconds = 5; + const batches = drive(1000, seconds); + + expect(batches.length / seconds).toBeLessThan(settings.maxInboundMessagesPerSecond); + }); + + it('still flushes real input on the frame it happens', () => { + let sentOn = -1; + const batches = drive(144, 0.1, (commandSocket) => { + if (sentOn === -1) { + commandSocket.queue(new MoveActionCommand(vec2.fromValues(1, 0), 0)); + sentOn = 0; + } + }); + + expect(batches.length).toBeGreaterThan(0); + expect(batches[0]).toContain('MoveActionCommand'); + }); + + it('closes every batch with a heartbeat, so a held key keeps the ack moving', () => { + const batches = drive(60, 1); + + expect(batches.length).toBeGreaterThan(0); + expect(batches.every((b) => b.includes(ClientHeartbeatCommand.name))).toBe(true); + }); + + it('sends nothing at all while the transport is down', () => { + const batches = drive( + 60, + 30, + (commandSocket) => + commandSocket.queue(new MoveActionCommand(vec2.fromValues(1, 0), 0)), + false, + ); + + expect(batches).toHaveLength(0); + }); + + it('discards commands from the previous connection before the next join', () => { + const batches: Array = []; + const socket = new CommandSocket({ + connected: true, + emit: (_event: string, payload: string) => batches.push(payload), + } as never); + setFrameTimeMs(1000); + socket.sendQueuedCommands(); + socket.queue(new MoveActionCommand(vec2.fromValues(1, 0), 1000)); + socket.reset(); + socket.sendQueuedCommands(); + + expect(batches).toHaveLength(2); + expect(batches[1]).toContain(ClientHeartbeatCommand.name); + expect(batches[1]).not.toContain(MoveActionCommand.name); + }); +}); diff --git a/test/connection-lifecycle.test.ts b/test/connection-lifecycle.test.ts new file mode 100644 index 0000000..5ec9dbc --- /dev/null +++ b/test/connection-lifecycle.test.ts @@ -0,0 +1,363 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createRequire } from 'node:module'; + +import { GameServer } from '../backend/src/game-server'; +import { defaultOptions } from '../backend/src/options'; + +const require = createRequire(import.meta.url); +const shared = require('../shared/lib/main.js'); +const { Random, TransportEvents, JoinRejectionReason, settings } = shared; + +class FakeSocket { + public readonly sent: Array<{ event: string; payload: unknown }> = []; + public disconnected = false; + private handlers = new Map void>>(); + + public on(event: string, handler: (...args: never[]) => void) { + const list = this.handlers.get(event) ?? []; + list.push(handler); + this.handlers.set(event, list); + } + + public off(event: string, handler: (...args: never[]) => void) { + this.handlers.set( + event, + (this.handlers.get(event) ?? []).filter((h) => h !== handler), + ); + } + + public emit(event: string, payload?: unknown) { + this.sent.push({ event, payload }); + } + + public join() {} + + public disconnect() { + this.disconnected = true; + } + + public listenerCount(event: string): number { + return (this.handlers.get(event) ?? []).length; + } + + public fire(event: string, ...args: unknown[]) { + for (const handler of [...(this.handlers.get(event) ?? [])]) { + (handler as (...a: unknown[]) => void)(...args); + } + } + + public lastPayloadFor(event: string): unknown { + return [...this.sent].reverse().find((m) => m.event === event)?.payload; + } +} + +class FakeIo { + public connect?: (socket: FakeSocket) => void; + public on(event: string, handler: (socket: FakeSocket) => void) { + if (event === 'connection') { + this.connect = handler; + } + } + public to() { + return { emit: () => undefined }; + } +} + +const startServer = (overrides: Partial = {}) => { + const io = new FakeIo(); + const server = new GameServer(io as never, { + ...defaultOptions, + seed: 1, + ...overrides, + }); + const attach = () => { + const socket = new FakeSocket(); + io.connect!(socket); + return socket; + }; + return { server, attach }; +}; + +beforeEach(() => { + Random.seed = 1; +}); + +describe('join guard', () => { + it('accepts exactly one join per connection', () => { + const { server, attach } = startServer({ playerLimit: 4, npcCount: 1 }); + const socket = attach(); + + for (let i = 0; i < 8; i++) { + socket.fire(TransportEvents.PlayerJoining, { name: 'greedy' }); + } + + expect(server.serverInfo.playerCount).toBe(1); + expect(socket.listenerCount(TransportEvents.PlayerToServer)).toBe(1); + expect(socket.listenerCount('disconnect')).toBe(1); + expect(socket.lastPayloadFor(TransportEvents.JoinRejected)).toBe( + JoinRejectionReason.AlreadyJoined, + ); + }); + + it('leaves room for everyone else', () => { + const { server, attach } = startServer({ playerLimit: 4, npcCount: 1 }); + const hog = attach(); + for (let i = 0; i < 8; i++) { + hog.fire(TransportEvents.PlayerJoining, { name: 'greedy' }); + } + + const other = attach(); + other.fire(TransportEvents.PlayerJoining, { name: 'someone else' }); + + expect(server.serverInfo.playerCount).toBe(2); + expect(other.disconnected).toBe(false); + }); + + it('says why instead of hanging up silently when full', () => { + const { attach } = startServer({ playerLimit: 1, npcCount: 0 }); + attach().fire(TransportEvents.PlayerJoining, { name: 'first' }); + + const refused = attach(); + refused.fire(TransportEvents.PlayerJoining, { name: 'second' }); + + expect(refused.lastPayloadFor(TransportEvents.JoinRejected)).toBe( + JoinRejectionReason.ServerFull, + ); + }); +}); + +describe('hostile payloads', () => { + it('survives a name that is not a string, without leaking a bot', () => { + const { server, attach } = startServer({ playerLimit: 4, npcCount: 2 }); + + for (let i = 0; i < 4; i++) { + attach().fire(TransportEvents.PlayerJoining, { name: ['CharacterBase'] }); + } + + expect(server.serverInfo.playerCount).toBe(4); + }); + + it.each([ + ['no payload at all', undefined], + ['null', null], + ['a string', 'not-an-object'], + ['a number', 7], + ])('refuses a join payload that is %s instead of throwing', (_label, payload) => { + const { server, attach } = startServer({ playerLimit: 4, npcCount: 0 }); + const socket = attach(); + + expect(() => socket.fire(TransportEvents.PlayerJoining, payload)).not.toThrow(); + expect(socket.lastPayloadFor(TransportEvents.JoinRejected)).toBe( + JoinRejectionReason.InvalidRequest, + ); + expect(server.serverInfo.playerCount).toBe(0); + }); + + it('ignores an oversized command batch instead of parsing it', () => { + const { attach } = startServer({ playerLimit: 4, npcCount: 0 }); + const socket = attach(); + socket.fire(TransportEvents.PlayerJoining, { name: 'sender' }); + + expect(() => + socket.fire(TransportEvents.PlayerToServer, 'x'.repeat(2 * 1024 * 1024)), + ).not.toThrow(); + }); +}); + +describe('reconnect', () => { + it('hands out a token on join and honours it on the way back', () => { + const { server, attach } = startServer({ playerLimit: 4, npcCount: 0 }); + + const first = attach(); + first.fire(TransportEvents.PlayerJoining, { name: 'flaky' }); + const token = first.lastPayloadFor(TransportEvents.PlayerJoined); + expect(typeof token).toBe('string'); + + first.fire('disconnect'); + expect(server.serverInfo.playerCount).toBe(0); + + const second = attach(); + second.fire(TransportEvents.PlayerJoining, { name: 'flaky', reconnectToken: token }); + + expect(server.serverInfo.playerCount).toBe(1); + expect(second.disconnected).toBe(false); + expect(second.lastPayloadFor(TransportEvents.JoinRejected)).toBeUndefined(); + expect(second.lastPayloadFor(TransportEvents.PlayerJoined)).not.toBe(token); + }); + + it('treats an unknown token as an ordinary first join', () => { + const { server, attach } = startServer({ playerLimit: 4, npcCount: 0 }); + const socket = attach(); + socket.fire(TransportEvents.PlayerJoining, { + name: 'stranger', + reconnectToken: 'not-a-real-token', + }); + + expect(server.serverInfo.playerCount).toBe(1); + expect(socket.lastPayloadFor(TransportEvents.JoinRejected)).toBeUndefined(); + }); +}); + +describe('inbound rate limiting', () => { + const admitted = (socket: { fire: (e: string, p: string) => void }, count: number) => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + for (let i = 0; i < count; i++) { + socket.fire(TransportEvents.PlayerToServer, '{'); + } + const seen = spy.mock.calls.length; + spy.mockRestore(); + return seen; + }; + + it('allows several times what a well-behaved client sends', () => { + const clientMessagesPerSecond = 1 / settings.clientSendInterval; + + expect(settings.maxInboundMessagesPerSecond).toBeGreaterThanOrEqual( + clientMessagesPerSecond * 4, + ); + expect(settings.maxInboundMessageBurst).toBeGreaterThanOrEqual( + settings.maxInboundMessagesPerSecond, + ); + }); + + it('does not drop a second of a real client arriving at once', () => { + const { attach } = startServer({ playerLimit: 4, npcCount: 0 }); + const socket = attach(); + socket.fire(TransportEvents.PlayerJoining, { name: 'player' }); + + const oneSecond = Math.ceil(1 / settings.clientSendInterval) + 100; + + expect(admitted(socket, oneSecond)).toBe(oneSecond); + }); + + it('still clamps an actual flood', () => { + const { attach } = startServer({ playerLimit: 4, npcCount: 0 }); + const socket = attach(); + socket.fire(TransportEvents.PlayerJoining, { name: 'flooder' }); + + const flood = settings.maxInboundMessageBurst * 4; + + expect(admitted(socket, flood)).toBeLessThan(flood); + }); +}); + +describe('pre-join rate limiting', () => { + it('clamps a flood of joins on an already-joined connection', () => { + const { attach } = startServer({ playerLimit: 4, npcCount: 0 }); + const socket = attach(); + socket.fire(TransportEvents.PlayerJoining, { name: 'flooder' }); + + const flood = settings.maxInboundMessageBurst * 4; + for (let i = 0; i < flood; i++) { + socket.fire(TransportEvents.PlayerJoining, { name: 'flooder' }); + } + + const rejections = socket.sent.filter( + (m) => m.event === TransportEvents.JoinRejected, + ); + expect(rejections.length).toBeLessThan(flood); + }); + + it('clamps a flood of server-info subscriptions', () => { + const { attach } = startServer({ playerLimit: 4, npcCount: 0 }); + const socket = attach(); + let joins = 0; + socket.join = () => { + joins++; + }; + + const flood = settings.maxInboundMessageBurst * 4; + for (let i = 0; i < flood; i++) { + socket.fire(TransportEvents.SubscribeForServerInfoUpdates); + } + + expect(joins).toBeLessThan(flood); + }); +}); + +describe('reconnect tokens', () => { + const tokenFromFreshServer = () => { + Random.seed = 1; + const { attach } = startServer({ playerLimit: 4, npcCount: 0, seed: 1 }); + const socket = attach(); + socket.fire(TransportEvents.PlayerJoining, { name: 'player' }); + return socket.lastPayloadFor(TransportEvents.PlayerJoined) as string; + }; + + it('does not derive them from the seeded gameplay stream', () => { + expect(tokenFromFreshServer()).not.toBe(tokenFromFreshServer()); + }); + + it('mints them from the platform CSPRNG, not the gameplay stream', () => { + const { attach } = startServer({ playerLimit: 4, npcCount: 0 }); + const socket = attach(); + socket.fire(TransportEvents.PlayerJoining, { name: 'player' }); + + expect(socket.lastPayloadFor(TransportEvents.PlayerJoined)).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); +}); + +describe('reconnect while the dropped socket is still live', () => { + it('retires the ghost so the returning player keeps its slot, team and score', () => { + const { server, attach } = startServer({ playerLimit: 1, npcCount: 0 }); + + const first = attach(); + first.fire(TransportEvents.PlayerJoining, { name: 'flaky' }); + const token = first.lastPayloadFor(TransportEvents.PlayerJoined) as string; + expect(server.serverInfo.playerCount).toBe(1); + + const second = attach(); + second.fire(TransportEvents.PlayerJoining, { name: 'flaky', reconnectToken: token }); + + expect(second.lastPayloadFor(TransportEvents.JoinRejected)).toBeUndefined(); + expect(second.lastPayloadFor(TransportEvents.PlayerJoined)).toBeTruthy(); + expect(server.serverInfo.playerCount).toBe(1); + expect(first.listenerCount(TransportEvents.PlayerToServer)).toBe(0); + }); + + it('does not hand a slot to an unknown token', () => { + const { server, attach } = startServer({ playerLimit: 1, npcCount: 0 }); + + attach().fire(TransportEvents.PlayerJoining, { name: 'holder' }); + + const stranger = attach(); + stranger.fire(TransportEvents.PlayerJoining, { + name: 'stranger', + reconnectToken: 'not-a-real-token', + }); + + expect(stranger.lastPayloadFor(TransportEvents.JoinRejected)).toBe( + JoinRejectionReason.ServerFull, + ); + expect(server.serverInfo.playerCount).toBe(1); + }); +}); + +describe('socket listeners', () => { + it('takes every listener back off when the player drops', () => { + const { attach } = startServer({ playerLimit: 4, npcCount: 0 }); + const socket = attach(); + socket.fire(TransportEvents.PlayerJoining, { name: 'leaver' }); + + expect(socket.listenerCount(TransportEvents.Pong)).toBe(1); + + socket.fire('disconnect'); + + expect(socket.listenerCount(TransportEvents.Pong)).toBe(0); + expect(socket.listenerCount(TransportEvents.PlayerToServer)).toBe(0); + expect(socket.listenerCount('disconnect')).toBe(0); + }); + + it('does not stack a listener per reconnect', () => { + const { attach } = startServer({ playerLimit: 4, npcCount: 0 }); + + for (let i = 0; i < 5; i++) { + const socket = attach(); + socket.fire(TransportEvents.PlayerJoining, { name: 'flaky' }); + expect(socket.listenerCount(TransportEvents.Pong)).toBe(1); + socket.fire('disconnect'); + } + }); +}); diff --git a/test/frontend-regressions.test.ts b/test/frontend-regressions.test.ts new file mode 100644 index 0000000..5379f5b --- /dev/null +++ b/test/frontend-regressions.test.ts @@ -0,0 +1,302 @@ +import { EventEmitter } from 'node:events'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { KeyboardListener } from '../frontend/src/scripts/commands/keyboard-listener'; +import { TouchListener } from '../frontend/src/scripts/commands/touch-listener'; +import { JoinFormHandler } from '../frontend/src/scripts/join-form-handler'; +import { initializeOptions, options } from '../frontend/src/scripts/options-handler'; +import { loadStoredValue, saveStoredValue } from '../frontend/src/scripts/helper/storage'; + +const { connect } = vi.hoisted(() => ({ connect: vi.fn() })); +vi.mock('../frontend/node_modules/socket.io-client/build/esm-debug/index.js', () => ({ + io: connect, +})); +vi.mock('../frontend/src/scripts/configuration', () => ({ + servers: ['https://first.test', 'https://second.test'], +})); +vi.mock('../frontend/src/scripts/sound-handler', () => ({ + Sounds: { click: 'click' }, + SoundHandler: { play: vi.fn(), playAmbient: vi.fn(), stopAmbient: vi.fn() }, +})); +vi.mock('../frontend/src/scripts/charge-indicator', () => ({ + ChargeIndicator: { begin: vi.fn(), end: vi.fn() }, +})); + +// Only the DOM operations exercised by the chooser and input listeners are needed. +class Element extends EventTarget { + public children: Element[] = []; + public parentElement?: Element; + public style: Record = {}; + public className = ''; + public checked = false; + public disabled = false; + public onsubmit: ((event: Event) => void) | null = null; + + public appendChild(child: Element) { + child.remove(); + this.children.push(child); + child.parentElement = this; + } + public append(...children: Element[]) { + children.forEach((child) => this.appendChild(child)); + } + public remove() { + if (this.parentElement) { + this.parentElement.children = this.parentElement.children.filter((c) => c !== this); + this.parentElement = undefined; + } + } + public querySelector() { + return this.children[0]; + } + public getBoundingClientRect() { + return { left: 0, top: 0, width: 100, height: 100 }; + } +} + +class Socket extends EventEmitter { + public io = new EventEmitter(); + public close = vi.fn(); +} + +const dispatch = (target: EventTarget, type: string, fields = {}) => { + const event = Object.assign(new Event(type, { cancelable: true }), fields); + target.dispatchEvent(event); + return event; +}; +const touch = (identifier: number, clientX = 50, clientY = 50) => ({ + identifier, + clientX, + clientY, +}); +const touchEvent = ( + target: EventTarget, + type: string, + changedTouches: unknown[], + touches = changedTouches, +) => dispatch(target, type, { changedTouches, touches }); + +let windowTarget: EventTarget; +let sockets: Socket[]; + +beforeEach(() => { + vi.useFakeTimers(); + windowTarget = new EventTarget(); + sockets = []; + vi.stubGlobal('window', windowTarget); + vi.stubGlobal('addEventListener', windowTarget.addEventListener.bind(windowTarget)); + vi.stubGlobal( + 'removeEventListener', + windowTarget.removeEventListener.bind(windowTarget), + ); + vi.stubGlobal('document', { createElement: () => new Element() }); + vi.stubGlobal('localStorage', { getItem: vi.fn(() => null), setItem: vi.fn() }); + connect.mockImplementation(() => { + const socket = new Socket(); + sockets.push(socket); + return socket; + }); +}); + +afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +const chooser = () => { + const form = new Element(); + form.appendChild(new Element()); + const container = new Element(); + const handler = new JoinFormHandler(form as never, container as never); + return { form, container, handler }; +}; +const serverInfo = { + serverName: 'test', + playerCount: 1, + playerLimit: 8, + gameStatePercent: 20, +}; + +describe('server chooser lifecycle', () => { + it('aborts discovery and ignores responses completing after the chooser closes', async () => { + const signals: AbortSignal[] = []; + let resolveBody!: (value: unknown) => void; + const body = new Promise((resolve) => { + resolveBody = resolve; + }); + vi.stubGlobal( + 'fetch', + vi.fn((_url, { signal }) => { + signals.push(signal); + return Promise.resolve({ ok: true, json: () => body }); + }), + ); + const { form, container, handler } = chooser(); + await Promise.resolve(); + handler.destroy(); + resolveBody(serverInfo); + await vi.runAllTimersAsync(); + + expect(signals.every((signal) => signal.aborted)).toBe(true); + expect(sockets).toHaveLength(0); + expect(container.children).toHaveLength(0); + expect(form.onsubmit).toBeNull(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('selects a remaining server when the selected connection fails and removes listeners on exit', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: true, json: async () => serverInfo })), + ); + const { form, container, handler } = chooser(); + await vi.advanceTimersByTimeAsync(0); + expect(container.children).toHaveLength(2); + expect(container.children[0].children[0].checked).toBe(true); + expect(container.children[1].children[0].checked).toBe(false); + + sockets[0].io.emit('reconnect_failed'); + expect(container.children).toHaveLength(1); + expect(container.children[0].children[0].checked).toBe(true); + expect(form.children[0].disabled).toBe(false); + + handler.destroy(); + expect( + sockets.every((socket) => socket.io.listenerCount('reconnect_failed') === 0), + ).toBe(true); + expect(container.children).toHaveLength(0); + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe('movement input', () => { + it('ignores unrelated keys, prevents browser movement defaults, and resends held keys after respawn', () => { + const send = vi.fn(); + const listener = new KeyboardListener(send); + dispatch(windowTarget, 'keydown', { code: 'KeyP' }); + expect(send).not.toHaveBeenCalled(); + const key = dispatch(windowTarget, 'keydown', { code: 'ArrowRight' }); + expect(key.defaultPrevented).toBe(true); + expect(send.mock.lastCall?.[0].direction).toEqual([1, 0]); + listener.resendMovement(); + expect(send).toHaveBeenCalledTimes(2); + dispatch(windowTarget, 'blur'); + expect(send.mock.lastCall?.[0].direction).toEqual([0, 0]); + listener.resendMovement(); + expect(send).toHaveBeenCalledTimes(3); + listener.destroy(); + }); +}); + +const touchControls = () => { + const canvas = new Element(); + const overlay = new Element(); + const send = vi.fn(); + const game = { + displayToWorldCoordinates: (position: number[]) => position, + gameObjects: { + localPlayer: { bodyCenter: [0, 0], facingDirection: [1, 0], strengthFraction: 1 }, + }, + }; + const listener = new TouchListener( + canvas as never, + overlay as never, + game as never, + send, + ); + listener.update(); + return { canvas, overlay, send, listener, fire: overlay.children[0] }; +}; + +describe('touch ownership', () => { + it('keeps the first canvas gesture when a second finger lands before dragging', () => { + const { canvas, send, listener } = touchControls(); + touchEvent(canvas, 'touchstart', [touch(1, 10, 20)]); + touchEvent( + canvas, + 'touchstart', + [touch(2, 80, 90)], + [touch(1, 10, 20), touch(2, 80, 90)], + ); + touchEvent(canvas, 'touchend', [touch(2, 80, 90)], [touch(1, 10, 20)]); + expect(send).not.toHaveBeenCalled(); + touchEvent(canvas, 'touchend', [touch(1, 10, 20)], []); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.lastCall?.[0].constructor.name).toBe('PrimaryActionCommand'); + listener.destroy(); + }); + + it('does not fire when a charge is cancelled or another finger releases', () => { + const { fire, send, listener } = touchControls(); + touchEvent(fire, 'touchstart', [touch(1)]); + touchEvent(fire, 'touchstart', [touch(2)]); + touchEvent(fire, 'touchend', [touch(2)], [touch(1)]); + expect(send).not.toHaveBeenCalled(); + touchEvent(fire, 'touchcancel', [touch(1)], []); + touchEvent(fire, 'touchend', [touch(1)], []); + expect(send).not.toHaveBeenCalled(); + touchEvent(fire, 'touchstart', [touch(3)]); + touchEvent(fire, 'touchend', [touch(3)], []); + expect(send).toHaveBeenCalledTimes(1); + listener.destroy(); + }); + + it('resends a held joystick after respawn and stops movement when focus is lost', () => { + const { canvas, overlay, send, listener } = touchControls(); + touchEvent(canvas, 'touchstart', [touch(1)]); + touchEvent(canvas, 'touchmove', [touch(1, 100, 50)]); + listener.resendMovement(); + expect(send.mock.lastCall?.[0].direction[0]).toBe(1); + expect(send.mock.lastCall?.[0].direction[1]).toBeCloseTo(0); + dispatch(windowTarget, 'blur'); + expect(send.mock.lastCall?.[0].direction).toEqual([0, 0]); + expect(overlay.children).toHaveLength(2); + touchEvent(canvas, 'touchstart', [touch(2)]); + touchEvent(canvas, 'touchmove', [touch(2, 100, 50)]); + listener.destroy(); + expect(overlay.children).toHaveLength(0); + }); +}); + +describe('stored preferences', () => { + it('ignores corrupt JSON and unavailable storage', () => { + vi.mocked(localStorage.getItem).mockReturnValue('{'); + expect(loadStoredValue('userName')).toBeUndefined(); + vi.mocked(localStorage.getItem).mockImplementation(() => { + throw new Error('disabled'); + }); + vi.mocked(localStorage.setItem).mockImplementation(() => { + throw new Error('disabled'); + }); + expect(loadStoredValue('options')).toBeUndefined(); + expect(() => saveStoredValue('userName', 'player')).not.toThrow(); + }); + + it('only loads boolean options that the game recognizes', () => { + Object.assign(options, { + soundsEnabled: true, + musicEnabled: true, + vibrationEnabled: true, + }); + vi.mocked(localStorage.getItem).mockReturnValue( + JSON.stringify({ + soundsEnabled: 'false', + musicEnabled: false, + vibrationEnabled: null, + extra: true, + }), + ); + initializeOptions({ + soundsEnabled: new Element(), + musicEnabled: new Element(), + vibrationEnabled: new Element(), + } as never); + expect(options).toEqual({ + soundsEnabled: true, + musicEnabled: false, + vibrationEnabled: true, + }); + }); +}); diff --git a/test/physics-determinism.test.mjs b/test/physics-determinism.test.mjs index d0fee1a..300f4cf 100644 --- a/test/physics-determinism.test.mjs +++ b/test/physics-determinism.test.mjs @@ -1,11 +1,6 @@ -// Determinism guard for the shared character simulation. The client predictor -// and the authoritative server run the EXACT same stepCharacterMovement; if it -// were non-deterministic (a stray Math.random / Date, or an order-dependent -// reduce) prediction would rubber-band and could never reconcile. This is also -// the regression net for the upcoming GC/scratch-pool perf rewrites: the pinned -// hash must not change unless physics behaviour is intentionally changed. import { describe, it, expect } from 'vitest'; import { createRequire } from 'node:module'; +import { execFileSync } from 'node:child_process'; const require = createRequire(import.meta.url); const shared = require('../shared/lib/main.js'); @@ -13,7 +8,10 @@ const { vec2 } = require('../shared/node_modules/gl-matrix'); const { stepCharacterMovement, + tickPlanetDetachment, resolveCircleMovement, + PlanetBase, + Random, headRadius, feetRadius, headOffset, @@ -21,6 +19,8 @@ const { rightFootOffset, } = shared; +const stepSeconds = 1 / 200; + const makeBody = (center, radius) => ({ center: vec2.clone(center), radius, @@ -29,55 +29,125 @@ const makeBody = (center, radius) => ({ restitution: 0, }); -// A free-space world (no planets): the body is driven purely by the movement -// input force, posture springs and momentum decay — enough to exercise the -// deterministic core without coupling the test to planet SDF geometry. -const emptyWorld = { - groundsNear: () => [], +const makeState = (start) => ({ + head: makeBody(vec2.add(vec2.create(), start, headOffset), headRadius), + leftFoot: makeBody(vec2.add(vec2.create(), start, leftFootOffset), feetRadius), + rightFoot: makeBody(vec2.add(vec2.create(), start, rightFootOffset), feetRadius), + direction: 0, + currentPlanet: undefined, + secondsSinceOnSurface: 1, + bodyVelocity: vec2.create(), +}); + +const makeWorld = (grounds) => ({ + groundsNear: () => grounds, stepBody: (body, dt) => { - resolveCircleMovement(body, dt, []); - return undefined; + const hit = resolveCircleMovement(body, dt, grounds); + return hit && hit.isGround ? hit : undefined; }, -}; +}); -const stepSeconds = 1 / 200; +const round = (v) => Math.round(v * 1000) / 1000; +const roundVec = (v) => [round(v[0]), round(v[1])]; -// Run a fixed, scripted input sequence through the shared sim and return a -// stable string snapshot of the final pose + carried momentum. const runSimulation = () => { - const start = vec2.fromValues(100, 100); - const state = { - head: makeBody(vec2.add(vec2.create(), start, headOffset), headRadius), - leftFoot: makeBody(vec2.add(vec2.create(), start, leftFootOffset), feetRadius), - rightFoot: makeBody(vec2.add(vec2.create(), start, rightFootOffset), feetRadius), - direction: 0, - currentPlanet: undefined, - secondsSinceOnSurface: 1, - bodyVelocity: vec2.create(), - }; + const state = makeState(vec2.fromValues(100, 100)); + const world = makeWorld([]); for (let i = 0; i < 300; i++) { const angle = i * 0.1; - const input = vec2.fromValues(Math.cos(angle), Math.sin(angle)); - stepCharacterMovement(state, emptyWorld, input, stepSeconds); + stepCharacterMovement( + state, + world, + vec2.fromValues(Math.cos(angle), Math.sin(angle)), + stepSeconds, + ); } - const round = (v) => Math.round(v * 1000) / 1000; return JSON.stringify({ - head: [round(state.head.center[0]), round(state.head.center[1])], - leftFoot: [round(state.leftFoot.center[0]), round(state.leftFoot.center[1])], - rightFoot: [round(state.rightFoot.center[0]), round(state.rightFoot.center[1])], - bodyVelocity: [round(state.bodyVelocity[0]), round(state.bodyVelocity[1])], + head: roundVec(state.head.center), + leftFoot: roundVec(state.leftFoot.center), + rightFoot: roundVec(state.rightFoot.center), + bodyVelocity: roundVec(state.bodyVelocity), + }); +}; + +const runPlanetSimulation = () => { + Random.seed = 20; + const planet = new PlanetBase( + 1, + PlanetBase.createPlanetVertices(vec2.fromValues(0, -900), 1700, 1900, 70), + ); + planet.angularVelocity = 0.09; + const world = makeWorld([planet]); + const state = makeState(vec2.fromValues(0, 250)); + + for (let i = 0; i < 900; i++) { + const input = i < 200 ? vec2.create() : vec2.fromValues(i < 600 ? 1 : -1, 0); + tickPlanetDetachment(state, stepSeconds); + stepCharacterMovement(state, world, input, stepSeconds); + planet.advanceRotation(stepSeconds); + } + + return JSON.stringify({ + head: roundVec(state.head.center), + leftFoot: roundVec(state.leftFoot.center), + rightFoot: roundVec(state.rightFoot.center), + direction: round(state.direction), + onSurface: !!state.currentPlanet, + secondsSinceOnSurface: round(state.secondsSinceOnSurface), }); }; describe('shared character simulation determinism', () => { + it('does not depend on configuring vector precision before imports', () => { + const run = (configureBeforeImport) => + execFileSync( + process.execPath, + [ + '-e', + ` + const { glMatrix, vec2 } = require('./shared/node_modules/gl-matrix'); + if (${configureBeforeImport}) glMatrix.setMatrixArrayType(Array); + const s = require('./shared/lib/main.js'); + glMatrix.setMatrixArrayType(Array); + const body = (offset, radius) => ({ + center: vec2.clone(offset), radius, velocity: vec2.create(), + lastNormal: [0, 1], restitution: 0, + }); + const state = { + head: body(s.headOffset, s.headRadius), + leftFoot: body(s.leftFootOffset, s.feetRadius), + rightFoot: body(s.rightFootOffset, s.feetRadius), + direction: 0, currentPlanet: undefined, + secondsSinceOnSurface: 1, bodyVelocity: [0, 0], + }; + const world = { + groundsNear: () => [], + stepBody: (body, dt) => s.resolveCircleMovement(body, dt, []), + }; + for (let i = 0; i < 100; i++) s.stepCharacterMovement(state, world, [1, 0], 0.005); + process.stdout.write(JSON.stringify(state)); + `, + ], + { encoding: 'utf8' }, + ); + expect(run(false)).toBe(run(true)); + }); + it('produces identical output across independent runs', () => { expect(runSimulation()).toBe(runSimulation()); }); it('matches the pinned reference pose (changes only with intentional physics edits)', () => { - // Regression pin — update deliberately when physics behaviour changes. expect(runSimulation()).toMatchSnapshot(); }); + + it('is deterministic on a spinning planet too', () => { + expect(runPlanetSimulation()).toBe(runPlanetSimulation()); + }); + + it('matches the pinned reference pose on a spinning planet', () => { + expect(runPlanetSimulation()).toMatchSnapshot(); + }); }); diff --git a/test/prediction-reconciliation.test.ts b/test/prediction-reconciliation.test.ts index 98ffc1d..eed53f0 100644 --- a/test/prediction-reconciliation.test.ts +++ b/test/prediction-reconciliation.test.ts @@ -1,43 +1,44 @@ -// Reconciliation guard for the REAL client predictor. It exercises -// LocalCharacterPredictor end-to-end with an injected clock, verifying the two -// properties reconciliation depends on: -// 1. a fully-acknowledged snapshot reproduces the authoritative pose exactly -// (no spurious drift on top of server truth), and -// 2. un-acknowledged input is replayed forward deterministically. -// This is the net for prediction-touching changes (the death-while-dead fix, -// future netcode work) — a regression shows up as drift or non-determinism. import { describe, it, expect } from 'vitest'; import { LocalCharacterPredictor, - setPredictorClockForTesting, + setFrameTimeMs, } from '../frontend/src/scripts/helper/prediction/local-character-predictor'; const HEAD_RADIUS = 50; const FEET_RADIUS = 20; -// A Circle-shaped pose; the predictor only reads .center / .radius, so plain -// objects with array centres are sufficient (and avoid importing gl-matrix). const poseAt = (cx: number, cy: number) => ({ head: { center: [cx, cy + 37], radius: HEAD_RADIUS }, leftFoot: { center: [cx - 33, cy - 18], radius: FEET_RADIUS }, rightFoot: { center: [cx + 33, cy - 18], radius: FEET_RADIUS }, }); +const movementState = (direction = 0) => ({ + direction, + bodyVelocity: [0, 0], + leftFootNormal: [0, 1], + rightFootNormal: [0, 1], + groundPlanetId: null, + secondsSinceOnSurface: 1, +}); + describe('local prediction reconciliation', () => { it('reproduces the authoritative pose exactly when all input is acknowledged', () => { - let clock = 1000; - setPredictorClockForTesting(() => clock); + setFrameTimeMs(1000); const predictor = new LocalCharacterPredictor(); const auth = poseAt(500, 500); const t = predictor.recordInput([1, 0]); - predictor.acknowledge(t, [0, 0], -Infinity); // server has consumed this input + predictor.acknowledge(t, movementState() as never, -Infinity); predictor.setStrength(80); - predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never); + predictor.setAuthoritative( + auth.head as never, + auth.leftFoot as never, + auth.rightFoot as never, + ); - // No clock advance → zero replay window → predicted pose == authoritative. - const used = predictor.update([], 1 / 60); + const used = predictor.update([]); expect(used).toBe(true); expect(predictor.head.center[0]).toBeCloseTo(auth.head.center[0], 5); @@ -48,19 +49,20 @@ describe('local prediction reconciliation', () => { it('replays un-acknowledged input deterministically', () => { const drive = () => { - let clock = 0; - setPredictorClockForTesting(() => clock); - const predictor = new LocalCharacterPredictor(); - clock = 1000; - predictor.acknowledge(900, [0, 0], -Infinity); // baseline ack (older than the input below) + setFrameTimeMs(1000); + predictor.acknowledge(900, movementState() as never, -Infinity); predictor.setStrength(80); const auth = poseAt(0, 0); - predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never); - predictor.recordInput([1, 0]); // unacked rightward input at t=1000 + predictor.setAuthoritative( + auth.head as never, + auth.leftFoot as never, + auth.rightFoot as never, + ); + predictor.recordInput([1, 0]); - clock = 1100; // replay ~100 ms forward - predictor.update([], 1 / 60); + setFrameTimeMs(1100); + predictor.update([]); return [ predictor.head.center[0], predictor.head.center[1], @@ -71,26 +73,243 @@ describe('local prediction reconciliation', () => { const a = drive(); const b = drive(); - expect(a).toEqual(b); // deterministic replay + expect(a).toEqual(b); expect(a.every((n) => Number.isFinite(n))).toBe(true); - expect(a[0]).toBeGreaterThan(0.5); // rightward input actually moved the body + expect(a[0]).toBeGreaterThan(0.5); }); it('suppresses prediction while the local player is dead', () => { - let clock = 5000; - setPredictorClockForTesting(() => clock); + setFrameTimeMs(5000); const predictor = new LocalCharacterPredictor(); const auth = poseAt(200, 200); const t = predictor.recordInput([1, 0]); - predictor.acknowledge(t, [0, 0], -Infinity); + predictor.acknowledge(t, movementState() as never, -Infinity); predictor.setStrength(80); - predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never); - predictor.setAlive(false); // dead, awaiting respawn + predictor.setAuthoritative( + auth.head as never, + auth.leftFoot as never, + auth.rightFoot as never, + ); + predictor.setAlive(false); - clock = 5200; // input + elapsed time that would otherwise be replayed forward - // Even with a valid authoritative pose and pending input, a dead body must - // not be predicted/moved — that was the "move while dead" bug. - expect(predictor.update([], 1 / 60)).toBe(false); + setFrameTimeMs(5200); + expect(predictor.update([])).toBe(false); + }); + + it('replays the whole un-acknowledged span, so a longer trip predicts further', () => { + const driveWithUnackedSpan = (ackAgeMs: number) => { + const predictor = new LocalCharacterPredictor(); + predictor.setStrength(80); + setFrameTimeMs(9_000); + predictor.recordInput([1, 0]); + + setFrameTimeMs(10_000); + const auth = poseAt(0, 0); + predictor.acknowledge(10_000 - ackAgeMs, movementState() as never, -Infinity); + predictor.setAuthoritative( + auth.head as never, + auth.leftFoot as never, + auth.rightFoot as never, + ); + + predictor.update([]); + return predictor.head.center[0]; + }; + + const shortTrip = driveWithUnackedSpan(40); + const longTrip = driveWithUnackedSpan(200); + + expect(shortTrip).toBeGreaterThan(0); + expect(longTrip).toBeGreaterThan(shortTrip * 2); + }); + + it('cancels the age of the acknowledged input, so the send cadence cannot beat', () => { + const snapshotAgeMs = 20; + const drive = (inputAgeMs: number, serverReportsAge: boolean) => { + const predictor = new LocalCharacterPredictor(); + predictor.setStrength(80); + setFrameTimeMs(9_000); + predictor.recordInput([1, 0]); + + setFrameTimeMs(10_000); + predictor.acknowledge( + 10_000 - snapshotAgeMs - inputAgeMs, + movementState() as never, + -Infinity, + serverReportsAge ? inputAgeMs : 0, + ); + const auth = poseAt(0, 0); + predictor.setAuthoritative( + auth.head as never, + auth.leftFoot as never, + auth.rightFoot as never, + ); + + predictor.update([]); + return predictor.head.center[0]; + }; + + expect(drive(16, true)).toBeCloseTo(drive(0, true), 6); + expect(Math.abs(drive(16, false) - drive(0, false))).toBeGreaterThan(0.5); + }); + + it('replays the streamed facing direction, not one guessed from the pose', () => { + const walkingPose = () => ({ + head: { center: [-35, 37], radius: HEAD_RADIUS }, + leftFoot: { center: [-33, -18], radius: FEET_RADIUS }, + rightFoot: { center: [33, -18], radius: FEET_RADIUS }, + }); + + const predictWithDirection = (direction: number) => { + const predictor = new LocalCharacterPredictor(); + predictor.setStrength(80); + setFrameTimeMs(0); + predictor.recordInput([1, 0]); + predictor.acknowledge(0, movementState(direction) as never, -Infinity); + const auth = walkingPose(); + predictor.setAuthoritative( + auth.head as never, + auth.leftFoot as never, + auth.rightFoot as never, + ); + + setFrameTimeMs(40); + predictor.update([]); + return [predictor.head.center[0], predictor.head.center[1]]; + }; + + const upright = predictWithDirection(0); + const asThePoseImplies = predictWithDirection(0.566); + const apart = Math.hypot( + upright[0] - asThePoseImplies[0], + upright[1] - asThePoseImplies[1], + ); + + expect(apart).toBeGreaterThan(1); + }); +}); + +const settledPoseAt = (x: number) => ({ + head: { center: [x, 55 - 55 / 3], radius: HEAD_RADIUS }, + leftFoot: { center: [x - 20, -55 / 3], radius: FEET_RADIUS }, + rightFoot: { center: [x + 20, -55 / 3], radius: FEET_RADIUS }, +}); + +const install = (predictor: LocalCharacterPredictor, timeMs: number, x: number) => { + const pose = settledPoseAt(x); + predictor.acknowledge(timeMs, movementState() as never, -Infinity); + predictor.setAuthoritative( + pose.head as never, + pose.leftFoot as never, + pose.rightFoot as never, + ); +}; + +describe('prediction corrections and interruptions', () => { + it('blends an authoritative correction and converges without slowing ordinary motion', () => { + const predictor = new LocalCharacterPredictor(); + setFrameTimeMs(1000); + install(predictor, 1000, 0); + predictor.update([]); + + setFrameTimeMs(1016); + install(predictor, 1016, 100); + predictor.update([]); + expect(predictor.head.center[0]).toBeCloseTo(0, 5); + + setFrameTimeMs(1032); + predictor.update([]); + expect(predictor.head.center[0]).toBeGreaterThan(0); + expect(predictor.head.center[0]).toBeLessThan(100); + + setFrameTimeMs(1516); + predictor.update([]); + expect(predictor.head.center[0]).toBeCloseTo(100, 0); + }); + + it('freezes at the replay horizon during an outage even if held input changes', () => { + const predictor = new LocalCharacterPredictor(); + setFrameTimeMs(0); + install(predictor, 0, 0); + predictor.recordInput([1, 0] as never); + setFrameTimeMs(400); + predictor.update([]); + const frozen = [...predictor.head.center]; + + setFrameTimeMs(450); + predictor.recordInput([-1, 0] as never); + setFrameTimeMs(600); + predictor.update([]); + expect([...predictor.head.center]).toEqual(frozen); + + setFrameTimeMs(2000); + predictor.recordInput([0, 0] as never); + setFrameTimeMs(2500); + predictor.update([]); + expect([...predictor.head.center]).toEqual(frozen); + }); + + it('clears visual correction on respawn and disables prediction for end-game snapshots', () => { + const predictor = new LocalCharacterPredictor(); + setFrameTimeMs(1000); + install(predictor, 1000, 0); + predictor.update([]); + install(predictor, 1000, 100); + predictor.update([]); + predictor.reset(); + install(predictor, 1000, 1000); + predictor.update([]); + expect(predictor.head.center[0]).toBe(1000); + predictor.enabled = false; + expect(predictor.update([])).toBe(false); + predictor.reset(); + install(predictor, 1000, 1000); + expect(predictor.update([])).toBe(false); + }); +}); + +const ground = { + id: 1, + vertices: Array.from({ length: 15 }, (_, i) => { + const angle = (i / 15) * -Math.PI * 2; + return [800 * Math.cos(angle), 800 * Math.sin(angle)]; + }), + snapshotRotation: 0, + snapshotRotationSpeed: 0, +}; + +const leapHeight = (cooldown: number, leap: boolean) => { + const predictor = new LocalCharacterPredictor(); + setFrameTimeMs(1000); + const pose = poseAt(0, 900); + predictor.acknowledge( + 1000, + { + ...movementState(), + groundPlanetId: 1, + secondsSinceOnSurface: 0, + leapCooldownRemaining: cooldown, + } as never, + -Infinity, + ); + predictor.setAuthoritative( + pose.head as never, + pose.leftFoot as never, + pose.rightFoot as never, + ); + if (leap) predictor.recordLeap(); + setFrameTimeMs(1002); + predictor.update([ground] as never); + return predictor.head.center[1]; +}; + +describe('leap prediction', () => { + it('applies a newly pressed leap in the very next fractional render tick', () => { + expect(leapHeight(0, true)).toBeGreaterThan(leapHeight(0, false) + 1); + }); + + it('does not predict a leap the authoritative cooldown will reject', () => { + expect(leapHeight(0.2, true)).toEqual(leapHeight(0.2, false)); }); }); diff --git a/test/render-smoothness.test.ts b/test/render-smoothness.test.ts new file mode 100644 index 0000000..1e65de9 --- /dev/null +++ b/test/render-smoothness.test.ts @@ -0,0 +1,367 @@ +import { describe, it, expect } from 'vitest'; +import { + LocalCharacterPredictor, + setFrameTimeMs, +} from '../frontend/src/scripts/helper/prediction/local-character-predictor'; +import { ClientCharacterWorld } from '../frontend/src/scripts/helper/prediction/client-character-world'; +import { + stepCharacterMovement, + tickPlanetDetachment, + headRadius, + feetRadius, +} from '../shared/src/physics/character-movement'; +import { settings } from '../shared/src/settings'; + +const stepSeconds = settings.targetPhysicsDeltaTimeInSeconds; +const snapshotMs = settings.updateMessageInterval * 1000; +const heartbeatMs = settings.clientSendInterval * 1000; +const planetRadius = 800; + +// The client's frame clock is rebased to its first frame (sdf-2d passes currentTime - startTime), +// so it shares no origin with the server's clock. Anything that only works when the two happen to +// agree is not a fix. +const clientClockOffsetMs = 987_654.321; + +const vertices = Array.from({ length: settings.planetEdgeCount }, (_, i) => { + const angle = (i / settings.planetEdgeCount) * -Math.PI * 2; + return [planetRadius * Math.cos(angle), planetRadius * Math.sin(angle)]; +}); + +const planetAt = (rotation: number) => + ({ id: 1, vertices, snapshotRotation: rotation, snapshotRotationSpeed: 0 }) as never; + +const body = (center: Array, radius: number) => ({ + center: center.slice(), + radius, + velocity: [0, 0], + lastNormal: [0, 1], + restitution: 0, +}); + +interface Conditions { + fps: number; + /** One way. The round trip is twice this. */ + wireMs: number; + /** Client CPU spent between the frame timestamp and the socket emit at the end of the loop. */ + emitLagMs: number; + /** Peak random variation in frame duration, as a fraction of the nominal frame. */ + frameJitter: number; + /** Probability that a frame is missed entirely and the next one is twice as long. */ + dropRate: number; + /** Server work that can delay a socket callback past the packet's arrival. */ + serverBusyMs: number; + wireJitterMs?: number; + emitJitterMs?: number; + changeInput?: boolean; +} + +interface Result { + /** Peak-to-peak variation in apparent speed, over its mean. Zero means perfectly even motion. */ + speedSpread: number; + /** How far the rendered character trails the true server pose, in world units. */ + lagUnits: number; + finalError: number; + maximumSpeed: number; +} + +// Measure world travel: the camera tracks the local character exactly, so measuring +// their relative position would always report zero even when the world visibly stutters. +const measure = ({ + fps, + wireMs, + emitLagMs, + frameJitter, + dropRate, + serverBusyMs, + wireJitterMs = 0, + emitJitterMs = 0, + changeInput = false, +}: Conditions): Result => { + const world = new ClientCharacterWorld(); + world.sync([planetAt(0)]); + + const server: any = { + head: body([0, planetRadius + 90], headRadius), + leftFoot: body([-33, planetRadius + 35], feetRadius), + rightFoot: body([33, planetRadius + 35], feetRadius), + direction: 0, + currentPlanet: world.surfaceById(1), + secondsSinceOnSurface: 0, + bodyVelocity: [0, 0], + }; + + let input = [1, 0]; + let serverInput = [1, 0]; + const predictor = new LocalCharacterPredictor(); + predictor.setStrength(settings.playerMaxStrength); + + let seed = 12345; + const random = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff; + + const nominalFrameMs = 1000 / fps; + const toServer: Array<{ atMs: number; clientStampMs: number; direction?: number[] }> = + []; + const toClient: Array<{ + atMs: number; + ackAgeMs: number; + clientStampMs: number; + pose: any; + }> = []; + const samples: Array<{ + world: Array; + atMs: number; + error: number; + }> = []; + + let simMs = 0; + let nextSnapshotMs = 0; + let nextFrameAtMs = 0; + let lastHeartbeatClientMs = -Infinity; + let lastInputClientTimeMs = 0; + let lastInputReceiptMs = 0; + let hasPressed = false; + let lastServerArrivalMs = 0; + let lastClientArrivalMs = 0; + + while (simMs < 8000) { + tickPlanetDetachment(server, stepSeconds); + stepCharacterMovement(server, world, serverInput.slice() as never, stepSeconds); + simMs += stepSeconds * 1000; + + while (toServer.length > 0 && toServer[0].atMs <= simMs) { + const packet = toServer.shift()!; + if (packet.direction) serverInput = packet.direction; + if (packet.clientStampMs > lastInputClientTimeMs) { + lastInputClientTimeMs = packet.clientStampMs; + // Socket callbacks run between physics frames, so this is the true arrival instant, + // not the frame boundary — plus however long the frame in progress still had to run. + lastInputReceiptMs = packet.atMs; + } + } + + if (simMs >= nextSnapshotMs) { + nextSnapshotMs += snapshotMs; + lastClientArrivalMs = Math.max( + lastClientArrivalMs, + simMs + Math.max(0, wireMs + (random() * 2 - 1) * wireJitterMs), + ); + toClient.push({ + atMs: lastClientArrivalMs, + clientStampMs: lastInputClientTimeMs, + ackAgeMs: lastInputReceiptMs > 0 ? Math.max(0, simMs - lastInputReceiptMs) : 0, + pose: { + head: server.head.center.slice(), + leftFoot: server.leftFoot.center.slice(), + rightFoot: server.rightFoot.center.slice(), + movement: { + direction: server.direction, + bodyVelocity: server.bodyVelocity.slice(), + leftFootNormal: server.leftFoot.lastNormal.slice(), + rightFootNormal: server.rightFoot.lastNormal.slice(), + groundPlanetId: 1, + secondsSinceOnSurface: server.secondsSinceOnSurface, + }, + }, + }); + } + + while (toClient.length > 0 && toClient[0].atMs <= simMs) { + const packet = toClient.shift()!; + predictor.acknowledge( + packet.clientStampMs, + packet.pose.movement, + -Infinity, + packet.ackAgeMs, + ); + predictor.setAuthoritative( + { center: packet.pose.head, radius: headRadius } as never, + { center: packet.pose.leftFoot, radius: feetRadius } as never, + { center: packet.pose.rightFoot, radius: feetRadius } as never, + ); + } + + while (nextFrameAtMs <= simMs) { + const frameClientMs = nextFrameAtMs + clientClockOffsetMs; + setFrameTimeMs(frameClientMs); + + // Movement input is edge-triggered, exactly as KeyboardListener sends it: recorded once + // when the key goes down, then held by both sides. + const desiredInput = + changeInput && nextFrameAtMs >= 4500 + ? nextFrameAtMs >= 6000 + ? [0, 0] + : [-1, 0] + : [1, 0]; + const inputChanged = !hasPressed || desiredInput[0] !== input[0]; + if (inputChanged) { + hasPressed = true; + input = desiredInput; + predictor.recordInput(input as never); + } + + const dropped = random() < dropRate; + const jitter = 1 + (random() * 2 - 1) * frameJitter; + const frameMs = nominalFrameMs * jitter * (dropped ? 2 : 1); + + if (!dropped) { + predictor.update([planetAt(0)]); + const head = predictor.head.center; + samples.push({ + world: [head[0], head[1]], + atMs: frameClientMs, + error: Math.hypot( + head[0] - server.head.center[0], + head[1] - server.head.center[1], + ), + }); + } + + // Stamped at the top of the frame, emitted at the bottom. + if ( + inputChanged || + (!dropped && frameClientMs - lastHeartbeatClientMs >= heartbeatMs - 1) + ) { + lastHeartbeatClientMs = frameClientMs; + lastServerArrivalMs = Math.max( + lastServerArrivalMs, + nextFrameAtMs + + serverBusyMs + + Math.max(0, emitLagMs + (random() * 2 - 1) * emitJitterMs) + + Math.max(0, wireMs + (random() * 2 - 1) * wireJitterMs), + ); + toServer.push({ + atMs: lastServerArrivalMs, + clientStampMs: frameClientMs, + direction: inputChanged ? input.slice() : undefined, + }); + } + + nextFrameAtMs += frameMs; + } + } + + const tail = samples.slice(Math.floor(samples.length / 2)); + + // Apparent speed: world travel divided by how long the frame was on screen. A longer frame + // should show proportionally more travel; anything else reads as a lurch. + const speeds = tail + .slice(1) + .map( + (s, i) => + Math.hypot(s.world[0] - tail[i].world[0], s.world[1] - tail[i].world[1]) / + (s.atMs - tail[i].atMs), + ); + const meanSpeed = speeds.reduce((a, b) => a + b, 0) / speeds.length; + + return { + speedSpread: (Math.max(...speeds) - Math.min(...speeds)) / meanSpeed, + lagUnits: tail.reduce((sum, s) => sum + s.error, 0) / tail.length, + finalError: tail[tail.length - 1].error, + maximumSpeed: Math.max(...speeds), + }; +}; + +const CONDITIONS: Array<[string, Conditions]> = [ + [ + 'localhost, clean frames', + { fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 }, + ], + [ + 'localhost, 144 Hz', + { fps: 144, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 }, + ], + [ + 'localhost, frame jitter', + { + fps: 60, + wireMs: 0.2, + emitLagMs: 2, + frameJitter: 0.15, + dropRate: 0, + serverBusyMs: 0, + }, + ], + [ + 'localhost, 2% dropped', + { + fps: 60, + wireMs: 0.2, + emitLagMs: 2, + frameJitter: 0.05, + dropRate: 0.02, + serverBusyMs: 0, + }, + ], + [ + 'localhost, slower CPU', + { + fps: 60, + wireMs: 0.2, + emitLagMs: 8, + frameJitter: 0.15, + dropRate: 0.02, + serverBusyMs: 0, + }, + ], + [ + '30 ms link', + { + fps: 60, + wireMs: 15, + emitLagMs: 2, + frameJitter: 0.05, + dropRate: 0, + serverBusyMs: 0, + }, + ], + [ + '120 ms link', + { + fps: 60, + wireMs: 60, + emitLagMs: 2, + frameJitter: 0.05, + dropRate: 0, + serverBusyMs: 0, + }, + ], +]; + +describe('local character render smoothness', () => { + it.each(CONDITIONS)('keeps travel even with %s', (_label, conditions) => { + const result = measure(conditions); + expect(result.speedSpread).toBeLessThan(0.05); + if (conditions.wireMs < 1) expect(result.lagUnits).toBeLessThan(5); + }); + + it('absorbs changing network and client CPU delay without snapshot jumps', () => { + const result = measure({ + fps: 144, + wireMs: 30, + emitLagMs: 5, + wireJitterMs: 10, + emitJitterMs: 5, + frameJitter: 0.15, + dropRate: 0.02, + serverBusyMs: 2, + }); + expect(result.speedSpread).toBeLessThan(0.4); + expect(result.lagUnits).toBeLessThan(25); + }); + + it('reconciles direction changes and stopping across an irregular link', () => { + const result = measure({ + fps: 144, + wireMs: 30, + emitLagMs: 5, + wireJitterMs: 10, + emitJitterMs: 5, + frameJitter: 0.15, + dropRate: 0.02, + serverBusyMs: 2, + changeInput: true, + }); + expect(result.maximumSpeed).toBeLessThan(1); + expect(result.finalError).toBeLessThan(3); + }); +}); diff --git a/test/serialization.test.mjs b/test/serialization.test.mjs index 9c7bc11..209736d 100644 --- a/test/serialization.test.mjs +++ b/test/serialization.test.mjs @@ -1,8 +1,3 @@ -// Round-trip tests for the custom wire serializer — the single most fragile, -// highest-blast-radius mechanism in the codebase (every networked message goes -// through it, and dispatch is keyed on class name). We exercise the BUILT shared -// bundle (shared/lib/main.js) rather than the TS source, so the decorators are -// already applied exactly as they ship and there is no transform/ESM ambiguity. import { describe, it, expect } from 'vitest'; import { createRequire } from 'node:module'; @@ -17,21 +12,27 @@ const { MoveActionCommand, UpdatePropertyCommand, PropertyUpdatesForObject, - // networked entity bases — their toArray() must mirror their constructor order CharacterBase, PlanetBase, ProjectileBase, LampBase, - // geometry — single source of truth shared by server & client prediction headRadius, feetRadius, boundRadius, headOffset, leftFootOffset, rightFootOffset, + CharacterMovementSnapshot, } = shared; describe('serialization round-trip (built shared lib)', () => { + it('preserves the authoritative leap cooldown for prediction', () => { + const snapshot = new CharacterMovementSnapshot(0, [1, 2], [0, 1], [0, 1], 5, 0, 0.25); + const out = deserialize(serialize(snapshot)); + expect(out).toBeInstanceOf(CharacterMovementSnapshot); + expect(out.leapCooldownRemaining).toBe(0.25); + }); + it('reconstructs a ServerAnnouncement as the right class with its payload', () => { const [out] = deserialize(serialize([new ServerAnnouncement('Hello world')])); expect(out).toBeInstanceOf(ServerAnnouncement); @@ -41,7 +42,6 @@ describe('serialization round-trip (built shared lib)', () => { it('round-trips a Circle and rounds floats to 3 decimals', () => { const out = deserialize(serialize(new Circle([12.34567, -7.1], 5.43219))); expect(out).toBeInstanceOf(Circle); - // serialize.ts rounds every float via toFixed(3) expect(out.center[0]).toBeCloseTo(12.346, 6); expect(out.center[1]).toBeCloseTo(-7.1, 6); expect(out.radius).toBeCloseTo(5.432, 6); @@ -68,8 +68,6 @@ describe('serialization round-trip (built shared lib)', () => { }); it('preserves per-item class identity across a mixed batch', () => { - // A clobbered name→constructor mapping (two classes sharing a name) would - // surface here as an item deserializing to the wrong class. const batch = [ new ServerAnnouncement('a'), new MoveActionCommand([0, 1], 5), @@ -132,14 +130,25 @@ describe('networked entity round-trips (toArray ↔ constructor contract)', () = it('round-trips a PlanetBase (derived centre/radius recomputed from vertices)', () => { const out = deserialize( - serialize(new PlanetBase(10, [[0, 0], [100, 0], [50, 100]], 0.7, true)), + serialize( + new PlanetBase( + 10, + [ + [0, 0], + [100, 0], + [50, 100], + ], + 0.7, + true, + ), + ), ); expect(out).toBeInstanceOf(PlanetBase); expect(out.id).toBe(10); expect(out.vertices).toHaveLength(3); expect(out.ownership).toBeCloseTo(0.7, 5); expect(out.isKeystone).toBe(true); - expect(out.center[0]).toBeCloseTo(50, 5); // recomputed by the constructor + expect(out.center[0]).toBeCloseTo(50, 5); }); }); @@ -147,7 +156,7 @@ describe('shared character geometry — single source of truth', () => { it('matches the known body layout', () => { expect(headRadius).toBe(50); expect(feetRadius).toBe(20); - expect(boundRadius).toBe((headRadius + feetRadius * 2) * 2); // 180 + expect(boundRadius).toBe((headRadius + feetRadius * 2) * 2); }); it('posture offsets are measured from the centre of mass (they sum to ~0)', () => { diff --git a/test/setup.mjs b/test/setup.mjs new file mode 100644 index 0000000..65bf24b --- /dev/null +++ b/test/setup.mjs @@ -0,0 +1,13 @@ +// Use production's f64 vector type in every package. The import-order regression separately +// verifies that physics constants also retain full precision before startup configures it. +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +for (const path of [ + '../shared/node_modules/gl-matrix', + '../backend/node_modules/gl-matrix', + '../frontend/node_modules/gl-matrix', +]) { + require(path).glMatrix.setMatrixArrayType(Array); +} diff --git a/test/shared-regressions.test.ts b/test/shared-regressions.test.ts new file mode 100644 index 0000000..770ddc5 --- /dev/null +++ b/test/shared-regressions.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { marchCircle } from '../shared/src/physics/march-circle'; +import { resolveCircleMovement } from '../shared/src/physics/resolve-circle-movement'; +import { finiteVec2, sanitizeName } from '../shared/src/helper/finite'; +import { CommandReceiver } from '../shared/src/commands/command-receiver'; +import type { PhysicsBody, Sdf } from '../shared/src/physics/sdf'; + +const bodyAt = (x: number, y: number, vx: number, vy: number): PhysicsBody => ({ + center: [x, y], + radius: 1, + velocity: [vx, vy], + lastNormal: [0, 1], + restitution: 1, +}); + +const rightWall: Sdf = { canCollide: true, distance: ([x]) => 10 - x }; + +describe('circle collision movement', () => { + it('spends only the remaining step time after a bounce', () => { + const body = bodyAt(0, 0, 100, 0); + expect(resolveCircleMovement(body, 0.2, [rightWall])).toBe(rightWall); + // 9 units to the wall, then 11 units back: 20 units of total travel. + expect(body.center[0]).toBeCloseTo(-2, 6); + expect(body.velocity[0]).toBeCloseTo(-100, 6); + }); + + it('resolves velocity at the second contact instead of leaving it aimed into a wall', () => { + const body = bodyAt(0, 0, 100, 0); + const leftWall: Sdf = { canCollide: true, distance: ([x]) => x + 5 }; + resolveCircleMovement(body, 0.3, [rightWall, leftWall]); + expect(body.center[0]).toBeCloseTo(-4, 6); + expect(body.velocity[0]).toBeCloseTo(100, 6); + }); + + it('allows slow tangential sliding along a surface', () => { + const body = { ...bodyAt(9, 0, 100, 10), restitution: 0 }; + resolveCircleMovement(body, 0.1, [rightWall]); + expect(body.center[0]).toBeCloseTo(9, 6); + expect(body.center[1]).toBeCloseTo(1, 6); + expect(body.velocity[0]).toBeCloseTo(0, 6); + }); + + it('ignores disabled objects when identifying the contacted surface', () => { + const inactive: Sdf = { canCollide: false, distance: () => -100 }; + const onHit = vi.fn(); + const body = bodyAt(0, 0, 100, 0); + const result = marchCircle(body, [20, 0], [inactive, rightWall], onHit); + expect(result.hitObject).toBe(rightWall); + expect(onHit).toHaveBeenCalledExactlyOnceWith(rightWall); + expect(result.normal).toEqual([-1, 0]); + }); + + it('samples the collision normal before the callback removes the obstacle', () => { + const wall = { ...rightWall }; + const body = bodyAt(0, 0, 100, 0); + resolveCircleMovement(body, 0.2, [wall], () => { + wall.canCollide = false; + }); + expect(body.center.every(Number.isFinite)).toBe(true); + expect(body.velocity[0]).toBeCloseTo(-100, 6); + }); +}); + +describe('network input helpers', () => { + it('clamps huge finite vectors before allocating a float vector', () => { + const direction = finiteVec2([1e300, 1e300], 1)!; + expect(direction.every(Number.isFinite)).toBe(true); + expect(Math.hypot(...direction)).toBeCloseTo(1, 6); + expect(direction[0]).toBeGreaterThan(0.7); + }); + + it('never calls methods on an untrusted player name', () => { + expect(sanitizeName({ toString: {} }, 10)).toBe(''); + expect(sanitizeName('abcdefghijk', 10)).toBe('abcdefghij'); + }); + + it('does not dispatch inherited object methods as commands', () => { + class Receiver extends CommandReceiver { + public fallback = vi.fn(); + protected defaultCommandExecutor(command: object) { + this.fallback(command); + } + } + const receiver = new Receiver(); + const command = { constructor: { name: 'constructor' } }; + receiver.handleCommand(command); + expect(receiver.fallback).toHaveBeenCalledExactlyOnceWith(command); + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('random world seeds', () => { + it('uses the random seed bits instead of truncating every initial seed to zero', async () => { + const random = vi.spyOn(Math, 'random'); + const firstValues: number[] = []; + for (const seed of [0.1, 0.5, 0.9]) { + random.mockReturnValue(seed); + vi.resetModules(); + const { Random } = await import('../shared/src/helper/random'); + firstValues.push(Random.getRandom()); + Random.seed = 42; + const expected = [Random.getRandom(), Random.getRandom()]; + Random.seed = 42; + expect([Random.getRandom(), Random.getRandom()]).toEqual(expected); + } + expect(new Set(firstValues).size).toBe(3); + }); +}); diff --git a/test/suspended-rendering.test.ts b/test/suspended-rendering.test.ts new file mode 100644 index 0000000..92b777b --- /dev/null +++ b/test/suspended-rendering.test.ts @@ -0,0 +1,276 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + CharacterBase, + CharacterTeam, + Circle, + CreateObjectsCommand, + DeleteObjectsCommand, + GameObject, + PropertyUpdatesForObjects, + RemoteCall, + RemoteCallsForObject, + RemoteCallsForObjects, +} from '../frontend/node_modules/shared'; +import { GameObjectContainer } from '../frontend/src/scripts/objects/game-object-container'; +import { PlanetView } from '../frontend/src/scripts/objects/types/planet-view'; +import { serverTimeline } from '../frontend/src/scripts/helper/server-timeline'; + +vi.mock('../frontend/src/scripts/objects/types/camera', () => ({ + Camera: class { + draw() {} + follow() {} + }, +})); +vi.mock('../frontend/src/scripts/objects/types/planet-view', () => ({ + PlanetView: class { + beforeDestroy = vi.fn(); + render = vi.fn(); + processRemoteCalls = vi.fn(); + step() {} + constructor(public id: number) {} + }, +})); +vi.mock('../frontend/src/scripts/sound-handler', () => ({ + SoundHandler: { play: vi.fn() }, + Sounds: {}, +})); +vi.mock('../frontend/src/scripts/feedback-hud', () => ({ FeedbackHud: {} })); + +let nowMs = 0; +beforeEach(() => { + nowMs = 0; + vi.spyOn(performance, 'now').mockImplementation(() => nowMs); + serverTimeline.reset(); +}); +afterEach(() => vi.restoreAllMocks()); + +function character(id = 1) { + const pose = new Circle([0, 0], 10); + const object = new CharacterBase( + id, + 'Remote player', + 0, + 0, + CharacterTeam.red, + 100, + pose, + pose, + pose, + ); + return Object.assign(object, { + step: vi.fn(), + render: vi.fn(), + beforeDestroy: vi.fn(), + setHealth: vi.fn((health: number) => (object.health = health)), + onShoot: vi.fn(), + onDie: vi.fn(), + }); +} + +function calls( + container: GameObjectContainer, + id: number, + ...entries: Array<[string, ...unknown[]]> +) { + container.handleCommand( + new RemoteCallsForObjects([ + new RemoteCallsForObject( + id, + entries.map(([name, ...args]) => new RemoteCall(name, args)), + ), + ]), + ); +} + +function snapshot(container: GameObjectContainer, timestamp: number) { + nowMs = (timestamp - 5) * 1000; + container.handleCommand(new PropertyUpdatesForObjects([], timestamp)); +} + +function retained(container: GameObjectContainer) { + return container as unknown as { + objects: Map; + deleteAt: Map; + remoteCalls: Array<{ object: unknown }>; + }; +} + +describe('suspended rendering', () => { + it('bounds deleted views and effect batches as snapshots arrive without frames', () => { + const container = new GameObjectContainer({} as never); + const survivor = character(); + container.handleCommand(new CreateObjectsCommand([survivor])); + const deleted: PlanetView[] = []; + + for (let i = 0; i < 1000; i++) { + const object = new PlanetView(i + 100, []); + deleted.push(object); + container.handleCommand(new CreateObjectsCommand([object])); + calls(container, survivor.id, ['onShoot', 40]); + container.handleCommand(new DeleteObjectsCommand([object.id])); + snapshot(container, 5 + i * 0.04); + + expect(container.planets.length).toBeLessThanOrEqual(15); + expect(retained(container).objects.size).toBeLessThanOrEqual(16); + expect(retained(container).deleteAt.size).toBeLessThanOrEqual(15); + expect(retained(container).remoteCalls.length).toBeLessThanOrEqual(15); + } + + expect(deleted[0].beforeDestroy).toHaveBeenCalledOnce(); + expect(deleted[984].beforeDestroy).toHaveBeenCalledOnce(); + expect(survivor.onShoot).not.toHaveBeenCalled(); + + serverTimeline.step(0); + container.step(0); + expect(survivor.onShoot).not.toHaveBeenCalled(); + expect(retained(container).remoteCalls.length).toBeLessThanOrEqual(3); + + snapshot(container, 46); + expect(container.planets).toHaveLength(0); + expect(retained(container).objects.size).toBe(1); + expect(retained(container).deleteAt.size).toBe(0); + expect(retained(container).remoteCalls).toHaveLength(0); + serverTimeline.step(0); + container.step(0); + expect(survivor.onShoot).not.toHaveBeenCalled(); + }); + + it('preserves expired state silently and applies fresh health feedback in order', () => { + const container = new GameObjectContainer({} as never); + const object = character(); + container.handleCommand(new CreateObjectsCommand([object])); + calls(container, object.id, ['setHealth', 80], ['setKillCount', 1], ['onShoot', 40]); + snapshot(container, 5); + calls(container, object.id, ['setHealth', 60], ['setKillCount', 2], ['onDie']); + snapshot(container, 5.04); + snapshot(container, 5.6); + + expect(object.health).toBe(60); + expect(object.killCount).toBe(2); + expect(object.setHealth).not.toHaveBeenCalled(); + expect(object.onShoot).not.toHaveBeenCalled(); + expect(object.onDie).not.toHaveBeenCalled(); + + calls(container, object.id, ['setHealth', 40], ['setKillCount', 3], ['onShoot', 50]); + snapshot(container, 5.7); + serverTimeline.step(0); + container.step(0); + expect(object.health).toBe(60); + expect(object.setHealth).not.toHaveBeenCalled(); + + nowMs += 110; + serverTimeline.step(0.11); + container.step(0); + expect(object.health).toBe(40); + expect(object.killCount).toBe(3); + expect(object.setHealth).toHaveBeenCalledExactlyOnceWith(40); + expect(object.onShoot).toHaveBeenCalledExactlyOnceWith(50); + expect(object.onDie).not.toHaveBeenCalled(); + }); + + it('preserves final light and contested state while discarding transient planet effects', () => { + const container = new GameObjectContainer({} as never); + const object = Object.assign(new (class extends GameObject {})(1), { + contested: false, + lightness: 0, + color: [0, 0, 0], + step() {}, + render() {}, + setContested(value: boolean) { + this.contested = value; + }, + setLight(color: number[], lightness: number) { + this.color = color; + this.lightness = lightness; + }, + onFlipped: vi.fn(), + generatedPoints: vi.fn(), + }); + container.handleCommand(new CreateObjectsCommand([object])); + calls( + container, + object.id, + ['setContested', true], + ['setLight', [1, 0, 0], 2], + ['onFlipped', CharacterTeam.red], + ['generatedPoints', 10], + ); + snapshot(container, 5); + calls( + container, + object.id, + ['setContested', false], + ['setLight', [0, 0, 1], 3], + ['onFlipped', CharacterTeam.blue], + ['generatedPoints', 20], + ); + snapshot(container, 5.04); + snapshot(container, 6); + + expect(object.contested).toBe(false); + expect(object.color).toEqual([0, 0, 1]); + expect(object.lightness).toBe(3); + expect(object.onFlipped).not.toHaveBeenCalled(); + expect(object.generatedPoints).not.toHaveBeenCalled(); + expect(retained(container).remoteCalls).toHaveLength(0); + }); + + it('releases effect references immediately when a paused view is replaced', () => { + const container = new GameObjectContainer({} as never); + let object = character(); + container.handleCommand(new CreateObjectsCommand([object])); + snapshot(container, 5); + + for (let i = 0; i < 100; i++) { + calls(container, object.id, ['onShoot', 40]); + container.handleCommand(new DeleteObjectsCommand([object.id])); + const replacement = character(object.id); + container.handleCommand(new CreateObjectsCommand([replacement])); + expect(object.beforeDestroy).toHaveBeenCalledOnce(); + expect(retained(container).remoteCalls).toHaveLength(0); + expect(retained(container).objects.get(object.id)).toBe(replacement); + object = replacement; + } + + snapshot(container, 5.04); + nowMs += 200; + serverTimeline.step(0.2); + container.step(0); + expect(object.beforeDestroy).not.toHaveBeenCalled(); + }); + + it('expires the last queued effects on resume even when snapshots also stopped', () => { + const container = new GameObjectContainer({} as never); + const object = character(); + container.handleCommand(new CreateObjectsCommand([object])); + calls(container, object.id, ['setHealth', 20], ['setKillCount', 4], ['onShoot', 40]); + snapshot(container, 5); + + nowMs += 10_000; + serverTimeline.step(10); + container.step(10); + + expect(object.health).toBe(20); + expect(object.killCount).toBe(4); + expect(object.setHealth).not.toHaveBeenCalled(); + expect(object.onShoot).not.toHaveBeenCalled(); + expect(retained(container).remoteCalls).toHaveLength(0); + }); + + it('releases a deleted local player reference without waiting for a frame', () => { + const container = new GameObjectContainer({} as never); + const object = character(); + container.handleCommand(new CreateObjectsCommand([object])); + container.player = object as never; + snapshot(container, 5); + container.handleCommand(new DeleteObjectsCommand([object.id])); + snapshot(container, 5.04); + expect(container.localPlayer).toBe(object); + + snapshot(container, 6); + expect(object.beforeDestroy).toHaveBeenCalledOnce(); + expect(container.player).toBeUndefined(); + expect(container.localPlayer).toBeUndefined(); + expect(retained(container).objects.size).toBe(0); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 4b59984..62eedff 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,11 +3,8 @@ "target": "es6", "esModuleInterop": true, "strict": true, - "experimentalDecorators": true, - "downlevelIteration": true, "moduleResolution": "node", "module": "commonjs", - "composite": true, - "lib": ["dom", "es2017"] + "lib": ["es2017"] } } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..4bf89fe --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + setupFiles: ['./test/setup.mjs'], + }, +});