From b246c9a7fdf4a77d1c47eb678e58295d70cae929 Mon Sep 17 00:00:00 2001 From: Jage9 Date: Fri, 20 Feb 2026 08:16:43 -0500 Subject: [PATCH] Initial commit --- .gitignore | 18 + AGENTS.md | 36 + README.md | 40 + client/README.md | 11 + client/index.html | 84 + client/package-lock.json | 3942 ++++++++++++++++++ client/package.json | 24 + client/public/sounds/logon.ogg | Bin 0 -> 21864 bytes client/public/sounds/logout.ogg | Bin 0 -> 19612 bytes client/public/sounds/notify.ogg | Bin 0 -> 16170 bytes client/public/sounds/roll.ogg | Bin 0 -> 11419 bytes client/public/version.js | 3 + client/src/audio/audioEngine.ts | 535 +++ client/src/input/textInput.ts | 30 + client/src/main.ts | 1779 ++++++++ client/src/network/protocol.ts | 149 + client/src/network/signalingClient.ts | 76 + client/src/render/canvasRenderer.ts | 86 + client/src/state/gameState.ts | 149 + client/src/styles.css | 124 + client/src/webrtc/peerManager.ts | 173 + client/tsconfig.json | 14 + client/vite.config.ts | 17 + deploy/README.md | 116 + deploy/apache/chgrid-vhost-snippet.conf | 7 + deploy/scripts/deploy_client.sh | 27 + deploy/scripts/install_apache.sh | 41 + deploy/scripts/install_server.sh | 53 + deploy/scripts/install_service.sh | 18 + deploy/systemd/chgrid-client-preview.service | 16 + deploy/systemd/chgrid-signaling.service | 16 + docs/item-schema.md | 118 + docs/local.md | 33 + item.md | 92 + refactor.md | 101 + server/README.md | 35 + server/app/__init__.py | 0 server/app/client.py | 17 + server/app/config.py | 60 + server/app/item_catalog.py | 34 + server/app/item_service.py | 131 + server/app/models.py | 207 + server/app/server.py | 599 +++ server/config.example.toml | 23 + server/main.py | 5 + server/pyproject.toml | 19 + server/tests/__init__.py | 0 server/tests/test_config.py | 24 + server/tests/test_item_persistence.py | 35 + server/tests/test_models.py | 18 + server/tests/test_nickname_uniqueness.py | 28 + server/tests/test_nickname_updates.py | 73 + server/uv.lock | 302 ++ 53 files changed, 9538 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 client/README.md create mode 100644 client/index.html create mode 100644 client/package-lock.json create mode 100644 client/package.json create mode 100644 client/public/sounds/logon.ogg create mode 100644 client/public/sounds/logout.ogg create mode 100644 client/public/sounds/notify.ogg create mode 100644 client/public/sounds/roll.ogg create mode 100644 client/public/version.js create mode 100644 client/src/audio/audioEngine.ts create mode 100644 client/src/input/textInput.ts create mode 100644 client/src/main.ts create mode 100644 client/src/network/protocol.ts create mode 100644 client/src/network/signalingClient.ts create mode 100644 client/src/render/canvasRenderer.ts create mode 100644 client/src/state/gameState.ts create mode 100644 client/src/styles.css create mode 100644 client/src/webrtc/peerManager.ts create mode 100644 client/tsconfig.json create mode 100644 client/vite.config.ts create mode 100644 deploy/README.md create mode 100644 deploy/apache/chgrid-vhost-snippet.conf create mode 100755 deploy/scripts/deploy_client.sh create mode 100755 deploy/scripts/install_apache.sh create mode 100755 deploy/scripts/install_server.sh create mode 100755 deploy/scripts/install_service.sh create mode 100644 deploy/systemd/chgrid-client-preview.service create mode 100644 deploy/systemd/chgrid-signaling.service create mode 100644 docs/item-schema.md create mode 100644 docs/local.md create mode 100644 item.md create mode 100644 refactor.md create mode 100644 server/README.md create mode 100644 server/app/__init__.py create mode 100644 server/app/client.py create mode 100644 server/app/config.py create mode 100644 server/app/item_catalog.py create mode 100644 server/app/item_service.py create mode 100644 server/app/models.py create mode 100644 server/app/server.py create mode 100644 server/config.example.toml create mode 100644 server/main.py create mode 100644 server/pyproject.toml create mode 100644 server/tests/__init__.py create mode 100644 server/tests/test_config.py create mode 100644 server/tests/test_item_persistence.py create mode 100644 server/tests/test_models.py create mode 100644 server/tests/test_nickname_uniqueness.py create mode 100644 server/tests/test_nickname_updates.py create mode 100644 server/uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5185e40 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Local config/state +server/config.toml +server/runtime/ + +# Python +server/.venv/ +**/__pycache__/ +*.py[cod] +.pytest_cache/ + +# Node/Vite +client/node_modules/ +client/dist/ + +# OS/editor/log noise +.DS_Store +*.log +*.bak diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6975632 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,36 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `client/`: Vite + TypeScript web app. + - `src/main.ts`: connect flow, key commands, status/audio cues. + - `src/audio`, `src/network`, `src/state`, `src/render`, `src/webrtc`, `src/input`: feature modules. + - `public/version.js`: single source of truth for web version. + - `public/sounds/`: all client sound assets. +- `server/`: Python signaling service. + - `app/server.py`: websocket lifecycle + packet routing. + - `app/client.py`: client connection model. + - `app/item_service.py`: item persistence + hydration. + - `app/item_catalog.py`: global item-type properties. + - `app/models.py`: packet/data schemas. +- `deploy/`: Apache snippet + systemd unit examples. + +## Build, Test, and Development Commands +- Client dev: `cd client && npm install && npm run dev -- --host 0.0.0.0 --port 5173` +- Client build: `cd client && npm run build` +- Server run: `cd server && cp config.example.toml config.toml && uv run python main.py --config config.toml` +- Server tests: `cd server && uv run --extra dev pytest` + +## Coding Style & Naming Conventions +- TypeScript: strict typing, `camelCase`, small focused modules. +- Python: PEP 8, 4 spaces, `snake_case`, typed Pydantic models. +- Keep protocol changes synced in `client/src/network/protocol.ts` and `server/app/models.py`. + +## Versioning & Configuration +- Bump `client/public/version.js` on every user-visible change using `YYYY.MM.DD Rn`. +- Do not duplicate version constants elsewhere in client code. +- `server/config.toml` is deployment-local and must not be committed. +- Production should use TLS (`network.allow_insecure_ws = false`). + +## Audio Asset Rules +- Keep all runtime sounds in `client/public/sounds/`. +- Reference sounds as absolute web paths (example: `/sounds/roll.ogg`). diff --git a/README.md b/README.md new file mode 100644 index 0000000..a747230 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# Chat Grid + +Realtime spatial chat grid with: +- `client/` TypeScript web app +- `server/` Python websocket signaling server + +## Local Run + +1) Start server +```bash +cd server +cp config.example.toml config.toml +uv run python main.py --config config.toml +``` + +2) Start client +```bash +cd client +npm install +npm run dev -- --host 0.0.0.0 --port 5173 +``` + +3) Open `http://localhost:5173` + +## Production Deploy (quick path) + +Use `deploy/README.md`. + +Summary: +1. Copy repo to `/home/bestmidi/chgrid`. +2. Build client and publish `client/dist/` to `/home/bestmidi/public_html/chgrid/`. +3. Configure server `config.toml` and run it via `systemd`. +4. Add Apache `/ws` websocket proxy from `deploy/apache/chgrid-vhost-snippet.conf`. + +## Key Paths + +- Client version: `client/public/version.js` +- Client sounds: `client/public/sounds/` +- Server config template: `server/config.example.toml` +- Server runtime items: `server/runtime/items.json` diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..dd5b08f --- /dev/null +++ b/client/README.md @@ -0,0 +1,11 @@ +# chgrid client + +## Run + +```bash +cd client +npm install +npm run dev +``` + +Open `http://localhost:5173`. diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..b6f6e30 --- /dev/null +++ b/client/index.html @@ -0,0 +1,84 @@ + + + + + + Chat Grid + + +
+

Chat Grid

+
+ + +
+
+ + + + +
+
+ + +
+
+ + + + Another AI experiment with Jage. Version + + +
+ + + + diff --git a/client/package-lock.json b/client/package-lock.json new file mode 100644 index 0000000..12cdc23 --- /dev/null +++ b/client/package-lock.json @@ -0,0 +1,3942 @@ +{ + "name": "chat-grid-client", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "chat-grid-client", + "version": "0.1.0", + "dependencies": { + "zod": "^3.24.1" + }, + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^8.19.0", + "@typescript-eslint/parser": "^8.19.0", + "eslint": "^9.17.0", + "typescript": "^5.7.2", + "vite": "^6.0.5", + "vitest": "^2.1.8" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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/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.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/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/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.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/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "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.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", + "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/type-utils": "8.56.0", + "@typescript-eslint/utils": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.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.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", + "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "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.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", + "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.56.0", + "@typescript-eslint/types": "^8.56.0", + "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.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", + "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0" + }, + "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.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", + "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", + "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.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", + "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/utils": "8.56.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.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.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", + "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", + "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.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", + "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.56.0", + "@typescript-eslint/tsconfig-utils": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.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.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", + "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.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.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", + "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.0", + "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/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", + "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.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.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "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-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/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "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": "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/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "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/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "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/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/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-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "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": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.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", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "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/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/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/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "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-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.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "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/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/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/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/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "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/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/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "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": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "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.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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/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/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "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-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": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "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.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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.11", + "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/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/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "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/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": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/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": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "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": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "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": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "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/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" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..f0dadaf --- /dev/null +++ b/client/package.json @@ -0,0 +1,24 @@ +{ + "name": "chat-grid-client", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest run", + "lint": "eslint src --ext .ts" + }, + "dependencies": { + "zod": "^3.24.1" + }, + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^8.19.0", + "@typescript-eslint/parser": "^8.19.0", + "eslint": "^9.17.0", + "typescript": "^5.7.2", + "vite": "^6.0.5", + "vitest": "^2.1.8" + } +} diff --git a/client/public/sounds/logon.ogg b/client/public/sounds/logon.ogg new file mode 100644 index 0000000000000000000000000000000000000000..d67ef772b18e5609cac99bb5b142720350884a9b GIT binary patch literal 21864 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 QD@4PtmQyOQZ4pBBe}{=U9smFU literal 0 HcmV?d00001 diff --git a/client/public/sounds/logout.ogg b/client/public/sounds/logout.ogg new file mode 100644 index 0000000000000000000000000000000000000000..072f0d0324142982a7e8d4163aeda51b6df4ed28 GIT binary patch literal 19612 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 literal 0 HcmV?d00001 diff --git a/client/public/sounds/notify.ogg b/client/public/sounds/notify.ogg new file mode 100644 index 0000000000000000000000000000000000000000..8bc5feb0d38966ad01c39e757fb72f74d35a08f9 GIT binary patch literal 16170 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 RQE1QyzYT}QO46j@e*tfJnnM5p literal 0 HcmV?d00001 diff --git a/client/public/sounds/roll.ogg b/client/public/sounds/roll.ogg new file mode 100644 index 0000000000000000000000000000000000000000..18ce0950f937e8db3c8d34f2e819b6032aaa8b49 GIT binary patch literal 11419 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 jjI+y?)E76XT5#Nuv(DAivrc;)`(); + private readonly sampleLoaders = new Map>(); + + private outboundSource: MediaStreamAudioSourceNode | null = null; + private outboundInputGain: GainNode | null = null; + private outboundDestination: MediaStreamAudioDestinationNode | null = null; + private outboundEffectNodes: AudioNode[] = []; + private flangerLfo: OscillatorNode | null = null; + private flangerLfoGain: GainNode | null = null; + private outputMode: OutputMode = 'stereo'; + private effectIndex = EFFECT_SEQUENCE.findIndex((effect) => effect.id === 'off'); + private readonly effectValues: Record = { + reverb: 50, + echo: 50, + flanger: 50, + high_pass: 50, + low_pass: 50, + off: 0, + }; + + async ensureContext(): Promise { + if (!this.audioCtx) { + const Ctor = + window.AudioContext || + (window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!Ctor) return; + this.audioCtx = new Ctor(); + this.sfxGainNode = this.audioCtx.createGain(); + this.sfxGainNode.connect(this.audioCtx.destination); + } + if (this.audioCtx.state === 'suspended') { + await this.audioCtx.resume(); + } + } + + get context(): AudioContext | null { + return this.audioCtx; + } + + supportsStereoPanner(): boolean { + return !!this.audioCtx && typeof this.audioCtx.createStereoPanner === 'function'; + } + + supportsSinkId(element: HTMLMediaElement): boolean { + return ( + typeof (element as HTMLMediaElement & { setSinkId?: (id: string) => Promise }).setSinkId === + 'function' + ); + } + + async configureOutboundStream(inputStream: MediaStream): Promise { + await this.ensureContext(); + if (!this.audioCtx) { + return inputStream; + } + + if (this.outboundSource) { + this.outboundSource.disconnect(); + } + + this.outboundSource = this.audioCtx.createMediaStreamSource(inputStream); + if (!this.outboundInputGain) { + this.outboundInputGain = this.audioCtx.createGain(); + } + if (!this.outboundDestination) { + this.outboundDestination = this.audioCtx.createMediaStreamDestination(); + } + + this.outboundSource.connect(this.outboundInputGain); + this.rebuildOutboundEffectGraph(); + + return this.outboundDestination.stream; + } + + cycleOutboundEffect(): { id: EffectId; label: string } { + this.effectIndex = (this.effectIndex + 1) % EFFECT_SEQUENCE.length; + this.rebuildOutboundEffectGraph(); + return EFFECT_SEQUENCE[this.effectIndex]; + } + + getCurrentEffect(): { id: EffectId; label: string; value: number; defaultValue: number } { + const effect = EFFECT_SEQUENCE[this.effectIndex]; + return { + id: effect.id, + label: effect.label, + value: this.effectValues[effect.id], + defaultValue: effect.defaultValue, + }; + } + + adjustCurrentEffectLevel(step: number): { id: EffectId; label: string; value: number; defaultValue: number } | null { + const effect = EFFECT_SEQUENCE[this.effectIndex]; + if (effect.id === 'off') { + return null; + } + + const next = this.clampLevel(this.effectValues[effect.id] + step); + this.effectValues[effect.id] = next; + this.rebuildOutboundEffectGraph(); + + return { + id: effect.id, + label: effect.label, + value: next, + defaultValue: effect.defaultValue, + }; + } + + setEffectLevels(levels: Partial>): void { + for (const effect of EFFECT_SEQUENCE) { + if (effect.id === 'off') continue; + const value = levels[effect.id]; + if (typeof value !== 'number') continue; + this.effectValues[effect.id] = this.clampLevel(value); + } + this.rebuildOutboundEffectGraph(); + } + + getEffectLevels(): Record { + return { ...this.effectValues }; + } + + setOutputMode(mode: OutputMode): void { + this.outputMode = mode; + } + + toggleOutputMode(): OutputMode { + this.outputMode = this.outputMode === 'stereo' ? 'mono' : 'stereo'; + return this.outputMode; + } + + async attachRemoteStream( + peer: SpatialPeerRuntime, + stream: MediaStream, + outputDeviceId: string, + ): Promise { + await this.ensureContext(); + if (!this.audioCtx) return; + + const audioElement = new Audio(); + audioElement.srcObject = stream; + audioElement.muted = true; + + if (outputDeviceId && this.supportsSinkId(audioElement)) { + const sinkTarget = audioElement as HTMLMediaElement & { setSinkId?: (id: string) => Promise }; + await sinkTarget.setSinkId?.(outputDeviceId); + } + + await audioElement.play().catch(() => undefined); + document.body.appendChild(audioElement); + + const sourceNode = this.audioCtx.createMediaStreamSource(stream); + const gainNode = this.audioCtx.createGain(); + sourceNode.connect(gainNode); + + let pannerNode: StereoPannerNode | undefined; + if (this.supportsStereoPanner()) { + pannerNode = this.audioCtx.createStereoPanner(); + gainNode.connect(pannerNode).connect(this.audioCtx.destination); + } else { + gainNode.connect(this.audioCtx.destination); + } + + peer.audioElement = audioElement; + peer.gain = gainNode; + peer.panner = pannerNode; + } + + updateSpatialAudio(peers: Iterable, playerPosition: { x: number; y: number }): void { + if (!this.audioCtx) return; + + for (const peer of peers) { + if (!peer.gain) continue; + const dist = Math.hypot(peer.x - playerPosition.x, peer.y - playerPosition.y); + let gainValue = 0; + let panValue = 0; + if (dist < HEARING_RADIUS) { + gainValue = Math.pow(1 - dist / HEARING_RADIUS, 2); + panValue = Math.sin(((peer.x - playerPosition.x) / HEARING_RADIUS) * (Math.PI / 2)); + } + if (dist < 1.5) gainValue = 1; + peer.gain.gain.linearRampToValueAtTime(gainValue, this.audioCtx.currentTime + 0.1); + if (peer.panner) { + const resolvedPan = this.outputMode === 'mono' ? 0 : Math.max(-1, Math.min(1, panValue)); + peer.panner.pan.setValueAtTime(resolvedPan, this.audioCtx.currentTime); + } + } + } + + sfxMove(player: { x: number; y: number }): void { + void player; + this.playSound({ freq: 165, duration: 0.05, type: 'triangle', gain: 0.13 }); + } + + sfxPeerMove(peer: { x: number; y: number }): void { + this.playSound({ freq: 330, duration: 0.05, type: 'triangle', gain: 0.12, sourcePosition: peer }); + } + + sfxLocate(peer: { x: number; y: number }): void { + this.playSound({ freq: 880, duration: 0.2, type: 'sine', gain: 0.5, sourcePosition: peer }); + } + + sfxUiConfirm(): void { + this.playSound({ freq: 880, duration: 0.1, gain: 0.5 }); + } + + sfxUiCancel(): void { + this.playSound({ freq: 440, duration: 0.1, type: 'sawtooth', gain: 0.3 }); + } + + sfxUiBlip(): void { + this.playSound({ freq: 660, duration: 0.05, type: 'triangle', gain: 0.35 }); + } + + sfxEffectLevel(isDefault: boolean): void { + this.playSound({ freq: isDefault ? 659.25 : 440, duration: 0.1, type: 'sine', gain: 0.35 }); + } + + sfxTileOccupantPing(): void { + this.playSound({ freq: 1320, duration: 0.12, type: 'sine', gain: 0.45 }); + } + + async playSpatialSample(url: string, sourcePosition: { x: number; y: number }, gain = 1): Promise { + await this.ensureContext(); + const { audioCtx, sfxGainNode } = this; + if (!audioCtx || !sfxGainNode) return; + + const resolved = this.resolveSpatialMix(sourcePosition, gain); + if (!resolved) return; + + try { + const buffer = await this.getSampleBuffer(url); + const source = audioCtx.createBufferSource(); + source.buffer = buffer; + const gainNode = audioCtx.createGain(); + gainNode.gain.value = resolved.gain; + source.connect(gainNode); + if (resolved.pan !== undefined && this.supportsStereoPanner() && this.outputMode === 'stereo') { + const panner = audioCtx.createStereoPanner(); + panner.pan.setValueAtTime(resolved.pan, audioCtx.currentTime); + gainNode.connect(panner).connect(sfxGainNode); + } else { + gainNode.connect(sfxGainNode); + } + source.start(); + } catch { + // Ignore sample decode/load errors. + } + } + + async playSample(url: string, gain = 1): Promise { + await this.ensureContext(); + const { audioCtx, sfxGainNode } = this; + if (!audioCtx || !sfxGainNode) return; + if (gain <= 0) return; + + try { + const buffer = await this.getSampleBuffer(url); + const source = audioCtx.createBufferSource(); + source.buffer = buffer; + const gainNode = audioCtx.createGain(); + gainNode.gain.value = gain; + source.connect(gainNode).connect(sfxGainNode); + source.start(); + } catch { + // Ignore sample decode/load errors. + } + } + + cleanupPeerAudio(peer: SpatialPeerRuntime): void { + peer.audioElement?.remove(); + peer.gain?.disconnect(); + peer.panner?.disconnect(); + } + + private rebuildOutboundEffectGraph(): void { + if (!this.audioCtx || !this.outboundInputGain || !this.outboundDestination) { + return; + } + + this.cleanupEffectNodes(); + this.outboundInputGain.disconnect(); + + const effect = EFFECT_SEQUENCE[this.effectIndex].id; + const effectMix = this.effectValues[effect] / 100; + + if (effect === 'off') { + this.outboundInputGain.connect(this.outboundDestination); + return; + } + + if (effect === 'high_pass' || effect === 'low_pass') { + const filter = this.audioCtx.createBiquadFilter(); + filter.type = effect === 'high_pass' ? 'highpass' : 'lowpass'; + if (effect === 'high_pass') { + filter.frequency.value = 120 + effectMix * 7000; + } else { + filter.frequency.value = 7800 - effectMix * 7600; + } + filter.Q.value = 0.7 + effectMix * 8; + this.outboundInputGain.connect(filter); + filter.connect(this.outboundDestination); + this.outboundEffectNodes.push(filter); + return; + } + + if (effect === 'echo') { + const delay = this.audioCtx.createDelay(1); + delay.delayTime.value = 0.04 + effectMix * 0.76; + const feedback = this.audioCtx.createGain(); + feedback.gain.value = 0.04 + effectMix * 0.88; + const wetGain = this.audioCtx.createGain(); + wetGain.gain.value = 0.08 + effectMix * 0.92; + const dryGain = this.audioCtx.createGain(); + dryGain.gain.value = 1 - effectMix * 0.85; + + this.outboundInputGain.connect(dryGain); + dryGain.connect(this.outboundDestination); + this.outboundInputGain.connect(delay); + delay.connect(wetGain); + wetGain.connect(this.outboundDestination); + delay.connect(feedback); + feedback.connect(delay); + + this.outboundEffectNodes.push(delay, feedback, wetGain, dryGain); + return; + } + + if (effect === 'reverb') { + const convolver = this.audioCtx.createConvolver(); + convolver.buffer = this.createImpulseResponse(0.4 + effectMix * 4.2, 1 + effectMix * 3.6); + const wetGain = this.audioCtx.createGain(); + wetGain.gain.value = 0.06 + effectMix * 0.94; + const dryGain = this.audioCtx.createGain(); + dryGain.gain.value = 1 - effectMix * 0.8; + + this.outboundInputGain.connect(dryGain); + dryGain.connect(this.outboundDestination); + this.outboundInputGain.connect(convolver); + convolver.connect(wetGain); + wetGain.connect(this.outboundDestination); + + this.outboundEffectNodes.push(convolver, wetGain, dryGain); + return; + } + + const delay = this.audioCtx.createDelay(0.05); + delay.delayTime.value = 0.0005 + effectMix * 0.012; + const feedback = this.audioCtx.createGain(); + feedback.gain.value = 0.04 + effectMix * 0.9; + const wetGain = this.audioCtx.createGain(); + wetGain.gain.value = 0.05 + effectMix * 0.95; + const dryGain = this.audioCtx.createGain(); + dryGain.gain.value = 1 - effectMix * 0.82; + + const lfo = this.audioCtx.createOscillator(); + lfo.type = 'sine'; + lfo.frequency.value = 0.05 + effectMix * 1.8; + const lfoGain = this.audioCtx.createGain(); + lfoGain.gain.value = 0.0002 + effectMix * 0.015; + + lfo.connect(lfoGain); + lfoGain.connect(delay.delayTime); + lfo.start(); + + this.outboundInputGain.connect(dryGain); + dryGain.connect(this.outboundDestination); + this.outboundInputGain.connect(delay); + delay.connect(wetGain); + wetGain.connect(this.outboundDestination); + delay.connect(feedback); + feedback.connect(delay); + + this.flangerLfo = lfo; + this.flangerLfoGain = lfoGain; + this.outboundEffectNodes.push(delay, feedback, wetGain, lfoGain, dryGain); + } + + private cleanupEffectNodes(): void { + for (const node of this.outboundEffectNodes) { + node.disconnect(); + } + this.outboundEffectNodes = []; + + if (this.flangerLfo) { + this.flangerLfo.stop(); + this.flangerLfo.disconnect(); + this.flangerLfo = null; + } + if (this.flangerLfoGain) { + this.flangerLfoGain.disconnect(); + this.flangerLfoGain = null; + } + } + + private createImpulseResponse(duration: number, decay: number): AudioBuffer { + if (!this.audioCtx) { + throw new Error('Audio context not initialized'); + } + const length = Math.floor(this.audioCtx.sampleRate * duration); + const impulse = this.audioCtx.createBuffer(2, length, this.audioCtx.sampleRate); + for (let channel = 0; channel < impulse.numberOfChannels; channel += 1) { + const data = impulse.getChannelData(channel); + for (let i = 0; i < length; i += 1) { + const noise = Math.random() * 2 - 1; + data[i] = noise * Math.pow(1 - i / length, decay); + } + } + return impulse; + } + + private clampLevel(value: number): number { + const clamped = Math.max(0, Math.min(100, value)); + return Math.round(clamped / 5) * 5; + } + + private playSound(spec: SoundSpec): void { + const { audioCtx, sfxGainNode } = this; + if (!audioCtx || !sfxGainNode) return; + + const baseGain = spec.gain ?? 1; + const resolved = this.resolveSpatialMix(spec.sourcePosition, baseGain); + if (!resolved) return; + const finalGain = resolved.gain; + const panValue = resolved.pan; + + if (finalGain <= 0) return; + + const startTime = audioCtx.currentTime + (spec.delay ?? 0); + const oscillator = audioCtx.createOscillator(); + oscillator.type = spec.type ?? 'sine'; + oscillator.frequency.setValueAtTime(spec.freq, startTime); + + const gainNode = audioCtx.createGain(); + gainNode.gain.setValueAtTime(finalGain, startTime); + gainNode.gain.exponentialRampToValueAtTime(0.001, startTime + spec.duration); + + oscillator.connect(gainNode); + if (panValue !== undefined && this.supportsStereoPanner() && this.outputMode === 'stereo') { + const panner = audioCtx.createStereoPanner(); + panner.pan.setValueAtTime(Math.max(-1, Math.min(1, panValue)), startTime); + gainNode.connect(panner).connect(sfxGainNode); + } else { + gainNode.connect(sfxGainNode); + } + + oscillator.start(startTime); + oscillator.stop(startTime + spec.duration); + } + + private resolveSpatialMix( + sourcePosition: { x: number; y: number } | undefined, + baseGain: number, + ): { gain: number; pan?: number } | null { + if (!sourcePosition) { + return { gain: baseGain }; + } + const distance = Math.hypot(sourcePosition.x, sourcePosition.y); + if (distance > HEARING_RADIUS) { + return null; + } + const volumeRatio = Math.max(0, 1 - distance / HEARING_RADIUS); + const finalGain = baseGain * Math.pow(volumeRatio, 2); + const clampedX = Math.max(-HEARING_RADIUS, Math.min(HEARING_RADIUS, sourcePosition.x)); + const pan = Math.sin((clampedX / HEARING_RADIUS) * (Math.PI / 2)); + return { gain: finalGain, pan }; + } + + private async getSampleBuffer(url: string): Promise { + if (!this.audioCtx) { + throw new Error('Audio context not initialized'); + } + if (this.sampleCache.has(url)) { + return this.sampleCache.get(url)!; + } + if (!this.sampleLoaders.has(url)) { + this.sampleLoaders.set( + url, + fetch(url) + .then((response) => { + if (!response.ok) throw new Error(`Failed to fetch sample: ${url}`); + return response.arrayBuffer(); + }) + .then((data) => this.audioCtx!.decodeAudioData(data)) + .then((buffer) => { + this.sampleCache.set(url, buffer); + this.sampleLoaders.delete(url); + return buffer; + }) + .catch((error) => { + this.sampleLoaders.delete(url); + throw error; + }), + ); + } + return this.sampleLoaders.get(url)!; + } +} diff --git a/client/src/input/textInput.ts b/client/src/input/textInput.ts new file mode 100644 index 0000000..b19ab33 --- /dev/null +++ b/client/src/input/textInput.ts @@ -0,0 +1,30 @@ +export function applyTextInput( + key: string, + currentString: string, + cursorPos: number, + maxLength: number, +): { newString: string; newCursorPos: number } { + let newString = currentString; + let newCursorPos = cursorPos; + const lowerKey = key.toLowerCase(); + + if (lowerKey === 'arrowleft') { + newCursorPos = Math.max(0, cursorPos - 1); + } else if (lowerKey === 'arrowright') { + newCursorPos = Math.min(newString.length, cursorPos + 1); + } else if (lowerKey === 'backspace') { + if (cursorPos > 0) { + newString = newString.slice(0, cursorPos - 1) + newString.slice(cursorPos); + newCursorPos = cursorPos - 1; + } + } else if (lowerKey === 'home') { + newCursorPos = 0; + } else if (lowerKey === 'end') { + newCursorPos = newString.length; + } else if (key.length === 1 && newString.length < maxLength) { + newString = newString.slice(0, cursorPos) + key + newString.slice(cursorPos); + newCursorPos = cursorPos + 1; + } + + return { newString, newCursorPos }; +} diff --git a/client/src/main.ts b/client/src/main.ts new file mode 100644 index 0000000..4ea52b8 --- /dev/null +++ b/client/src/main.ts @@ -0,0 +1,1779 @@ +import './styles.css'; +import { AudioEngine } from './audio/audioEngine'; +import { applyTextInput } from './input/textInput'; +import { type IncomingMessage, type OutgoingMessage } from './network/protocol'; +import { SignalingClient } from './network/signalingClient'; +import { CanvasRenderer } from './render/canvasRenderer'; +import { + GRID_SIZE, + HEARING_RADIUS, + MOVE_COOLDOWN_MS, + createInitialState, + getDirection, + getNearestItem, + getNearestPeer, + type GameState, + type ItemType, + type WorldItem, +} from './state/gameState'; +import { PeerManager } from './webrtc/peerManager'; + +const EFFECT_LEVELS_STORAGE_KEY = 'chatGridEffectLevels'; +const AUDIO_INPUT_STORAGE_KEY = 'chatGridAudioInputDeviceId'; +const AUDIO_OUTPUT_STORAGE_KEY = 'chatGridAudioOutputDeviceId'; +const AUDIO_INPUT_NAME_STORAGE_KEY = 'chatGridAudioInputDeviceName'; +const AUDIO_OUTPUT_NAME_STORAGE_KEY = 'chatGridAudioOutputDeviceName'; +const AUDIO_OUTPUT_MODE_STORAGE_KEY = 'chatGridAudioOutputMode'; +const NICKNAME_STORAGE_KEY = 'spatialChatNickname'; +const NICKNAME_MAX_LENGTH = 32; + +declare global { + interface Window { + CHGRID_WEB_VERSION?: string; + } +} + +type Dom = { + appVersion: HTMLElement; + nicknameContainer: HTMLDivElement; + preconnectNickname: HTMLInputElement; + connectButton: HTMLButtonElement; + disconnectButton: HTMLButtonElement; + focusGridButton: HTMLButtonElement; + settingsButton: HTMLButtonElement; + closeSettingsButton: HTMLButtonElement; + settingsModal: HTMLDivElement; + audioInputSelect: HTMLSelectElement; + audioOutputSelect: HTMLSelectElement; + audioInputCurrent: HTMLParagraphElement; + audioOutputCurrent: HTMLParagraphElement; + canvas: HTMLCanvasElement; + status: HTMLDivElement; + instructions: HTMLDivElement; +}; + +const dom: Dom = { + appVersion: requiredById('appVersion'), + nicknameContainer: requiredById('nicknameContainer'), + preconnectNickname: requiredById('preconnectNickname'), + connectButton: requiredById('connectButton'), + disconnectButton: requiredById('disconnectButton'), + focusGridButton: requiredById('focusGridButton'), + settingsButton: requiredById('settingsButton'), + closeSettingsButton: requiredById('closeSettingsButton'), + settingsModal: requiredById('settingsModal'), + audioInputSelect: requiredById('audioInputSelect'), + audioOutputSelect: requiredById('audioOutputSelect'), + audioInputCurrent: requiredById('audioInputCurrent'), + audioOutputCurrent: requiredById('audioOutputCurrent'), + canvas: requiredById('gameCanvas'), + status: requiredById('status'), + instructions: requiredById('instructions'), +}; + +const APP_VERSION = String(window.CHGRID_WEB_VERSION ?? '').trim(); +dom.appVersion.textContent = APP_VERSION + ? `Another AI experiment with Jage. Version ${APP_VERSION}` + : 'Another AI experiment with Jage. Version unknown'; +const ITEM_TYPE_SEQUENCE: ItemType[] = ['radio_station', 'dice']; +const APP_BASE_URL = import.meta.env.BASE_URL || '/'; +function withBase(path: string): string { + const normalizedBase = APP_BASE_URL.endsWith('/') ? APP_BASE_URL : `${APP_BASE_URL}/`; + return `${normalizedBase}${path.replace(/^\/+/, '')}`; +} +const SYSTEM_SOUND_URLS = { + logon: withBase('sounds/logon.ogg'), + logout: withBase('sounds/logout.ogg'), + notify: withBase('sounds/notify.ogg'), +} as const; + +const state = createInitialState(); +const renderer = new CanvasRenderer(dom.canvas); +const audio = new AudioEngine(); +let localStream: MediaStream | null = null; +let outboundStream: MediaStream | null = null; +let statusTimeout: number | null = null; +let lastFocusedElement: Element | null = null; +let lastAnnouncementText = ''; +let lastAnnouncementAt = 0; +let preferredInputDeviceId = localStorage.getItem(AUDIO_INPUT_STORAGE_KEY) || ''; +let preferredOutputDeviceId = localStorage.getItem(AUDIO_OUTPUT_STORAGE_KEY) || ''; +let preferredInputDeviceName = localStorage.getItem(AUDIO_INPUT_NAME_STORAGE_KEY) || ''; +let preferredOutputDeviceName = localStorage.getItem(AUDIO_OUTPUT_NAME_STORAGE_KEY) || ''; +let outputMode = localStorage.getItem(AUDIO_OUTPUT_MODE_STORAGE_KEY) === 'mono' ? 'mono' : 'stereo'; +let connecting = false; +const messageBuffer: string[] = []; +let messageCursor = -1; +type SharedRadioSource = { + streamUrl: string; + element: HTMLAudioElement; + source: MediaElementAudioSourceNode; + refCount: number; +}; +type ItemRadioOutput = { + streamUrl: string; + gain: GainNode; + panner: StereoPannerNode | null; +}; +const sharedRadioSources = new Map(); +const itemRadioOutputs = new Map(); +let replaceTextOnNextType = false; + +const signalingProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; +const signalingUrl = `${signalingProtocol}://${window.location.host}/ws`; +const signaling = new SignalingClient(signalingUrl, updateStatus); + +const peerManager = new PeerManager( + audio, + (targetId, payload) => { + signaling.send({ type: 'signal', targetId, ...payload }); + }, + () => outboundStream, + updateStatus, +); +audio.setOutputMode(outputMode); + +loadEffectLevels(); + +function requiredById(id: string): T { + const found = document.getElementById(id); + if (!found) { + throw new Error(`Missing element: ${id}`); + } + return found as T; +} + +function updateStatus(message: string): void { + const normalized = String(message) + .replace(/\s*\n+\s*/g, ' ') + .replace(/\s{2,}/g, ' ') + .trim(); + const now = performance.now(); + if (normalized && normalized === lastAnnouncementText && now - lastAnnouncementAt < 300) { + return; + } + lastAnnouncementText = normalized; + lastAnnouncementAt = now; + + if (statusTimeout !== null) { + window.clearTimeout(statusTimeout); + } + dom.status.textContent = ''; + requestAnimationFrame(() => { + dom.status.textContent = normalized; + }); + statusTimeout = window.setTimeout(() => { + if (dom.status.textContent === normalized) { + dom.status.textContent = ''; + } + }, 4000); +} + +function sanitizeName(value: string): string { + return value.replace(/[\u0000-\u001F\u007F<>]/g, '').trim().slice(0, NICKNAME_MAX_LENGTH); +} + +function updateConnectAvailability(): void { + if (state.running) { + dom.connectButton.disabled = true; + return; + } + const hasNickname = sanitizeName(dom.preconnectNickname.value).length > 0; + dom.connectButton.disabled = connecting || !hasNickname; +} + +function loadEffectLevels(): void { + const raw = localStorage.getItem(EFFECT_LEVELS_STORAGE_KEY); + if (!raw) return; + try { + const parsed = JSON.parse(raw) as Partial< + Record<'reverb' | 'echo' | 'flanger' | 'high_pass' | 'low_pass' | 'off', number> + >; + audio.setEffectLevels(parsed); + } catch { + // Ignore malformed persisted values. + } +} + +function persistEffectLevels(): void { + localStorage.setItem(EFFECT_LEVELS_STORAGE_KEY, JSON.stringify(audio.getEffectLevels())); +} + +function pushChatMessage(message: string): void { + messageBuffer.push(message); + if (messageBuffer.length > 300) { + messageBuffer.shift(); + } + messageCursor = messageBuffer.length - 1; + updateStatus(message); +} + +function classifySystemMessageSound(message: string): keyof typeof SYSTEM_SOUND_URLS | null { + const normalized = message.trim().toLowerCase(); + if (!normalized) return null; + if (normalized.startsWith('welcome. logged in as ') || normalized.endsWith(' has logged in.')) { + return 'logon'; + } + if (normalized.endsWith(' has logged out.')) { + return 'logout'; + } + if (normalized.includes(' is now known as ') || normalized.startsWith('you are now known as ')) { + return 'notify'; + } + return null; +} + +function resolveIncomingSoundUrl(url: string): string { + const raw = String(url || '').trim(); + if (!raw) return ''; + if (/^(https?:|data:|blob:)/i.test(raw)) return raw; + if (raw.startsWith('/sounds/')) { + return withBase(raw.slice(1)); + } + if (raw.startsWith('sounds/')) { + return withBase(raw); + } + return raw; +} + +function navigateChatBuffer(target: 'prev' | 'next' | 'first' | 'last'): void { + if (messageBuffer.length === 0) { + updateStatus('No chat messages.'); + audio.sfxUiCancel(); + return; + } + + if (target === 'first') { + messageCursor = 0; + } else if (target === 'last') { + messageCursor = messageBuffer.length - 1; + } else if (target === 'prev') { + messageCursor = Math.max(0, messageCursor - 1); + } else if (target === 'next') { + messageCursor = Math.min(messageBuffer.length - 1, messageCursor + 1); + } + + updateStatus(messageBuffer[messageCursor]); + audio.sfxUiBlip(); +} + +function updateDeviceSummary(): void { + if (preferredInputDeviceId) { + const text = dom.audioInputSelect.selectedOptions[0]?.text || preferredInputDeviceName || 'Saved microphone'; + dom.audioInputCurrent.textContent = `Input: ${text}`; + dom.audioInputCurrent.classList.remove('hidden'); + } else { + dom.audioInputCurrent.classList.add('hidden'); + } + + if (preferredOutputDeviceId) { + const text = dom.audioOutputSelect.selectedOptions[0]?.text || preferredOutputDeviceName || 'Saved speakers'; + dom.audioOutputCurrent.textContent = `Output: ${text}`; + dom.audioOutputCurrent.classList.remove('hidden'); + } else { + dom.audioOutputCurrent.classList.add('hidden'); + } +} + +function getPeerNamesAtPosition(x: number, y: number): string[] { + return Array.from(state.peers.values()) + .filter((peer) => peer.x === x && peer.y === y) + .map((peer) => peer.nickname); +} + +function itemTypeLabel(type: ItemType): string { + return type === 'radio_station' ? 'radio' : type; +} + +function itemLabel(item: WorldItem): string { + return `${item.title} (${itemTypeLabel(item.type)})`; +} + +function getItemsAtPosition(x: number, y: number): WorldItem[] { + return Array.from(state.items.values()).filter((item) => !item.carrierId && item.x === x && item.y === y); +} + +function getCarriedItem(): WorldItem | null { + if (!state.player.id) return null; + return Array.from(state.items.values()).find((item) => item.carrierId === state.player.id) || null; +} + +function beginItemSelection(context: 'pickup' | 'delete' | 'edit' | 'use', items: WorldItem[]): void { + if (items.length === 0) { + updateStatus('No items available.'); + audio.sfxUiCancel(); + return; + } + state.mode = 'selectItem'; + state.selectionContext = context; + state.selectedItemIds = items.map((item) => item.id); + state.selectedItemIndex = 0; + updateStatus(`Select item: ${itemLabel(items[0])}.`); + audio.sfxUiBlip(); +} + +function beginItemProperties(item: WorldItem): void { + state.selectedItemId = item.id; + state.mode = 'itemProperties'; + state.itemPropertyKeys = ['title']; + if (item.type === 'radio_station') { + state.itemPropertyKeys.push('streamUrl', 'enabled', 'volume'); + } else if (item.type === 'dice') { + state.itemPropertyKeys.push('sides', 'number'); + } + state.itemPropertyIndex = 0; + const key = state.itemPropertyKeys[0]; + const value = getItemPropertyValue(item, key); + updateStatus(`${key}: ${value}`); + audio.sfxUiBlip(); +} + +function useItem(item: WorldItem): void { + signaling.send({ type: 'item_use', itemId: item.id }); +} + +function releaseSharedRadioSource(streamUrl: string): void { + const shared = sharedRadioSources.get(streamUrl); + if (!shared) return; + shared.refCount -= 1; + if (shared.refCount > 0) return; + shared.element.pause(); + shared.element.src = ''; + shared.source.disconnect(); + sharedRadioSources.delete(streamUrl); +} + +function getOrCreateSharedRadioSource(streamUrl: string): SharedRadioSource | null { + const existing = sharedRadioSources.get(streamUrl); + if (existing) { + existing.refCount += 1; + return existing; + } + const audioCtx = audio.context; + if (!audioCtx) return null; + const element = new Audio(streamUrl); + element.crossOrigin = 'anonymous'; + element.loop = true; + element.preload = 'none'; + const source = audioCtx.createMediaElementSource(element); + void element.play().catch(() => undefined); + const shared: SharedRadioSource = { + streamUrl, + element, + source, + refCount: 1, + }; + sharedRadioSources.set(streamUrl, shared); + return shared; +} + +function cleanupRadioRuntime(itemId: string): void { + const output = itemRadioOutputs.get(itemId); + if (!output) return; + output.gain.disconnect(); + output.panner?.disconnect(); + itemRadioOutputs.delete(itemId); + releaseSharedRadioSource(output.streamUrl); +} + +function cleanupAllRadioRuntimes(): void { + for (const id of Array.from(itemRadioOutputs.keys())) { + cleanupRadioRuntime(id); + } +} + +async function ensureRadioRuntime(item: WorldItem): Promise { + const streamUrl = String(item.params.streamUrl ?? '').trim(); + if (!streamUrl) { + cleanupRadioRuntime(item.id); + return; + } + await audio.ensureContext(); + const audioCtx = audio.context; + if (!audioCtx) return; + + const existing = itemRadioOutputs.get(item.id); + if (existing && existing.streamUrl === streamUrl) { + return; + } + if (existing) { + cleanupRadioRuntime(item.id); + } + + const shared = getOrCreateSharedRadioSource(streamUrl); + if (!shared) return; + + const gain = audioCtx.createGain(); + gain.gain.value = 0; + shared.source.connect(gain); + let panner: StereoPannerNode | null = null; + if (audio.supportsStereoPanner()) { + panner = audioCtx.createStereoPanner(); + gain.connect(panner).connect(audioCtx.destination); + } else { + gain.connect(audioCtx.destination); + } + itemRadioOutputs.set(item.id, { streamUrl, gain, panner }); +} + +async function syncRadioStationPlayback(): Promise { + const validIds = new Set(); + for (const item of state.items.values()) { + if (item.type !== 'radio_station') continue; + validIds.add(item.id); + await ensureRadioRuntime(item); + } + for (const id of Array.from(itemRadioOutputs.keys())) { + if (!validIds.has(id)) { + cleanupRadioRuntime(id); + } + } +} + +function updateRadioStationSpatialAudio(): void { + const audioCtx = audio.context; + if (!audioCtx) return; + for (const [itemId, output] of itemRadioOutputs.entries()) { + const item = state.items.get(itemId); + if (!item || item.type !== 'radio_station') { + cleanupRadioRuntime(itemId); + continue; + } + const streamUrl = String(item.params.streamUrl ?? '').trim(); + const enabled = item.params.enabled !== false; + const volume = Number(item.params.volume ?? 50); + const normalizedVolume = Number.isFinite(volume) ? Math.max(0, Math.min(100, volume)) / 100 : 0.5; + if (!streamUrl || !enabled) { + output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05); + continue; + } + const dist = Math.hypot(item.x - state.player.x, item.y - state.player.y); + let gainValue = 0; + let panValue = 0; + if (dist < HEARING_RADIUS) { + gainValue = Math.pow(1 - dist / HEARING_RADIUS, 2); + panValue = Math.sin(((item.x - state.player.x) / HEARING_RADIUS) * (Math.PI / 2)); + } + if (dist <= 1) { + gainValue = 1; + panValue = 0; + } + output.gain.gain.linearRampToValueAtTime(gainValue * normalizedVolume, audioCtx.currentTime + 0.1); + if (output.panner) { + output.panner.pan.linearRampToValueAtTime(Math.max(-1, Math.min(1, panValue)), audioCtx.currentTime + 0.1); + } + } +} + +function shouldReplaceCurrentText(code: string, key: string): boolean { + if (!replaceTextOnNextType) return false; + if (code === 'ArrowLeft' || code === 'ArrowRight' || code === 'Home' || code === 'End') { + replaceTextOnNextType = false; + return false; + } + if (code === 'Backspace' || code === 'Delete') { + replaceTextOnNextType = false; + return false; + } + if (key.length === 1) { + replaceTextOnNextType = false; + return true; + } + return false; +} + +function describeCharacter(ch: string): string { + if (ch === ' ') return 'space'; + if (ch === '\t') return 'tab'; + if (ch === '.') return 'period'; + if (ch === ',') return 'comma'; + if (ch === "'") return 'apostrophe'; + if (ch === '"') return 'quote'; + if (ch === '-') return 'dash'; + if (ch === '=') return 'equals'; + return ch; +} + +function getItemPropertyValue(item: WorldItem, key: string): string { + if (key === 'title') return item.title; + if (key === 'enabled') return item.params.enabled === false ? 'off' : 'on'; + return String(item.params[key] ?? ''); +} + +function announceCursorCharacter(text: string, cursorPos: number): void { + if (cursorPos < 0 || cursorPos >= text.length || text.length === 0) { + return; + } + updateStatus(describeCharacter(text[cursorPos])); +} + +function announceBackspaceDeletedCharacter(text: string, cursorPos: number): void { + if (cursorPos <= 0 || cursorPos > text.length) return; + updateStatus(describeCharacter(text[cursorPos - 1])); +} + +function squareWord(distance: number): string { + return distance === 1 ? 'square' : 'squares'; +} + +function gameLoop(): void { + if (!state.running) return; + handleMovement(); + audio.updateSpatialAudio(peerManager.getPeers(), { x: state.player.x, y: state.player.y }); + updateRadioStationSpatialAudio(); + state.cursorVisible = Math.floor(Date.now() / 500) % 2 === 0; + renderer.draw(state); + requestAnimationFrame(gameLoop); +} + +function handleMovement(): void { + if (state.mode !== 'normal') return; + const now = Date.now(); + if (now - state.player.lastMoveTime < MOVE_COOLDOWN_MS) return; + + let dx = 0; + let dy = 0; + if (state.keysPressed.ArrowUp) dy = 1; + if (state.keysPressed.ArrowDown) dy = -1; + if (state.keysPressed.ArrowLeft) dx = -1; + if (state.keysPressed.ArrowRight) dx = 1; + + if (dx === 0 && dy === 0) return; + + const nextX = state.player.x + dx; + const nextY = state.player.y + dy; + if (nextX < 0 || nextY < 0 || nextX >= GRID_SIZE || nextY >= GRID_SIZE) return; + + state.player.x = nextX; + state.player.y = nextY; + state.player.lastMoveTime = now; + audio.sfxMove(state.player); + signaling.send({ type: 'update_position', x: nextX, y: nextY }); + + const namesOnTile = getPeerNamesAtPosition(nextX, nextY); + const itemsOnTile = getItemsAtPosition(nextX, nextY); + const tileAnnouncements: string[] = []; + if (namesOnTile.length > 0) { + tileAnnouncements.push(namesOnTile.join(', ')); + } + if (itemsOnTile.length > 0) { + tileAnnouncements.push(itemsOnTile.map((item) => itemLabel(item)).join(', ')); + } + if (tileAnnouncements.length > 0) { + updateStatus(tileAnnouncements.join('. ')); + audio.sfxTileOccupantPing(); + } +} + +async function checkMicPermission(): Promise { + const permissionApi = navigator.permissions; + if (!permissionApi?.query) return true; + try { + const result = await permissionApi.query({ name: 'microphone' as PermissionName }); + return result.state !== 'denied'; + } catch { + return true; + } +} + +async function setupLocalMedia(audioDeviceId = ''): Promise { + if (localStream) { + localStream.getTracks().forEach((track) => track.stop()); + } + + await audio.ensureContext(); + + const constraints: MediaStreamConstraints = { + audio: { + deviceId: audioDeviceId ? { exact: audioDeviceId } : undefined, + sampleRate: 48000, + channelCount: 2, + echoCancellation: false, + noiseSuppression: false, + autoGainControl: false, + }, + video: false, + }; + + localStream = await navigator.mediaDevices.getUserMedia(constraints); + const audioTrack = localStream.getAudioTracks()[0]; + if (audioTrack) { + audioTrack.enabled = !state.isMuted; + } + outboundStream = await audio.configureOutboundStream(localStream); + await peerManager.replaceOutgoingTrack(outboundStream); +} + +function describeMediaError(error: unknown): string { + if (error instanceof DOMException) { + if (error.name === 'NotAllowedError') return 'Microphone blocked. Allow mic access in browser site settings.'; + if (error.name === 'NotFoundError') return 'No microphone found. Check that an input device is connected and enabled.'; + if (error.name === 'NotReadableError') return 'Microphone is busy or unavailable. Close other apps using the mic and retry.'; + if (error.name === 'OverconstrainedError') return 'Selected audio device is unavailable. Choose another input device.'; + if (error.name === 'SecurityError') return 'Microphone access requires a secure context (HTTPS) in production.'; + } + return 'Audio setup failed. Check browser permissions and selected input device.'; +} + +async function connect(): Promise { + if (connecting || state.running) { + return; + } + const nickname = sanitizeName(dom.preconnectNickname.value); + if (!nickname) { + updateStatus('Nickname is required.'); + updateConnectAvailability(); + return; + } + state.player.nickname = nickname; + dom.preconnectNickname.value = nickname; + localStorage.setItem(NICKNAME_STORAGE_KEY, nickname); + connecting = true; + updateConnectAvailability(); + const canProceed = await checkMicPermission(); + if (!canProceed) { + updateStatus('Microphone access is required.'); + connecting = false; + updateConnectAvailability(); + return; + } + + const storedPosition = localStorage.getItem('spatialChatPosition'); + if (storedPosition) { + const parsed = JSON.parse(storedPosition) as { x: number; y: number }; + state.player.x = parsed.x; + state.player.y = parsed.y; + } else { + state.player.x = Math.floor(Math.random() * GRID_SIZE); + state.player.y = Math.floor(Math.random() * GRID_SIZE); + } + + try { + await populateAudioDevices(); + if (dom.audioInputSelect.options.length === 0) { + updateStatus('No audio input device found. Open Settings or connect a microphone.'); + connecting = false; + updateConnectAvailability(); + return; + } + const inputDeviceId = dom.audioInputSelect.value || preferredInputDeviceId; + await setupLocalMedia(inputDeviceId); + } catch (error) { + console.error(error); + updateStatus(describeMediaError(error)); + connecting = false; + updateConnectAvailability(); + return; + } + + try { + await signaling.connect(onMessage); + } catch (error) { + console.error(error); + updateStatus('Connect failed. Signaling server may be offline or unreachable.'); + connecting = false; + updateConnectAvailability(); + } +} + +function disconnect(): void { + const wasRunning = state.running; + if (state.running) { + localStorage.setItem( + 'spatialChatPosition', + JSON.stringify({ x: state.player.x, y: state.player.y }), + ); + } + + signaling.disconnect(); + if (localStream) { + localStream.getTracks().forEach((track) => track.stop()); + localStream = null; + } + outboundStream = null; + + peerManager.cleanupAll(); + cleanupAllRadioRuntimes(); + state.running = false; + state.keysPressed = {}; + state.peers.clear(); + state.items.clear(); + state.carriedItemId = null; + state.mode = 'normal'; + state.sortedItemIds = []; + state.itemListIndex = 0; + state.selectedItemIds = []; + state.selectionContext = null; + state.selectedItemIndex = 0; + state.selectedItemId = null; + state.itemPropertyKeys = []; + state.itemPropertyIndex = 0; + state.editingPropertyKey = null; + + connecting = false; + dom.nicknameContainer.classList.remove('hidden'); + dom.connectButton.classList.remove('hidden'); + dom.disconnectButton.classList.add('hidden'); + dom.focusGridButton.classList.add('hidden'); + dom.canvas.classList.add('hidden'); + dom.instructions.classList.add('hidden'); + updateConnectAvailability(); + + updateStatus('Disconnected.'); + if (wasRunning) { + void audio.playSample(SYSTEM_SOUND_URLS.logout, 1); + } +} + +async function onMessage(message: IncomingMessage): Promise { + switch (message.type) { + case 'welcome': + state.player.id = message.id; + state.running = true; + connecting = false; + dom.nicknameContainer.classList.add('hidden'); + dom.connectButton.classList.add('hidden'); + dom.disconnectButton.classList.remove('hidden'); + dom.focusGridButton.classList.remove('hidden'); + dom.canvas.classList.remove('hidden'); + dom.instructions.classList.remove('hidden'); + dom.canvas.focus(); + + signaling.send({ type: 'update_position', x: state.player.x, y: state.player.y }); + signaling.send({ type: 'update_nickname', nickname: state.player.nickname }); + + for (const user of message.users) { + state.peers.set(user.id, { ...user }); + await peerManager.createOrGetPeer(user.id, true, user); + } + state.items.clear(); + for (const item of message.items || []) { + state.items.set(item.id, { + ...item, + carrierId: item.carrierId ?? null, + }); + } + await syncRadioStationPlayback(); + + gameLoop(); + break; + + case 'signal': { + const peer = await peerManager.handleSignal(message); + if (!state.peers.has(peer.id)) { + state.peers.set(peer.id, { + id: peer.id, + nickname: sanitizeName(peer.nickname) || 'user...', + x: peer.x, + y: peer.y, + }); + } + break; + } + + case 'update_position': { + const peer = state.peers.get(message.id); + if (peer) { + peer.x = message.x; + peer.y = message.y; + } + peerManager.setPeerPosition(message.id, message.x, message.y); + if (peer) { + audio.sfxPeerMove({ x: peer.x - state.player.x, y: peer.y - state.player.y }); + } + break; + } + + case 'update_nickname': { + const peer = state.peers.get(message.id); + if (peer) { + peer.nickname = sanitizeName(message.nickname) || 'user...'; + } + peerManager.setPeerNickname(message.id, sanitizeName(message.nickname) || 'user...'); + break; + } + + case 'user_left': { + const peer = state.peers.get(message.id); + if (peer) { + updateStatus(`${peer.nickname} has left.`); + } + state.peers.delete(message.id); + peerManager.removePeer(message.id); + break; + } + + case 'chat_message': { + if (message.system) { + pushChatMessage(message.message); + const sound = classifySystemMessageSound(message.message); + if (sound) { + void audio.playSample(SYSTEM_SOUND_URLS[sound], 1); + } + } else { + const sender = message.senderNickname || 'Unknown'; + pushChatMessage(`${sender}: ${message.message}`); + } + break; + } + + case 'pong': { + const elapsed = Math.max(0, Date.now() - message.clientSentAt); + updateStatus(`Ping ${elapsed} ms`); + audio.sfxUiBlip(); + break; + } + + case 'nickname_result': { + state.player.nickname = sanitizeName(message.effectiveNickname) || 'user...'; + if (message.accepted) { + dom.preconnectNickname.value = state.player.nickname; + localStorage.setItem(NICKNAME_STORAGE_KEY, state.player.nickname); + } else { + pushChatMessage(message.reason || 'Nickname unavailable.'); + audio.sfxUiCancel(); + } + break; + } + + case 'item_upsert': { + state.items.set(message.item.id, { + ...message.item, + carrierId: message.item.carrierId ?? null, + }); + state.carriedItemId = getCarriedItem()?.id ?? null; + if (state.mode === 'itemProperties' && state.selectedItemId === message.item.id) { + const key = state.itemPropertyKeys[state.itemPropertyIndex]; + if (key) { + updateStatus(`${key}: ${getItemPropertyValue(message.item, key)}`); + } + } + await syncRadioStationPlayback(); + break; + } + + case 'item_remove': { + state.items.delete(message.itemId); + state.carriedItemId = getCarriedItem()?.id ?? null; + cleanupRadioRuntime(message.itemId); + break; + } + + case 'item_action_result': { + if (message.ok) { + if (message.action === 'use') { + pushChatMessage(message.message); + const item = message.itemId ? state.items.get(message.itemId) : null; + if (!item?.useSound && item) { + audio.sfxLocate({ x: item.x - state.player.x, y: item.y - state.player.y }); + } + } else if (message.action !== 'update') { + pushChatMessage(message.message); + audio.sfxUiConfirm(); + } + } else { + pushChatMessage(message.message); + audio.sfxUiCancel(); + } + break; + } + + case 'item_use_sound': { + const soundUrl = resolveIncomingSoundUrl(message.sound); + if (!soundUrl) break; + void audio.playSpatialSample( + soundUrl, + { x: message.x - state.player.x, y: message.y - state.player.y }, + 1, + ); + break; + } + } +} + +function toggleMute(): void { + state.isMuted = !state.isMuted; + if (localStream) { + const track = localStream.getAudioTracks()[0]; + if (track) track.enabled = !state.isMuted; + } + updateStatus(state.isMuted ? 'Muted.' : 'Unmuted.'); +} + +function handleNormalModeInput(code: string, shiftKey: boolean): void { + if (code === 'KeyN') { + state.mode = 'nickname'; + state.nicknameInput = state.player.nickname; + state.cursorPos = state.player.nickname.length; + replaceTextOnNextType = true; + updateStatus(`Nickname edit: ${state.nicknameInput}`); + audio.sfxUiBlip(); + return; + } + + if (code === 'KeyM') { + if (shiftKey) { + outputMode = audio.toggleOutputMode(); + localStorage.setItem(AUDIO_OUTPUT_MODE_STORAGE_KEY, outputMode); + updateStatus(outputMode === 'mono' ? 'Mono output.' : 'Stereo output.'); + audio.sfxUiBlip(); + return; + } + toggleMute(); + return; + } + + if (code === 'KeyE') { + const effect = audio.cycleOutboundEffect(); + updateStatus(effect.label); + audio.sfxUiBlip(); + return; + } + + if (code === 'Equal' || code === 'NumpadAdd' || code === 'Minus' || code === 'NumpadSubtract') { + const step = code === 'Equal' || code === 'NumpadAdd' ? 5 : -5; + const adjusted = audio.adjustCurrentEffectLevel(step); + if (!adjusted) { + return; + } + persistEffectLevels(); + audio.sfxEffectLevel(adjusted.value === adjusted.defaultValue); + updateStatus(`${adjusted.label} ${adjusted.value}`); + return; + } + + if (code === 'KeyC') { + updateStatus(`${state.player.x}, ${state.player.y}`); + audio.sfxUiBlip(); + return; + } + + if (code === 'KeyU') { + if (shiftKey) { + const allUsers = [state.player.nickname, ...Array.from(state.peers.values()).map((p) => p.nickname)]; + const label = allUsers.length === 1 ? 'user' : 'users'; + updateStatus(`${allUsers.length} ${label}: ${allUsers.join(', ')}`); + audio.sfxUiBlip(); + return; + } + const carried = getCarriedItem(); + if (carried) { + useItem(carried); + return; + } + const squareItems = getItemsAtPosition(state.player.x, state.player.y); + const usable = squareItems.filter((item) => item.capabilities.includes('usable')); + if (usable.length === 0) { + updateStatus('No usable items here.'); + audio.sfxUiCancel(); + return; + } + if (usable.length === 1) { + useItem(usable[0]); + return; + } + beginItemSelection('use', usable); + return; + } + + if (code === 'KeyA') { + state.mode = 'addItem'; + updateStatus(`Add item: ${itemTypeLabel(ITEM_TYPE_SEQUENCE[state.addItemTypeIndex])}.`); + audio.sfxUiBlip(); + return; + } + + if (code === 'KeyI') { + if (shiftKey) { + if (state.items.size === 0) { + updateStatus('No items to list.'); + audio.sfxUiCancel(); + return; + } + state.sortedItemIds = Array.from(state.items.entries()) + .filter(([, item]) => !item.carrierId) + .sort( + (a, b) => + Math.hypot(a[1].x - state.player.x, a[1].y - state.player.y) - + Math.hypot(b[1].x - state.player.x, b[1].y - state.player.y), + ) + .map(([id]) => id); + if (state.sortedItemIds.length === 0) { + updateStatus('No items to list.'); + audio.sfxUiCancel(); + return; + } + state.itemListIndex = 0; + state.mode = 'listItems'; + const first = state.items.get(state.sortedItemIds[0]); + if (first) { + const distance = Math.round(Math.hypot(first.x - state.player.x, first.y - state.player.y)); + updateStatus( + `List: ${itemLabel(first)}, ${distance} ${squareWord(distance)} ${getDirection(state.player.x, state.player.y, first.x, first.y)}, ${first.x}, ${first.y}`, + ); + } + audio.sfxUiBlip(); + return; + } + const nearest = getNearestItem(state); + if (!nearest.itemId) { + updateStatus('No items to locate.'); + audio.sfxUiCancel(); + return; + } + const item = state.items.get(nearest.itemId); + if (!item) return; + audio.sfxLocate({ x: item.x - state.player.x, y: item.y - state.player.y }); + const roundedDistance = Math.round(nearest.distance); + updateStatus( + `${itemLabel(item)}, ${roundedDistance} ${squareWord(roundedDistance)} ${getDirection(state.player.x, state.player.y, item.x, item.y)}, ${item.x}, ${item.y}`, + ); + return; + } + + if (code === 'KeyD') { + const carried = getCarriedItem(); + if (shiftKey) { + const squareItems = getItemsAtPosition(state.player.x, state.player.y); + if (squareItems.length === 0) { + updateStatus('No items to delete.'); + audio.sfxUiCancel(); + return; + } + if (squareItems.length === 1) { + signaling.send({ type: 'item_delete', itemId: squareItems[0].id }); + return; + } + beginItemSelection('delete', squareItems); + return; + } + + if (carried) { + signaling.send({ type: 'item_drop', itemId: carried.id, x: state.player.x, y: state.player.y }); + return; + } + const squareItems = getItemsAtPosition(state.player.x, state.player.y); + if (squareItems.length === 0) { + updateStatus('No items to pick up.'); + audio.sfxUiCancel(); + return; + } + if (squareItems.length === 1) { + signaling.send({ type: 'item_pickup', itemId: squareItems[0].id }); + return; + } + beginItemSelection('pickup', squareItems); + return; + } + + if (code === 'KeyO') { + const squareItems = getItemsAtPosition(state.player.x, state.player.y); + if (squareItems.length === 0) { + const carried = getCarriedItem(); + if (!carried) { + updateStatus('No editable item here.'); + audio.sfxUiCancel(); + return; + } + beginItemProperties(carried); + return; + } + if (squareItems.length === 1) { + beginItemProperties(squareItems[0]); + return; + } + beginItemSelection('edit', squareItems); + return; + } + + if (code === 'KeyP') { + signaling.send({ type: 'ping', clientSentAt: Date.now() }); + return; + } + + if (code === 'KeyL') { + if (shiftKey) { + if (state.peers.size === 0) { + updateStatus('No users to list.'); + audio.sfxUiCancel(); + return; + } + state.sortedPeerIds = Array.from(state.peers.entries()) + .sort( + (a, b) => + Math.hypot(a[1].x - state.player.x, a[1].y - state.player.y) - + Math.hypot(b[1].x - state.player.x, b[1].y - state.player.y), + ) + .map(([id]) => id); + state.listIndex = 0; + state.mode = 'listUsers'; + const first = state.peers.get(state.sortedPeerIds[0]); + if (first) { + const distance = Math.round(Math.hypot(first.x - state.player.x, first.y - state.player.y)); + updateStatus( + `List: ${first.nickname}, ${distance} ${squareWord(distance)} ${getDirection(state.player.x, state.player.y, first.x, first.y)}, ${first.x}, ${first.y}`, + ); + } + audio.sfxUiBlip(); + return; + } + + const nearest = getNearestPeer(state); + if (!nearest.peerId) { + updateStatus('No users to locate.'); + audio.sfxUiCancel(); + return; + } + const peer = state.peers.get(nearest.peerId); + if (!peer) return; + audio.sfxLocate({ x: peer.x - state.player.x, y: peer.y - state.player.y }); + const roundedDistance = Math.round(nearest.distance); + updateStatus( + `${peer.nickname}, ${roundedDistance} ${squareWord(roundedDistance)} ${getDirection(state.player.x, state.player.y, peer.x, peer.y)}, ${peer.x}, ${peer.y}`, + ); + return; + } + + if (code === 'Quote') { + state.mode = 'chat'; + state.nicknameInput = ''; + state.cursorPos = 0; + replaceTextOnNextType = false; + updateStatus('Chat.'); + audio.sfxUiBlip(); + return; + } + + if (code === 'Comma') { + if (shiftKey) { + navigateChatBuffer('first'); + } else { + navigateChatBuffer('prev'); + } + return; + } + + if (code === 'Period') { + if (shiftKey) { + navigateChatBuffer('last'); + } else { + navigateChatBuffer('next'); + } + return; + } + + if (code === 'Escape') { + disconnect(); + } +} + +function handleChatModeInput(code: string, key: string): void { + if (code === 'Enter') { + const message = state.nicknameInput.trim(); + if (message.length > 0) { + signaling.send({ type: 'chat_message', message }); + state.mode = 'normal'; + state.nicknameInput = ''; + state.cursorPos = 0; + audio.sfxUiConfirm(); + } else { + state.mode = 'normal'; + audio.sfxUiCancel(); + updateStatus('Cancelled.'); + } + return; + } + + if (code === 'Escape') { + state.mode = 'normal'; + state.nicknameInput = ''; + state.cursorPos = 0; + updateStatus('Cancelled.'); + audio.sfxUiCancel(); + return; + } + + const beforeText = state.nicknameInput; + const beforeCursor = state.cursorPos; + const mappedKey = + code === 'ArrowLeft' + ? 'arrowleft' + : code === 'ArrowRight' + ? 'arrowright' + : code === 'Backspace' + ? 'backspace' + : code === 'Home' + ? 'home' + : code === 'End' + ? 'end' + : key; + + const result = applyTextInput(mappedKey, state.nicknameInput, state.cursorPos, 500); + state.nicknameInput = result.newString; + state.cursorPos = result.newCursorPos; + if (code === 'Backspace') { + announceBackspaceDeletedCharacter(beforeText, beforeCursor); + } + if (code === 'ArrowLeft' || code === 'ArrowRight' || code === 'Home' || code === 'End') { + announceCursorCharacter(state.nicknameInput, state.cursorPos); + } +} + +function handleListModeInput(code: string): void { + if (state.sortedPeerIds.length === 0) { + state.mode = 'normal'; + return; + } + + if (code === 'ArrowDown' || code === 'ArrowUp') { + state.listIndex = + code === 'ArrowDown' + ? (state.listIndex + 1) % state.sortedPeerIds.length + : (state.listIndex - 1 + state.sortedPeerIds.length) % state.sortedPeerIds.length; + const peer = state.peers.get(state.sortedPeerIds[state.listIndex]); + if (!peer) return; + const distance = Math.round(Math.hypot(peer.x - state.player.x, peer.y - state.player.y)); + updateStatus( + `${peer.nickname}, ${distance} ${squareWord(distance)} ${getDirection(state.player.x, state.player.y, peer.x, peer.y)}, ${peer.x}, ${peer.y}`, + ); + return; + } + + if (code === 'Enter') { + const peer = state.peers.get(state.sortedPeerIds[state.listIndex]); + if (!peer) return; + state.player.x = peer.x; + state.player.y = peer.y; + signaling.send({ type: 'update_position', x: peer.x, y: peer.y }); + state.mode = 'normal'; + updateStatus(`Moved to ${peer.nickname}.`); + audio.sfxUiConfirm(); + return; + } + + if (code === 'Escape') { + state.mode = 'normal'; + updateStatus('Exit list mode.'); + audio.sfxUiCancel(); + } +} + +function handleListItemsModeInput(code: string): void { + if (state.sortedItemIds.length === 0) { + state.mode = 'normal'; + return; + } + if (code === 'ArrowDown' || code === 'ArrowUp') { + state.itemListIndex = + code === 'ArrowDown' + ? (state.itemListIndex + 1) % state.sortedItemIds.length + : (state.itemListIndex - 1 + state.sortedItemIds.length) % state.sortedItemIds.length; + const item = state.items.get(state.sortedItemIds[state.itemListIndex]); + if (!item) return; + const distance = Math.round(Math.hypot(item.x - state.player.x, item.y - state.player.y)); + updateStatus( + `${itemLabel(item)}, ${distance} ${squareWord(distance)} ${getDirection(state.player.x, state.player.y, item.x, item.y)}, ${item.x}, ${item.y}`, + ); + return; + } + if (code === 'Enter') { + const item = state.items.get(state.sortedItemIds[state.itemListIndex]); + if (!item) return; + state.player.x = item.x; + state.player.y = item.y; + signaling.send({ type: 'update_position', x: item.x, y: item.y }); + state.mode = 'normal'; + updateStatus(`Moved to ${itemLabel(item)}.`); + audio.sfxUiConfirm(); + return; + } + if (code === 'Escape') { + state.mode = 'normal'; + updateStatus('Exit item list mode.'); + audio.sfxUiCancel(); + } +} + +function handleAddItemModeInput(code: string): void { + if (code === 'ArrowDown' || code === 'ArrowUp') { + state.addItemTypeIndex = + code === 'ArrowDown' + ? (state.addItemTypeIndex + 1) % ITEM_TYPE_SEQUENCE.length + : (state.addItemTypeIndex - 1 + ITEM_TYPE_SEQUENCE.length) % ITEM_TYPE_SEQUENCE.length; + updateStatus(`${itemTypeLabel(ITEM_TYPE_SEQUENCE[state.addItemTypeIndex])}.`); + audio.sfxUiBlip(); + return; + } + if (code === 'Enter') { + signaling.send({ type: 'item_add', itemType: ITEM_TYPE_SEQUENCE[state.addItemTypeIndex] }); + state.mode = 'normal'; + return; + } + if (code === 'Escape') { + state.mode = 'normal'; + updateStatus('Cancelled.'); + audio.sfxUiCancel(); + } +} + +function handleSelectItemModeInput(code: string): void { + if (state.selectedItemIds.length === 0) { + state.mode = 'normal'; + state.selectionContext = null; + return; + } + if (code === 'ArrowDown' || code === 'ArrowUp') { + state.selectedItemIndex = + code === 'ArrowDown' + ? (state.selectedItemIndex + 1) % state.selectedItemIds.length + : (state.selectedItemIndex - 1 + state.selectedItemIds.length) % state.selectedItemIds.length; + const current = state.items.get(state.selectedItemIds[state.selectedItemIndex]); + if (current) { + updateStatus(itemLabel(current)); + audio.sfxUiBlip(); + } + return; + } + if (code === 'Enter') { + const selected = state.items.get(state.selectedItemIds[state.selectedItemIndex]); + if (!selected) { + state.mode = 'normal'; + state.selectionContext = null; + return; + } + const context = state.selectionContext; + state.mode = 'normal'; + state.selectionContext = null; + if (context === 'pickup') { + signaling.send({ type: 'item_pickup', itemId: selected.id }); + return; + } + if (context === 'delete') { + signaling.send({ type: 'item_delete', itemId: selected.id }); + return; + } + if (context === 'edit') { + beginItemProperties(selected); + return; + } + if (context === 'use') { + useItem(selected); + return; + } + return; + } + if (code === 'Escape') { + state.mode = 'normal'; + state.selectionContext = null; + updateStatus('Cancelled.'); + audio.sfxUiCancel(); + } +} + +function handleItemPropertiesModeInput(code: string): void { + const itemId = state.selectedItemId; + if (!itemId) { + state.mode = 'normal'; + return; + } + const item = state.items.get(itemId); + if (!item) { + state.mode = 'normal'; + updateStatus('Item no longer exists.'); + audio.sfxUiCancel(); + return; + } + if (code === 'ArrowDown' || code === 'ArrowUp') { + state.itemPropertyIndex = + code === 'ArrowDown' + ? (state.itemPropertyIndex + 1) % state.itemPropertyKeys.length + : (state.itemPropertyIndex - 1 + state.itemPropertyKeys.length) % state.itemPropertyKeys.length; + const key = state.itemPropertyKeys[state.itemPropertyIndex]; + const value = getItemPropertyValue(item, key); + updateStatus(`${key}: ${value}`); + audio.sfxUiBlip(); + return; + } + if (code === 'Enter') { + const key = state.itemPropertyKeys[state.itemPropertyIndex]; + if (key === 'enabled') { + const nextEnabled = item.params.enabled === false; + signaling.send({ type: 'item_update', itemId, params: { enabled: nextEnabled } }); + updateStatus(`enabled: ${nextEnabled ? 'on' : 'off'}`); + audio.sfxUiBlip(); + return; + } + state.mode = 'itemPropertyEdit'; + state.editingPropertyKey = key; + state.nicknameInput = + key === 'title' + ? item.title + : key === 'enabled' + ? item.params.enabled === false + ? 'off' + : 'on' + : String(item.params[key] ?? ''); + state.cursorPos = state.nicknameInput.length; + replaceTextOnNextType = true; + updateStatus(`Edit ${key}: ${state.nicknameInput}`); + audio.sfxUiBlip(); + return; + } + if (code === 'Escape') { + state.mode = 'normal'; + state.selectedItemId = null; + state.itemPropertyKeys = []; + state.itemPropertyIndex = 0; + updateStatus('Closed item properties.'); + audio.sfxUiCancel(); + } +} + +function handleItemPropertyEditModeInput(code: string, key: string): void { + const itemId = state.selectedItemId; + const propertyKey = state.editingPropertyKey; + if (!itemId || !propertyKey) { + state.mode = 'normal'; + return; + } + if (code === 'Enter') { + const value = state.nicknameInput.trim(); + if (propertyKey === 'title') { + if (!value) { + updateStatus('Value is required.'); + audio.sfxUiCancel(); + return; + } + signaling.send({ type: 'item_update', itemId, title: value }); + } else if (propertyKey === 'streamUrl') { + signaling.send({ type: 'item_update', itemId, params: { streamUrl: value } }); + } else if (propertyKey === 'enabled') { + const normalized = value.toLowerCase(); + if (!['on', 'off', 'true', 'false', '1', '0', 'yes', 'no'].includes(normalized)) { + updateStatus('enabled must be on or off.'); + audio.sfxUiCancel(); + return; + } + const enabled = ['on', 'true', '1', 'yes'].includes(normalized); + signaling.send({ type: 'item_update', itemId, params: { enabled } }); + } else if (propertyKey === 'volume') { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) { + updateStatus('volume must be an integer between 0 and 100.'); + audio.sfxUiCancel(); + return; + } + signaling.send({ type: 'item_update', itemId, params: { volume: parsed } }); + } else if (propertyKey === 'sides' || propertyKey === 'number') { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) { + updateStatus(`${propertyKey} must be an integer between 1 and 100.`); + audio.sfxUiCancel(); + return; + } + signaling.send({ type: 'item_update', itemId, params: { [propertyKey]: parsed } }); + } + state.mode = 'itemProperties'; + state.editingPropertyKey = null; + replaceTextOnNextType = false; + return; + } + if (code === 'Escape') { + state.mode = 'itemProperties'; + state.editingPropertyKey = null; + replaceTextOnNextType = false; + updateStatus('Cancelled.'); + audio.sfxUiCancel(); + return; + } + const beforeText = state.nicknameInput; + const beforeCursor = state.cursorPos; + const mappedKey = + code === 'ArrowLeft' + ? 'arrowleft' + : code === 'ArrowRight' + ? 'arrowright' + : code === 'Backspace' + ? 'backspace' + : code === 'Home' + ? 'home' + : code === 'End' + ? 'end' + : key; + if (shouldReplaceCurrentText(code, key)) { + state.nicknameInput = key; + state.cursorPos = key.length; + return; + } + const result = applyTextInput(mappedKey, state.nicknameInput, state.cursorPos, 500); + state.nicknameInput = result.newString; + state.cursorPos = result.newCursorPos; + if (code === 'Backspace') { + announceBackspaceDeletedCharacter(beforeText, beforeCursor); + } + if (code === 'ArrowLeft' || code === 'ArrowRight' || code === 'Home' || code === 'End') { + announceCursorCharacter(state.nicknameInput, state.cursorPos); + } +} + +function handleNicknameModeInput(code: string, key: string): void { + if (code === 'Enter') { + const clean = sanitizeName(state.nicknameInput); + if (clean) { + const payload: OutgoingMessage = { type: 'update_nickname', nickname: clean }; + signaling.send(payload); + audio.sfxUiConfirm(); + } else { + updateStatus('Cancelled.'); + audio.sfxUiCancel(); + } + state.mode = 'normal'; + replaceTextOnNextType = false; + return; + } + + if (code === 'Escape') { + state.mode = 'normal'; + replaceTextOnNextType = false; + updateStatus('Cancelled.'); + audio.sfxUiCancel(); + return; + } + + const beforeText = state.nicknameInput; + const beforeCursor = state.cursorPos; + const mappedKey = + code === 'ArrowLeft' + ? 'arrowleft' + : code === 'ArrowRight' + ? 'arrowright' + : code === 'Backspace' + ? 'backspace' + : code === 'Home' + ? 'home' + : code === 'End' + ? 'end' + : key; + if (shouldReplaceCurrentText(code, key)) { + state.nicknameInput = key; + state.cursorPos = key.length; + return; + } + + const result = applyTextInput(mappedKey, state.nicknameInput, state.cursorPos, NICKNAME_MAX_LENGTH); + state.nicknameInput = result.newString; + state.cursorPos = result.newCursorPos; + if (code === 'Backspace') { + announceBackspaceDeletedCharacter(beforeText, beforeCursor); + } + if (code === 'ArrowLeft' || code === 'ArrowRight' || code === 'Home' || code === 'End') { + announceCursorCharacter(state.nicknameInput, state.cursorPos); + } +} + +function isTypingKey(code: string): boolean { + return code.startsWith('Key') || code === 'Space'; +} + +function setupInputHandlers(): void { + document.addEventListener('keydown', (event) => { + const code = event.code; + + if (!dom.settingsModal.classList.contains('hidden') && code === 'Escape') { + closeSettings(); + return; + } + + if (!state.running) return; + if (document.activeElement !== dom.canvas) return; + if (event.ctrlKey || event.altKey) return; + + if (state.mode !== 'normal' || !code.startsWith('Arrow')) { + event.preventDefault(); + } + + if (isTypingKey(code) && state.keysPressed[code]) return; + + if (state.mode === 'nickname') { + handleNicknameModeInput(code, event.key); + } else if (state.mode === 'chat') { + handleChatModeInput(code, event.key); + } else if (state.mode === 'listUsers') { + handleListModeInput(code); + } else if (state.mode === 'listItems') { + handleListItemsModeInput(code); + } else if (state.mode === 'addItem') { + handleAddItemModeInput(code); + } else if (state.mode === 'selectItem') { + handleSelectItemModeInput(code); + } else if (state.mode === 'itemProperties') { + handleItemPropertiesModeInput(code); + } else if (state.mode === 'itemPropertyEdit') { + handleItemPropertyEditModeInput(code, event.key); + } else { + handleNormalModeInput(code, event.shiftKey); + } + + state.keysPressed[code] = true; + }); + + document.addEventListener('keyup', (event) => { + state.keysPressed[event.code] = false; + }); +} + +async function populateAudioDevices(): Promise { + if (!navigator.mediaDevices?.enumerateDevices) { + return; + } + + try { + await navigator.mediaDevices.getUserMedia({ audio: true }); + const devices = await navigator.mediaDevices.enumerateDevices(); + + dom.audioInputSelect.innerHTML = ''; + dom.audioOutputSelect.innerHTML = ''; + + for (const device of devices) { + if (device.kind === 'audioinput') { + dom.audioInputSelect.add(new Option(device.label || `Microphone ${dom.audioInputSelect.length + 1}`, device.deviceId)); + } + if (device.kind === 'audiooutput') { + const option = new Option(device.label || `Speaker ${dom.audioOutputSelect.length + 1}`, device.deviceId); + dom.audioOutputSelect.add(option); + } + } + + if (preferredInputDeviceId && Array.from(dom.audioInputSelect.options).some((option) => option.value === preferredInputDeviceId)) { + dom.audioInputSelect.value = preferredInputDeviceId; + preferredInputDeviceName = dom.audioInputSelect.selectedOptions[0]?.text || preferredInputDeviceName; + } else if (dom.audioInputSelect.options.length > 0) { + preferredInputDeviceId = dom.audioInputSelect.value; + preferredInputDeviceName = dom.audioInputSelect.selectedOptions[0]?.text || preferredInputDeviceName; + localStorage.setItem(AUDIO_INPUT_STORAGE_KEY, preferredInputDeviceId); + localStorage.setItem(AUDIO_INPUT_NAME_STORAGE_KEY, preferredInputDeviceName); + } + + if (preferredOutputDeviceId && Array.from(dom.audioOutputSelect.options).some((option) => option.value === preferredOutputDeviceId)) { + dom.audioOutputSelect.value = preferredOutputDeviceId; + preferredOutputDeviceName = dom.audioOutputSelect.selectedOptions[0]?.text || preferredOutputDeviceName; + void peerManager.setOutputDevice(preferredOutputDeviceId); + } + + const sinkCapable = typeof (HTMLMediaElement.prototype as HTMLMediaElement & { setSinkId?: unknown }).setSinkId === 'function'; + dom.audioOutputSelect.disabled = !sinkCapable; + updateDeviceSummary(); + } catch { + updateStatus('Could not list devices.'); + } +} + +function openSettings(): void { + lastFocusedElement = document.activeElement; + dom.settingsModal.classList.remove('hidden'); + void populateAudioDevices(); + dom.audioInputSelect.focus(); +} + +function closeSettings(): void { + dom.settingsModal.classList.add('hidden'); + if (lastFocusedElement instanceof HTMLElement) { + lastFocusedElement.focus(); + } else { + dom.canvas.focus(); + } +} + +function setupUiHandlers(): void { + dom.connectButton.addEventListener('click', () => { + void connect(); + }); + dom.preconnectNickname.addEventListener('input', () => { + updateConnectAvailability(); + }); + dom.preconnectNickname.addEventListener('change', () => { + const clean = sanitizeName(dom.preconnectNickname.value); + dom.preconnectNickname.value = clean; + if (clean) { + localStorage.setItem(NICKNAME_STORAGE_KEY, clean); + } else { + localStorage.removeItem(NICKNAME_STORAGE_KEY); + } + updateConnectAvailability(); + }); + dom.preconnectNickname.addEventListener('keydown', (event) => { + if (event.key === 'Enter' && !dom.connectButton.disabled) { + event.preventDefault(); + void connect(); + } + }); + + dom.disconnectButton.addEventListener('click', () => { + disconnect(); + }); + + dom.focusGridButton.addEventListener('click', () => { + dom.canvas.focus(); + updateStatus('Chat Grid focused.'); + audio.sfxUiBlip(); + }); + + dom.settingsButton.addEventListener('click', () => { + openSettings(); + }); + + dom.closeSettingsButton.addEventListener('click', () => { + closeSettings(); + }); + + dom.audioInputSelect.addEventListener('change', (event) => { + const target = event.target as HTMLSelectElement; + if (!target.value) return; + preferredInputDeviceId = target.value; + preferredInputDeviceName = target.selectedOptions[0]?.text || preferredInputDeviceName; + localStorage.setItem(AUDIO_INPUT_STORAGE_KEY, preferredInputDeviceId); + localStorage.setItem(AUDIO_INPUT_NAME_STORAGE_KEY, preferredInputDeviceName); + updateDeviceSummary(); + void setupLocalMedia(target.value); + }); + + dom.audioOutputSelect.addEventListener('change', (event) => { + const target = event.target as HTMLSelectElement; + preferredOutputDeviceId = target.value; + preferredOutputDeviceName = target.selectedOptions[0]?.text || preferredOutputDeviceName; + localStorage.setItem(AUDIO_OUTPUT_STORAGE_KEY, preferredOutputDeviceId); + localStorage.setItem(AUDIO_OUTPUT_NAME_STORAGE_KEY, preferredOutputDeviceName); + updateDeviceSummary(); + void peerManager.setOutputDevice(preferredOutputDeviceId); + }); + + dom.settingsModal.addEventListener('keydown', (event) => { + if (event.key !== 'Tab') return; + const focusable = Array.from(dom.settingsModal.querySelectorAll('select, button')); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (event.shiftKey && document.activeElement === first) { + last.focus(); + event.preventDefault(); + return; + } + + if (!event.shiftKey && document.activeElement === last) { + first.focus(); + event.preventDefault(); + } + }); +} + +setupInputHandlers(); +setupUiHandlers(); +const storedNickname = sanitizeName(localStorage.getItem(NICKNAME_STORAGE_KEY) || ''); +dom.preconnectNickname.value = storedNickname; +if (storedNickname) { + state.player.nickname = storedNickname; +} +updateConnectAvailability(); +updateDeviceSummary(); +updateStatus('Welcome to the Chat Grid. Press the Settings button to configure your audio, then Connect to join the grid.'); diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts new file mode 100644 index 0000000..7563507 --- /dev/null +++ b/client/src/network/protocol.ts @@ -0,0 +1,149 @@ +import { z } from 'zod'; + +export const itemSchema = z.object({ + id: z.string(), + type: z.enum(['radio_station', 'dice']), + title: z.string(), + x: z.number().int(), + y: z.number().int(), + createdBy: z.string(), + createdAt: z.number().int(), + updatedAt: z.number().int(), + version: z.number().int(), + capabilities: z.array(z.string()), + useSound: z.string().optional(), + params: z.record(z.string(), z.unknown()), + carrierId: z.string().nullable().optional(), +}); + +export const welcomeMessageSchema = z.object({ + type: z.literal('welcome'), + id: z.string(), + users: z.array( + z.object({ + id: z.string(), + nickname: z.string(), + x: z.number().int(), + y: z.number().int(), + }), + ), + items: z.array(itemSchema).optional(), +}); + +export const signalMessageSchema = z.object({ + type: z.literal('signal'), + senderId: z.string(), + senderNickname: z.string().optional(), + x: z.number().int().optional(), + y: z.number().int().optional(), + targetId: z.string().optional(), + sdp: z.any().optional(), + ice: z.any().optional(), +}); + +export const updatePositionSchema = z.object({ + type: z.literal('update_position'), + id: z.string(), + x: z.number().int(), + y: z.number().int(), +}); + +export const updateNicknameSchema = z.object({ + type: z.literal('update_nickname'), + id: z.string(), + nickname: z.string().min(1).max(32), +}); + +export const userLeftSchema = z.object({ + type: z.literal('user_left'), + id: z.string(), +}); + +export const chatMessageSchema = z.object({ + type: z.literal('chat_message'), + message: z.string(), + senderId: z.string().optional(), + senderNickname: z.string().optional(), + system: z.boolean().optional(), +}); + +export const pongSchema = z.object({ + type: z.literal('pong'), + clientSentAt: z.number().int(), +}); + +export const nicknameResultSchema = z.object({ + type: z.literal('nickname_result'), + accepted: z.boolean(), + requestedNickname: z.string(), + effectiveNickname: z.string(), + reason: z.string().optional(), +}); + +export const itemUpsertSchema = z.object({ + type: z.literal('item_upsert'), + item: itemSchema, +}); + +export const itemRemoveSchema = z.object({ + type: z.literal('item_remove'), + itemId: z.string(), +}); + +export const itemActionResultSchema = z.object({ + type: z.literal('item_action_result'), + ok: z.boolean(), + action: z.enum(['add', 'pickup', 'drop', 'delete', 'use', 'update']), + message: z.string(), + itemId: z.string().optional(), +}); + +export const itemUseSoundSchema = z.object({ + type: z.literal('item_use_sound'), + itemId: z.string(), + sound: z.string(), + x: z.number().int(), + y: z.number().int(), +}); + +export const incomingMessageSchema = z.discriminatedUnion('type', [ + welcomeMessageSchema, + signalMessageSchema, + updatePositionSchema, + updateNicknameSchema, + userLeftSchema, + chatMessageSchema, + pongSchema, + nicknameResultSchema, + itemUpsertSchema, + itemRemoveSchema, + itemActionResultSchema, + itemUseSoundSchema, +]); + +export type IncomingMessage = z.infer; + +export type OutgoingMessage = + | { type: 'signal'; targetId: string; sdp?: RTCSessionDescriptionInit; ice?: RTCIceCandidateInit } + | { type: 'update_position'; x: number; y: number } + | { type: 'update_nickname'; nickname: string } + | { type: 'chat_message'; message: string } + | { type: 'ping'; clientSentAt: number } + | { type: 'item_add'; itemType: 'radio_station' | 'dice' } + | { type: 'item_pickup'; itemId: string } + | { type: 'item_drop'; itemId: string; x: number; y: number } + | { type: 'item_delete'; itemId: string } + | { type: 'item_use'; itemId: string } + | { + type: 'item_update'; + itemId: string; + title?: string; + params?: Record; + }; + +export type RemoteUser = { + id: string; + nickname: string; + x: number; + y: number; +}; diff --git a/client/src/network/signalingClient.ts b/client/src/network/signalingClient.ts new file mode 100644 index 0000000..cdaf0ac --- /dev/null +++ b/client/src/network/signalingClient.ts @@ -0,0 +1,76 @@ +import { incomingMessageSchema, type IncomingMessage, type OutgoingMessage } from './protocol'; + +type MessageHandler = (message: IncomingMessage) => void | Promise; +type StatusHandler = (message: string) => void; + +export class SignalingClient { + private ws: WebSocket | null = null; + private timeoutId: number | null = null; + + constructor(private readonly url: string, private readonly status: StatusHandler) {} + + async connect(onMessage: MessageHandler): Promise { + if (this.ws && this.ws.readyState === WebSocket.OPEN) return; + + this.ws = new WebSocket(this.url); + + await new Promise((resolve, reject) => { + if (!this.ws) { + reject(new Error('WebSocket unavailable')); + return; + } + + this.timeoutId = window.setTimeout(() => { + this.status('Connection timed out.'); + this.disconnect(); + reject(new Error('Connection timed out')); + }, 10_000); + + this.ws.onopen = () => { + this.clearTimeout(); + this.status('Connected.'); + resolve(); + }; + + this.ws.onerror = () => { + this.clearTimeout(); + reject(new Error('WebSocket error')); + }; + + this.ws.onmessage = async (event) => { + const parsed = JSON.parse(String(event.data)); + const validated = incomingMessageSchema.safeParse(parsed); + if (!validated.success) return; + await onMessage(validated.data); + }; + + this.ws.onclose = () => { + this.clearTimeout(); + this.status('Disconnected.'); + }; + }); + } + + send(payload: OutgoingMessage): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; + this.ws.send(JSON.stringify(payload)); + } + + disconnect(): void { + this.clearTimeout(); + if (!this.ws) return; + this.ws.onopen = null; + this.ws.onmessage = null; + this.ws.onclose = null; + this.ws.onerror = null; + this.ws.close(); + this.ws = null; + } + + private clearTimeout(): void { + if (this.timeoutId !== null) { + window.clearTimeout(this.timeoutId); + this.timeoutId = null; + } + } +} diff --git a/client/src/render/canvasRenderer.ts b/client/src/render/canvasRenderer.ts new file mode 100644 index 0000000..99dc2ed --- /dev/null +++ b/client/src/render/canvasRenderer.ts @@ -0,0 +1,86 @@ +import { GRID_SIZE, type GameState, type PeerState, type WorldItem } from '../state/gameState'; + +export class CanvasRenderer { + private readonly ctx: CanvasRenderingContext2D; + private readonly squarePixelSize: number; + + constructor(private readonly canvas: HTMLCanvasElement) { + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Unable to create 2D context'); + } + this.ctx = ctx; + this.squarePixelSize = canvas.width / GRID_SIZE; + } + + draw(state: GameState): void { + const { ctx } = this; + ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + ctx.strokeStyle = '#374151'; + for (let i = 0; i <= GRID_SIZE; i += 1) { + ctx.beginPath(); + ctx.moveTo(i * this.squarePixelSize, 0); + ctx.lineTo(i * this.squarePixelSize, this.canvas.height); + ctx.moveTo(0, i * this.squarePixelSize); + ctx.lineTo(this.canvas.width, i * this.squarePixelSize); + ctx.stroke(); + } + + for (const peer of state.peers.values()) { + this.drawObject(peer, '#f87171', peer.nickname); + } + for (const item of state.items.values()) { + if (item.carrierId) continue; + this.drawItem(item); + } + this.drawObject(state.player, '#34d399', state.player.nickname); + + if (state.mode === 'nickname' || state.mode === 'chat' || state.mode === 'itemPropertyEdit') { + const label = + state.mode === 'nickname' ? 'New Nickname' : state.mode === 'chat' ? 'Message' : 'Property Value'; + this.drawTextOverlay(state, label); + } + } + + private drawObject(obj: Pick, color: string, name: string): void { + const drawX = obj.x * this.squarePixelSize; + const drawY = this.canvas.height - (obj.y * this.squarePixelSize) - this.squarePixelSize; + this.ctx.fillStyle = color; + this.ctx.fillRect(drawX, drawY, this.squarePixelSize, this.squarePixelSize); + this.ctx.fillStyle = 'white'; + this.ctx.font = '12px Courier New'; + this.ctx.textAlign = 'center'; + this.ctx.fillText(name, drawX + this.squarePixelSize / 2, drawY - 5); + } + + private drawTextOverlay(state: GameState, label: string): void { + const { ctx } = this; + ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; + ctx.fillRect(0, this.canvas.height / 2 - 30, this.canvas.width, 60); + ctx.fillStyle = 'white'; + ctx.font = '24px Courier New'; + ctx.textAlign = 'center'; + + const text = `${label}: ${state.nicknameInput}`; + const textMetrics = ctx.measureText(text); + const preCursorText = `${label}: ${state.nicknameInput.substring(0, state.cursorPos)}`; + const preCursorWidth = ctx.measureText(preCursorText).width; + const textX = this.canvas.width / 2; + + ctx.fillText(text, textX, this.canvas.height / 2); + if (state.cursorVisible) { + ctx.fillRect(textX - textMetrics.width / 2 + preCursorWidth, this.canvas.height / 2 - 20, 2, 24); + } + } + + private drawItem(item: WorldItem): void { + const drawX = item.x * this.squarePixelSize; + const drawY = this.canvas.height - (item.y * this.squarePixelSize) - this.squarePixelSize; + this.ctx.fillStyle = item.type === 'radio_station' ? '#fbbf24' : '#60a5fa'; + this.ctx.fillRect(drawX, drawY, this.squarePixelSize, this.squarePixelSize); + this.ctx.fillStyle = '#111827'; + this.ctx.font = 'bold 12px Courier New'; + this.ctx.textAlign = 'center'; + this.ctx.fillText(item.type === 'radio_station' ? 'R' : 'D', drawX + this.squarePixelSize / 2, drawY + 13); + } +} diff --git a/client/src/state/gameState.ts b/client/src/state/gameState.ts new file mode 100644 index 0000000..6eb7d33 --- /dev/null +++ b/client/src/state/gameState.ts @@ -0,0 +1,149 @@ +export const GRID_SIZE = 40; +export const HEARING_RADIUS = 15; +export const MOVE_COOLDOWN_MS = 100; + +export type ItemType = 'radio_station' | 'dice'; + +export type WorldItem = { + id: string; + type: ItemType; + title: string; + x: number; + y: number; + createdBy: string; + createdAt: number; + updatedAt: number; + version: number; + capabilities: string[]; + useSound?: string; + params: Record; + carrierId?: string | null; +}; + +export type SelectionContext = 'pickup' | 'drop' | 'delete' | 'edit' | 'use' | null; + +export type GameMode = + | 'normal' + | 'nickname' + | 'chat' + | 'listUsers' + | 'listItems' + | 'addItem' + | 'selectItem' + | 'itemProperties' + | 'itemPropertyEdit'; + +export type Player = { + id: string | null; + nickname: string; + x: number; + y: number; + lastMoveTime: number; +}; + +export type PeerState = { + id: string; + nickname: string; + x: number; + y: number; +}; + +export type GameState = { + running: boolean; + mode: GameMode; + keysPressed: Record; + nicknameInput: string; + cursorPos: number; + cursorVisible: boolean; + sortedPeerIds: string[]; + listIndex: number; + sortedItemIds: string[]; + itemListIndex: number; + selectedItemIds: string[]; + selectionContext: SelectionContext; + selectedItemIndex: number; + selectedItemId: string | null; + itemPropertyKeys: string[]; + itemPropertyIndex: number; + editingPropertyKey: string | null; + addItemTypeIndex: number; + isMuted: boolean; + player: Player; + peers: Map; + items: Map; + carriedItemId: string | null; +}; + +export function createInitialState(): GameState { + return { + running: false, + mode: 'normal', + keysPressed: {}, + nicknameInput: '', + cursorPos: 0, + cursorVisible: true, + sortedPeerIds: [], + listIndex: 0, + sortedItemIds: [], + itemListIndex: 0, + selectedItemIds: [], + selectionContext: null, + selectedItemIndex: 0, + selectedItemId: null, + itemPropertyKeys: [], + itemPropertyIndex: 0, + editingPropertyKey: null, + addItemTypeIndex: 0, + isMuted: false, + player: { + id: null, + nickname: 'anon', + x: 20, + y: 20, + lastMoveTime: 0, + }, + peers: new Map(), + items: new Map(), + carriedItemId: null, + }; +} + +export function getNearestPeer(state: GameState): { peerId: string | null; distance: number } { + let nearest: string | null = null; + let minDist = Infinity; + for (const [id, peer] of state.peers.entries()) { + const dist = Math.hypot(peer.x - state.player.x, peer.y - state.player.y); + if (dist < minDist) { + minDist = dist; + nearest = id; + } + } + return { peerId: nearest, distance: minDist }; +} + +export function getDirection(px: number, py: number, tx: number, ty: number): string { + const dx = tx - px; + const dy = ty - py; + if (dx === 0 && dy === 0) return 'here'; + let vDir = ''; + let hDir = ''; + if (dy > 0) vDir = 'north'; + if (dy < 0) vDir = 'south'; + if (dx > 0) hDir = 'east'; + if (dx < 0) hDir = 'west'; + return `${vDir} ${hDir}`.trim(); +} + +export function getNearestItem(state: GameState): { itemId: string | null; distance: number } { + let nearest: string | null = null; + let minDist = Infinity; + for (const [id, item] of state.items.entries()) { + if (item.carrierId) continue; + const dist = Math.hypot(item.x - state.player.x, item.y - state.player.y); + if (dist < minDist) { + minDist = dist; + nearest = id; + } + } + return { itemId: nearest, distance: minDist }; +} diff --git a/client/src/styles.css b/client/src/styles.css new file mode 100644 index 0000000..8956936 --- /dev/null +++ b/client/src/styles.css @@ -0,0 +1,124 @@ +:root { + color-scheme: dark; + font-family: "Courier New", Courier, monospace; +} + +body { + margin: 0; + min-height: 100vh; + display: grid; + place-items: center; + background: radial-gradient(circle at top, #1f2937, #0b1220 50%, #030712); + color: #e5e7eb; +} + +.app { + width: min(860px, 100%); + padding: 1rem; + text-align: center; +} + +#appVersion { + display: block; + color: #94a3b8; + margin-bottom: 0.5rem; +} + +#deviceSummary { + color: #94a3b8; + margin: 0 auto 0.75rem; + min-height: 2.4rem; +} + +#deviceSummary p { + margin: 0.15rem 0; +} + +.controls { + display: flex; + justify-content: center; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.nickname-row { + display: flex; + justify-content: center; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.nickname-row label { + color: #cbd5e1; +} + +.nickname-row input { + background: #111827; + color: #e5e7eb; + border: 1px solid #334155; + border-radius: 0.5rem; + padding: 0.4rem 0.6rem; + width: min(320px, 70vw); +} + +button, +select { + background: #1d4ed8; + color: white; + border: 1px solid #3b82f6; + border-radius: 0.5rem; + padding: 0.5rem 0.75rem; +} + +button:hover { + background: #1e40af; +} + +button:disabled { + background: #475569; + border-color: #64748b; + cursor: not-allowed; +} + +canvas { + background: #111827; + border: 2px solid #60a5fa; + border-radius: 8px; + box-shadow: 0 0 20px rgb(96 165 250 / 35%); +} + +#status { + height: 2rem; + color: #86efac; + margin-bottom: 0.75rem; +} + +#instructions { + color: #94a3b8; + text-align: left; + margin: 0.75rem auto; + width: fit-content; +} + +.hidden { + display: none !important; +} + +#settingsModal { + position: fixed; + inset: 0; + background: rgb(0 0 0 / 70%); + display: grid; + place-items: center; +} + +.modal-content { + min-width: 300px; + background: #0f172a; + border: 1px solid #334155; + border-radius: 0.75rem; + padding: 1rem; + display: grid; + gap: 0.5rem; +} diff --git a/client/src/webrtc/peerManager.ts b/client/src/webrtc/peerManager.ts new file mode 100644 index 0000000..f9e97c1 --- /dev/null +++ b/client/src/webrtc/peerManager.ts @@ -0,0 +1,173 @@ +import { AudioEngine, type SpatialPeerRuntime } from '../audio/audioEngine'; +import type { RemoteUser } from '../network/protocol'; + +export type PeerRuntime = SpatialPeerRuntime & { + id: string; + pc: RTCPeerConnection; +}; + +type SendSignal = (targetId: string, payload: { sdp?: RTCSessionDescriptionInit; ice?: RTCIceCandidateInit }) => void; + +type StatusHandler = (message: string) => void; + +export class PeerManager { + private readonly peers = new Map(); + private outputDeviceId = ''; + + constructor( + private readonly audio: AudioEngine, + private readonly sendSignal: SendSignal, + private readonly getLocalStream: () => MediaStream | null, + private readonly status: StatusHandler, + ) {} + + getPeer(id: string): PeerRuntime | undefined { + return this.peers.get(id); + } + + getPeers(): Iterable { + return this.peers.values(); + } + + async createOrGetPeer(targetId: string, isInitiator: boolean, userData: Partial): Promise { + const existing = this.peers.get(targetId); + if (existing) return existing; + + const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }); + + const peer: PeerRuntime = { + id: targetId, + nickname: userData.nickname ?? 'user...', + x: userData.x ?? 20, + y: userData.y ?? 20, + pc, + }; + + this.peers.set(targetId, peer); + + const stream = this.getLocalStream(); + if (stream) { + stream.getTracks().forEach((track) => pc.addTrack(track, stream)); + } + + pc.onicecandidate = (event) => { + if (event.candidate) { + this.sendSignal(targetId, { ice: event.candidate.toJSON() }); + } + }; + + pc.ontrack = async (event) => { + await this.audio.attachRemoteStream(peer, event.streams[0], this.outputDeviceId); + }; + + if (isInitiator) { + let offer = await pc.createOffer(); + offer = this.tuneOpus(offer); + await pc.setLocalDescription(offer); + this.sendSignal(targetId, { sdp: pc.localDescription ?? undefined }); + } + + return peer; + } + + async handleSignal(data: { + senderId: string; + senderNickname?: string; + x?: number; + y?: number; + sdp?: RTCSessionDescriptionInit; + ice?: RTCIceCandidateInit; + }): Promise { + const peer = await this.createOrGetPeer(data.senderId, false, { + id: data.senderId, + nickname: data.senderNickname, + x: data.x, + y: data.y, + }); + + if (data.sdp) { + await peer.pc.setRemoteDescription(new RTCSessionDescription(data.sdp)); + if (data.sdp.type === 'offer') { + let answer = await peer.pc.createAnswer(); + answer = this.tuneOpus(answer); + await peer.pc.setLocalDescription(answer); + this.sendSignal(data.senderId, { sdp: peer.pc.localDescription ?? undefined }); + } + } + + if (data.ice) { + await peer.pc.addIceCandidate(new RTCIceCandidate(data.ice)).catch(() => undefined); + } + + return peer; + } + + async replaceOutgoingTrack(stream: MediaStream): Promise { + for (const peer of this.peers.values()) { + const sender = peer.pc.getSenders().find((candidate) => candidate.track?.kind === 'audio'); + const newTrack = stream.getAudioTracks()[0]; + if (sender && newTrack) { + await sender.replaceTrack(newTrack); + } + } + } + + removePeer(id: string): void { + const peer = this.peers.get(id); + if (!peer) return; + peer.pc.close(); + this.audio.cleanupPeerAudio(peer); + this.peers.delete(id); + } + + cleanupAll(): void { + for (const id of this.peers.keys()) { + this.removePeer(id); + } + } + + setPeerPosition(id: string, x: number, y: number): void { + const peer = this.peers.get(id); + if (!peer) return; + peer.x = x; + peer.y = y; + } + + setPeerNickname(id: string, nickname: string): void { + const peer = this.peers.get(id); + if (!peer) return; + peer.nickname = nickname; + } + + async setOutputDevice(deviceId: string): Promise { + this.outputDeviceId = deviceId; + for (const peer of this.peers.values()) { + if (!peer.audioElement) continue; + const sinkTarget = peer.audioElement as HTMLMediaElement & { + setSinkId?: (id: string) => Promise; + }; + await sinkTarget.setSinkId?.(deviceId).catch(() => undefined); + } + } + + private tuneOpus(desc: RTCSessionDescriptionInit): RTCSessionDescriptionInit { + if (!desc.sdp) return desc; + const lines = desc.sdp.split('\r\n'); + let opusPayload: string | undefined; + for (const line of lines) { + if (line.includes('opus/48000')) { + const match = line.match(/(\d+) opus\/48000/); + if (match) opusPayload = match[1]; + } + } + if (opusPayload) { + for (let index = 0; index < lines.length; index += 1) { + if (lines[index].includes(`a=fmtp:${opusPayload}`)) { + lines[index] += ';maxaveragebitrate=128000;stereo=1;sprop-stereo=1;useinbandfec=1;usedtx=0'; + break; + } + } + } + return { ...desc, sdp: lines.join('\r\n') }; + } +} diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..fb65974 --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["vitest/globals"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/client/vite.config.ts b/client/vite.config.ts new file mode 100644 index 0000000..5191708 --- /dev/null +++ b/client/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite'; + +const base = process.env.VITE_BASE_PATH || '/'; + +export default defineConfig({ + base, + server: { + host: true, + port: 5173, + proxy: { + '/ws': { + target: 'ws://127.0.0.1:8765', + ws: true, + }, + }, + }, +}); diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..bb7c23e --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,116 @@ +# Deployment Guide + +Target example: AlmaLinux/cPanel host with files under `/home/bestmidi`. + +## 1) Place project files +- Repo root: `/home/bestmidi/chgrid` + +## 2) Make deploy scripts executable (once) + +```bash +cd /home/bestmidi/chgrid +chmod +x deploy/scripts/*.sh +``` + +## 3) Install server (uv) + +Verify server files first: + +```bash +ls -l /home/bestmidi/chgrid/server/pyproject.toml +``` + +Run install scripts from repo root (`/home/bestmidi/chgrid`), not from `server/`. + +```bash +cd /home/bestmidi/chgrid +./deploy/scripts/install_server.sh /home/bestmidi/chgrid +``` + +Notes: +- Script defaults to Python `3.13` (`PYTHON_SPEC=3.13`). +- It reuses existing `.venv` instead of replacing it interactively. +- If you need to force a fresh 3.13 env: + - `rm -rf /home/bestmidi/chgrid/server/.venv` + - rerun `./deploy/scripts/install_server.sh /home/bestmidi/chgrid` + +This creates: +- `/home/bestmidi/chgrid/server/.venv` +- `/home/bestmidi/chgrid/server/config.toml` (if missing) + +Edit `/home/bestmidi/chgrid/server/config.toml`: +- `server.bind_ip = "127.0.0.1"` +- `server.port = 8765` +- `network.allow_insecure_ws = true` +- `tls.cert_file = ""` +- `tls.key_file = ""` +- `storage.state_file = "runtime/items.json"` + +## 4) Build and publish client + +```bash +cd /home/bestmidi/chgrid +./deploy/scripts/deploy_client.sh /home/bestmidi/chgrid /home/bestmidi/public_html/chgrid /chgrid/ +``` + +Notes: +- Third arg is Vite base path for production assets. +- For `https://bestmidi.com/chgrid/`, use `/chgrid/`. +- For site root deploy (`https://bestmidi.com/`), use `/`. + +## 5) Install/restart signaling service (systemd) + +```bash +cd /home/bestmidi/chgrid +./deploy/scripts/install_service.sh /home/bestmidi/chgrid +``` + +Logs: + +```bash +journalctl -u chgrid-signaling.service -f +``` + +## 6) Apache websocket proxy + +Install using script: + +```bash +cd /home/bestmidi/chgrid +./deploy/scripts/install_apache.sh \ + /home/bestmidi/chgrid \ + /etc/apache2/conf.d/userdata/ssl/2_4/bestmidi/yourdomain.com/chgrid.conf +``` + +Notes: +- Replace `yourdomain.com` with your real domain. +- Script copies `deploy/apache/chgrid-vhost-snippet.conf`, runs `rebuildhttpdconf`, then restarts Apache via WHM restart command. + +## 7) Optional HTTPS relay for HTTP radio streams + +If stream sources are plain HTTP (for example ports `8000`, `8010`, `8020`, `8030`), add relays in: + +`/etc/apache2/conf.d/userdata/ssl/2_4/bestmidi/bestmidi.com/chgrid.conf` + +Example: + +```apache +ProxyPass /listen/8000/ http://127.0.0.1:8000/ +ProxyPassReverse /listen/8000/ http://127.0.0.1:8000/ +ProxyPass /listen/8010/ http://127.0.0.1:8010/ +ProxyPassReverse /listen/8010/ http://127.0.0.1:8010/ +ProxyPass /listen/8020/ http://127.0.0.1:8020/ +ProxyPassReverse /listen/8020/ http://127.0.0.1:8020/ +ProxyPass /listen/8030/ http://127.0.0.1:8030/ +ProxyPassReverse /listen/8030/ http://127.0.0.1:8030/ +``` + +Apply changes: + +```bash +sudo /usr/local/cpanel/scripts/rebuildhttpdconf +sudo /usr/local/cpanel/scripts/restartsrv_httpd +``` + +Usage example in Chat Grid: +- `https://bestmidi.com/listen/8000/stream` diff --git a/deploy/apache/chgrid-vhost-snippet.conf b/deploy/apache/chgrid-vhost-snippet.conf new file mode 100644 index 0000000..033e8f5 --- /dev/null +++ b/deploy/apache/chgrid-vhost-snippet.conf @@ -0,0 +1,7 @@ +# Add inside your SSL VirtualHost include for bestmidi.com. +# Keep your existing main DocumentRoot unchanged when hosting Chat Grid under /chgrid. +# Required modules: proxy, proxy_http, proxy_wstunnel + +# Proxy websocket signaling endpoint to local Python service. +ProxyPass /ws ws://127.0.0.1:8765 +ProxyPassReverse /ws ws://127.0.0.1:8765 diff --git a/deploy/scripts/deploy_client.sh b/deploy/scripts/deploy_client.sh new file mode 100755 index 0000000..f5b36bc --- /dev/null +++ b/deploy/scripts/deploy_client.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="${1:-/home/bestmidi/chgrid}" +PUBLISH_DIR="${2:-/home/bestmidi/public_html/chgrid}" +BASE_PATH="${3:-/chgrid/}" +CLIENT_DIR="$REPO_ROOT/client" + +if [[ ! -d "$CLIENT_DIR" ]]; then + echo "error: client directory not found: $CLIENT_DIR" >&2 + exit 1 +fi + +if ! command -v rsync >/dev/null 2>&1; then + echo "error: rsync is required but not found in PATH" >&2 + exit 1 +fi + +cd "$CLIENT_DIR" +npm install +VITE_BASE_PATH="$BASE_PATH" npm run build + +mkdir -p "$PUBLISH_DIR" +rsync -a --delete dist/ "$PUBLISH_DIR/" + +echo "client deploy complete: $PUBLISH_DIR" +echo "client base path: $BASE_PATH" diff --git a/deploy/scripts/install_apache.sh b/deploy/scripts/install_apache.sh new file mode 100755 index 0000000..ef330ba --- /dev/null +++ b/deploy/scripts/install_apache.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="${1:-/home/bestmidi/chgrid}" +INCLUDE_PATH="${2:-}" +RESTART_CMD="${3:-/usr/local/cpanel/scripts/restartsrv_httpd}" +SNIPPET_PATH="$REPO_ROOT/deploy/apache/chgrid-vhost-snippet.conf" + +if [[ -z "$INCLUDE_PATH" ]]; then + echo "usage: $0 [restart_cmd]" >&2 + echo "example: $0 /home/bestmidi/chgrid /etc/apache2/conf.d/userdata/ssl/2_4/bestmidi/example.com/chgrid.conf" >&2 + exit 1 +fi + +if [[ ! -f "$SNIPPET_PATH" ]]; then + echo "error: snippet not found: $SNIPPET_PATH" >&2 + exit 1 +fi + +sudo mkdir -p "$(dirname "$INCLUDE_PATH")" +sudo cp "$SNIPPET_PATH" "$INCLUDE_PATH" + +echo "installed apache include: $INCLUDE_PATH" + +if [[ -x /usr/local/cpanel/scripts/rebuildhttpdconf ]]; then + sudo /usr/local/cpanel/scripts/rebuildhttpdconf +else + echo "warning: /usr/local/cpanel/scripts/rebuildhttpdconf not found; skipping rebuild" >&2 +fi + +if [[ -x "$RESTART_CMD" ]]; then + sudo "$RESTART_CMD" +elif [[ -x /scripts/restartsrv_httpd ]]; then + sudo /scripts/restartsrv_httpd +else + echo "error: apache restart command not found" >&2 + echo "tried: $RESTART_CMD and /scripts/restartsrv_httpd" >&2 + exit 1 +fi + +echo "apache include applied and apache restarted" diff --git a/deploy/scripts/install_server.sh b/deploy/scripts/install_server.sh new file mode 100755 index 0000000..fa2432b --- /dev/null +++ b/deploy/scripts/install_server.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="${1:-/home/bestmidi/chgrid}" +SERVER_DIR="$REPO_ROOT/server" +PYTHON_SPEC="${PYTHON_SPEC:-3.13}" + +if ! command -v uv >/dev/null 2>&1; then + echo "error: uv is required but not found in PATH" >&2 + exit 1 +fi + +if [[ ! -d "$SERVER_DIR" ]]; then + echo "error: server directory not found: $SERVER_DIR" >&2 + exit 1 +fi +if [[ ! -f "$SERVER_DIR/pyproject.toml" ]]; then + echo "error: missing $SERVER_DIR/pyproject.toml" >&2 + echo " verify repository files were copied to /home/bestmidi/chgrid/server" >&2 + exit 1 +fi + +cd "$SERVER_DIR" + +# Avoid interactive prompts: reuse existing venv; create only when missing. +if [[ ! -d .venv ]]; then + uv venv .venv --python "$PYTHON_SPEC" + echo "created .venv with Python $PYTHON_SPEC" +else + echo "using existing .venv" +fi + +if [[ -x .venv/bin/python ]]; then + VENV_PYTHON_VERSION="$( + .venv/bin/python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' + )" + if [[ "$VENV_PYTHON_VERSION" != "$PYTHON_SPEC" ]]; then + echo "warning: .venv uses Python $VENV_PYTHON_VERSION (requested $PYTHON_SPEC)" >&2 + echo " remove .venv and rerun script to recreate with Python $PYTHON_SPEC" >&2 + fi +fi + +uv sync --no-dev --project "$SERVER_DIR" + +if [[ ! -f config.toml ]]; then + cp config.example.toml config.toml + echo "created $SERVER_DIR/config.toml from template" +fi + +mkdir -p runtime + +echo "server install complete" +echo "next: edit $SERVER_DIR/config.toml (TLS, bind_ip, port)" diff --git a/deploy/scripts/install_service.sh b/deploy/scripts/install_service.sh new file mode 100755 index 0000000..145eebf --- /dev/null +++ b/deploy/scripts/install_service.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="${1:-/home/bestmidi/chgrid}" +UNIT_NAME="${2:-chgrid-signaling.service}" +SRC_UNIT="$REPO_ROOT/deploy/systemd/$UNIT_NAME" +DST_UNIT="/etc/systemd/system/$UNIT_NAME" + +if [[ ! -f "$SRC_UNIT" ]]; then + echo "error: unit file not found: $SRC_UNIT" >&2 + exit 1 +fi + +sudo cp "$SRC_UNIT" "$DST_UNIT" +sudo systemctl daemon-reload +sudo systemctl enable --now "$UNIT_NAME" +sudo systemctl restart "$UNIT_NAME" +sudo systemctl status "$UNIT_NAME" --no-pager diff --git a/deploy/systemd/chgrid-client-preview.service b/deploy/systemd/chgrid-client-preview.service new file mode 100644 index 0000000..ea8f24b --- /dev/null +++ b/deploy/systemd/chgrid-client-preview.service @@ -0,0 +1,16 @@ +[Unit] +Description=chgrid client preview server (vite) +After=network.target + +[Service] +Type=simple +User=chgrid +Group=chgrid +WorkingDirectory=/opt/chgrid/client +Environment=PATH=/usr/bin:/bin +ExecStart=/usr/bin/npm run preview -- --host 0.0.0.0 --port 4173 +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/chgrid-signaling.service b/deploy/systemd/chgrid-signaling.service new file mode 100644 index 0000000..5bcc143 --- /dev/null +++ b/deploy/systemd/chgrid-signaling.service @@ -0,0 +1,16 @@ +[Unit] +Description=chgrid signaling server +After=network.target + +[Service] +Type=simple +User=bestmidi +Group=bestmidi +WorkingDirectory=/home/bestmidi/chgrid/server +Environment=PATH=/home/bestmidi/chgrid/server/.venv/bin:/usr/bin:/bin +ExecStart=/home/bestmidi/chgrid/server/.venv/bin/python main.py --config /home/bestmidi/chgrid/server/config.toml +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/docs/item-schema.md b/docs/item-schema.md new file mode 100644 index 0000000..e5f2cf2 --- /dev/null +++ b/docs/item-schema.md @@ -0,0 +1,118 @@ +# Item Schema + +## World Item (server-authoritative) + +```json +{ + "id": "string", + "type": "radio_station | dice", + "title": "string", + "x": 0, + "y": 0, + "createdBy": "user-id", + "createdAt": 1735689600000, + "updatedAt": 1735689600000, + "version": 1, + "capabilities": ["editable", "carryable", "deletable", "usable"], + "useSound": "sounds/roll.ogg", + "params": {}, + "carrierId": null +} +``` + +- `useSound`: optional client-played sound path when item `use` succeeds; global item field and not user-editable in V1. +- `capabilities` and `useSound` are derived from global item-type definitions at runtime (not stored per-instance in persisted state). + +## Persisted Item State (`server/runtime/items.json`) + +```json +{ + "id": "string", + "type": "radio_station | dice", + "title": "string", + "x": 0, + "y": 0, + "createdBy": "user-id", + "createdAt": 1735689600000, + "updatedAt": 1735689600000, + "version": 1, + "params": {}, + "carrierId": null +} +``` + +- Persisted state stores only instance data. +- Global/type-level properties are loaded from server registry in `server/app/item_catalog.py`. + +## Type Params + +### `radio_station` + +```json +{ + "streamUrl": "", + "enabled": true, + "volume": 50 +} +``` + +- `streamUrl`: string, empty allowed until configured. +- `enabled`: boolean on/off flag. + - UI behavior: in property menu, `Enter` toggles on/off directly. +- `volume`: integer, range `0-100`, default `50`. + +### `dice` + +```json +{ + "sides": 6, + "number": 2 +} +``` + +- `sides`: integer, range `1-100`. +- `number`: integer, range `1-100`. + +## Packet Shapes + +- `item_upsert`: + +```json +{ + "type": "item_upsert", + "item": { "..." : "World Item" } +} +``` + +- `item_remove`: + +```json +{ + "type": "item_remove", + "itemId": "item-id" +} +``` + +- `item_action_result`: + +```json +{ + "type": "item_action_result", + "ok": true, + "action": "add | pickup | drop | delete | use | update", + "message": "human-readable status", + "itemId": "optional-item-id" +} +``` + +- `item_use_sound`: + +```json +{ + "type": "item_use_sound", + "itemId": "item-id", + "sound": "sounds/roll.ogg", + "x": 12, + "y": 8 +} +``` diff --git a/docs/local.md b/docs/local.md new file mode 100644 index 0000000..97c734b --- /dev/null +++ b/docs/local.md @@ -0,0 +1,33 @@ +# Local Development + +## Start Server + +```bash +cd /home/jjm/code/chgrid/server +.venv/bin/python main.py --config config.toml --port 8765 +``` + +## Start Client + +```bash +cd /home/jjm/code/chgrid/client +npm run dev -- --host 0.0.0.0 --port 5173 +``` + +Open: `http://localhost:5173` + +## Quick Restarts + +Server: +```bash +lsof -tiTCP:8765 -sTCP:LISTEN | xargs -r kill +cd /home/jjm/code/chgrid/server +nohup .venv/bin/python main.py --config config.toml --port 8765 > /tmp/chgrid-server.log 2>&1 & +``` + +Client: +```bash +lsof -tiTCP:5173 -sTCP:LISTEN | xargs -r kill +cd /home/jjm/code/chgrid/client +nohup npm run dev -- --host 0.0.0.0 --port 5173 > /tmp/chgrid-client.log 2>&1 & +``` diff --git a/item.md b/item.md new file mode 100644 index 0000000..bd26ccc --- /dev/null +++ b/item.md @@ -0,0 +1,92 @@ +# Chat Grid Item System Plan + +## Goals +- Add world items without hard-coding every new feature. +- Start with `radio_station` as the first real item type. +- Keep design compatible with future carry/use/object mechanics. + +## Commands (V1) +- `A`: Add-item mode. + - Opens list of available item types. + - `Enter` places selected type on current square. +- `O`: Edit item properties on current square. + - If one item on square: open property edit mode for that item. + - If multiple items: open selector first, then property edit. +- `U`: Use item on current square (or held item if carrying one). + - If multiple usable items are available, open selector first. + - V1 behavior: implemented for `dice`; `radio_station` is configurable but not "used" yet. +- `Shift+U`: List connected users (moves old `U` users-list behavior here). +- `I`: Locate nearest item (name/type, distance, direction, coordinates). +- `Shift+I`: List items mode (nearest-first; arrows navigate; `Enter` focuses/moves, same pattern as user list). +- `D`: Carry/drop toggle. + - If not carrying: pick up one item from current square. + - If carrying: drop held item on current square. + - If multiple items exist on square, open short selector first. +- `Shift+D`: Delete item on current square. + - If multiple items, open selector first, then delete selected item. + +## Add Flow Options +- Option 1: Add with required properties immediately. + - Pros: item is valid at creation time. + - Cons: slower flow due to prompts. +- Option 2: Add placeholder first, then edit with `O`. (Recommended for V1) + - Pros: faster placement, cleaner keyboard flow, scales to many item types. + - Cons: requires incomplete-item handling. + +### Recommended V1 behavior +- `A` places item immediately with defaults. +- `radio_station` defaults: + - `title`: `New station` + - `params.streamUrl`: empty string (no default URL) + - `params.enabled`: `true` + - `params.volume`: `50` +- Incomplete rule: + - Item exists in world, but does not activate until required params are set. + - `O` is the standard command to complete/update params. + +## Property Editor (`O`) Behavior +- `O` opens a property list for the selected item. +- Arrow keys move between properties. +- Focused property announces: property name + current value. +- `Enter` on a property starts edit mode for that value. +- For switch properties (V1: `radio_station.enabled`), `Enter` toggles directly between `on` and `off`. +- `Enter` saves value after validation. +- `Escape` exits edit mode or closes the property menu. +- Validation failures are announced and also pushed to message buffer. + +## Data Model + +### Global fields (all item types) +- `id`: unique item id. +- `type`: item type key (ex: `radio_station`, `dice`). +- `title`: spoken/display label. +- `x`, `y`: world position. +- `createdBy`, `createdAt`, `updatedAt`. +- `version`: schema version for migration. +- `capabilities`: list of supported actions (examples: `editable`, `carryable`, `usable`, `deletable`). +- `useSound`: optional sound path played on successful `U` use (global field, not editable in V1). +- `params`: per-type payload object. + +### Per-item fields (inside `params`) +- `radio_station` (V1): + - `streamUrl` (required for playback; may be empty until configured) + - `enabled` (boolean on/off flag) + - `volume` (number `0-100`, default `50`) + - future: `filter`. +- `dice` (V1): + - `sides` (number, default `6`, range `1-100`) + - `number` (number of dice, default `2`, range `1-100`) +- `dice` (future example): + - optional future: `lastRoll`, `rollMode`, `modifier`. + +## Networking and Authority +- Server-authoritative item state. +- Client sends intent packets (`add`, `pickup`, `drop`, `delete`, later `use`). +- Server validates and returns: + - success result + broadcast item state update, or + - reject result with reason (also added to message buffer). + +## Why this structure +- Stable global schema with extensible `params`. +- New item types can be added without changing core item pipeline. +- Supports shared multiplayer consistency and future inventory/carry rules. diff --git a/refactor.md b/refactor.md new file mode 100644 index 0000000..31e7bf3 --- /dev/null +++ b/refactor.md @@ -0,0 +1,101 @@ +# Rewrite Plan: Modern Cross-Browser Spatial Grid + +## What This Code Is Today +The current code is a functional prototype: one large HTML file with tightly coupled UI/game/network/audio logic, plus a single Python signaling script. It proves the concept, but it is hard to test, hard to evolve safely, and brittle across browser differences. +At its core, the product is a realtime spatial chat grid with movement and command-driven interaction. + +## Rewrite Strategy (No Backward-Compatibility Constraints) +Build a new app in parallel, then cut over once parity + quality gates pass. +V1 explicitly ships without TURN relay infrastructure. +Design the new core so future features (objects, pickups, walls, collisions, and interaction rules) can be added without architectural rework. + +## Target Architecture +- `client/`: TypeScript + Vite + Canvas renderer + state store. +- `server/`: Python signaling service using `websockets` (schema-validated). +- `shared/`: Message contracts (JSON schema + generated TS types). +- `tests/`: Unit + Playwright E2E (Chromium, Firefox, WebKit). +- `docs/`: Browser compatibility matrix and ops runbook. +- Core domain model: + - world map + tile metadata (walkable/blocked/zone) + - entities (`player`, `object`, future NPC/system entities) + - actions/commands (move, rename, locate, interact, pickup, use) + - simulation rules (movement, collision, proximity effects) + +## Technology Choices +- Client: TypeScript, Vite, ESLint, Prettier, Vitest. +- Realtime: WebSocket signaling + WebRTC media. +- Server runtime: Python + `websockets`. +- Validation: Zod/JSON Schema for all inbound/outbound messages. +- Deployability: Environment-based config only (no hardcoded cert paths). +- ICE for v1: STUN-only (`stun:stun.l.google.com:19302`) with robust retry and failure handling. + +## Phases + +### Phase 1: Parity Baseline + Browser Hardening (5-8 days) +- Scaffold monorepo structure. +- Define protocol schemas: `welcome`, `signal`, `update_position`, `update_nickname`, `user_left`. +- Implement strict server validation and structured logging. +- Rebuild current behavior with parity: + - grid render + movement + presence sync + - existing commands: `c`, `l`, `shift+l`, `u`, `n`, `m`, `escape` + - nickname flow and reconnect/disconnect behavior +- Cross-browser hardening for latest Chrome/Edge/Firefox/Safari: + - keyboard handling via `event.code` + - capability checks for `setSinkId`, `StereoPannerNode`, autoplay/permissions differences + - explicit no-TURN recovery UX and bounded retry/backoff +- Keep grid/presence functional even if voice fails on restrictive networks. +- V1 server requirements (Python-focused): + - Use Python `websockets` for signaling transport. + - Enforce strict message validation (Pydantic/JSON schema) on receive and send. + - Add structured logging and websocket behavior tests. + +### Phase 2: World + Extensibility Architecture (3-5 days) +- Introduce world + entity foundation: + - tile map abstraction with collision checks + - player entity and object entity schema + - action dispatcher for current commands and future interactions +- Keep simulation pure and testable (state in, state out). + +### Phase 3: Advanced Audio + WebRTC Robustness (2-4 days) +- Implement peer connection manager and retry/timeout policy. +- Build browser capability layer: + - `setSinkId` optional + - `StereoPannerNode` optional + - autoplay/promise failure handling +- Graceful degradation: if unsupported, keep grid/presence fully functional and reduce to basic audio. +- Implement explicit no-TURN recovery UX: + - Detect ICE `failed`/`disconnected` states and auto-retry with bounded backoff. + - Surface actionable status text (network-restricted, retrying, voice unavailable). + - Keep text/status + grid presence fully functional when voice cannot connect. + +### Phase 4: Quality Gates (2-4 days) +- Unit tests for state, protocol, input, and audio math. +- Playwright multi-user E2E in Chromium/Firefox/WebKit. +- Add CI for lint, typecheck, unit tests, and cross-browser smoke tests. +- Add world-rule tests: + - wall collision and blocked-tile movement rejection + - object pickup/use action validation + - deterministic command outcomes + +### Phase 5: Cutover (1-2 days) +- Deploy rewrite behind new route or domain. +- Run soak tests and monitor connection/error metrics. +- Decommission old prototype once stable. + +## Definition of Done +- Grid + movement + presence stable in latest Chrome, Edge, Firefox, Safari. +- Audio works where supported and degrades cleanly where not. +- Zero runtime dependence on inline scripts or CDN Tailwind runtime. +- One-command local startup and passing CI. +- Known no-TURN limitation documented: some restrictive NAT/firewall networks may not establish voice. + +## Post-v1 TURN Trigger +Add TURN when either condition is met: +- Voice connection failure rate exceeds agreed threshold in production telemetry. +- Target users include enterprise/school/mobile networks where relay need is expected. + +## Recommended First PR +Create repo skeleton + protocol schema + server validator + parity client slice that renders grid, syncs positions, and supports current commands. + +## Recommended Second PR +Add browser hardening completion (capability fallbacks, reconnect UX) and Playwright parity tests across Chromium/Firefox/WebKit. diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..4d36562 --- /dev/null +++ b/server/README.md @@ -0,0 +1,35 @@ +# chgrid signaling server + +## Config + +Copy `config.example.toml` to `config.toml` and set values. + +```bash +cd server +cp config.example.toml config.toml +``` + +Key options: +- `server.bind_ip`, `server.port` +- `network.max_message_bytes` +- `network.allow_insecure_ws` +- `tls.cert_file`, `tls.key_file` + +If `network.allow_insecure_ws = false`, TLS cert/key are required and server runs as `wss://`. + +## Run + +```bash +cd server +python -m venv .venv +source .venv/bin/activate +pip install -e .[dev] +python main.py --config config.toml +``` + +## CLI overrides + +```bash +python main.py --config config.toml --host 127.0.0.1 --port 8765 +python main.py --config config.toml --ssl-cert /path/cert.pem --ssl-key /path/key.pem +``` diff --git a/server/app/__init__.py b/server/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/client.py b/server/app/client.py new file mode 100644 index 0000000..481c831 --- /dev/null +++ b/server/app/client.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from websockets.asyncio.server import ServerConnection + + +@dataclass +class ClientConnection: + websocket: ServerConnection + id: str + nickname: str = "user..." + x: int = 20 + y: int = 20 + + def summary(self) -> dict[str, str | int]: + return {"id": self.id, "nickname": self.nickname, "x": self.x, "y": self.y} diff --git a/server/app/config.py b/server/app/config.py new file mode 100644 index 0000000..108d491 --- /dev/null +++ b/server/app/config.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from pathlib import Path +import tomllib + +from pydantic import BaseModel, Field + + +class ServerConfigSection(BaseModel): + bind_ip: str = "127.0.0.1" + port: int = 8765 + + +class NetworkConfigSection(BaseModel): + max_message_bytes: int = Field(default=2_000_000, gt=0) + allow_insecure_ws: bool = True + + +class TlsConfigSection(BaseModel): + cert_file: str = "" + key_file: str = "" + + +class LoggingConfigSection(BaseModel): + level: str = "INFO" + + +class StorageConfigSection(BaseModel): + state_file: str = "runtime/items.json" + + +class AppConfig(BaseModel): + server: ServerConfigSection = ServerConfigSection() + network: NetworkConfigSection = NetworkConfigSection() + tls: TlsConfigSection = TlsConfigSection() + logging: LoggingConfigSection = LoggingConfigSection() + storage: StorageConfigSection = StorageConfigSection() + + +def load_config(path: Path | None) -> AppConfig: + if path is None: + return AppConfig() + + if not path.exists(): + raise FileNotFoundError(f"Config file not found: {path}") + + with path.open("rb") as fp: + data = tomllib.load(fp) + + config = AppConfig.model_validate(data) + + cert = config.tls.cert_file.strip() + key = config.tls.key_file.strip() + + if not config.network.allow_insecure_ws and (not cert or not key): + raise ValueError( + "TLS is required when network.allow_insecure_ws=false; set tls.cert_file and tls.key_file" + ) + + return config diff --git a/server/app/item_catalog.py b/server/app/item_catalog.py new file mode 100644 index 0000000..bb4aedb --- /dev/null +++ b/server/app/item_catalog.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +ItemType = Literal["radio_station", "dice"] + + +@dataclass(frozen=True) +class ItemDefinition: + default_title: str + capabilities: tuple[str, ...] + use_sound: str | None + default_params: dict + + +ITEM_DEFINITIONS: dict[ItemType, ItemDefinition] = { + "radio_station": ItemDefinition( + default_title="radio", + capabilities=("editable", "carryable", "deletable"), + use_sound=None, + default_params={"streamUrl": "", "enabled": True, "volume": 50}, + ), + "dice": ItemDefinition( + default_title="Dice", + capabilities=("editable", "carryable", "deletable", "usable"), + use_sound="sounds/roll.ogg", + default_params={"sides": 6, "number": 2}, + ), +} + + +def get_item_definition(item_type: ItemType) -> ItemDefinition: + return ITEM_DEFINITIONS[item_type] diff --git a/server/app/item_service.py b/server/app/item_service.py new file mode 100644 index 0000000..a4c3b62 --- /dev/null +++ b/server/app/item_service.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import json +import logging +import time +import uuid +from copy import deepcopy +from pathlib import Path +from typing import Literal + +from .client import ClientConnection +from .item_catalog import get_item_definition +from .models import PersistedWorldItem, WorldItem + +LOGGER = logging.getLogger("chgrid.server") + + +class ItemService: + def __init__(self, state_file: Path | None = None): + self.state_file = state_file + self.items: dict[str, WorldItem] = {} + self.load_state() + + @staticmethod + def now_ms() -> int: + return int(time.time() * 1000) + + def default_item(self, client: ClientConnection, item_type: Literal["radio_station", "dice"]) -> WorldItem: + item_def = get_item_definition(item_type) + now = self.now_ms() + return WorldItem( + id=str(uuid.uuid4()), + type=item_type, + title=item_def.default_title, + x=client.x, + y=client.y, + createdBy=client.id, + createdAt=now, + updatedAt=now, + version=1, + capabilities=list(item_def.capabilities), + useSound=item_def.use_sound, + params=deepcopy(item_def.default_params), + carrierId=None, + ) + + def add_item(self, item: WorldItem) -> None: + self.items[item.id] = item + + def remove_item(self, item_id: str) -> None: + if item_id in self.items: + del self.items[item_id] + + def find_carried_item(self, client_id: str) -> WorldItem | None: + for item in self.items.values(): + if item.carrierId == client_id: + return item + return None + + def items_on_square(self, x: int, y: int) -> list[WorldItem]: + return [item for item in self.items.values() if item.carrierId is None and item.x == x and item.y == y] + + def drop_carried_items_for_disconnect(self, client: ClientConnection) -> list[WorldItem]: + changed: list[WorldItem] = [] + for item in self.items.values(): + if item.carrierId == client.id: + item.carrierId = None + item.x = client.x + item.y = client.y + item.updatedAt = self.now_ms() + changed.append(item) + return changed + + def load_state(self) -> None: + if not self.state_file: + return + try: + if not self.state_file.exists(): + return + raw = json.loads(self.state_file.read_text(encoding="utf-8")) + if not isinstance(raw, list): + return + loaded: dict[str, WorldItem] = {} + for entry in raw: + persisted = PersistedWorldItem.model_validate(entry) + item_def = get_item_definition(persisted.type) + item = WorldItem( + id=persisted.id, + type=persisted.type, + title=persisted.title, + x=persisted.x, + y=persisted.y, + createdBy=persisted.createdBy, + createdAt=persisted.createdAt, + updatedAt=persisted.updatedAt, + version=persisted.version, + capabilities=list(item_def.capabilities), + useSound=item_def.use_sound, + params=persisted.params, + carrierId=persisted.carrierId, + ) + loaded[item.id] = item + self.items = loaded + LOGGER.info("loaded %d persisted items from %s", len(self.items), self.state_file) + except Exception as exc: + LOGGER.warning("failed to load persisted item state from %s: %s", self.state_file, exc) + + def save_state(self) -> None: + if not self.state_file: + return + try: + self.state_file.parent.mkdir(parents=True, exist_ok=True) + payload = [ + PersistedWorldItem( + id=item.id, + type=item.type, + title=item.title, + x=item.x, + y=item.y, + createdBy=item.createdBy, + createdAt=item.createdAt, + updatedAt=item.updatedAt, + version=item.version, + params=item.params, + carrierId=item.carrierId, + ).model_dump(exclude_none=True) + for item in self.items.values() + ] + self.state_file.write_text(json.dumps(payload, ensure_ascii=True, separators=(",", ":")), encoding="utf-8") + except Exception as exc: + LOGGER.warning("failed to persist item state to %s: %s", self.state_file, exc) diff --git a/server/app/models.py b/server/app/models.py new file mode 100644 index 0000000..b42cf0f --- /dev/null +++ b/server/app/models.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class BasePacket(BaseModel): + model_config = ConfigDict(extra="ignore") + type: str + + +class SignalPacket(BasePacket): + type: Literal["signal"] + targetId: str + sdp: dict | None = None + ice: dict | None = None + + +class UpdatePositionPacket(BasePacket): + type: Literal["update_position"] + x: int + y: int + + +class UpdateNicknamePacket(BasePacket): + type: Literal["update_nickname"] + nickname: str = Field(min_length=1, max_length=32) + + +class ChatMessagePacket(BasePacket): + type: Literal["chat_message"] + message: str = Field(min_length=1, max_length=500) + + +class PingPacket(BasePacket): + type: Literal["ping"] + clientSentAt: int + + +class ItemAddPacket(BasePacket): + type: Literal["item_add"] + itemType: Literal["radio_station", "dice"] + + +class ItemPickupPacket(BasePacket): + type: Literal["item_pickup"] + itemId: str + + +class ItemDropPacket(BasePacket): + type: Literal["item_drop"] + itemId: str + x: int + y: int + + +class ItemDeletePacket(BasePacket): + type: Literal["item_delete"] + itemId: str + + +class ItemUsePacket(BasePacket): + type: Literal["item_use"] + itemId: str + + +class ItemUpdatePacket(BasePacket): + type: Literal["item_update"] + itemId: str + title: str | None = Field(default=None, max_length=80) + params: dict | None = None + + +ClientPacket = ( + SignalPacket + | UpdatePositionPacket + | UpdateNicknamePacket + | ChatMessagePacket + | PingPacket + | ItemAddPacket + | ItemPickupPacket + | ItemDropPacket + | ItemDeletePacket + | ItemUsePacket + | ItemUpdatePacket +) + + +class RemoteUser(BaseModel): + id: str + nickname: str + x: int + y: int + + +class WelcomePacket(BasePacket): + type: Literal["welcome"] + id: str + users: list[RemoteUser] + items: list[dict] | None = None + + +class UserLeftPacket(BasePacket): + type: Literal["user_left"] + id: str + + +class BroadcastPositionPacket(BasePacket): + type: Literal["update_position"] + id: str + x: int + y: int + + +class BroadcastNicknamePacket(BasePacket): + type: Literal["update_nickname"] + id: str + nickname: str + + +class ForwardSignalPacket(BasePacket): + type: Literal["signal"] + senderId: str + senderNickname: str + x: int + y: int + sdp: dict | None = None + ice: dict | None = None + + +class BroadcastChatMessagePacket(BasePacket): + type: Literal["chat_message"] + message: str + senderId: str | None = None + senderNickname: str | None = None + system: bool = False + + +class PongPacket(BasePacket): + type: Literal["pong"] + clientSentAt: int + + +class NicknameResultPacket(BasePacket): + type: Literal["nickname_result"] + accepted: bool + requestedNickname: str + effectiveNickname: str + reason: str | None = None + + +class WorldItem(BaseModel): + id: str + type: Literal["radio_station", "dice"] + title: str + x: int + y: int + createdBy: str + createdAt: int + updatedAt: int + version: int + capabilities: list[str] + useSound: str | None = None + params: dict + carrierId: str | None = None + + +class PersistedWorldItem(BaseModel): + model_config = ConfigDict(extra="ignore") + id: str + type: Literal["radio_station", "dice"] + title: str + x: int + y: int + createdBy: str + createdAt: int + updatedAt: int + version: int + params: dict + carrierId: str | None = None + + +class ItemUpsertPacket(BasePacket): + type: Literal["item_upsert"] + item: WorldItem + + +class ItemRemovePacket(BasePacket): + type: Literal["item_remove"] + itemId: str + + +class ItemActionResultPacket(BasePacket): + type: Literal["item_action_result"] + ok: bool + action: Literal["add", "pickup", "drop", "delete", "use", "update"] + message: str + itemId: str | None = None + + +class ItemUseSoundPacket(BasePacket): + type: Literal["item_use_sound"] + itemId: str + sound: str + x: int + y: int diff --git a/server/app/server.py b/server/app/server.py new file mode 100644 index 0000000..b2f81c1 --- /dev/null +++ b/server/app/server.py @@ -0,0 +1,599 @@ +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import random +import ssl +import uuid +from pathlib import Path +from typing import Literal + +from pydantic import ValidationError, TypeAdapter +from websockets.asyncio.server import ServerConnection, serve + +from .client import ClientConnection +from .config import load_config +from .item_service import ItemService +from .models import ( + BroadcastChatMessagePacket, + BroadcastNicknamePacket, + BroadcastPositionPacket, + ChatMessagePacket, + ClientPacket, + ForwardSignalPacket, + ItemActionResultPacket, + ItemAddPacket, + ItemDeletePacket, + ItemDropPacket, + ItemPickupPacket, + ItemRemovePacket, + ItemUpdatePacket, + ItemUpsertPacket, + ItemUsePacket, + ItemUseSoundPacket, + NicknameResultPacket, + PingPacket, + PongPacket, + RemoteUser, + UpdateNicknamePacket, + UpdatePositionPacket, + UserLeftPacket, + WelcomePacket, + WorldItem, +) + +LOGGER = logging.getLogger("chgrid.server") +PACKET_LOGGER = logging.getLogger("chgrid.server.packet") +CLIENT_PACKET_ADAPTER = TypeAdapter(ClientPacket) + + +class SignalingServer: + def __init__( + self, + host: str, + port: int, + ssl_cert: str | None, + ssl_key: str | None, + max_message_size: int = 2_000_000, + state_file: Path | None = None, + ): + self.host = host + self.port = port + self.max_message_size = max_message_size + self._ssl_context = self._build_ssl_context(ssl_cert, ssl_key) + self.clients: dict[ServerConnection, ClientConnection] = {} + self.item_service = ItemService(state_file=state_file) + + @property + def items(self) -> dict[str, WorldItem]: + return self.item_service.items + + def _nickname_key(self, nickname: str) -> str: + return nickname.casefold() + + def _is_nickname_taken(self, nickname: str, exclude_client_id: str | None = None) -> bool: + wanted = self._nickname_key(nickname) + for other in self.clients.values(): + if exclude_client_id is not None and other.id == exclude_client_id: + continue + if self._nickname_key(other.nickname) == wanted: + return True + return False + + @staticmethod + def _item_type_label(item: WorldItem) -> str: + return "radio" if item.type == "radio_station" else item.type + + async def _send_item_result( + self, + client: ClientConnection, + ok: bool, + action: Literal["add", "pickup", "drop", "delete", "use", "update"], + message: str, + item_id: str | None = None, + ) -> None: + await self._send( + client.websocket, + ItemActionResultPacket( + type="item_action_result", + ok=ok, + action=action, + message=message, + itemId=item_id, + ), + ) + + async def _broadcast_item(self, item: WorldItem) -> None: + await self._broadcast(ItemUpsertPacket(type="item_upsert", item=item)) + + async def start(self) -> None: + protocol = "wss" if self._ssl_context else "ws" + LOGGER.info("starting signaling server on %s://%s:%d", protocol, self.host, self.port) + async with serve( + self._handle_client, + self.host, + self.port, + ssl=self._ssl_context, + max_size=self.max_message_size, + ): + await asyncio.Future() + + async def _handle_client(self, websocket: ServerConnection) -> None: + client = ClientConnection(websocket=websocket, id=str(uuid.uuid4())) + self.clients[websocket] = client + LOGGER.info("client connected id=%s total=%d", client.id, len(self.clients)) + + try: + await self._send_welcome(client) + async for raw_message in websocket: + await self._handle_message(client, raw_message) + finally: + if websocket in self.clients: + disconnected = self.clients.pop(websocket) + for item in self.item_service.drop_carried_items_for_disconnect(disconnected): + await self._broadcast_item(item) + self.item_service.save_state() + LOGGER.info("client disconnected id=%s total=%d", disconnected.id, len(self.clients)) + await self._broadcast(UserLeftPacket(type="user_left", id=disconnected.id), exclude=websocket) + await self._broadcast( + BroadcastChatMessagePacket( + type="chat_message", + message=f"{disconnected.nickname} has logged out.", + system=True, + ), + exclude=websocket, + ) + + async def _send_welcome(self, client: ClientConnection) -> None: + users = [ + RemoteUser(id=other.id, nickname=other.nickname, x=other.x, y=other.y) + for ws, other in self.clients.items() + if ws is not client.websocket + ] + packet = WelcomePacket( + type="welcome", + id=client.id, + users=users, + items=[item.model_dump(exclude_none=True) for item in self.items.values()], + ) + await self._send(client.websocket, packet) + + async def _handle_message(self, client: ClientConnection, raw_message: str) -> None: + try: + payload = json.loads(raw_message) + except json.JSONDecodeError: + PACKET_LOGGER.warning("non-json packet from id=%s", client.id) + return + + try: + packet = CLIENT_PACKET_ADAPTER.validate_python(payload) + except ValidationError as exc: + PACKET_LOGGER.warning("invalid packet from id=%s: %s", client.id, exc) + return + + if isinstance(packet, UpdatePositionPacket): + client.x = packet.x + client.y = packet.y + await self._broadcast( + BroadcastPositionPacket(type="update_position", id=client.id, x=client.x, y=client.y), + exclude=client.websocket, + ) + carried = self.item_service.find_carried_item(client.id) + if carried: + carried.x = client.x + carried.y = client.y + carried.updatedAt = self.item_service.now_ms() + await self._broadcast_item(carried) + return + + if isinstance(packet, UpdateNicknamePacket): + requested_nickname = packet.nickname.strip() + if not requested_nickname: + await self._send( + client.websocket, + NicknameResultPacket( + type="nickname_result", + accepted=False, + requestedNickname=packet.nickname, + effectiveNickname=client.nickname, + reason="Nickname is required.", + ), + ) + return + old_nickname = client.nickname + if self._is_nickname_taken(requested_nickname, exclude_client_id=client.id): + await self._send( + client.websocket, + NicknameResultPacket( + type="nickname_result", + accepted=False, + requestedNickname=requested_nickname, + effectiveNickname=client.nickname, + reason="Nickname already in use.", + ), + ) + return + if requested_nickname == old_nickname: + await self._send( + client.websocket, + NicknameResultPacket( + type="nickname_result", + accepted=True, + requestedNickname=requested_nickname, + effectiveNickname=client.nickname, + ), + ) + return + client.nickname = requested_nickname + await self._send( + client.websocket, + NicknameResultPacket( + type="nickname_result", + accepted=True, + requestedNickname=requested_nickname, + effectiveNickname=client.nickname, + ), + ) + await self._broadcast( + BroadcastNicknamePacket(type="update_nickname", id=client.id, nickname=client.nickname), + exclude=client.websocket, + ) + if old_nickname == "user...": + await self._broadcast( + BroadcastChatMessagePacket( + type="chat_message", + message=f"{client.nickname} has logged in.", + system=True, + ), + exclude=client.websocket, + ) + else: + await self._broadcast( + BroadcastChatMessagePacket( + type="chat_message", + message=f"{old_nickname} is now known as {client.nickname}.", + system=True, + ), + exclude=client.websocket, + ) + self_message = ( + f"Welcome. Logged in as {client.nickname}." + if old_nickname == "user..." + else f"You are now known as {client.nickname}." + ) + await self._send( + client.websocket, + BroadcastChatMessagePacket( + type="chat_message", + message=self_message, + system=True, + ), + ) + return + + if isinstance(packet, ChatMessagePacket): + await self._broadcast( + BroadcastChatMessagePacket( + type="chat_message", + message=packet.message, + senderId=client.id, + senderNickname=client.nickname, + system=False, + ) + ) + return + + if isinstance(packet, PingPacket): + await self._send( + client.websocket, + PongPacket(type="pong", clientSentAt=packet.clientSentAt), + ) + return + + if isinstance(packet, ItemAddPacket): + item = self.item_service.default_item(client, packet.itemType) + self.item_service.add_item(item) + await self._broadcast_item(item) + self.item_service.save_state() + item_text = f"{item.title} ({self._item_type_label(item)})" + await self._broadcast( + BroadcastChatMessagePacket( + type="chat_message", + message=f"{client.nickname} placed {item_text} at {item.x}, {item.y}.", + system=True, + ), + exclude=client.websocket, + ) + await self._send_item_result( + client, + True, + "add", + f"You placed {item_text} at {item.x}, {item.y}.", + item.id, + ) + return + + if isinstance(packet, ItemPickupPacket): + item = self.items.get(packet.itemId) + if not item: + await self._send_item_result(client, False, "pickup", "Item not found.") + return + if item.carrierId and item.carrierId != client.id: + await self._send_item_result(client, False, "pickup", "Item is already being carried.", item.id) + return + carried = self.item_service.find_carried_item(client.id) + if carried and carried.id != item.id: + await self._send_item_result(client, False, "pickup", "You are already carrying an item.", item.id) + return + if item.carrierId is None and (item.x != client.x or item.y != client.y): + await self._send_item_result(client, False, "pickup", "Item is not on your square.", item.id) + return + item.carrierId = client.id + item.x = client.x + item.y = client.y + item.updatedAt = self.item_service.now_ms() + await self._broadcast_item(item) + self.item_service.save_state() + await self._send_item_result(client, True, "pickup", f"Picked up {item.title}.", item.id) + return + + if isinstance(packet, ItemDropPacket): + item = self.items.get(packet.itemId) + if not item: + await self._send_item_result(client, False, "drop", "Item not found.") + return + if item.carrierId != client.id: + await self._send_item_result(client, False, "drop", "You are not carrying that item.", item.id) + return + item.carrierId = None + item.x = packet.x + item.y = packet.y + item.updatedAt = self.item_service.now_ms() + await self._broadcast_item(item) + self.item_service.save_state() + await self._send_item_result(client, True, "drop", f"Dropped {item.title}.", item.id) + return + + if isinstance(packet, ItemDeletePacket): + item = self.items.get(packet.itemId) + if not item: + await self._send_item_result(client, False, "delete", "Item not found.") + return + if item.carrierId and item.carrierId != client.id: + await self._send_item_result(client, False, "delete", "Item is being carried by another user.", item.id) + return + if item.carrierId is None and (item.x != client.x or item.y != client.y): + await self._send_item_result(client, False, "delete", "Item is not on your square.", item.id) + return + self.item_service.remove_item(item.id) + await self._broadcast(ItemRemovePacket(type="item_remove", itemId=item.id)) + self.item_service.save_state() + await self._send_item_result(client, True, "delete", f"Deleted {item.title}.", item.id) + return + + if isinstance(packet, ItemUsePacket): + item = self.items.get(packet.itemId) + if not item: + await self._send_item_result(client, False, "use", "Item not found.") + return + if item.carrierId not in (None, client.id): + await self._send_item_result(client, False, "use", "Item is not available.", item.id) + return + if item.carrierId is None and (item.x != client.x or item.y != client.y): + await self._send_item_result(client, False, "use", "Item is not on your square.", item.id) + return + if item.type != "dice": + await self._send_item_result(client, False, "use", "This item cannot be used yet.", item.id) + return + try: + sides = max(1, min(100, int(item.params.get("sides", 6)))) + number = max(1, min(100, int(item.params.get("number", 2)))) + except (TypeError, ValueError): + sides = 6 + number = 2 + rolls = [random.randint(1, sides) for _ in range(number)] + total = sum(rolls) + others_message = ( + f"{client.nickname} rolled {item.title}: {', '.join(str(value) for value in rolls)} (total {total})." + ) + self_message = f"You rolled {item.title}: {', '.join(str(value) for value in rolls)} (total {total})." + await self._broadcast( + BroadcastChatMessagePacket(type="chat_message", message=others_message, system=True), + exclude=client.websocket, + ) + if item.useSound: + await self._broadcast( + ItemUseSoundPacket( + type="item_use_sound", + itemId=item.id, + sound=item.useSound, + x=item.x, + y=item.y, + ) + ) + await self._send_item_result(client, True, "use", self_message, item.id) + return + + if isinstance(packet, ItemUpdatePacket): + item = self.items.get(packet.itemId) + if not item: + await self._send_item_result(client, False, "update", "Item not found.") + return + if item.carrierId not in (None, client.id): + await self._send_item_result(client, False, "update", "Item is not available for editing.", item.id) + return + if item.carrierId is None and (item.x != client.x or item.y != client.y): + await self._send_item_result(client, False, "update", "Item is not on your square.", item.id) + return + if packet.title is not None: + title = packet.title.strip() + if not title: + await self._send_item_result(client, False, "update", "Title cannot be empty.", item.id) + return + item.title = title[:80] + if packet.params: + next_params = {**item.params, **packet.params} + if item.type == "dice": + try: + sides = int(next_params.get("sides", 6)) + number = int(next_params.get("number", 2)) + except (TypeError, ValueError): + await self._send_item_result(client, False, "update", "Dice values must be numbers.", item.id) + return + if not (1 <= sides <= 100 and 1 <= number <= 100): + await self._send_item_result( + client, False, "update", "Dice sides and number must be between 1 and 100.", item.id + ) + return + next_params["sides"] = sides + next_params["number"] = number + if item.type == "radio_station": + stream_url = str(next_params.get("streamUrl", "")).strip() + previous_stream_url = str(item.params.get("streamUrl", "")).strip() + next_params["streamUrl"] = stream_url + enabled_value = next_params.get("enabled", True) + if isinstance(enabled_value, bool): + enabled = enabled_value + elif isinstance(enabled_value, (int, float)): + enabled = bool(enabled_value) + elif isinstance(enabled_value, str): + token = enabled_value.strip().lower() + if token in {"on", "true", "1", "yes"}: + enabled = True + elif token in {"off", "false", "0", "no"}: + enabled = False + else: + await self._send_item_result( + client, False, "update", "enabled must be true/false or on/off.", item.id + ) + return + else: + await self._send_item_result( + client, False, "update", "enabled must be true/false or on/off.", item.id + ) + return + if stream_url and stream_url != previous_stream_url: + enabled = True + if not stream_url: + enabled = False + next_params["enabled"] = enabled + + try: + volume = int(next_params.get("volume", 50)) + except (TypeError, ValueError): + await self._send_item_result(client, False, "update", "volume must be a number.", item.id) + return + if not (0 <= volume <= 100): + await self._send_item_result( + client, False, "update", "volume must be between 0 and 100.", item.id + ) + return + next_params["volume"] = volume + item.params = next_params + item.updatedAt = self.item_service.now_ms() + item.version += 1 + await self._broadcast_item(item) + self.item_service.save_state() + await self._send_item_result(client, True, "update", f"Updated {item.title}.", item.id) + return + + target = self._find_by_id(packet.targetId) + if not target: + PACKET_LOGGER.info("signal target not found sender=%s target=%s", client.id, packet.targetId) + return + + await self._send( + target.websocket, + ForwardSignalPacket( + type="signal", + senderId=client.id, + senderNickname=client.nickname, + x=client.x, + y=client.y, + sdp=packet.sdp, + ice=packet.ice, + ), + ) + + async def _broadcast(self, packet: object, exclude: ServerConnection | None = None) -> None: + for websocket in list(self.clients.keys()): + if websocket is exclude: + continue + await self._send(websocket, packet) + + async def _send(self, websocket: ServerConnection, packet: object) -> None: + try: + if hasattr(packet, "model_dump"): + data = packet.model_dump(exclude_none=True) + else: + data = packet + await websocket.send(json.dumps(data)) + except Exception as exc: # intentionally broad to keep server alive per client error + LOGGER.debug("send failure: %s", exc) + + def _find_by_id(self, client_id: str) -> ClientConnection | None: + for client in self.clients.values(): + if client.id == client_id: + return client + return None + + @staticmethod + def _build_ssl_context(cert: str | None, key: str | None) -> ssl.SSLContext | None: + if not cert or not key: + return None + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(certfile=Path(cert), keyfile=Path(key)) + return context + + +def run() -> None: + parser = argparse.ArgumentParser(description="chgrid signaling server") + parser.add_argument("--config", default="config.toml") + parser.add_argument("--host", default=None) + parser.add_argument("--port", type=int, default=None) + parser.add_argument("--ssl-cert", default=None) + parser.add_argument("--ssl-key", default=None) + parser.add_argument("--allow-insecure-ws", action="store_true", default=None) + args = parser.parse_args() + + config_path = Path(args.config) if args.config else None + if config_path and not config_path.exists() and args.config == "config.toml": + config_path = None + config = load_config(config_path) + + host = args.host or config.server.bind_ip + port = args.port or config.server.port + allow_insecure_ws = config.network.allow_insecure_ws + if args.allow_insecure_ws is True: + allow_insecure_ws = True + + ssl_cert = args.ssl_cert if args.ssl_cert is not None else config.tls.cert_file or None + ssl_key = args.ssl_key if args.ssl_key is not None else config.tls.key_file or None + state_file_value = config.storage.state_file.strip() + state_file: Path | None = None + if state_file_value: + base_dir = config_path.parent if config_path is not None else Path.cwd() + state_file = Path(state_file_value) + if not state_file.is_absolute(): + state_file = base_dir / state_file + + if not allow_insecure_ws and (not ssl_cert or not ssl_key): + raise SystemExit( + "TLS is required when insecure ws is disabled. Set tls.cert_file/tls.key_file in config.toml." + ) + + logging.basicConfig( + level=getattr(logging, config.logging.level.upper(), logging.INFO), + format="%(asctime)s %(levelname)s %(name)s %(message)s", + ) + server = SignalingServer( + host, + port, + ssl_cert, + ssl_key, + max_message_size=config.network.max_message_bytes, + state_file=state_file, + ) + asyncio.run(server.start()) diff --git a/server/config.example.toml b/server/config.example.toml new file mode 100644 index 0000000..fe3f0e7 --- /dev/null +++ b/server/config.example.toml @@ -0,0 +1,23 @@ +[server] +# Bind IP for signaling server. +bind_ip = "127.0.0.1" +# Listen port for signaling websocket server. +port = 8765 + +[network] +# Maximum inbound websocket message size in bytes. +max_message_bytes = 2000000 +# If false, TLS cert and key are required and server runs as wss:// only. +allow_insecure_ws = true + +[tls] +# Required when allow_insecure_ws = false. +cert_file = "" +key_file = "" + +[logging] +level = "INFO" + +[storage] +# Item persistence file. Relative paths are resolved from this config file directory. +state_file = "runtime/items.json" diff --git a/server/main.py b/server/main.py new file mode 100644 index 0000000..72838f9 --- /dev/null +++ b/server/main.py @@ -0,0 +1,5 @@ +from app.server import run + + +if __name__ == "__main__": + run() diff --git a/server/pyproject.toml b/server/pyproject.toml new file mode 100644 index 0000000..4e2ac11 --- /dev/null +++ b/server/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "chat-grid-server" +version = "0.1.0" +description = "Chat Grid signaling server" +requires-python = ">=3.11" +dependencies = [ + "pydantic>=2.10.4", + "websockets>=15.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3.4", + "pytest-asyncio>=0.25.2", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/server/tests/__init__.py b/server/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/tests/test_config.py b/server/tests/test_config.py new file mode 100644 index 0000000..fcf9611 --- /dev/null +++ b/server/tests/test_config.py @@ -0,0 +1,24 @@ +from pathlib import Path + +import pytest + +from app.config import load_config + + +def test_load_config_defaults_when_path_none() -> None: + cfg = load_config(None) + assert cfg.server.bind_ip == "127.0.0.1" + assert cfg.network.allow_insecure_ws is True + assert cfg.storage.state_file == "runtime/items.json" + + +def test_load_config_requires_tls_when_insecure_disabled(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + config_path.write_text( + """ +[network] +allow_insecure_ws = false +""".strip() + ) + with pytest.raises(ValueError): + load_config(config_path) diff --git a/server/tests/test_item_persistence.py b/server/tests/test_item_persistence.py new file mode 100644 index 0000000..7579bc8 --- /dev/null +++ b/server/tests/test_item_persistence.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import cast + +from websockets.asyncio.server import ServerConnection + +from app.client import ClientConnection +from app.item_service import ItemService + + +def _fake_ws() -> ServerConnection: + return cast(ServerConnection, object()) + + +def test_item_persistence_omits_global_type_properties(tmp_path: Path) -> None: + state_file = tmp_path / "items.json" + service = ItemService(state_file=state_file) + client = ClientConnection(websocket=_fake_ws(), id="u1", x=3, y=4) + + item = service.default_item(client, "dice") + service.add_item(item) + service.save_state() + + saved = json.loads(state_file.read_text(encoding="utf-8")) + assert isinstance(saved, list) + assert len(saved) == 1 + assert "capabilities" not in saved[0] + assert "useSound" not in saved[0] + + reloaded = ItemService(state_file=state_file) + loaded_item = reloaded.items[item.id] + assert loaded_item.useSound == "sounds/roll.ogg" + assert "usable" in loaded_item.capabilities diff --git a/server/tests/test_models.py b/server/tests/test_models.py new file mode 100644 index 0000000..03388ed --- /dev/null +++ b/server/tests/test_models.py @@ -0,0 +1,18 @@ +from pydantic import ValidationError, TypeAdapter + +from app.models import ClientPacket + + +def test_update_position_validates() -> None: + adapter = TypeAdapter(ClientPacket) + packet = adapter.validate_python({"type": "update_position", "x": 10, "y": 12}) + assert packet.type == "update_position" + + +def test_unknown_type_rejected() -> None: + adapter = TypeAdapter(ClientPacket) + try: + adapter.validate_python({"type": "unknown"}) + except ValidationError: + return + assert False, "validation should fail" diff --git a/server/tests/test_nickname_uniqueness.py b/server/tests/test_nickname_uniqueness.py new file mode 100644 index 0000000..efe560e --- /dev/null +++ b/server/tests/test_nickname_uniqueness.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import cast + +from websockets.asyncio.server import ServerConnection + +from app.server import ClientConnection, SignalingServer + + +def _fake_ws() -> ServerConnection: + return cast(ServerConnection, object()) + + +def test_nickname_taken_is_case_insensitive() -> None: + server = SignalingServer("127.0.0.1", 8765, None, None) + first_ws = _fake_ws() + second_ws = _fake_ws() + server.clients[first_ws] = ClientConnection(websocket=first_ws, id="1", nickname="Jage") + server.clients[second_ws] = ClientConnection(websocket=second_ws, id="2", nickname="Alice") + + assert server._is_nickname_taken("jage", exclude_client_id="2") + assert server._is_nickname_taken("JAGE", exclude_client_id="2") + assert not server._is_nickname_taken("jage", exclude_client_id="1") + + +def test_nickname_key_uses_casefold() -> None: + server = SignalingServer("127.0.0.1", 8765, None, None) + assert server._nickname_key("Jage") == server._nickname_key("jage") diff --git a/server/tests/test_nickname_updates.py b/server/tests/test_nickname_updates.py new file mode 100644 index 0000000..48ec3b2 --- /dev/null +++ b/server/tests/test_nickname_updates.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import json +from typing import cast + +import pytest +from websockets.asyncio.server import ServerConnection + +from app.models import BroadcastChatMessagePacket, BroadcastNicknamePacket, NicknameResultPacket +from app.server import ClientConnection, SignalingServer + + +def _fake_ws() -> ServerConnection: + return cast(ServerConnection, object()) + + +@pytest.mark.asyncio +async def test_same_nickname_same_case_is_noop(monkeypatch: pytest.MonkeyPatch) -> None: + server = SignalingServer("127.0.0.1", 8765, None, None) + ws = _fake_ws() + client = ClientConnection(websocket=ws, id="1", nickname="Jage") + server.clients[ws] = client + + sent_packets: list[object] = [] + broadcast_packets: list[object] = [] + + async def fake_send(websocket: ServerConnection, packet: object) -> None: + sent_packets.append(packet) + + async def fake_broadcast(packet: object, exclude: ServerConnection | None = None) -> None: + broadcast_packets.append(packet) + + monkeypatch.setattr(server, "_send", fake_send) + monkeypatch.setattr(server, "_broadcast", fake_broadcast) + + await server._handle_message(client, json.dumps({"type": "update_nickname", "nickname": "Jage"})) + + assert client.nickname == "Jage" + assert broadcast_packets == [] + assert any( + isinstance(packet, NicknameResultPacket) and packet.accepted and packet.effectiveNickname == "Jage" + for packet in sent_packets + ) + + +@pytest.mark.asyncio +async def test_case_only_change_is_allowed_and_broadcast(monkeypatch: pytest.MonkeyPatch) -> None: + server = SignalingServer("127.0.0.1", 8765, None, None) + ws = _fake_ws() + client = ClientConnection(websocket=ws, id="1", nickname="jage") + server.clients[ws] = client + + sent_packets: list[object] = [] + broadcast_packets: list[object] = [] + + async def fake_send(websocket: ServerConnection, packet: object) -> None: + sent_packets.append(packet) + + async def fake_broadcast(packet: object, exclude: ServerConnection | None = None) -> None: + broadcast_packets.append(packet) + + monkeypatch.setattr(server, "_send", fake_send) + monkeypatch.setattr(server, "_broadcast", fake_broadcast) + + await server._handle_message(client, json.dumps({"type": "update_nickname", "nickname": "Jage"})) + + assert client.nickname == "Jage" + assert any( + isinstance(packet, NicknameResultPacket) and packet.accepted and packet.effectiveNickname == "Jage" + for packet in sent_packets + ) + assert any(isinstance(packet, BroadcastNicknamePacket) for packet in broadcast_packets) + assert any(isinstance(packet, BroadcastChatMessagePacket) for packet in broadcast_packets) diff --git a/server/uv.lock b/server/uv.lock new file mode 100644 index 0000000..c759f3d --- /dev/null +++ b/server/uv.lock @@ -0,0 +1,302 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "chat-grid-server" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "pydantic" }, + { name = "websockets" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.10.4" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.4" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.25.2" }, + { name = "websockets", specifier = ">=15.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +]