commit f08297dd0df47f10dfb169d70354b20b70caf031 Author: MrEgosar Date: Sun Oct 25 16:50:22 2026 +0100 Backend: Match-Lobbys, Map-Veto, Echtzeit und CS2-Serveradapter Express + Socket.IO + SQLite. Veto-Engine mit konfigurierbaren Map-Pools und validierten Abläufen, rollenbasierte Berechtigungen, Server-Lebenszyklus mit Demo-, Docker- und Static-Pool-Adapter (RCON), automatisches Herunterfahren. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5aa41f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +client/dist/ +data/ +*.db +*.db-journal +*.db-wal +*.db-shm +.env +.env.local +npm-debug.log* diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..dfdbcb4 --- /dev/null +++ b/client/package.json @@ -0,0 +1,21 @@ +{ + "name": "@cs2-mapveto/client", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "socket.io-client": "^4.8.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^5.4.21" + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ca67deb --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3564 @@ +{ + "name": "cs2-mapveto", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cs2-mapveto", + "version": "0.1.0", + "workspaces": [ + "server", + "client" + ], + "devDependencies": { + "concurrently": "^9.1.0" + }, + "engines": { + "node": ">=18.18" + } + }, + "client": { + "name": "@cs2-mapveto/client", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "socket.io-client": "^4.8.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^5.4.21" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", + "integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.11.tgz", + "integrity": "sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cs2-mapveto/client": { + "resolved": "client", + "link": true + }, + "node_modules/@cs2-mapveto/server": { + "resolved": "server", + "link": true + }, + "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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.4", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz", + "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.65.0.tgz", + "integrity": "sha512-t2AnVba25baFdq7gczzS13JyVcdubLVbFX1X04mxrggDrTQPUXNv3XM0k9H1hr5tqvg7AVvtQ720QHAj0iYqhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.65.0.tgz", + "integrity": "sha512-dNONUaEwPGhl/nuO2KV8VABWlcjYyU6KtZGN8dQv5OS5JU7JSCxPijVNKXbRJd0iuTzTiJ4TjPt/4bbvDRNiMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.65.0.tgz", + "integrity": "sha512-zWQqUH+9zavr2Bxuzfpuv2SIF3X31cr58TvSQC9o1GhpOvkqRwr60GkZdQcudyT/BEgdpdSafjgd2I13MbTL7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.65.0.tgz", + "integrity": "sha512-RQYreBFWtO4//hIuOaZkYUnF/lPU4lz35dveDimDshWCJvVisVtJ/shmT9bkXK8wFJY+FJF89X6F3Cq1qI3LCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.65.0.tgz", + "integrity": "sha512-A0NfMj6Cuh/9BE5k3awJKv6HoxFVXIcLAb6SZqAMoxUHPA1ZNNK4RsGuUPD/vL7nEAm9LG+ZzXWWTrnC0XtkpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.65.0.tgz", + "integrity": "sha512-BI6/H7q3aP0I7S7GOrjyxTjgEJlSnovXE38+bdxBpuKN6z+d//uF+T+VQ3Q7ltJL/71p17ufartARX9Ja5gOGQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.65.0.tgz", + "integrity": "sha512-W1vUprf09RWaVWnsFP1vlUkEWJzTZlpObOEAYZXt9Fq0UVAOYEkeGr7wBX7McKqqIQskXdBnpiOMgwPujordgA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.65.0.tgz", + "integrity": "sha512-s8QTcZWgrl9haFzEGIqyE5BWyo89pFDGLMBFZ752WYxk8DJwNU/O1X6pZgPw829d9/sm6CCHyrMP3aurt76p1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.65.0.tgz", + "integrity": "sha512-ke1f6dLRzkqveyjjF2fkEwQYZvxHtNNwjegY8nJrwcO0Bdqr8CzFO/pKE0Bns13cx0qz2Ba2Y0Hs3dSwMdnHxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.65.0.tgz", + "integrity": "sha512-926SMeUcy4y18Rz/w2aJiUKOnyAnm6czg72bXkQEaeemsll+Wr0hnsjb2BGRpHvWgwAMYy5ACJCotbvfLCfj0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.65.0.tgz", + "integrity": "sha512-6T7JNRP1hc4hqF2dUsEoVLOt9K8tmODoCLldtQRYnrddGGQY8Gqe2pM/qMMUo7EMowpP7oa2N95k0/8sYK/ipw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.65.0.tgz", + "integrity": "sha512-SImGFl1Je5kpyAgOCPONVT7h6ASV8mAUwGRjY7eZvVjPg//PfHOXgI+P241RF/+HqsA4PP3qD527ysWFKNQyhQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.65.0.tgz", + "integrity": "sha512-XeDTbmPF1Yu5asfXGPrMkWoxTDhgryQbmw5Q1+R582O51VNCk23J6f2pDrsAp5YkneBVBwtExx6ZGOb9D2aQ6A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.65.0.tgz", + "integrity": "sha512-VS55JUI/1nsljmV/jfqNkLZXFw8uMO8nNlfoHmSevNf6fO3neuhyDvx9dv3c/Ytt8r/431SL8Qax1dpYvOMZTg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.65.0.tgz", + "integrity": "sha512-Vblw84TbX7RS4la1Y4+yQ7aore/oSyaNtFbppuBr5Vs/4LfBeXKq9dIfoExw6zOvRERqd2rRAjnZRaMhH/0x5w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.65.0.tgz", + "integrity": "sha512-7+gHjtONvKgwR9InTKFaLq53f6YzBJkgeMRBENdWTsQr+spahGqQm8HF5aXpq8i4JaRrOqYyv6Zn1WltDTxaZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.65.0.tgz", + "integrity": "sha512-KvNDC0J6s4vVHhtJ6KrDXU0Er0au/hmRIBq/+1maCV8TseGupeKizq0bWcrbp2HE1T8xoknvN0Q/KZ/YF0qX3w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.65.0.tgz", + "integrity": "sha512-GExdD15WPvu/x/drELjVam7vX8EsPgvTMOa/iJL/p/4m/oOqzbb7JXZg8fXR4hlXtg/sF2zyTZ3bR0AEtEMlhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.65.0.tgz", + "integrity": "sha512-8sT9PemIZfIMED4Vd4KGoOXVDj5CMSWNcGdbheRUx9TYS03yOWdT2Q0lXPyEyNBB9YTilwewIQKNIBk6Nxi0Kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.65.0.tgz", + "integrity": "sha512-pO6Ehi0jo5yE0R6PltKhJhv9mlIiyzBJk5N5NUF2OF6xtVKI+1bdXxj/rb0jGXy3hck1ELVT0QPY3tzkAm360w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.65.0.tgz", + "integrity": "sha512-vo7N3/+cjjPDYr8vtxC+qSNgWa9sDvJ7g8N8R5SWqFY4WiNH1ULcAU70SKVyHeiIZOYYwNP9pTipmhN5sl1NPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.65.0.tgz", + "integrity": "sha512-ArwvgFAbjKcDM7qqotZ4OVYf+UMT9xH2YzkCzK4+AuZSDihMlz1Ekoe4INwBVXzn2mnJzuIYOcSx07iTBijGRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.65.0.tgz", + "integrity": "sha512-QGMmK4/cxKE/36FoLMSHQBCyymv/eFTnsCvvsBF0rgmOvVbvX78VWZRerL3dHa77f4pM6Xcc2iGDhJ10w/svKQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.65.0.tgz", + "integrity": "sha512-XfKYFWHIPs4HPODIMsGF1IKlS3H1e3WokUZIgpmn4+uNnZBlnRDDHEylNyazbQXwhMKZre68OjtRYbKqb5+j8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.65.0.tgz", + "integrity": "sha512-wPWi++K4Bs1VR4XihOhfGi4IGX6Cub7FlerTf8X2v+2BtDHddgSqWCXwGD+h2yAe4/niIhNDwAltioOC+wEogQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.32.tgz", + "integrity": "sha512-UvuxFU9P3hM29LrQdwu0cD6w6/+SFr+UlB9s/knI7+O2rK9xFxKUwSDWxGdbbqQ9/V7BJ5s0NI5nQAE4uJp3gQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.9", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz", + "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.16.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/browserslist": { + "version": "4.29.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.4.tgz", + "integrity": "sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.29", + "caniuse-lite": "^1.0.30001816", + "electron-to-chromium": "^1.5.459", + "node-releases": "^2.0.59", + "update-browserslist-db": "^1.3.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001819", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001819.tgz", + "integrity": "sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "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/concurrently": { + "version": "9.2.5", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.5.tgz", + "integrity": "sha512-YMZlvWrTPQEhjKp60XSmMGt60+pffbv6DyRnOMvVO98+J7aF8w8Ui9FhdcOwvSixc/kyR6SoVvmejcn/2lZBqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.12.0", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.464", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.464.tgz", + "integrity": "sha512-nRa65aFge40viPAKxMKULIY8tcXKwKboJWNn4RoKwyWXJ6SP7BnrDGKUTzc64818jIciH0PK7LSF8QzZes19GA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/engine.io": { + "version": "6.6.11", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.11.tgz", + "integrity": "sha512-z34mGpLoY/82ZbkX1LqGMIaf3wjVMPL6gVC681QDqnmzJ26synI8qAE32P+a7UazmpJZ8UGrFIeAavgRfXzYrg==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-client": { + "version": "6.6.7", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.7.tgz", + "integrity": "sha512-GyAvmPA7gnRCpDQbYACAy79Lg4ewevrmaPzySZz0OVvgyZc3gEfLLqPTYk8wVBScjOEpM9ZO4KryyFpVItjGEA==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/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/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", + "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.13", + "proxy-addr": "~2.0.7", + "qs": "~6.16.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "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/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.101.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.101.0.tgz", + "integrity": "sha512-bkb/RkuWCE64eWu1PCh3Fu2pGc9mKRs+kOE+FU2bGHRYlQa86/D321DSATV54AsCQMKz3/xSt9sRgraZTpqdhQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-releases": { + "version": "2.0.60", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.60.tgz", + "integrity": "sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz", + "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "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.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz", + "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz", + "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4", + "react-router": "6.30.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.65.0.tgz", + "integrity": "sha512-QVCvQUTGDN6JRwi3jVGCY3H4aTHvi9rX0LyoBMkGUvdh/Z6O51CzcLz85BWZaTCDGhl6ySGqYZs+xI1jmPqGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.65.0", + "@rollup/rollup-android-arm64": "4.65.0", + "@rollup/rollup-darwin-arm64": "4.65.0", + "@rollup/rollup-darwin-x64": "4.65.0", + "@rollup/rollup-freebsd-arm64": "4.65.0", + "@rollup/rollup-freebsd-x64": "4.65.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.65.0", + "@rollup/rollup-linux-arm-musleabihf": "4.65.0", + "@rollup/rollup-linux-arm64-gnu": "4.65.0", + "@rollup/rollup-linux-arm64-musl": "4.65.0", + "@rollup/rollup-linux-loong64-gnu": "4.65.0", + "@rollup/rollup-linux-loong64-musl": "4.65.0", + "@rollup/rollup-linux-ppc64-gnu": "4.65.0", + "@rollup/rollup-linux-ppc64-musl": "4.65.0", + "@rollup/rollup-linux-riscv64-gnu": "4.65.0", + "@rollup/rollup-linux-riscv64-musl": "4.65.0", + "@rollup/rollup-linux-s390x-gnu": "4.65.0", + "@rollup/rollup-linux-x64-gnu": "4.65.0", + "@rollup/rollup-linux-x64-musl": "4.65.0", + "@rollup/rollup-openbsd-x64": "4.65.0", + "@rollup/rollup-openharmony-arm64": "4.65.0", + "@rollup/rollup-win32-arm64-msvc": "4.65.0", + "@rollup/rollup-win32-ia32-msvc": "4.65.0", + "@rollup/rollup-win32-x64-gnu": "4.65.0", + "@rollup/rollup-win32-x64-msvc": "4.65.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shell-quote": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz", + "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/socket.io": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.4.tgz", + "integrity": "sha512-9ZV9zg8vN8hO3Y25DhR7nUkJ1VDnBhq5O/JrXhFT9s6+F0gqytSASTHVfCFc2mJXaTfARroErplXlzFWwETZqQ==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.21.0" + } + }, + "node_modules/socket.io-client": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.4.tgz", + "integrity": "sha512-7wjKAvVXYOJsbmUgkWbnhPD4bFpkqrUR0++XOjpGFmqDqsqV1CI93VTraeExLxpjxEqrWgFoSRZu1ihjLWfkAA==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/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/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "server": { + "name": "@cs2-mapveto/server", + "version": "0.1.0", + "dependencies": { + "better-sqlite3": "^11.10.0", + "dotenv": "^16.4.5", + "express": "^4.21.2", + "socket.io": "^4.8.1" + }, + "devDependencies": { + "socket.io-client": "^4.8.1" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c377056 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "cs2-mapveto", + "private": true, + "version": "0.1.0", + "description": "Match-Lobbys, Map-Veto und CS2-Serververwaltung", + "workspaces": [ + "server", + "client" + ], + "scripts": { + "dev": "concurrently -n server,client -c cyan,magenta \"npm run dev --workspace server\" \"npm run dev --workspace client\"", + "dev:server": "npm run dev --workspace server", + "dev:client": "npm run dev --workspace client", + "build": "npm run build --workspace client", + "start": "npm run start --workspace server", + "test": "npm test --workspace server" + }, + "devDependencies": { + "concurrently": "^9.1.0" + }, + "engines": { + "node": ">=18.18" + } +} diff --git a/server/config/map-pools.json b/server/config/map-pools.json new file mode 100644 index 0000000..5f0f757 --- /dev/null +++ b/server/config/map-pools.json @@ -0,0 +1,41 @@ +{ + "$comment": "Map-Pools und Veto-Abläufe. Schritte: ban:X, pick:X, side:X, decider. X = erstes Team (startet das Veto), Y = zweites Team. 'side' folgt immer auf einen Pick bzw. auf 'decider' und bezieht sich auf diese Map. Ungültige Abläufe werden beim Start gemeldet und das Format ist dann nicht auswählbar.", + "pools": { + "competitive": { + "label": "Competitive 5 gegen 5", + "teamSize": 5, + "gameType": 0, + "gameMode": 1, + "maps": [ + { "id": "de_ancient", "name": "Ancient", "color": "#3f8f5a" }, + { "id": "de_anubis", "name": "Anubis", "color": "#c79a3d" }, + { "id": "de_dust2", "name": "Dust II", "color": "#d3a45c" }, + { "id": "de_inferno", "name": "Inferno", "color": "#c4573a" }, + { "id": "de_mirage", "name": "Mirage", "color": "#b98a4a" }, + { "id": "de_nuke", "name": "Nuke", "color": "#4a8fbf" }, + { "id": "de_train", "name": "Train", "color": "#7a7f8a" } + ], + "flows": { + "bo1": ["ban:X", "ban:Y", "ban:X", "ban:Y", "ban:X", "ban:Y", "decider", "side:X"], + "bo3": ["ban:X", "ban:Y", "pick:X", "side:Y", "pick:Y", "side:X", "ban:X", "ban:Y", "decider", "side:X"], + "bo5": ["ban:X", "ban:Y", "pick:X", "side:Y", "pick:Y", "side:X", "pick:X", "side:Y", "pick:Y", "side:X", "decider", "side:Y"] + } + }, + "wingman": { + "label": "Wingman 2 gegen 2", + "teamSize": 2, + "gameType": 0, + "gameMode": 2, + "maps": [ + { "id": "de_inferno", "name": "Inferno", "color": "#c4573a" }, + { "id": "de_nuke", "name": "Nuke", "color": "#4a8fbf" }, + { "id": "de_overpass", "name": "Overpass", "color": "#5f8f7a" }, + { "id": "de_vertigo", "name": "Vertigo", "color": "#8a74b8" } + ], + "flows": { + "bo1": ["ban:X", "ban:Y", "ban:X", "decider", "side:Y"], + "bo3": ["ban:X", "pick:Y", "side:X", "pick:X", "side:Y", "decider", "side:X"] + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..bef4e60 --- /dev/null +++ b/server/package.json @@ -0,0 +1,21 @@ +{ + "name": "@cs2-mapveto/server", + "private": true, + "version": "0.1.0", + "type": "module", + "main": "src/index.js", + "scripts": { + "dev": "node --watch src/index.js", + "start": "node src/index.js", + "test": "node --test test/" + }, + "dependencies": { + "better-sqlite3": "^11.10.0", + "dotenv": "^16.4.5", + "express": "^4.21.2", + "socket.io": "^4.8.1" + }, + "devDependencies": { + "socket.io-client": "^4.8.1" + } +} diff --git a/server/src/app.js b/server/src/app.js new file mode 100644 index 0000000..22b57c3 --- /dev/null +++ b/server/src/app.js @@ -0,0 +1,60 @@ +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import express from 'express'; +import { REPO_ROOT } from './config.js'; +import { openDatabase } from './db.js'; +import { createApiRouter } from './http.js'; +import { loadMapPools } from './mapPools.js'; +import { MatchService } from './matchService.js'; +import { attachRealtime } from './realtime.js'; +import { createServerAdapter } from './servers/adapter.js'; +import { ServerManager } from './servers/serverManager.js'; + +/** Baut die komplette Anwendung zusammen (auch für Tests nutzbar). */ +export function createApp(config, { adapter: adapterOverride, logger = console } = {}) { + const db = openDatabase(config.databaseFile); + const pools = loadMapPools(config.mapPoolFile); + const service = new MatchService({ db, pools, config }); + + let manager; + const adapter = adapterOverride ?? createServerAdapter(config, { getUsedPorts: () => manager.usedPorts() }); + manager = new ServerManager({ db, service, adapter, config, logger }); + manager.init(); + + const app = express(); + if (config.trustProxy) app.set('trust proxy', 1); + app.disable('x-powered-by'); + app.use((req, res, next) => { + res.set('X-Content-Type-Options', 'nosniff'); + res.set('Referrer-Policy', 'no-referrer'); + next(); + }); + app.use('/api', createApiRouter({ service, manager, config, pools, adapter })); + + // Gebautes Frontend ausliefern (npm run build), SPA-Fallback auf index.html. + const dist = path.join(REPO_ROOT, 'client', 'dist'); + if (fs.existsSync(dist)) { + app.use(express.static(dist, { index: false, maxAge: '1h' })); + app.get('*', (req, res) => res.sendFile(path.join(dist, 'index.html'))); + } + + const server = http.createServer(app); + const io = attachRealtime(server, { service, config }); + + return { + app, server, io, db, service, manager, adapter, + async start() { + await manager.resume(); + manager.startLoops(); + await new Promise((resolve) => server.listen(config.port, resolve)); + return server.address().port; + }, + async close() { + manager.stopLoops(); + io.close(); + await new Promise((resolve) => server.close(() => resolve())); + db.close(); + }, + }; +} diff --git a/server/src/config.js b/server/src/config.js new file mode 100644 index 0000000..428592e --- /dev/null +++ b/server/src/config.js @@ -0,0 +1,110 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import dotenv from 'dotenv'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +export const SERVER_ROOT = path.resolve(here, '..'); +export const REPO_ROOT = path.resolve(SERVER_ROOT, '..'); + +dotenv.config({ path: path.join(REPO_ROOT, '.env') }); + +function int(name, fallback) { + const raw = process.env[name]; + if (raw === undefined || raw === '') return fallback; + const value = Number.parseInt(raw, 10); + if (Number.isNaN(value)) throw new Error(`Umgebungsvariable ${name} muss eine Zahl sein (ist: "${raw}")`); + return value; +} + +function bool(name, fallback) { + const raw = process.env[name]; + if (raw === undefined || raw === '') return fallback; + return ['1', 'true', 'yes', 'ja', 'on'].includes(raw.toLowerCase()); +} + +function str(name, fallback = '') { + const raw = process.env[name]; + return raw === undefined || raw === '' ? fallback : raw; +} + +export function loadConfig(overrides = {}) { + const config = { + port: int('PORT', 3000), + publicUrl: str('PUBLIC_URL', 'http://localhost:5173').replace(/\/$/, ''), + databaseFile: path.resolve(REPO_ROOT, str('DATABASE_FILE', 'data/cs2-mapveto.db')), + mapPoolFile: path.resolve(REPO_ROOT, str('MAP_POOL_FILE', 'server/config/map-pools.json')), + createMatchPassword: str('CREATE_MATCH_PASSWORD'), + requireFullTeams: bool('REQUIRE_FULL_TEAMS', true), + trustProxy: bool('TRUST_PROXY', false), + + serverAdapter: str('SERVER_ADAPTER', 'demo'), + idleTimeoutMinutes: int('SERVER_IDLE_TIMEOUT_MINUTES', 30), + maxLifetimeHours: int('SERVER_MAX_LIFETIME_HOURS', 8), + statusPollSeconds: int('SERVER_STATUS_POLL_SECONDS', 5), + reaperIntervalSeconds: int('SERVER_REAPER_INTERVAL_SECONDS', 60), + + match: { + tvDelaySeconds: int('CS2_TV_DELAY', 90), + tvEnabled: bool('CS2_TV_ENABLE', true), + maxRoundsCompetitive: int('CS2_MAXROUNDS_COMPETITIVE', 24), + maxRoundsWingman: int('CS2_MAXROUNDS_WINGMAN', 16), + overtime: bool('CS2_OVERTIME', true), + extraCommands: str('CS2_EXTRA_COMMANDS') + .split(';') + .map((c) => c.trim()) + .filter(Boolean), + matchzy: bool('MATCHZY_ENABLED', false), + }, + + demo: { + startSeconds: int('DEMO_START_SECONDS', 20), + failFirstConfigure: bool('DEMO_FAIL_FIRST_CONFIGURE', false), + }, + + docker: { + bin: str('DOCKER_BIN', 'docker'), + image: str('CS2_DOCKER_IMAGE', 'cm2network/cs2:latest'), + host: str('CS2_PUBLIC_HOST'), + rconHost: str('CS2_RCON_HOST', '127.0.0.1'), + portRangeStart: int('CS2_PORT_RANGE_START', 27015), + portRangeEnd: int('CS2_PORT_RANGE_END', 27115), + gslt: str('CS2_GSLT'), + volume: str('CS2_DOCKER_VOLUME', 'cs2-mapveto-gamefiles'), + extraArgs: str('CS2_DOCKER_EXTRA_ARGS'), + startTimeoutMinutes: int('CS2_START_TIMEOUT_MINUTES', 20), + }, + + staticPool: { + // Format: host:port:rconPasswort[:tvPort], mehrere durch Komma getrennt + servers: str('CS2_STATIC_SERVERS') + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + .map(parseStaticServer), + }, + }; + return deepMerge(config, overrides); +} + +function parseStaticServer(entry) { + const parts = entry.split(':'); + if (parts.length < 3) { + throw new Error(`CS2_STATIC_SERVERS-Eintrag "${entry}" hat nicht das Format host:port:rconPasswort[:tvPort]`); + } + const [host, port, ...rest] = parts; + let tvPort = null; + // Das Passwort darf Doppelpunkte enthalten; ein abschließender rein numerischer Teil ist der TV-Port. + if (rest.length > 1 && /^\d+$/.test(rest[rest.length - 1])) tvPort = Number(rest.pop()); + return { host, port: Number(port), rconPassword: rest.join(':'), tvPort: tvPort ?? Number(port) + 5 }; +} + +function deepMerge(target, source) { + for (const [key, value] of Object.entries(source)) { + if (value && typeof value === 'object' && !Array.isArray(value) && typeof target[key] === 'object') { + deepMerge(target[key], value); + } else { + target[key] = value; + } + } + return target; +} diff --git a/server/src/db.js b/server/src/db.js new file mode 100644 index 0000000..4189589 --- /dev/null +++ b/server/src/db.js @@ -0,0 +1,109 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import Database from 'better-sqlite3'; + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS matches ( + id TEXT PRIMARY KEY, + team_a_name TEXT NOT NULL, + team_b_name TEXT NOT NULL, + mode TEXT NOT NULL, + format TEXT NOT NULL, + flow TEXT NOT NULL, + maps TEXT NOT NULL, + veto_start TEXT NOT NULL, + first_team TEXT, + status TEXT NOT NULL, + organizer_token_hash TEXT NOT NULL, + code_a TEXT NOT NULL UNIQUE, + code_b TEXT NOT NULL UNIQUE, + code_spectator TEXT NOT NULL UNIQUE, + current_map_number INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + veto_started_at INTEGER, + veto_finished_at INTEGER, + finished_at INTEGER, + last_activity_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS players ( + id TEXT PRIMARY KEY, + match_id TEXT NOT NULL REFERENCES matches(id) ON DELETE CASCADE, + team TEXT NOT NULL CHECK (team IN ('A','B')), + nickname TEXT NOT NULL, + steam_id TEXT, + token_hash TEXT NOT NULL UNIQUE, + is_captain INTEGER NOT NULL DEFAULT 0, + ready INTEGER NOT NULL DEFAULT 0, + active INTEGER NOT NULL DEFAULT 1, + joined_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS players_match ON players(match_id, team, active); + +CREATE TABLE IF NOT EXISTS veto_actions ( + match_id TEXT NOT NULL REFERENCES matches(id) ON DELETE CASCADE, + idx INTEGER NOT NULL, + type TEXT NOT NULL, + team TEXT, + map TEXT, + side TEXT, + player_id TEXT, + created_at INTEGER NOT NULL, + PRIMARY KEY (match_id, idx) +); + +CREATE TABLE IF NOT EXISTS series_maps ( + match_id TEXT NOT NULL REFERENCES matches(id) ON DELETE CASCADE, + number INTEGER NOT NULL, + map TEXT NOT NULL, + picked_by TEXT, + side_chosen_by TEXT, + ct_team TEXT, + status TEXT NOT NULL DEFAULT 'pending', + PRIMARY KEY (match_id, number) +); + +-- Serverdaten inkl. Zugangsdaten. Diese Tabelle verlässt nie ungefiltert das Backend. +CREATE TABLE IF NOT EXISTS servers ( + match_id TEXT PRIMARY KEY REFERENCES matches(id) ON DELETE CASCADE, + adapter TEXT NOT NULL, + simulated INTEGER NOT NULL, + status TEXT NOT NULL, + message TEXT, + handle TEXT, + host TEXT, + port INTEGER, + tv_port INTEGER, + rcon_password TEXT NOT NULL, + server_password TEXT NOT NULL, + tv_password TEXT NOT NULL, + config_key TEXT NOT NULL, + player_count INTEGER, + attempts INTEGER NOT NULL DEFAULT 0, + started_at INTEGER, + ready_at INTEGER, + configured_at INTEGER, + stopped_at INTEGER, + last_player_seen_at INTEGER, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + match_id TEXT NOT NULL REFERENCES matches(id) ON DELETE CASCADE, + type TEXT NOT NULL, + message TEXT NOT NULL, + created_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS events_match ON events(match_id, id); +`; + +export function openDatabase(file) { + if (file !== ':memory:') fs.mkdirSync(path.dirname(file), { recursive: true }); + const db = new Database(file); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + db.exec(SCHEMA); + return db; +} diff --git a/server/src/http.js b/server/src/http.js new file mode 100644 index 0000000..054d2e6 --- /dev/null +++ b/server/src/http.js @@ -0,0 +1,109 @@ +import express from 'express'; +import { HttpError } from './matchService.js'; +import { publicPools } from './mapPools.js'; +import { createRateLimiter, safeEqual } from './security.js'; + +function bearer(req) { + const header = req.get('authorization') ?? ''; + return header.startsWith('Bearer ') ? header.slice(7).trim() : null; +} + +export function createApiRouter({ service, manager, config, pools, adapter }) { + const router = express.Router(); + router.use(express.json({ limit: '32kb' })); + + const limiters = { + create: createRateLimiter({ windowMs: 60_000, max: 10 }), + code: createRateLimiter({ windowMs: 60_000, max: 20 }), + action: createRateLimiter({ windowMs: 60_000, max: 120 }), + }; + const limit = (name) => (req, res, next) => { + if (!limiters[name](`${name}:${req.ip}`)) { + return next(new HttpError(429, 'Zu viele Anfragen – bitte kurz warten', 'rate_limited')); + } + next(); + }; + + const wrap = (fn) => async (req, res, next) => { + try { + const result = await fn(req, res); + if (!res.headersSent) res.json(result ?? { ok: true }); + } catch (err) { + next(err); + } + }; + + // Ermittelt die Rolle des Aufrufers für das Match in der URL. + const viewerFor = (req) => { + const viewer = service.resolveViewer(req.params.id, bearer(req)); + service.touch(req.params.id); + return viewer; + }; + + router.get('/config', wrap(() => ({ + pools: publicPools(pools), + createRequiresPassword: Boolean(config.createMatchPassword), + requireFullTeams: config.requireFullTeams, + server: { adapter: adapter.name, simulated: adapter.simulated, idleTimeoutMinutes: config.idleTimeoutMinutes }, + }))); + + router.get('/matches', wrap(() => ({ matches: service.listMatches() }))); + + router.post('/matches', limit('create'), wrap((req) => { + const { teamA, teamB, mode, format, vetoStart, password } = req.body ?? {}; + if (config.createMatchPassword && !safeEqual(password ?? '', config.createMatchPassword)) { + throw new HttpError(403, 'Falsches Passwort zum Erstellen von Matches', 'forbidden'); + } + const { id, organizerToken } = service.createMatch({ teamA, teamB, mode, format, vetoStart }); + return { id, organizerToken }; + })); + + router.post('/access', limit('code'), wrap((req) => { + const { matchId, access, team, match } = service.lookupCode(req.body?.code); + return { + matchId, + access, + team, + teamName: team ? service.teamName(match, team) : null, + title: `${match.team_a_name} vs. ${match.team_b_name}`, + status: match.status, + }; + })); + + router.get('/matches/:id', wrap((req) => service.snapshot(req.params.id, viewerFor(req)))); + + router.post('/matches/:id/join', limit('code'), wrap((req) => { + service.getMatch(req.params.id); + return service.join(req.params.id, req.body ?? {}); + })); + + const action = (path, handler) => router.post(`/matches/:id/${path}`, limit('action'), wrap((req) => handler(viewerFor(req), req.body ?? {}))); + + action('ready', (v, b) => service.setReady(v, Boolean(b.ready))); + action('leave', (v) => service.leave(v)); + action('kick', (v, b) => service.kick(v, b.playerId)); + action('captain', (v, b) => service.setCaptain(v, b.playerId)); + action('codes/regenerate', (v, b) => service.regenerateCode(v, b.target)); + action('veto/start', (v) => service.startVeto(v)); + action('veto/action', (v, b) => service.vetoAction(v, b)); + action('server/retry', (v) => manager.retry(v)); + action('server/stop', (v) => manager.stopByOrganizer(v)); + action('server/warmup-end', (v) => manager.endWarmup(v)); + action('series/next', (v) => manager.loadNextMap(v)); + action('finish', (v) => service.close(v, 'finished')); + action('cancel', (v) => service.close(v, 'cancelled')); + + router.use((req, res, next) => next(new HttpError(404, 'Unbekannter Endpunkt', 'not_found'))); + + // eslint-disable-next-line no-unused-vars + router.use((err, req, res, next) => { + const status = err.status ?? 500; + if (status >= 500) console.error(err); + res.status(status).json({ + error: status >= 500 && !err.code ? 'Interner Fehler' : err.message, + code: err.code ?? 'error', + }); + }); + + return router; +} diff --git a/server/src/index.js b/server/src/index.js new file mode 100644 index 0000000..0f3a4fb --- /dev/null +++ b/server/src/index.js @@ -0,0 +1,20 @@ +import { loadConfig } from './config.js'; +import { createApp } from './app.js'; + +const config = loadConfig(); +const app = createApp(config); +const port = await app.start(); + +const label = app.adapter.simulated + ? 'DEMO (simuliert – es werden keine echten CS2-Server gestartet)' + : app.adapter.name; +console.log(`CS2 Mapveto Backend läuft auf http://localhost:${port}`); +console.log(`Serveradapter: ${label}`); + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, async () => { + console.log('Fahre herunter …'); + await app.close(); + process.exit(0); + }); +} diff --git a/server/src/mapPools.js b/server/src/mapPools.js new file mode 100644 index 0000000..2f87a05 --- /dev/null +++ b/server/src/mapPools.js @@ -0,0 +1,58 @@ +import fs from 'node:fs'; +import { BEST_OF, validateFlow } from './veto.js'; + +/** + * Lädt die Map-Pools und markiert jede Kombination aus Modus und Format als unterstützt oder nicht. + * Nur gültige Kombinationen können beim Erstellen eines Matches gewählt werden. + */ +export function loadMapPools(file) { + const raw = JSON.parse(fs.readFileSync(file, 'utf8')); + return buildMapPools(raw); +} + +export function buildMapPools(raw) { + const pools = {}; + for (const [mode, pool] of Object.entries(raw.pools ?? {})) { + const ids = new Set(); + for (const map of pool.maps ?? []) { + if (!/^[a-z0-9_]+$/.test(map.id)) throw new Error(`Ungültige Map-ID "${map.id}" im Pool "${mode}"`); + if (ids.has(map.id)) throw new Error(`Map "${map.id}" ist im Pool "${mode}" doppelt`); + ids.add(map.id); + } + const formats = {}; + for (const format of Object.keys(BEST_OF)) { + const flow = pool.flows?.[format]; + if (!flow) { + formats[format] = { supported: false, reason: 'Kein Veto-Ablauf für dieses Format hinterlegt' }; + continue; + } + const errors = validateFlow(flow, pool.maps.length, format); + formats[format] = errors.length + ? { supported: false, reason: errors.join('; ') } + : { supported: true, flow }; + if (errors.length) console.warn(`[map-pools] ${mode}/${format} deaktiviert: ${errors.join('; ')}`); + } + pools[mode] = { + mode, + label: pool.label ?? mode, + teamSize: pool.teamSize, + gameType: pool.gameType, + gameMode: pool.gameMode, + maps: pool.maps, + formats, + }; + } + return pools; +} + +export function publicPools(pools) { + return Object.values(pools).map((p) => ({ + mode: p.mode, + label: p.label, + teamSize: p.teamSize, + maps: p.maps, + formats: Object.fromEntries( + Object.entries(p.formats).map(([f, v]) => [f, { supported: v.supported, reason: v.reason ?? null, flow: v.flow ?? null }]), + ), + })); +} diff --git a/server/src/matchService.js b/server/src/matchService.js new file mode 100644 index 0000000..c6260fd --- /dev/null +++ b/server/src/matchService.js @@ -0,0 +1,615 @@ +import { EventEmitter } from 'node:events'; +import { BEST_OF, computeVetoState, resolveAction, VetoError } from './veto.js'; +import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken, safeEqual } from './security.js'; + +export class HttpError extends Error { + constructor(status, message, code = 'error') { + super(message); + this.status = status; + this.code = code; + } +} + +export const MATCH_STATUS = { + lobby: 'Lobby offen', + veto: 'Map-Veto läuft', + server_setup: 'Server wird eingerichtet', + live: 'Match läuft', + finished: 'Abgeschlossen', + cancelled: 'Abgebrochen', +}; +export const OPEN_STATUSES = ['lobby', 'veto']; +export const RUNNING_STATUSES = ['server_setup', 'live']; +export const CLOSED_STATUSES = ['finished', 'cancelled']; + +const ROLE_RANK = { public: 0, spectator: 1, player: 2, captain: 3, organizer: 4 }; + +function cleanName(value, field, max = 32) { + const name = String(value ?? '').replace(/\s+/g, ' ').trim(); + if (name.length < 2) throw new HttpError(400, `${field} muss mindestens 2 Zeichen haben`, 'invalid_input'); + if (name.length > max) throw new HttpError(400, `${field} darf höchstens ${max} Zeichen haben`, 'invalid_input'); + return name; +} + +function cleanSteamId(value) { + const raw = String(value ?? '').trim(); + if (!raw) return null; + if (!/^\d{17}$/.test(raw)) throw new HttpError(400, 'Steam-ID muss eine 17-stellige SteamID64 sein', 'invalid_input'); + return raw; +} + +export class MatchService extends EventEmitter { + constructor({ db, pools, config }) { + super(); + this.db = db; + this.pools = pools; + this.config = config; + this.presence = () => new Set(); + } + + now() { + return Date.now(); + } + + // ---------- Lesen ---------- + + getMatch(id) { + const match = this.db.prepare('SELECT * FROM matches WHERE id = ?').get(id); + if (!match) throw new HttpError(404, 'Match nicht gefunden', 'not_found'); + return match; + } + + players(matchId) { + return this.db + .prepare('SELECT * FROM players WHERE match_id = ? AND active = 1 ORDER BY joined_at') + .all(matchId); + } + + vetoActions(matchId) { + return this.db.prepare('SELECT * FROM veto_actions WHERE match_id = ? ORDER BY idx').all(matchId); + } + + seriesMaps(matchId) { + return this.db.prepare('SELECT * FROM series_maps WHERE match_id = ? ORDER BY number').all(matchId); + } + + serverRow(matchId) { + return this.db.prepare('SELECT * FROM servers WHERE match_id = ?').get(matchId) ?? null; + } + + poolFor(match) { + const pool = this.pools[match.mode]; + return { ...pool, maps: JSON.parse(match.maps) }; + } + + vetoState(match) { + return computeVetoState({ + maps: JSON.parse(match.maps), + flow: JSON.parse(match.flow), + firstTeam: match.first_team ?? 'A', + actions: this.vetoActions(match.id), + }); + } + + /** Ermittelt die Rolle eines Tokens für ein Match. Unbekannte Tokens ergeben "public". */ + resolveViewer(matchId, token) { + const match = this.getMatch(matchId); + const viewer = { matchId, role: 'public', playerId: null, team: null }; + if (!token) return viewer; + if (safeEqual(hashToken(token), match.organizer_token_hash)) return { ...viewer, role: 'organizer' }; + const player = this.db + .prepare('SELECT * FROM players WHERE match_id = ? AND token_hash = ? AND active = 1') + .get(matchId, hashToken(token)); + if (player) { + return { ...viewer, role: player.is_captain ? 'captain' : 'player', playerId: player.id, team: player.team }; + } + if (safeEqual(normalizeCode(token), match.code_spectator)) return { ...viewer, role: 'spectator' }; + return viewer; + } + + require(viewer, minimum) { + if (ROLE_RANK[viewer.role] < ROLE_RANK[minimum]) { + const labels = { organizer: 'den Organisator', captain: 'Team-Captains', player: 'Spieler', spectator: 'Teilnehmer' }; + throw new HttpError(403, `Diese Aktion ist nur für ${labels[minimum]} erlaubt`, 'forbidden'); + } + } + + // ---------- Match anlegen ---------- + + createMatch({ teamA, teamB, mode, format, vetoStart = 'coin' }) { + const pool = this.pools[mode]; + if (!pool) throw new HttpError(400, 'Unbekannter Spielmodus', 'invalid_input'); + const formatInfo = pool.formats[format]; + if (!formatInfo?.supported) { + throw new HttpError(400, `${String(format).toUpperCase()} wird für ${pool.label} nicht unterstützt`, 'unsupported_format'); + } + if (!['coin', 'A', 'B'].includes(vetoStart)) throw new HttpError(400, 'Ungültige Veto-Startregel', 'invalid_input'); + const nameA = cleanName(teamA, 'Name von Team A'); + const nameB = cleanName(teamB, 'Name von Team B'); + if (nameA.toLowerCase() === nameB.toLowerCase()) { + throw new HttpError(400, 'Die Teams brauchen unterschiedliche Namen', 'invalid_input'); + } + + const id = randomId(); + const organizerToken = randomToken(); + const now = this.now(); + const codes = this.uniqueCodes(3); + this.db + .prepare( + `INSERT INTO matches (id, team_a_name, team_b_name, mode, format, flow, maps, veto_start, first_team, status, + organizer_token_hash, code_a, code_b, code_spectator, created_at, updated_at, last_activity_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'lobby', ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + id, nameA, nameB, mode, format, + JSON.stringify(formatInfo.flow), JSON.stringify(pool.maps), + vetoStart, vetoStart === 'coin' ? null : vetoStart, + hashToken(organizerToken), codes[0], codes[1], codes[2], now, now, now, + ); + this.log(id, 'created', `Match ${nameA} vs. ${nameB} erstellt (${pool.label}, ${format.toUpperCase()})`); + this.emit('created', id); + this.changed(id); + return { id, organizerToken }; + } + + uniqueCodes(count) { + const exists = this.db.prepare( + 'SELECT 1 FROM matches WHERE code_a = ? OR code_b = ? OR code_spectator = ?', + ); + const codes = new Set(); + while (codes.size < count) { + const code = randomCode(8); + if (!exists.get(code, code, code)) codes.add(code); + } + return [...codes]; + } + + /** Findet Match und Zugangsart zu einem Einladungs- oder Zuschauercode. */ + lookupCode(rawCode) { + const code = normalizeCode(rawCode); + if (code.length !== 8) throw new HttpError(404, 'Code ungültig', 'invalid_code'); + const match = this.db + .prepare('SELECT * FROM matches WHERE code_a = ? OR code_b = ? OR code_spectator = ?') + .get(code, code, code); + if (!match) throw new HttpError(404, 'Code ungültig', 'invalid_code'); + const access = match.code_spectator === code ? 'spectator' : 'team'; + const team = access === 'team' ? (match.code_a === code ? 'A' : 'B') : null; + return { matchId: match.id, access, team, match }; + } + + // ---------- Lobby ---------- + + join(matchId, { code, nickname, steamId }) { + const found = this.lookupCode(code); + if (found.matchId !== matchId || found.access !== 'team') { + throw new HttpError(403, 'Dieser Code gehört nicht zu einem Team dieses Matches', 'invalid_code'); + } + const name = cleanName(nickname, 'Spielername', 24); + const steam = cleanSteamId(steamId); + const token = randomToken(); + const playerId = randomId(); + + this.db.transaction(() => { + const match = this.getMatch(matchId); + if (CLOSED_STATUSES.includes(match.status)) throw new HttpError(409, 'Das Match ist bereits beendet', 'match_closed'); + const teamPlayers = this.players(matchId).filter((p) => p.team === found.team); + const pool = this.pools[match.mode]; + if (teamPlayers.length >= pool.teamSize) { + throw new HttpError(409, `Das Team ist voll (${pool.teamSize}/${pool.teamSize})`, 'team_full'); + } + const all = this.players(matchId); + if (all.some((p) => p.nickname.toLowerCase() === name.toLowerCase())) { + throw new HttpError(409, 'Dieser Spielername ist in diesem Match bereits vergeben', 'name_taken'); + } + if (steam && all.some((p) => p.steam_id === steam)) { + throw new HttpError(409, 'Diese Steam-ID ist in diesem Match bereits eingetragen', 'steam_taken'); + } + const isCaptain = teamPlayers.length === 0 ? 1 : 0; + this.db + .prepare( + `INSERT INTO players (id, match_id, team, nickname, steam_id, token_hash, is_captain, ready, active, joined_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, 1, ?)`, + ) + .run(playerId, matchId, found.team, name, steam, hashToken(token), isCaptain, this.now()); + this.log(matchId, 'join', `${name} ist ${this.teamName(match, found.team)} beigetreten${isCaptain ? ' (Captain)' : ''}`); + })(); + this.changed(matchId); + return { playerToken: token, playerId, team: found.team }; + } + + setReady(viewer, ready) { + this.require(viewer, 'player'); + const match = this.getMatch(viewer.matchId); + if (match.status !== 'lobby') throw new HttpError(409, 'Bereitschaft kann nur in der Lobby geändert werden', 'wrong_phase'); + this.db.prepare('UPDATE players SET ready = ? WHERE id = ?').run(ready ? 1 : 0, viewer.playerId); + this.changed(viewer.matchId); + } + + leave(viewer) { + this.require(viewer, 'player'); + this.removePlayer(viewer.matchId, viewer.playerId, 'hat das Match verlassen'); + } + + kick(viewer, playerId) { + this.require(viewer, 'organizer'); + this.removePlayer(viewer.matchId, playerId, 'wurde vom Organisator entfernt'); + } + + removePlayer(matchId, playerId, reason) { + this.db.transaction(() => { + const match = this.getMatch(matchId); + if (match.status !== 'lobby') { + throw new HttpError(409, 'Spieler können nur in der Lobby entfernt werden', 'wrong_phase'); + } + const player = this.db.prepare('SELECT * FROM players WHERE id = ? AND match_id = ? AND active = 1').get(playerId, matchId); + if (!player) throw new HttpError(404, 'Spieler nicht gefunden', 'not_found'); + this.db.prepare('UPDATE players SET active = 0, is_captain = 0, ready = 0 WHERE id = ?').run(playerId); + if (player.is_captain) { + const next = this.players(matchId).find((p) => p.team === player.team); + if (next) this.db.prepare('UPDATE players SET is_captain = 1 WHERE id = ?').run(next.id); + } + this.log(matchId, 'leave', `${player.nickname} ${reason}`); + })(); + this.changed(matchId); + } + + setCaptain(viewer, playerId) { + this.db.transaction(() => { + const target = this.db + .prepare('SELECT * FROM players WHERE id = ? AND match_id = ? AND active = 1') + .get(playerId, viewer.matchId); + if (!target) throw new HttpError(404, 'Spieler nicht gefunden', 'not_found'); + const allowed = viewer.role === 'organizer' || (viewer.role === 'captain' && viewer.team === target.team); + if (!allowed) throw new HttpError(403, 'Nur der Organisator oder der Captain des Teams kann den Captain ändern', 'forbidden'); + this.db.prepare('UPDATE players SET is_captain = 0 WHERE match_id = ? AND team = ?').run(viewer.matchId, target.team); + this.db.prepare('UPDATE players SET is_captain = 1 WHERE id = ?').run(target.id); + this.log(viewer.matchId, 'captain', `${target.nickname} ist jetzt Captain`); + })(); + this.changed(viewer.matchId); + } + + regenerateCode(viewer, target) { + this.require(viewer, 'organizer'); + const column = { A: 'code_a', B: 'code_b', spectator: 'code_spectator' }[target]; + if (!column) throw new HttpError(400, 'Unbekannter Code', 'invalid_input'); + const [code] = this.uniqueCodes(1); + this.db.prepare(`UPDATE matches SET ${column} = ?, updated_at = ? WHERE id = ?`).run(code, this.now(), viewer.matchId); + this.log(viewer.matchId, 'code', `Neuer ${target === 'spectator' ? 'Zuschauer' : `Team-${target}`}-Code erzeugt`); + this.changed(viewer.matchId); + } + + teamReadiness(match) { + const teamSize = this.pools[match.mode].teamSize; + const players = this.players(match.id); + const result = {}; + for (const team of ['A', 'B']) { + const list = players.filter((p) => p.team === team); + const enough = this.config.requireFullTeams ? list.length === teamSize : list.length >= 1; + result[team] = { + count: list.length, + teamSize, + hasCaptain: list.some((p) => p.is_captain), + allReady: list.length > 0 && list.every((p) => p.ready), + enough, + }; + result[team].ready = result[team].enough && result[team].allReady && result[team].hasCaptain; + } + return result; + } + + // ---------- Veto ---------- + + startVeto(viewer) { + this.require(viewer, 'organizer'); + this.db.transaction(() => { + const match = this.getMatch(viewer.matchId); + if (match.status !== 'lobby') throw new HttpError(409, 'Das Veto wurde bereits gestartet', 'wrong_phase'); + const readiness = this.teamReadiness(match); + if (!readiness.A.ready || !readiness.B.ready) { + throw new HttpError(409, 'Beide Teams müssen vollständig und bereit sein', 'teams_not_ready'); + } + const firstTeam = match.first_team ?? (Math.random() < 0.5 ? 'A' : 'B'); + this.db + .prepare("UPDATE matches SET status = 'veto', first_team = ?, veto_started_at = ?, updated_at = ? WHERE id = ?") + .run(firstTeam, this.now(), this.now(), match.id); + const how = match.veto_start === 'coin' ? 'per Münzwurf ausgelost' : 'vom Organisator festgelegt'; + this.log(match.id, 'veto', `Veto gestartet – ${this.teamName(match, firstTeam)} beginnt (${how})`); + })(); + this.changed(viewer.matchId); + } + + vetoAction(viewer, { stepIndex, map, side }) { + this.require(viewer, 'captain'); + let finished = false; + this.db.transaction(() => { + const match = this.getMatch(viewer.matchId); + if (match.status !== 'veto') throw new HttpError(409, 'Es läuft gerade kein Veto', 'wrong_phase'); + const actions = this.vetoActions(match.id); + const context = { + maps: JSON.parse(match.maps), + flow: JSON.parse(match.flow), + firstTeam: match.first_team, + actions, + }; + let newActions; + try { + newActions = resolveAction(context, { team: viewer.team, stepIndex: Number(stepIndex), map, side }); + } catch (err) { + if (err instanceof VetoError) throw new HttpError(409, err.message, err.code); + throw err; + } + const insert = this.db.prepare( + 'INSERT INTO veto_actions (match_id, idx, type, team, map, side, player_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + ); + const maps = Object.fromEntries(context.maps.map((m) => [m.id, m.name])); + newActions.forEach((a, i) => { + insert.run(match.id, actions.length + i, a.type, a.team, a.map ?? null, a.side ?? null, + a.type === 'decider' ? null : viewer.playerId, this.now()); + this.log(match.id, 'veto', this.describeAction(match, a, maps)); + }); + + const state = computeVetoState({ ...context, actions: [...actions, ...newActions] }); + if (state.done) { + finished = true; + const insertMap = this.db.prepare( + 'INSERT INTO series_maps (match_id, number, map, picked_by, side_chosen_by, ct_team, status) VALUES (?, ?, ?, ?, ?, ?, ?)', + ); + for (const entry of state.series) { + const ctTeam = entry.startSides ? (entry.startSides.A === 'CT' ? 'A' : 'B') : null; + insertMap.run(match.id, entry.number, entry.map, entry.pickedBy, entry.sideChosenBy, ctTeam, + entry.number === 1 ? 'next' : 'pending'); + } + this.db + .prepare("UPDATE matches SET status = 'server_setup', veto_finished_at = ?, current_map_number = 1, updated_at = ? WHERE id = ?") + .run(this.now(), this.now(), match.id); + this.log(match.id, 'veto', `Veto abgeschlossen – Reihenfolge: ${state.series.map((s) => maps[s.map]).join(', ')}`); + } + })(); + this.changed(viewer.matchId); + if (finished) this.emit('vetoFinished', viewer.matchId); + } + + describeAction(match, action, mapNames) { + const team = action.team ? this.teamName(match, action.team) : null; + switch (action.type) { + case 'ban': return `${team} bannt ${mapNames[action.map]}`; + case 'pick': return `${team} wählt ${mapNames[action.map]}`; + case 'side': return `${team} startet auf ${mapNames[action.map]} als ${action.side}`; + case 'decider': return `${mapNames[action.map]} bleibt übrig und ist die Entscheidungsmap`; + default: return action.type; + } + } + + // ---------- Serie / Abschluss ---------- + + setStatus(matchId, status) { + const extra = CLOSED_STATUSES.includes(status) ? ', finished_at = @now' : ''; + this.db + .prepare(`UPDATE matches SET status = @status, updated_at = @now${extra} WHERE id = @id`) + .run({ status, now: this.now(), id: matchId }); + this.changed(matchId); + } + + advanceSeries(viewer) { + this.require(viewer, 'organizer'); + let nextNumber; + this.db.transaction(() => { + const match = this.getMatch(viewer.matchId); + if (match.status !== 'live') throw new HttpError(409, 'Die nächste Map kann erst geladen werden, wenn das Match läuft', 'wrong_phase'); + const maps = this.seriesMaps(match.id); + nextNumber = match.current_map_number + 1; + if (!maps.find((m) => m.number === nextNumber)) throw new HttpError(409, 'Es gibt keine weitere Map in dieser Serie', 'series_done'); + this.db.prepare("UPDATE series_maps SET status = 'done' WHERE match_id = ? AND number = ?").run(match.id, match.current_map_number); + this.db.prepare("UPDATE series_maps SET status = 'next' WHERE match_id = ? AND number = ?").run(match.id, nextNumber); + this.db + .prepare("UPDATE matches SET current_map_number = ?, status = 'server_setup', updated_at = ? WHERE id = ?") + .run(nextNumber, this.now(), match.id); + this.log(match.id, 'series', `Map ${nextNumber} der Serie wird geladen`); + })(); + this.changed(viewer.matchId); + return nextNumber; + } + + markCurrentMapLive(matchId) { + const match = this.getMatch(matchId); + this.db + .prepare("UPDATE series_maps SET status = 'live' WHERE match_id = ? AND number = ?") + .run(matchId, match.current_map_number); + } + + close(viewer, status) { + this.require(viewer, 'organizer'); + const match = this.getMatch(viewer.matchId); + if (CLOSED_STATUSES.includes(match.status)) throw new HttpError(409, 'Das Match ist bereits beendet', 'match_closed'); + if (status === 'finished') { + this.db.prepare("UPDATE series_maps SET status = 'done' WHERE match_id = ? AND status IN ('live','next')").run(match.id); + } + this.log(match.id, 'status', status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen'); + this.setStatus(match.id, status); + this.emit('closed', match.id); + } + + // ---------- Hilfen ---------- + + teamName(match, team) { + return team === 'A' ? match.team_a_name : match.team_b_name; + } + + touch(matchId) { + this.db.prepare('UPDATE matches SET last_activity_at = ? WHERE id = ?').run(this.now(), matchId); + } + + log(matchId, type, message) { + this.db + .prepare('INSERT INTO events (match_id, type, message, created_at) VALUES (?, ?, ?, ?)') + .run(matchId, type, message, this.now()); + } + + changed(matchId) { + this.db.prepare('UPDATE matches SET updated_at = ? WHERE id = ?').run(this.now(), matchId); + this.emit('changed', matchId); + } + + listMatches() { + const rows = this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all(); + const counts = this.db.prepare('SELECT team, COUNT(*) AS n FROM players WHERE match_id = ? AND active = 1 GROUP BY team'); + return rows.map((m) => { + const byTeam = Object.fromEntries(counts.all(m.id).map((r) => [r.team, r.n])); + const server = this.serverRow(m.id); + const pool = this.pools[m.mode]; + return { + id: m.id, + teamA: m.team_a_name, + teamB: m.team_b_name, + mode: m.mode, + modeLabel: pool?.label ?? m.mode, + format: m.format, + status: m.status, + statusLabel: MATCH_STATUS[m.status], + group: OPEN_STATUSES.includes(m.status) ? 'open' : RUNNING_STATUSES.includes(m.status) ? 'running' : 'closed', + players: { A: byTeam.A ?? 0, B: byTeam.B ?? 0, teamSize: pool?.teamSize ?? 0 }, + server: server ? { status: server.status, simulated: !!server.simulated } : null, + createdAt: m.created_at, + }; + }); + } + + /** Rollenabhängige Sicht auf ein Match. Server-Verwaltungsdaten (RCON) sind nie enthalten. */ + snapshot(matchId, viewer) { + const match = this.getMatch(matchId); + const pool = this.poolFor(match); + const mapNames = Object.fromEntries(pool.maps.map((m) => [m.id, m.name])); + const players = this.players(matchId); + const online = this.presence(matchId); + const readiness = this.teamReadiness(match); + const publicView = viewer.role === 'public'; + + const team = (t) => ({ + key: t, + name: this.teamName(match, t), + teamSize: pool.teamSize, + readiness: readiness[t], + players: publicView + ? [] + : players + .filter((p) => p.team === t) + .map((p) => ({ + id: p.id, + nickname: p.nickname, + isCaptain: !!p.is_captain, + ready: !!p.ready, + online: online.has(p.id), + isYou: p.id === viewer.playerId, + hasSteamId: !!p.steam_id, + })), + }); + + const veto = this.vetoState(match); + const actions = this.vetoActions(matchId); + const series = this.seriesMaps(matchId).map((s) => ({ + number: s.number, + map: s.map, + mapName: mapNames[s.map] ?? s.map, + pickedBy: s.picked_by, + sideChosenBy: s.side_chosen_by, + ctTeam: s.ct_team, + status: s.status, + })); + + const snapshot = { + id: match.id, + teams: { A: team('A'), B: team('B') }, + mode: match.mode, + modeLabel: pool.label, + format: match.format, + bestOf: BEST_OF[match.format], + status: match.status, + statusLabel: MATCH_STATUS[match.status], + createdAt: match.created_at, + updatedAt: match.updated_at, + vetoStart: match.veto_start, + currentMapNumber: match.current_map_number, + viewer: { role: viewer.role, playerId: viewer.playerId, team: viewer.team }, + canStartVeto: match.status === 'lobby' && readiness.A.ready && readiness.B.ready, + maps: pool.maps, + veto: publicView + ? null + : { + started: match.status !== 'lobby', + firstTeam: match.first_team, + steps: veto.steps, + maps: veto.maps, + current: match.status === 'veto' ? veto.current : null, + done: veto.done, + history: actions.map((a) => ({ + index: a.idx, + type: a.type, + team: a.team, + map: a.map, + side: a.side, + at: a.created_at, + text: this.describeAction(match, a, mapNames), + })), + }, + series, + server: this.serverView(match, viewer), + events: publicView + ? [] + : this.db + .prepare('SELECT type, message, created_at AS at FROM events WHERE match_id = ? ORDER BY id DESC LIMIT 40') + .all(matchId), + }; + + if (viewer.role === 'organizer') { + const base = this.config.publicUrl; + snapshot.organizer = { + codes: { A: formatCode(match.code_a), B: formatCode(match.code_b), spectator: formatCode(match.code_spectator) }, + links: { + A: `${base}/join/${formatCode(match.code_a)}`, + B: `${base}/join/${formatCode(match.code_b)}`, + spectator: `${base}/watch/${formatCode(match.code_spectator)}`, + }, + }; + } + return snapshot; + } + + serverView(match, viewer) { + const row = this.serverRow(match.id); + if (!row) return null; + const view = { + adapter: row.adapter, + simulated: !!row.simulated, + status: row.status, + message: row.message, + playerCount: row.player_count, + attempts: row.attempts, + updatedAt: row.updated_at, + idleTimeoutMinutes: this.config.idleTimeoutMinutes, + }; + const configured = row.status === 'configured'; + if (!configured || viewer.role === 'public') return view; + + const isTeam = ['player', 'captain', 'organizer'].includes(viewer.role); + const isSpectatorView = ['spectator', 'organizer'].includes(viewer.role); + if (isTeam) { + view.connect = { + host: row.host, + port: row.port, + password: row.server_password, + command: `password ${row.server_password}; connect ${row.host}:${row.port}`, + steamUrl: `steam://connect/${row.host}:${row.port}/${row.server_password}`, + }; + } + if (isSpectatorView && this.config.match.tvEnabled) { + view.gotv = { + host: row.host, + port: row.tv_port, + password: row.tv_password, + delaySeconds: this.config.match.tvDelaySeconds, + command: `password ${row.tv_password}; connect ${row.host}:${row.tv_port}`, + }; + } + return view; + } +} diff --git a/server/src/realtime.js b/server/src/realtime.js new file mode 100644 index 0000000..940d484 --- /dev/null +++ b/server/src/realtime.js @@ -0,0 +1,92 @@ +import { Server } from 'socket.io'; + +/** + * Echtzeit-Kanal. Clients senden nie Aktionen über den Socket – alle Änderungen laufen über die + * REST-API. Der Socket liefert nach jeder Änderung den vollständigen, rollenabhängigen Match-Zustand. + * Dadurch ist ein Client nach Neuladen oder Verbindungsabbruch sofort wieder synchron. + */ +export function attachRealtime(httpServer, { service, config }) { + const io = new Server(httpServer, { + path: '/socket.io', + cors: { origin: config.publicUrl, credentials: false }, + connectionStateRecovery: { maxDisconnectionDuration: 2 * 60_000 }, + }); + + // matchId -> Map(socketId -> presenceKey) + const presence = new Map(); + service.presence = (matchId) => new Set((presence.get(matchId) ?? new Map()).values()); + + const sendState = (socket) => { + const { matchId, token } = socket.data; + try { + const viewer = service.resolveViewer(matchId, token); + socket.emit('match:state', service.snapshot(matchId, viewer)); + // Rolle kann sich geändert haben (z. B. Spieler entfernt, neuer Captain) – Anwesenheit nachziehen. + presence.get(matchId)?.set(socket.id, viewer.playerId ?? `socket:${socket.id}`); + } catch (err) { + socket.emit('match:error', { error: err.message, code: err.code ?? 'error' }); + } + }; + + let overviewTimer = null; + const broadcastOverview = () => { + clearTimeout(overviewTimer); + overviewTimer = setTimeout(() => io.to('overview').emit('overview', { matches: service.listMatches() }), 150); + }; + + const pending = new Map(); + const broadcastMatch = (matchId) => { + // Mehrere Änderungen innerhalb kurzer Zeit werden zu einem Update zusammengefasst. + if (pending.has(matchId)) return; + pending.set(matchId, setTimeout(async () => { + pending.delete(matchId); + const sockets = await io.in(`match:${matchId}`).fetchSockets(); + sockets.forEach(sendState); + }, 50)); + }; + + service.on('changed', (matchId) => { + broadcastMatch(matchId); + broadcastOverview(); + }); + + io.on('connection', (socket) => { + socket.on('overview:subscribe', () => { + socket.join('overview'); + socket.emit('overview', { matches: service.listMatches() }); + }); + + socket.on('match:subscribe', ({ matchId, token } = {}) => { + if (typeof matchId !== 'string') return; + for (const room of socket.rooms) if (room.startsWith('match:')) leave(room.slice(6)); + socket.data = { matchId, token: typeof token === 'string' ? token : null }; + try { + service.getMatch(matchId); + } catch (err) { + socket.emit('match:error', { error: err.message, code: err.code }); + return; + } + socket.join(`match:${matchId}`); + if (!presence.has(matchId)) presence.set(matchId, new Map()); + service.touch(matchId); + sendState(socket); + broadcastMatch(matchId); // Online-Status für alle aktualisieren + }); + + const leave = (matchId) => { + socket.leave(`match:${matchId}`); + const map = presence.get(matchId); + if (map?.delete(socket.id)) { + if (map.size === 0) presence.delete(matchId); + service.touch(matchId); + broadcastMatch(matchId); + } + }; + + socket.on('disconnect', () => { + if (socket.data?.matchId) leave(socket.data.matchId); + }); + }); + + return io; +} diff --git a/server/src/security.js b/server/src/security.js new file mode 100644 index 0000000..59fbb4c --- /dev/null +++ b/server/src/security.js @@ -0,0 +1,66 @@ +import crypto from 'node:crypto'; + +// Ohne leicht verwechselbare Zeichen (0/O, 1/I/L). +const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; + +export function randomCode(length = 8) { + const bytes = crypto.randomBytes(length); + let out = ''; + for (let i = 0; i < length; i++) out += CODE_ALPHABET[bytes[i] % CODE_ALPHABET.length]; + return out; +} + +export function normalizeCode(input) { + return String(input ?? '') + .toUpperCase() + .replace(/[^A-Z0-9]/g, ''); +} + +export function formatCode(code) { + return code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code; +} + +export function randomToken() { + return crypto.randomBytes(32).toString('base64url'); +} + +export function randomId() { + return crypto.randomBytes(9).toString('base64url'); +} + +/** Alphanumerisches Passwort, sicher für Konsolenbefehle ohne Escaping. */ +export function randomPassword(length = 16) { + const alphabet = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + const bytes = crypto.randomBytes(length); + let out = ''; + for (let i = 0; i < length; i++) out += alphabet[bytes[i] % alphabet.length]; + return out; +} + +export function hashToken(token) { + return crypto.createHash('sha256').update(String(token)).digest('hex'); +} + +export function safeEqual(a, b) { + const ba = Buffer.from(String(a)); + const bb = Buffer.from(String(b)); + return ba.length === bb.length && crypto.timingSafeEqual(ba, bb); +} + +/** Einfacher In-Memory-Ratenbegrenzer (Fenster pro Schlüssel). */ +export function createRateLimiter({ windowMs, max }) { + const hits = new Map(); + return function check(key) { + const now = Date.now(); + const entry = hits.get(key); + if (!entry || now - entry.start > windowMs) { + hits.set(key, { start: now, count: 1 }); + return true; + } + entry.count += 1; + if (hits.size > 10_000) { + for (const [k, v] of hits) if (now - v.start > windowMs) hits.delete(k); + } + return entry.count <= max; + }; +} diff --git a/server/src/servers/adapter.js b/server/src/servers/adapter.js new file mode 100644 index 0000000..6ef0ae7 --- /dev/null +++ b/server/src/servers/adapter.js @@ -0,0 +1,46 @@ +/** + * Schnittstelle für die CS2-Serververwaltung. + * + * Jeder Adapter implementiert genau diese vier Operationen. Der ServerManager ruft sie auf; + * Routen und Frontend kennen nur den ServerManager. + * + * start({ matchId, mode, secrets }) + * -> { handle, host, port, tvPort } + * Startet (oder reserviert) einen Server. handle ist ein beliebiges JSON-Objekt, + * das ausschließlich im Backend gespeichert wird (z. B. Containername, RCON-Ziel). + * secrets = { rconPassword, serverPassword, tvPassword } werden vom Manager erzeugt. + * + * status(handle) + * -> { state: 'starting' | 'running' | 'stopped' | 'failed', message?, playerCount?, map? } + * + * configure(handle, { map, beforeMapChange, afterMapChange, commands }) + * -> void Wirft einen Fehler mit verständlicher Meldung, wenn die Konfiguration scheitert. + * + * stop(handle) + * -> void Darf mehrfach aufgerufen werden. + * + * Optional: + * sendCommands(handle, commands) -> string[] für Aktionen wie mp_warmup_end. + * + * Eigenschaften: + * name Kurzname des Adapters + * simulated true, wenn kein echter Server existiert. Das Frontend zeigt dann niemals + * "läuft" an, sondern kennzeichnet alles als Demo. + */ + +import { DemoServerAdapter } from './demoAdapter.js'; +import { DockerServerAdapter } from './dockerAdapter.js'; +import { StaticPoolServerAdapter } from './staticPoolAdapter.js'; + +export function createServerAdapter(config, deps) { + switch (config.serverAdapter) { + case 'demo': + return new DemoServerAdapter(config.demo); + case 'docker': + return new DockerServerAdapter(config.docker, deps); + case 'static': + return new StaticPoolServerAdapter(config.staticPool, deps); + default: + throw new Error(`Unbekannter SERVER_ADAPTER "${config.serverAdapter}" (erlaubt: demo, docker, static)`); + } +} diff --git a/server/src/servers/cs2Config.js b/server/src/servers/cs2Config.js new file mode 100644 index 0000000..286502e --- /dev/null +++ b/server/src/servers/cs2Config.js @@ -0,0 +1,58 @@ +// Erzeugt die Konsolenbefehle, mit denen ein laufender CS2-Server für eine Map der Serie konfiguriert wird. +// Alle Werte werden so bereinigt, dass sie keine weiteren Befehle einschleusen können. + +export function sanitizeConsoleText(value, maxLength = 32) { + return String(value ?? '') + .replace(/["';\\\r\n\t]/g, '') + .replace(/[^\p{L}\p{N} ._\-+#!?()[\]|]/gu, '') + .trim() + .slice(0, maxLength) || 'Team'; +} + +/** + * Liefert zwei Befehlsgruppen: + * - beforeMapChange: Spielmodus, Passwörter, CSTV und der Mapwechsel selbst. + * game_type/game_mode und tv_enable greifen erst beim Laden einer Map. + * - afterMapChange: Match-Einstellungen. Diese werden erst nach dem Laden gesetzt, + * weil die gamemode_*.cfg des Spiels sie beim Mapwechsel sonst überschreibt. + * + * @param {object} p + * @param {object} p.match Match-Datensatz (Teamnamen) + * @param {object} p.pool Map-Pool (mode/gameType/gameMode) + * @param {object} p.seriesMap { map, ctTeam: 'A'|'B'|null } + * @param {object} p.secrets { serverPassword, tvPassword } + * @param {object} p.settings config.match + */ +export function buildMapCommands({ match, pool, seriesMap, secrets, settings }) { + const isWingman = pool.mode === 'wingman'; + const ctTeam = seriesMap.ctTeam ?? 'A'; + const tTeam = ctTeam === 'A' ? 'B' : 'A'; + const names = { A: sanitizeConsoleText(match.team_a_name), B: sanitizeConsoleText(match.team_b_name) }; + + const beforeMapChange = [ + `game_type ${Number(pool.gameType)}`, + `game_mode ${Number(pool.gameMode)}`, + `sv_password "${secrets.serverPassword}"`, + `tv_enable ${settings.tvEnabled ? 1 : 0}`, + `tv_delay ${Number(settings.tvDelaySeconds)}`, + `tv_password "${secrets.tvPassword}"`, + `changelevel ${seriesMap.map}`, + ]; + + const afterMapChange = [ + // mp_teamname_1 = Team, das auf CT startet; mp_teamname_2 = Team, das auf T startet + `mp_teamname_1 "${names[ctTeam]}"`, + `mp_teamname_2 "${names[tTeam]}"`, + `mp_maxrounds ${isWingman ? settings.maxRoundsWingman : settings.maxRoundsCompetitive}`, + `mp_overtime_enable ${settings.overtime ? 1 : 0}`, + 'mp_match_can_clinch 1', + 'mp_autoteambalance 0', + 'mp_limitteams 0', + // Aufwärmphase läuft, bis der Organisator das Match startet (mp_warmup_end) + 'mp_warmup_pausetimer 1', + ...settings.extraCommands, + ]; + + return { beforeMapChange, afterMapChange }; +} + diff --git a/server/src/servers/demoAdapter.js b/server/src/servers/demoAdapter.js new file mode 100644 index 0000000..68a45ab --- /dev/null +++ b/server/src/servers/demoAdapter.js @@ -0,0 +1,51 @@ +// DEMO-ADAPTER: Es wird KEIN echter CS2-Server gestartet. +// Er simuliert nur Startdauer und Konfiguration, damit der Ablauf ohne Hosting getestet werden kann. +// Das Flag simulated=true sorgt dafür, dass das Frontend den Server ausdrücklich als Simulation kennzeichnet. + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +export class DemoServerAdapter { + constructor({ startSeconds = 20, failFirstConfigure = false } = {}) { + this.name = 'demo'; + this.simulated = true; + this.startMs = startSeconds * 1000; + this.failFirstConfigure = failFirstConfigure; + this.configureCalls = new Map(); + } + + async start({ matchId }) { + return { + handle: { demo: true, matchId, startedAt: Date.now() }, + host: 'demo.invalid', + port: 27015, + tvPort: 27020, + }; + } + + async status(handle) { + if (handle.stopped) return { state: 'stopped', message: 'Simulation beendet' }; + const elapsed = Date.now() - handle.startedAt; + if (elapsed < this.startMs) { + return { state: 'starting', message: `Simulierter Start (${Math.ceil((this.startMs - elapsed) / 1000)} s)` }; + } + return { state: 'running', message: 'Simulation – kein echter Server', playerCount: null }; + } + + async configure(handle, plan) { + await sleep(1500); + const calls = (this.configureCalls.get(handle.matchId) ?? 0) + 1; + this.configureCalls.set(handle.matchId, calls); + if (this.failFirstConfigure && calls === 1) { + throw new Error('Simulierter Konfigurationsfehler (DEMO_FAIL_FIRST_CONFIGURE) – bitte erneut versuchen'); + } + handle.map = plan.map; + } + + async sendCommands(handle, commands) { + return commands.map(() => '(Demo – nicht ausgeführt)'); + } + + async stop(handle) { + handle.stopped = true; + } +} diff --git a/server/src/servers/dockerAdapter.js b/server/src/servers/dockerAdapter.js new file mode 100644 index 0000000..ca12a3c --- /dev/null +++ b/server/src/servers/dockerAdapter.js @@ -0,0 +1,122 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { configureViaRcon, rconStatus } from './rconConfigure.js'; +import { runRcon } from './rcon.js'; + +const exec = promisify(execFile); + +/** + * Startet pro Match einen eigenen CS2-Container (Standard-Image: cm2network/cs2). + * Benötigt Docker auf dem Backend-Host, ein Game Server Login Token (CS2_GSLT) + * und eine öffentlich erreichbare Adresse (CS2_PUBLIC_HOST). + * + * Pro Match werden zwei aufeinanderfolgende Ports belegt: Spielport (TCP+UDP, auch RCON) + * und CSTV-Port (UDP). + */ +export class DockerServerAdapter { + constructor(options, { getUsedPorts }) { + this.name = 'docker'; + this.simulated = false; + this.options = options; + this.getUsedPorts = getUsedPorts; + if (!options.host) throw new Error('SERVER_ADAPTER=docker benötigt CS2_PUBLIC_HOST'); + if (!options.gslt) throw new Error('SERVER_ADAPTER=docker benötigt CS2_GSLT (Game Server Login Token)'); + } + + async docker(args, timeout = 60_000) { + const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024 }); + return stdout.trim(); + } + + allocatePort() { + const used = this.getUsedPorts(); + for (let p = this.options.portRangeStart; p + 1 <= this.options.portRangeEnd; p += 2) { + if (!used.has(p) && !used.has(p + 1)) return p; + } + throw new Error('Kein freier Port im Bereich CS2_PORT_RANGE_START–CS2_PORT_RANGE_END'); + } + + async start({ matchId, mode, secrets }) { + const port = this.allocatePort(); + const tvPort = port + 1; + const container = `cs2mv-${matchId.replace(/[^a-zA-Z0-9_-]/g, '')}`.toLowerCase(); + const env = { + SRCDS_TOKEN: this.options.gslt, + CS2_SERVERNAME: `CS2 Mapveto ${matchId}`, + CS2_PORT: String(port), + CS2_RCONPW: secrets.rconPassword, + CS2_PW: secrets.serverPassword, + CS2_MAXPLAYERS: mode === 'wingman' ? '4' : '10', + CS2_GAMETYPE: '0', + CS2_GAMEMODE: mode === 'wingman' ? '2' : '1', + CS2_SERVER_HIBERNATE: '0', + TV_ENABLE: '1', + TV_PORT: String(tvPort), + TV_PW: secrets.tvPassword, + TV_RELAY_PW: secrets.tvPassword, + STEAMAPPVALIDATE: '0', + }; + const args = ['run', '-d', '--name', container, '--label', 'cs2-mapveto=1']; + for (const [k, v] of Object.entries(env)) args.push('-e', `${k}=${v}`); + args.push( + '-p', `${port}:${port}/tcp`, + '-p', `${port}:${port}/udp`, + '-p', `${tvPort}:${tvPort}/udp`, + '-v', `${this.options.volume}:/home/steam/cs2-dedicated`, + ); + if (this.options.extraArgs) args.push(...this.options.extraArgs.split(' ').filter(Boolean)); + args.push(this.options.image); + + // Ein evtl. übrig gebliebener Container gleichen Namens (z. B. nach Retry) wird ersetzt. + await this.docker(['rm', '-f', container]).catch(() => {}); + // Das erste "docker run" kann das Image laden und dauert dann länger. + await this.docker(args, 15 * 60_000); + + return { + handle: { container, rconHost: this.options.rconHost, port, startedAt: Date.now(), rconPassword: secrets.rconPassword }, + host: this.options.host, + port, + tvPort, + }; + } + + target(handle) { + return { host: handle.rconHost, port: handle.port, password: handle.rconPassword }; + } + + async status(handle) { + let state; + try { + state = await this.docker(['inspect', '-f', '{{.State.Status}}', handle.container], 15_000); + } catch { + return { state: 'stopped', message: 'Container existiert nicht mehr' }; + } + if (state === 'exited' || state === 'dead') { + const logs = await this.docker(['logs', '--tail', '5', handle.container]).catch(() => ''); + return { state: 'failed', message: `Container beendet. Letzte Ausgabe: ${logs.slice(-300) || '–'}` }; + } + if (state !== 'running') return { state: 'starting', message: `Container-Status: ${state}` }; + try { + const status = await rconStatus(this.target(handle)); + return { state: 'running', playerCount: status.playerCount, map: status.map }; + } catch { + const minutes = Math.round((Date.now() - handle.startedAt) / 60_000); + if (minutes >= this.options.startTimeoutMinutes) { + return { state: 'failed', message: `Server nach ${minutes} Minuten nicht per RCON erreichbar` }; + } + return { state: 'starting', message: 'Container läuft, Spielserver lädt (Download/Update möglich)' }; + } + } + + async configure(handle, plan) { + await configureViaRcon(this.target(handle), plan); + } + + async sendCommands(handle, commands) { + return runRcon(this.target(handle), commands); + } + + async stop(handle) { + await this.docker(['rm', '-f', handle.container]).catch(() => {}); + } +} diff --git a/server/src/servers/rcon.js b/server/src/servers/rcon.js new file mode 100644 index 0000000..5b986e4 --- /dev/null +++ b/server/src/servers/rcon.js @@ -0,0 +1,158 @@ +import net from 'node:net'; + +// Source-RCON-Protokoll (TCP), wie es auch der CS2-Dedicated-Server spricht. +// https://developer.valvesoftware.com/wiki/Source_RCON_Protocol +const SERVERDATA_AUTH = 3; +const SERVERDATA_AUTH_RESPONSE = 2; +const SERVERDATA_EXECCOMMAND = 2; + +export function encodePacket(id, type, body) { + const bodyBuf = Buffer.from(body, 'utf8'); + const size = 4 + 4 + bodyBuf.length + 2; + const buf = Buffer.alloc(4 + size); + buf.writeInt32LE(size, 0); + buf.writeInt32LE(id, 4); + buf.writeInt32LE(type, 8); + bodyBuf.copy(buf, 12); + // zwei abschließende Null-Bytes (Body-Terminator + leerer String) sind bereits durch alloc gesetzt + return buf; +} + +/** Zerlegt einen Puffer in vollständige Pakete; gibt Pakete und den Rest zurück. */ +export function decodePackets(buffer) { + const packets = []; + let offset = 0; + while (buffer.length - offset >= 4) { + const size = buffer.readInt32LE(offset); + if (size < 10 || size > 1024 * 1024) throw new Error(`Ungültige RCON-Paketgröße ${size}`); + if (buffer.length - offset < 4 + size) break; + const id = buffer.readInt32LE(offset + 4); + const type = buffer.readInt32LE(offset + 8); + const body = buffer.toString('utf8', offset + 12, offset + 4 + size - 2); + packets.push({ id, type, body }); + offset += 4 + size; + } + return { packets, rest: buffer.subarray(offset) }; +} + +export class RconError extends Error {} + +export class RconClient { + constructor({ host, port, password, timeoutMs = 5000 }) { + Object.assign(this, { host, port, password, timeoutMs }); + this.socket = null; + this.nextId = 1; + this.buffer = Buffer.alloc(0); + this.listeners = new Set(); + } + + connect() { + return new Promise((resolve, reject) => { + const socket = net.createConnection({ host: this.host, port: this.port }); + const timer = setTimeout(() => { + socket.destroy(); + reject(new RconError(`RCON-Verbindung zu ${this.host}:${this.port} – Zeitüberschreitung`)); + }, this.timeoutMs); + socket.once('error', (err) => { + clearTimeout(timer); + reject(new RconError(`RCON-Verbindung zu ${this.host}:${this.port} fehlgeschlagen: ${err.code ?? err.message}`)); + }); + socket.once('connect', async () => { + clearTimeout(timer); + this.socket = socket; + socket.on('data', (chunk) => this.onData(chunk)); + socket.on('error', () => {}); + try { + await this.authenticate(); + resolve(this); + } catch (err) { + this.close(); + reject(err); + } + }); + }); + } + + onData(chunk) { + this.buffer = Buffer.concat([this.buffer, chunk]); + const { packets, rest } = decodePackets(this.buffer); + this.buffer = rest; + for (const packet of packets) for (const listener of this.listeners) listener(packet); + } + + authenticate() { + const id = this.nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => done(new RconError('RCON-Anmeldung – keine Antwort')), this.timeoutMs); + const listener = (packet) => { + if (packet.type !== SERVERDATA_AUTH_RESPONSE) return; + if (packet.id === -1) done(new RconError('RCON-Passwort wurde abgelehnt')); + else if (packet.id === id) done(); + }; + const done = (err) => { + clearTimeout(timer); + this.listeners.delete(listener); + err ? reject(err) : resolve(); + }; + this.listeners.add(listener); + this.socket.write(encodePacket(id, SERVERDATA_AUTH, this.password)); + }); + } + + /** Führt einen Befehl aus und sammelt die (ggf. mehrteilige) Antwort. */ + command(cmd, { quietMs = 250 } = {}) { + if (!this.socket) return Promise.reject(new RconError('RCON nicht verbunden')); + const id = this.nextId++; + return new Promise((resolve, reject) => { + let body = ''; + let quietTimer = null; + const timer = setTimeout(() => finish(), this.timeoutMs); + const listener = (packet) => { + if (packet.id !== id) return; + body += packet.body; + clearTimeout(quietTimer); + quietTimer = setTimeout(finish, quietMs); + }; + const onClose = () => finish(body ? null : new RconError('RCON-Verbindung wurde geschlossen')); + const finish = (err) => { + clearTimeout(timer); + clearTimeout(quietTimer); + this.listeners.delete(listener); + this.socket?.off('close', onClose); + err ? reject(err) : resolve(body); + }; + this.listeners.add(listener); + this.socket.once('close', onClose); + this.socket.write(encodePacket(id, SERVERDATA_EXECCOMMAND, cmd)); + }); + } + + close() { + this.socket?.destroy(); + this.socket = null; + } +} + +/** Baut eine Verbindung auf, führt Befehle nacheinander aus und schließt wieder. */ +export async function runRcon({ host, port, password }, commands, options = {}) { + const client = new RconClient({ host, port, password, timeoutMs: options.timeoutMs }); + await client.connect(); + try { + const outputs = []; + for (const cmd of commands) outputs.push(await client.command(cmd, options)); + return outputs; + } finally { + client.close(); + } +} + +/** Liest Map und Spielerzahl aus der Ausgabe von "status". Gibt null-Werte zurück, wenn unbekannt. */ +export function parseStatus(output) { + const text = String(output ?? ''); + const map = /(?:loaded spawngroup\(\s*1\)\s*:\s*SV:\s*\[1:\s*(\w+)|^map\s*:\s*(\w+))/im.exec(text); + const players = /players\s*:\s*(\d+)\s*humans/i.exec(text); + return { + map: map ? map[1] ?? map[2] : null, + playerCount: players ? Number(players[1]) : null, + }; +} diff --git a/server/src/servers/rconConfigure.js b/server/src/servers/rconConfigure.js new file mode 100644 index 0000000..060ec94 --- /dev/null +++ b/server/src/servers/rconConfigure.js @@ -0,0 +1,44 @@ +import { parseStatus, runRcon } from './rcon.js'; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Gemeinsamer Konfigurationsablauf für echte Server per RCON: + * Befehle vor dem Mapwechsel senden, warten bis die Map geladen ist, dann Match-Einstellungen setzen. + */ +export async function configureViaRcon(target, plan, { loadTimeoutMs = 120_000, pollMs = 3000 } = {}) { + try { + await runRcon(target, plan.beforeMapChange); + } catch (err) { + // Beim changelevel trennt der Server die RCON-Verbindung teilweise – das ist kein Fehler. + if (!/geschlossen/.test(err.message)) throw new Error(`Befehle konnten nicht gesendet werden: ${err.message}`); + } + + const deadline = Date.now() + loadTimeoutMs; + let lastError = null; + for (;;) { + await sleep(pollMs); + try { + const [status] = await runRcon(target, ['status']); + const parsed = parseStatus(status); + if (parsed.map === plan.map || (!parsed.map && status.includes(plan.map))) break; + lastError = `aktuell geladen: ${parsed.map ?? 'unbekannt'}`; + } catch (err) { + lastError = err.message; + } + if (Date.now() > deadline) { + throw new Error(`Map ${plan.map} wurde nicht rechtzeitig geladen (${lastError ?? 'keine Antwort'})`); + } + } + + try { + await runRcon(target, plan.afterMapChange); + } catch (err) { + throw new Error(`Match-Einstellungen konnten nicht gesetzt werden: ${err.message}`); + } +} + +export async function rconStatus(target) { + const [output] = await runRcon(target, ['status'], { timeoutMs: 4000 }); + return parseStatus(output); +} diff --git a/server/src/servers/serverManager.js b/server/src/servers/serverManager.js new file mode 100644 index 0000000..dc65bd7 --- /dev/null +++ b/server/src/servers/serverManager.js @@ -0,0 +1,343 @@ +import { buildMapCommands } from './cs2Config.js'; +import { HttpError, CLOSED_STATUSES } from '../matchService.js'; +import { randomPassword } from '../security.js'; + +export const SERVER_STATUS = { + starting: 'Server startet', + ready: 'Server bereit – wartet auf Veto', + configuring: 'Server wird konfiguriert', + configured: 'Server konfiguriert', + start_failed: 'Serverstart fehlgeschlagen', + config_failed: 'Konfiguration fehlgeschlagen', + failed: 'Server ausgefallen', + stopped: 'Server gestoppt', +}; + +const ACTIVE = ['starting', 'ready', 'configuring', 'configured', 'config_failed', 'failed']; +const SLOW_POLL_MS = 30_000; + +/** + * Steuert den Lebenszyklus der Match-Server. Kennt nur die Adapter-Schnittstelle + * (start/status/configure/stop) und speichert alle Zugangsdaten ausschließlich in der Datenbank. + */ +export class ServerManager { + constructor({ db, service, adapter, config, logger = console }) { + Object.assign(this, { db, service, adapter, config, logger }); + this.busy = new Set(); + this.lastPoll = new Map(); + this.timers = []; + } + + init() { + this.service.on('created', (id) => this.provision(id).catch((err) => this.logger.error(err))); + this.service.on('vetoFinished', (id) => this.maybeConfigure(id)); + this.service.on('closed', (id) => this.stop(id, 'Match beendet').catch((err) => this.logger.error(err))); + } + + /** Nach einem Neustart des Backends: unterbrochene Vorgänge aufräumen und fortsetzen. */ + async resume() { + const rows = this.db.prepare(`SELECT * FROM servers WHERE status IN ('starting','configuring')`).all(); + for (const row of rows) { + if (row.status === 'starting' && !row.handle) { + this.update(row.match_id, { status: 'start_failed', message: 'Start wurde durch einen Neustart des Backends unterbrochen' }); + } else if (row.status === 'configuring') { + this.update(row.match_id, { status: 'ready', message: 'Konfiguration wurde unterbrochen und wird wiederholt' }); + this.maybeConfigure(row.match_id); + } + } + } + + startLoops() { + this.timers.push(setInterval(() => this.poll().catch((e) => this.logger.error(e)), this.config.statusPollSeconds * 1000)); + this.timers.push(setInterval(() => this.reap().catch((e) => this.logger.error(e)), this.config.reaperIntervalSeconds * 1000)); + } + + stopLoops() { + this.timers.forEach(clearInterval); + this.timers = []; + } + + row(matchId) { + return this.db.prepare('SELECT * FROM servers WHERE match_id = ?').get(matchId) ?? null; + } + + update(matchId, fields) { + const entries = { ...fields, updated_at: Date.now() }; + const sets = Object.keys(entries).map((k) => `${k} = @${k}`).join(', '); + this.db.prepare(`UPDATE servers SET ${sets} WHERE match_id = @match_id`).run({ ...entries, match_id: matchId }); + this.service.changed(matchId); + } + + usedPorts() { + const used = new Set(); + for (const r of this.db.prepare(`SELECT host, port, tv_port FROM servers WHERE status != 'stopped' AND port IS NOT NULL`).all()) { + used.add(r.port); + used.add(r.tv_port); + used.add(`${r.host}:${r.port}`); + } + return used; + } + + // ---------- Start ---------- + + async provision(matchId) { + if (this.busy.has(matchId)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy'); + this.busy.add(matchId); + try { + const previous = this.row(matchId); + const secrets = { + rconPassword: randomPassword(24), + serverPassword: randomPassword(10), + tvPassword: randomPassword(10), + }; + const now = Date.now(); + this.db + .prepare( + `INSERT INTO servers (match_id, adapter, simulated, status, message, handle, host, port, tv_port, + rcon_password, server_password, tv_password, config_key, attempts, started_at, updated_at) + VALUES (@match_id, @adapter, @simulated, 'starting', @message, NULL, NULL, NULL, NULL, + @rcon, @server, @tv, @key, @attempts, @now, @now) + ON CONFLICT(match_id) DO UPDATE SET adapter = excluded.adapter, simulated = excluded.simulated, + status = 'starting', message = excluded.message, handle = NULL, host = NULL, port = NULL, tv_port = NULL, + rcon_password = excluded.rcon_password, server_password = excluded.server_password, + tv_password = excluded.tv_password, config_key = excluded.config_key, attempts = excluded.attempts, + started_at = excluded.started_at, ready_at = NULL, configured_at = NULL, stopped_at = NULL, + player_count = NULL, last_player_seen_at = NULL, updated_at = excluded.updated_at`, + ) + .run({ + match_id: matchId, + adapter: this.adapter.name, + simulated: this.adapter.simulated ? 1 : 0, + message: this.adapter.simulated ? 'Demo-Modus: es wird kein echter Server gestartet' : 'Server wird angefordert', + rcon: secrets.rconPassword, + server: secrets.serverPassword, + tv: secrets.tvPassword, + key: randomPassword(24), + attempts: (previous?.attempts ?? 0) + 1, + now, + }); + this.service.changed(matchId); + this.service.log(matchId, 'server', this.adapter.simulated ? 'Demo-Server (Simulation) wird vorbereitet' : 'Server wird gestartet'); + + const match = this.service.getMatch(matchId); + try { + const result = await this.adapter.start({ matchId, mode: match.mode, secrets }); + this.update(matchId, { + handle: JSON.stringify(result.handle), + host: result.host, + port: result.port, + tv_port: result.tvPort, + message: this.adapter.simulated ? 'Simulierter Start läuft' : 'Server fährt hoch', + }); + } catch (err) { + this.update(matchId, { status: 'start_failed', message: err.message }); + this.service.log(matchId, 'server', `Serverstart fehlgeschlagen: ${err.message}`); + } + } finally { + this.busy.delete(matchId); + } + } + + // ---------- Status ---------- + + async poll() { + const rows = this.db + .prepare(`SELECT * FROM servers WHERE status IN ('starting','ready','configured','config_failed') AND handle IS NOT NULL`) + .all(); + await Promise.all(rows.map((row) => this.pollOne(row))); + } + + async pollOne(row) { + const matchId = row.match_id; + if (this.busy.has(matchId)) return; + const last = this.lastPoll.get(matchId) ?? 0; + if (row.status !== 'starting' && Date.now() - last < SLOW_POLL_MS) return; + this.lastPoll.set(matchId, Date.now()); + + let status; + try { + status = await this.adapter.status(JSON.parse(row.handle)); + } catch (err) { + status = { state: row.status === 'starting' ? 'starting' : 'running', message: `Statusabfrage fehlgeschlagen: ${err.message}` }; + } + const current = this.row(matchId); + if (!current || current.status !== row.status) return; // zwischenzeitlich geändert + + const fields = {}; + if (status.playerCount !== undefined && status.playerCount !== current.player_count) fields.player_count = status.playerCount; + if (status.playerCount > 0) fields.last_player_seen_at = Date.now(); + + if (status.state === 'running' && current.status === 'starting') { + Object.assign(fields, { status: 'ready', ready_at: Date.now(), message: status.message ?? 'Server ist erreichbar' }); + this.service.log(matchId, 'server', this.adapter.simulated ? 'Demo-Server (Simulation) bereit' : 'Server ist hochgefahren'); + } else if (status.state === 'failed') { + Object.assign(fields, { status: current.status === 'starting' ? 'start_failed' : 'failed', message: status.message }); + this.service.log(matchId, 'server', `Server-Fehler: ${status.message}`); + } else if (status.state === 'stopped') { + Object.assign(fields, { status: 'stopped', stopped_at: Date.now(), message: status.message ?? 'Server wurde extern beendet' }); + this.service.log(matchId, 'server', 'Server wurde außerhalb der Anwendung beendet'); + } else if (status.message && status.message !== current.message && current.status === 'starting') { + fields.message = status.message; + } + + if (Object.keys(fields).length) this.update(matchId, fields); + if (fields.status === 'ready') this.maybeConfigure(matchId); + } + + // ---------- Konfiguration ---------- + + maybeConfigure(matchId) { + const match = this.service.getMatch(matchId); + const row = this.row(matchId); + if (match.status === 'server_setup' && row?.status === 'ready') { + this.configure(matchId).catch((err) => this.logger.error(err)); + } + } + + async configure(matchId) { + const row = this.row(matchId); + if (!row?.handle) throw new HttpError(409, 'Es gibt noch keinen gestarteten Server', 'no_server'); + if (!['ready', 'configured', 'config_failed'].includes(row.status)) { + throw new HttpError(409, `Server kann im Zustand „${SERVER_STATUS[row.status]}" nicht konfiguriert werden`, 'wrong_state'); + } + if (this.busy.has(matchId)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy'); + this.busy.add(matchId); + try { + const match = this.service.getMatch(matchId); + const pool = this.service.poolFor(match); + const seriesMap = this.service + .seriesMaps(matchId) + .find((m) => m.number === match.current_map_number); + if (!seriesMap) throw new HttpError(409, 'Das Veto ist noch nicht abgeschlossen', 'veto_pending'); + + const mapName = pool.maps.find((m) => m.id === seriesMap.map)?.name ?? seriesMap.map; + this.update(matchId, { status: 'configuring', message: `Lade Map ${seriesMap.number}: ${mapName}` }); + this.service.log(matchId, 'server', `Konfiguration für Map ${seriesMap.number} (${mapName}) gestartet`); + + const plan = buildMapCommands({ + match, + pool, + seriesMap: { map: seriesMap.map, ctTeam: seriesMap.ct_team }, + secrets: { serverPassword: row.server_password, tvPassword: row.tv_password }, + settings: this.config.match, + }); + try { + await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan }); + } catch (err) { + this.update(matchId, { status: 'config_failed', message: err.message }); + this.service.log(matchId, 'server', `Konfiguration fehlgeschlagen: ${err.message}`); + return; + } + this.update(matchId, { + status: 'configured', + configured_at: Date.now(), + message: this.adapter.simulated + ? `Simuliert: ${mapName} wäre jetzt geladen – kein echter Server` + : `${mapName} geladen – Aufwärmphase läuft`, + }); + this.service.markCurrentMapLive(matchId); + if (this.service.getMatch(matchId).status === 'server_setup') this.service.setStatus(matchId, 'live'); + this.service.log(matchId, 'server', `Map ${seriesMap.number} (${mapName}) ist eingerichtet`); + } finally { + this.busy.delete(matchId); + } + } + + // ---------- Aktionen des Organisators ---------- + + async retry(viewer) { + this.service.require(viewer, 'organizer'); + const matchId = viewer.matchId; + const match = this.service.getMatch(matchId); + if (CLOSED_STATUSES.includes(match.status)) throw new HttpError(409, 'Das Match ist bereits beendet', 'match_closed'); + const row = this.row(matchId); + if (this.busy.has(matchId)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy'); + + if (!row || ['start_failed', 'failed', 'stopped'].includes(row.status)) { + if (row?.handle) await this.safeStop(row); + this.service.log(matchId, 'server', 'Organisator startet den Server neu'); + await this.provision(matchId); + return; + } + if (row.status === 'config_failed' || (row.status === 'ready' && match.status === 'server_setup')) { + this.service.log(matchId, 'server', 'Organisator wiederholt die Konfiguration'); + this.configure(matchId).catch((err) => this.logger.error(err)); + return; + } + throw new HttpError(409, `Im Zustand „${SERVER_STATUS[row.status]}" ist kein erneuter Versuch nötig`, 'wrong_state'); + } + + async loadNextMap(viewer) { + this.service.require(viewer, 'organizer'); + const row = this.row(viewer.matchId); + if (row?.status !== 'configured') throw new HttpError(409, 'Der Server ist nicht bereit für die nächste Map', 'wrong_state'); + this.service.advanceSeries(viewer); + this.configure(viewer.matchId).catch((err) => this.logger.error(err)); + } + + async endWarmup(viewer) { + this.service.require(viewer, 'organizer'); + const row = this.row(viewer.matchId); + if (row?.status !== 'configured') throw new HttpError(409, 'Der Server ist noch nicht konfiguriert', 'wrong_state'); + if (!this.adapter.sendCommands) throw new HttpError(501, 'Dieser Adapter unterstützt keine Befehle', 'unsupported'); + try { + await this.adapter.sendCommands(JSON.parse(row.handle), ['mp_warmup_pausetimer 0', 'mp_warmup_end']); + } catch (err) { + throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error'); + } + this.service.log(viewer.matchId, 'server', this.adapter.simulated ? 'Aufwärmphase beendet (simuliert)' : 'Aufwärmphase beendet – Match startet'); + this.service.changed(viewer.matchId); + } + + async stopByOrganizer(viewer) { + this.service.require(viewer, 'organizer'); + await this.stop(viewer.matchId, 'Vom Organisator gestoppt'); + } + + async safeStop(row) { + try { + await this.adapter.stop(JSON.parse(row.handle), { resetPassword: randomPassword(16) }); + } catch (err) { + this.logger.warn(`[server] Stopp für ${row.match_id} fehlgeschlagen: ${err.message}`); + } + } + + async stop(matchId, reason) { + const row = this.row(matchId); + if (!row || row.status === 'stopped') return; + if (row.handle) await this.safeStop(row); + this.update(matchId, { status: 'stopped', stopped_at: Date.now(), message: reason, player_count: null }); + this.service.log(matchId, 'server', `Server gestoppt: ${reason}`); + } + + // ---------- Automatisches Herunterfahren ---------- + + async reap(now = Date.now()) { + const timeoutMs = this.config.idleTimeoutMinutes * 60_000; + const rows = this.db.prepare(`SELECT * FROM servers WHERE status IN (${ACTIVE.map(() => '?').join(',')})`).all(...ACTIVE); + for (const row of rows) { + if (this.busy.has(row.match_id)) continue; + const match = this.service.getMatch(row.match_id); + if (CLOSED_STATUSES.includes(match.status)) { + await this.stop(row.match_id, 'Match beendet'); + continue; + } + if (row.started_at && now - row.started_at > this.config.maxLifetimeHours * 3_600_000) { + await this.stop(row.match_id, `Maximale Laufzeit von ${this.config.maxLifetimeHours} h erreicht`); + continue; + } + const webActive = this.service.presence(row.match_id).size > 0; + let lastUse = match.last_activity_at; + if (row.status === 'configured' && row.player_count !== null) { + // Echter Server mit bekannter Spielerzahl: entscheidend ist, ob jemand auf dem Server ist. + if (row.player_count > 0) continue; + lastUse = Math.max(row.configured_at ?? 0, row.last_player_seen_at ?? 0); + } else if (webActive) { + continue; + } + if (now - lastUse > timeoutMs) { + await this.stop(row.match_id, `Automatisch heruntergefahren – ${this.config.idleTimeoutMinutes} Minuten ungenutzt`); + } + } + } +} diff --git a/server/src/servers/staticPoolAdapter.js b/server/src/servers/staticPoolAdapter.js new file mode 100644 index 0000000..7387ada --- /dev/null +++ b/server/src/servers/staticPoolAdapter.js @@ -0,0 +1,56 @@ +import { configureViaRcon, rconStatus } from './rconConfigure.js'; +import { runRcon } from './rcon.js'; + +/** + * Nutzt bereits laufende, selbst betriebene CS2-Server (CS2_STATIC_SERVERS). + * "Start" bedeutet hier: einen freien Server aus dem Pool reservieren und per RCON prüfen. + * "Stopp" gibt ihn wieder frei und setzt ein zufälliges Serverpasswort, damit niemand + * mit alten Zugangsdaten auf dem Server bleibt. Der Prozess selbst wird nicht beendet. + */ +export class StaticPoolServerAdapter { + constructor({ servers }, { getUsedPorts }) { + this.name = 'static'; + this.simulated = false; + this.servers = servers; + this.getUsedPorts = getUsedPorts; + if (!servers.length) throw new Error('SERVER_ADAPTER=static benötigt CS2_STATIC_SERVERS'); + } + + async start() { + const used = this.getUsedPorts(); + const free = this.servers.find((s) => !used.has(`${s.host}:${s.port}`)); + if (!free) throw new Error('Alle konfigurierten Server sind belegt'); + return { + handle: { host: free.host, port: free.port, poolKey: `${free.host}:${free.port}`, rconPassword: free.rconPassword }, + host: free.host, + port: free.port, + tvPort: free.tvPort, + }; + } + + target(handle) { + return { host: handle.host, port: handle.port, password: handle.rconPassword }; + } + + async status(handle) { + try { + const status = await rconStatus(this.target(handle)); + return { state: 'running', playerCount: status.playerCount, map: status.map }; + } catch (err) { + return { state: 'starting', message: `Server nicht erreichbar: ${err.message}` }; + } + } + + async configure(handle, plan) { + await configureViaRcon(this.target(handle), plan); + } + + async sendCommands(handle, commands) { + return runRcon(this.target(handle), commands); + } + + async stop(handle, { resetPassword } = {}) { + if (!resetPassword) return; + await runRcon(this.target(handle), [`sv_password "${resetPassword}"`, 'tv_stoprecord']).catch(() => {}); + } +} diff --git a/server/src/veto.js b/server/src/veto.js new file mode 100644 index 0000000..1d1e726 --- /dev/null +++ b/server/src/veto.js @@ -0,0 +1,162 @@ +// Reine Veto-Logik ohne Datenbank- oder Netzwerkzugriff. +// Ein Ablauf (flow) ist eine Liste von Schritten wie "ban:X", "pick:Y", "side:X", "decider". +// X ist das Team, das das Veto beginnt, Y das andere Team. + +export const BEST_OF = { bo1: 1, bo3: 3, bo5: 5 }; +export const SIDES = ['CT', 'T']; + +export function parseStep(raw) { + if (raw === 'decider') return { type: 'decider' }; + const match = /^(ban|pick|side):(X|Y)$/.exec(raw); + if (!match) throw new Error(`Unbekannter Veto-Schritt "${raw}"`); + return { type: match[1], actor: match[2] }; +} + +/** + * Prüft, ob ein Ablauf zu Map-Pool und Best-of-Format passt. + * Gibt eine Liste deutschsprachiger Fehlermeldungen zurück (leer = gültig). + */ +export function validateFlow(flow, mapCount, format) { + const errors = []; + const bestOf = BEST_OF[format]; + if (!bestOf) return [`Unbekanntes Format "${format}"`]; + if (!Array.isArray(flow) || flow.length === 0) return ['Ablauf ist leer']; + + let steps; + try { + steps = flow.map(parseStep); + } catch (err) { + return [err.message]; + } + + const bans = steps.filter((s) => s.type === 'ban').length; + const picks = steps.filter((s) => s.type === 'pick').length; + const deciders = steps.filter((s) => s.type === 'decider').length; + + if (deciders !== 1) errors.push('Ablauf braucht genau einen "decider"-Schritt'); + if (picks !== bestOf - 1) errors.push(`${format.toUpperCase()} braucht ${bestOf - 1} Pick(s), Ablauf hat ${picks}`); + if (bans + picks + 1 !== mapCount) { + errors.push(`Bans + Picks + Entscheidungsmap (${bans + picks + 1}) müssen der Pool-Größe (${mapCount}) entsprechen`); + } + + const deciderIndex = steps.findIndex((s) => s.type === 'decider'); + steps.forEach((step, i) => { + if (step.type === 'side') { + const prev = steps[i - 1]; + if (!prev || (prev.type !== 'pick' && prev.type !== 'decider')) { + errors.push(`Schritt ${i + 1}: Seitenwahl muss direkt auf einen Pick oder die Entscheidungsmap folgen`); + } + } + if (deciderIndex !== -1 && i > deciderIndex && step.type !== 'side') { + errors.push(`Schritt ${i + 1}: Nach der Entscheidungsmap ist nur noch eine Seitenwahl erlaubt`); + } + }); + return errors; +} + +function teamFor(actor, firstTeam) { + const second = firstTeam === 'A' ? 'B' : 'A'; + return actor === 'X' ? firstTeam : second; +} + +function other(team) { + return team === 'A' ? 'B' : 'A'; +} + +/** + * Berechnet den vollständigen Veto-Zustand aus den bisherigen Aktionen. + * actions: [{ type, team, map?, side? }] in Reihenfolge der Schritte (inkl. automatischer "decider"-Aktion). + */ +export function computeVetoState({ maps, flow, firstTeam, actions }) { + const steps = flow.map(parseStep); + const mapState = new Map(maps.map((m) => [m.id, { ...m, status: 'available', by: null, order: null }])); + const series = []; + let lastMapRef = null; + + actions.forEach((action, i) => { + const step = steps[i]; + if (!step || step.type !== action.type) throw new Error(`Aktion ${i + 1} passt nicht zum Ablauf`); + if (action.type === 'ban') { + Object.assign(mapState.get(action.map), { status: 'banned', by: action.team, order: i }); + } else if (action.type === 'pick' || action.type === 'decider') { + const entry = mapState.get(action.map); + Object.assign(entry, { status: action.type === 'pick' ? 'picked' : 'decider', by: action.team ?? null, order: i }); + lastMapRef = { map: action.map, pickedBy: action.team ?? null, sideChosenBy: null, startSides: null }; + series.push(lastMapRef); + } else if (action.type === 'side') { + lastMapRef.sideChosenBy = action.team; + lastMapRef.startSides = { [action.team]: action.side, [other(action.team)]: action.side === 'CT' ? 'T' : 'CT' }; + } + }); + + const currentIndex = actions.length; + const raw = steps[currentIndex]; + const done = currentIndex >= steps.length; + let current = null; + if (!done) { + current = { index: currentIndex, type: raw.type, team: raw.actor ? teamFor(raw.actor, firstTeam) : null }; + if (raw.type === 'side') current.map = lastMapRef?.map ?? null; + } + + // Serienreihenfolge: Picks in Pick-Reihenfolge, Entscheidungsmap zuletzt (ergibt sich aus der Aktionsreihenfolge). + return { + steps: steps.map((s, i) => ({ + index: i, + type: s.type, + team: s.actor ? teamFor(s.actor, firstTeam) : null, + done: i < currentIndex, + })), + maps: [...mapState.values()], + series: series.map((s, i) => ({ ...s, number: i + 1 })), + current, + done, + }; +} + +/** + * Validiert eine Captain-Aktion und gibt die anzuhängenden Aktionen zurück + * (die eigentliche Aktion plus ggf. die automatisch übernommene Entscheidungsmap). + */ +export function resolveAction({ maps, flow, firstTeam, actions }, { team, stepIndex, map, side }) { + const state = computeVetoState({ maps, flow, firstTeam, actions }); + if (state.done) throw new VetoError('Das Veto ist bereits abgeschlossen', 'veto_done'); + if (stepIndex !== state.current.index) { + throw new VetoError('Diese Aktion ist veraltet – der Veto-Stand hat sich bereits geändert', 'stale_step'); + } + if (state.current.team !== team) throw new VetoError('Dein Team ist gerade nicht am Zug', 'not_your_turn'); + + const result = []; + const { type } = state.current; + if (type === 'ban' || type === 'pick') { + const target = state.maps.find((m) => m.id === map); + if (!target) throw new VetoError('Diese Map gehört nicht zum Map-Pool', 'invalid_map'); + if (target.status !== 'available') throw new VetoError('Diese Map ist nicht mehr verfügbar', 'map_unavailable'); + result.push({ type, team, map }); + } else if (type === 'side') { + if (!SIDES.includes(side)) throw new VetoError('Bitte CT oder T wählen', 'invalid_side'); + result.push({ type, team, side, map: state.current.map }); + } else { + throw new VetoError('Dieser Schritt wird automatisch ausgeführt', 'auto_step'); + } + + // Automatische Schritte direkt anhängen (Entscheidungsmap = letzte verfügbare Map). + let next = [...actions, ...result]; + for (;;) { + const after = computeVetoState({ maps, flow, firstTeam, actions: next }); + if (after.done || after.current.type !== 'decider') break; + const remaining = after.maps.filter((m) => m.status === 'available'); + if (remaining.length !== 1) throw new Error('Ablauf inkonsistent: Entscheidungsmap nicht eindeutig'); + const decider = { type: 'decider', team: null, map: remaining[0].id }; + result.push(decider); + next = [...next, decider]; + } + return result; +} + +export class VetoError extends Error { + constructor(message, code) { + super(message); + this.code = code; + this.status = 409; + } +} diff --git a/server/test/api.test.js b/server/test/api.test.js new file mode 100644 index 0000000..1b11c88 --- /dev/null +++ b/server/test/api.test.js @@ -0,0 +1,235 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { loadConfig } from '../src/config.js'; +import { createApp } from '../src/app.js'; + +const quiet = { log() {}, warn() {}, error() {} }; + +async function setup(overrides = {}) { + const config = loadConfig({ + databaseFile: ':memory:', + serverAdapter: 'demo', + createMatchPassword: '', + requireFullTeams: true, + idleTimeoutMinutes: 30, + demo: { startSeconds: 0, failFirstConfigure: false }, + ...overrides, + }); + const app = createApp(config, { logger: quiet }); + await new Promise((resolve) => app.server.listen(0, '127.0.0.1', resolve)); + const base = `http://127.0.0.1:${app.server.address().port}/api`; + + const call = async (method, path, { token, body } = {}) => { + const res = await fetch(base + path, { + method, + headers: { + 'content-type': 'application/json', + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + return { status: res.status, body: await res.json() }; + }; + return { app, call, close: () => app.close() }; +} + +async function waitFor(fn, { timeoutMs = 8000, label = 'Bedingung' } = {}) { + const deadline = Date.now() + timeoutMs; + for (;;) { + const value = await fn(); + if (value) return value; + if (Date.now() > deadline) throw new Error(`Zeitüberschreitung: ${label}`); + await new Promise((r) => setTimeout(r, 50)); + } +} + +/** Erstellt ein Wingman-Match, füllt beide Teams und setzt alle bereit. */ +async function readyWingmanMatch(call, format = 'bo3') { + const created = await call('POST', '/matches', { + body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format, vetoStart: 'A' }, + }); + assert.equal(created.status, 200, JSON.stringify(created.body)); + const { id, organizerToken: org } = created.body; + const orgView = (await call('GET', `/matches/${id}`, { token: org })).body; + const codes = orgView.organizer.codes; + const tokens = {}; + for (const [name, team] of [['a1', 'A'], ['a2', 'A'], ['b1', 'B'], ['b2', 'B']]) { + const res = await call('POST', `/matches/${id}/join`, { body: { code: codes[team], nickname: name } }); + assert.equal(res.status, 200, JSON.stringify(res.body)); + tokens[name] = res.body.playerToken; + } + for (const t of Object.values(tokens)) await call('POST', `/matches/${id}/ready`, { token: t, body: { ready: true } }); + return { id, org, codes, tokens }; +} + +async function playVeto(call, id, captains) { + for (let guard = 0; guard < 20; guard++) { + const snap = (await call('GET', `/matches/${id}`, { token: captains.A })).body; + if (snap.veto.done) return snap; + const { current } = snap.veto; + const body = { stepIndex: current.index }; + if (current.type === 'side') body.side = 'CT'; + else body.map = snap.veto.maps.find((m) => m.status === 'available').id; + const res = await call('POST', `/matches/${id}/veto/action`, { token: captains[current.team], body }); + assert.equal(res.status, 200, JSON.stringify(res.body)); + } + throw new Error('Veto endet nicht'); +} + +test('kompletter Ablauf: Lobby, Berechtigungen, Veto, Server-Konfiguration', async () => { + const { app, call, close } = await setup(); + try { + const cfg = (await call('GET', '/config')).body; + assert.equal(cfg.server.simulated, true); + const wingman = cfg.pools.find((p) => p.mode === 'wingman'); + assert.equal(wingman.formats.bo5.supported, false); + + const unsupported = await call('POST', '/matches', { + body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo5' }, + }); + assert.equal(unsupported.status, 400); + assert.equal(unsupported.body.code, 'unsupported_format'); + + const created = await call('POST', '/matches', { + body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo3', vetoStart: 'A' }, + }); + const { id, organizerToken: org } = created.body; + + // Server wird direkt beim Erstellen der Lobby angefordert + await waitFor(() => app.service.serverRow(id), { label: 'Server-Datensatz' }); + + const publicView = (await call('GET', `/matches/${id}`)).body; + assert.equal(publicView.viewer.role, 'public'); + assert.equal(publicView.organizer, undefined); + assert.equal(publicView.veto, null); + + const orgView = (await call('GET', `/matches/${id}`, { token: org })).body; + assert.equal(orgView.viewer.role, 'organizer'); + const { codes } = orgView.organizer; + + // Zuschauer- und fremde Codes erlauben keinen Teambeitritt + assert.equal((await call('POST', `/matches/${id}/join`, { body: { code: codes.spectator, nickname: 'spy' } })).status, 403); + assert.equal((await call('POST', `/matches/${id}/join`, { body: { code: 'AAAA-AAAA', nickname: 'spy' } })).status, 404); + + const join = async (team, nickname) => call('POST', `/matches/${id}/join`, { body: { code: codes[team], nickname } }); + const a1 = (await join('A', 'a1')).body; + const a2 = (await join('A', 'a2')).body; + assert.equal((await join('A', 'a3')).body.code, 'team_full'); + assert.equal((await join('B', 'A1')).body.code, 'name_taken'); + const b1 = (await join('B', 'b1')).body; + const b2 = (await join('B', 'b2')).body; + + const a1View = (await call('GET', `/matches/${id}`, { token: a1.playerToken })).body; + assert.equal(a1View.viewer.role, 'captain'); + assert.equal((await call('GET', `/matches/${id}`, { token: a2.playerToken })).body.viewer.role, 'player'); + + // Veto-Start nur durch Organisator und nur wenn alle bereit sind + assert.equal((await call('POST', `/matches/${id}/veto/start`, { token: a1.playerToken })).status, 403); + assert.equal((await call('POST', `/matches/${id}/veto/start`, { token: org })).body.code, 'teams_not_ready'); + for (const p of [a1, a2, b1, b2]) await call('POST', `/matches/${id}/ready`, { token: p.playerToken, body: { ready: true } }); + assert.equal((await call('POST', `/matches/${id}/veto/start`, { token: org })).status, 200); + assert.equal((await call('POST', `/matches/${id}/veto/start`, { token: org })).body.code, 'wrong_phase'); + + // Nur der Captain des Teams am Zug darf handeln + const firstMap = { stepIndex: 0, map: 'de_inferno' }; + assert.equal((await call('POST', `/matches/${id}/veto/action`, { token: a2.playerToken, body: firstMap })).status, 403); + assert.equal((await call('POST', `/matches/${id}/veto/action`, { token: codes.spectator, body: firstMap })).status, 403); + assert.equal((await call('POST', `/matches/${id}/veto/action`, { token: b1.playerToken, body: firstMap })).body.code, 'not_your_turn'); + assert.equal((await call('POST', `/matches/${id}/veto/action`, { token: a1.playerToken, body: firstMap })).status, 200); + // Doppelte Aktion (gleicher Schritt) wird abgelehnt + assert.equal((await call('POST', `/matches/${id}/veto/action`, { token: a1.playerToken, body: firstMap })).body.code, 'stale_step'); + + const done = await playVeto(call, id, { A: a1.playerToken, B: b1.playerToken }); + assert.equal(done.series.length, 3); + + // Demo-Server wird nach dem Veto automatisch konfiguriert + await app.manager.poll(); + await waitFor(async () => (await call('GET', `/matches/${id}`, { token: org })).body.status === 'live', { label: 'Match live' }); + + const player = (await call('GET', `/matches/${id}`, { token: a2.playerToken })).body; + const spectator = (await call('GET', `/matches/${id}`, { token: codes.spectator })).body; + const organizer = (await call('GET', `/matches/${id}`, { token: org })).body; + assert.equal(player.server.simulated, true); + assert.ok(player.server.connect); + assert.equal(player.server.gotv, undefined); + assert.ok(spectator.server.gotv); + assert.equal(spectator.server.connect, undefined); + assert.ok(organizer.server.connect && organizer.server.gotv); + assert.equal(organizer.series[0].status, 'live'); + + // RCON-Passwort und interne Handles verlassen nie das Backend + const row = app.service.serverRow(id); + for (const view of [player, spectator, organizer, publicView]) { + const json = JSON.stringify(view); + assert.ok(!json.includes(row.rcon_password)); + assert.ok(!json.includes('handle')); + } + + // Nächste Map der Serie laden + assert.equal((await call('POST', `/matches/${id}/series/next`, { token: a1.playerToken })).status, 403); + assert.equal((await call('POST', `/matches/${id}/series/next`, { token: org })).status, 200); + await waitFor(async () => { + const s = (await call('GET', `/matches/${id}`, { token: org })).body; + return s.status === 'live' && s.currentMapNumber === 2; + }, { label: 'Map 2' }); + + assert.equal((await call('POST', `/matches/${id}/finish`, { token: org })).status, 200); + await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'Server gestoppt' }); + const list = (await call('GET', '/matches')).body.matches; + assert.equal(list.find((m) => m.id === id).group, 'closed'); + } finally { + await close(); + } +}); + +test('fehlgeschlagene Konfiguration kann vom Organisator wiederholt werden', async () => { + const { app, call, close } = await setup({ demo: { startSeconds: 0, failFirstConfigure: true } }); + try { + const { id, org, tokens } = await readyWingmanMatch(call, 'bo1'); + await call('POST', `/matches/${id}/veto/start`, { token: org }); + await playVeto(call, id, { A: tokens.a1, B: tokens.b1 }); + await app.manager.poll(); + await waitFor(() => app.service.serverRow(id).status === 'config_failed', { label: 'config_failed' }); + + assert.equal((await call('POST', `/matches/${id}/server/retry`, { token: tokens.a1 })).status, 403); + assert.equal((await call('POST', `/matches/${id}/server/retry`, { token: org })).status, 200); + await waitFor(() => app.service.serverRow(id).status === 'configured', { label: 'configured' }); + assert.equal(app.service.getMatch(id).status, 'live'); + } finally { + await close(); + } +}); + +test('ungenutzte Server werden nach der Frist heruntergefahren', async () => { + const { app, call, close } = await setup({ idleTimeoutMinutes: 10 }); + try { + const { id } = (await call('POST', '/matches', { + body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'competitive', format: 'bo1' }, + })).body; + await waitFor(() => app.service.serverRow(id)?.handle, { label: 'Server gestartet' }); + await app.manager.reap(Date.now() + 5 * 60_000); + assert.notEqual(app.service.serverRow(id).status, 'stopped'); + await app.manager.reap(Date.now() + 11 * 60_000); + assert.equal(app.service.serverRow(id).status, 'stopped'); + assert.match(app.service.serverRow(id).message, /ungenutzt/); + } finally { + await close(); + } +}); + +test('Spieler verlassen die Lobby, Captain geht über', async () => { + const { call, close } = await setup(); + try { + const { id, org, tokens } = await readyWingmanMatch(call); + assert.equal((await call('POST', `/matches/${id}/leave`, { token: tokens.a1 })).status, 200); + const snap = (await call('GET', `/matches/${id}`, { token: org })).body; + const teamA = snap.teams.A.players; + assert.equal(teamA.length, 1); + assert.equal(teamA[0].nickname, 'a2'); + assert.equal(teamA[0].isCaptain, true); + // Alter Token ist ungültig + assert.equal((await call('GET', `/matches/${id}`, { token: tokens.a1 })).body.viewer.role, 'public'); + } finally { + await close(); + } +}); diff --git a/server/test/rcon.test.js b/server/test/rcon.test.js new file mode 100644 index 0000000..d4dcb8c --- /dev/null +++ b/server/test/rcon.test.js @@ -0,0 +1,67 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import net from 'node:net'; +import { decodePackets, encodePacket, parseStatus, runRcon } from '../src/servers/rcon.js'; +import { buildMapCommands, sanitizeConsoleText } from '../src/servers/cs2Config.js'; + +/** Minimaler Source-RCON-Server für Tests. */ +function fakeRconServer(password, onCommand) { + const server = net.createServer((socket) => { + let buffer = Buffer.alloc(0); + socket.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + const { packets, rest } = decodePackets(buffer); + buffer = rest; + for (const p of packets) { + if (p.type === 3) { + socket.write(encodePacket(p.body === password ? p.id : -1, 2, '')); + } else { + socket.write(encodePacket(p.id, 0, onCommand(p.body))); + } + } + }); + }); + return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server))); +} + +test('Paket-Kodierung ist umkehrbar', () => { + const buf = Buffer.concat([encodePacket(7, 2, 'status'), encodePacket(8, 2, 'mp_warmup_end')]); + const { packets, rest } = decodePackets(buf); + assert.deepEqual(packets.map((p) => [p.id, p.body]), [[7, 'status'], [8, 'mp_warmup_end']]); + assert.equal(rest.length, 0); + const partial = decodePackets(buf.subarray(0, 10)); + assert.equal(partial.packets.length, 0); +}); + +test('RCON-Client authentifiziert und führt Befehle aus', async () => { + const seen = []; + const server = await fakeRconServer('geheim', (cmd) => { + seen.push(cmd); + return cmd === 'status' ? 'map : de_mirage\nplayers : 3 humans, 0 bots (10 max)' : ''; + }); + const { port } = server.address(); + try { + const [out] = await runRcon({ host: '127.0.0.1', port, password: 'geheim' }, ['status', 'mp_warmup_end']); + assert.deepEqual(parseStatus(out), { map: 'de_mirage', playerCount: 3 }); + assert.deepEqual(seen, ['status', 'mp_warmup_end']); + await assert.rejects(runRcon({ host: '127.0.0.1', port, password: 'falsch' }, ['status']), /abgelehnt/); + } finally { + server.close(); + } +}); + +test('Konsolentexte können keine Befehle einschleusen', () => { + assert.equal(sanitizeConsoleText('Team"; rcon_password x'), 'Team rcon_password x'); + const { beforeMapChange, afterMapChange } = buildMapCommands({ + match: { team_a_name: 'Alpha";quit', team_b_name: 'Bravo' }, + pool: { mode: 'wingman', gameType: 0, gameMode: 2 }, + seriesMap: { map: 'de_nuke', ctTeam: 'B' }, + secrets: { serverPassword: 'abc', tvPassword: 'def' }, + settings: { tvEnabled: true, tvDelaySeconds: 90, maxRoundsWingman: 16, maxRoundsCompetitive: 24, overtime: true, extraCommands: [] }, + }); + assert.ok(beforeMapChange.includes('game_mode 2')); + assert.equal(beforeMapChange.at(-1), 'changelevel de_nuke'); + assert.ok(afterMapChange.includes('mp_teamname_1 "Bravo"')); + assert.ok(afterMapChange.includes('mp_teamname_2 "Alphaquit"')); + assert.ok(afterMapChange.includes('mp_maxrounds 16')); +}); diff --git a/server/test/veto.test.js b/server/test/veto.test.js new file mode 100644 index 0000000..678191d --- /dev/null +++ b/server/test/veto.test.js @@ -0,0 +1,93 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { computeVetoState, resolveAction, validateFlow } from '../src/veto.js'; +import { buildMapPools, loadMapPools } from '../src/mapPools.js'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const pools = loadMapPools(path.join(here, '..', 'config', 'map-pools.json')); + +test('alle ausgelieferten Abläufe sind gültig, Wingman-BO5 ist nicht unterstützt', () => { + assert.equal(pools.competitive.formats.bo1.supported, true); + assert.equal(pools.competitive.formats.bo3.supported, true); + assert.equal(pools.competitive.formats.bo5.supported, true); + assert.equal(pools.wingman.formats.bo1.supported, true); + assert.equal(pools.wingman.formats.bo3.supported, true); + assert.equal(pools.wingman.formats.bo5.supported, false); +}); + +test('validateFlow erkennt unpassende Abläufe', () => { + assert.deepEqual(validateFlow(['ban:X', 'ban:Y', 'decider'], 3, 'bo1'), []); + assert.ok(validateFlow(['ban:X', 'decider'], 3, 'bo1').length > 0, 'zu wenige Bans'); + assert.ok(validateFlow(['ban:X', 'ban:Y', 'decider'], 3, 'bo3').length > 0, 'fehlende Picks'); + assert.ok(validateFlow(['side:X', 'ban:Y', 'ban:X', 'decider'], 3, 'bo1').length > 0, 'Seitenwahl ohne Pick'); + assert.ok(validateFlow(['ban:X', 'decider', 'ban:Y'], 3, 'bo1').length > 0, 'Ban nach Entscheidungsmap'); + assert.ok(validateFlow(['foo:X'], 1, 'bo1').length > 0, 'unbekannter Schritt'); +}); + +test('ungültiger Ablauf in der Konfiguration deaktiviert nur dieses Format', () => { + const custom = buildMapPools({ + pools: { + mini: { + label: 'Mini', teamSize: 2, gameType: 0, gameMode: 2, + maps: [{ id: 'de_a', name: 'A' }, { id: 'de_b', name: 'B' }, { id: 'de_c', name: 'C' }], + flows: { bo1: ['ban:X', 'ban:Y', 'decider'], bo3: ['pick:X', 'decider'] }, + }, + }, + }); + assert.equal(custom.mini.formats.bo1.supported, true); + assert.equal(custom.mini.formats.bo3.supported, false); + assert.match(custom.mini.formats.bo5.reason, /Kein Veto-Ablauf/); +}); + +function play(pool, format, firstTeam, choices) { + const ctx = { maps: pool.maps, flow: pool.formats[format].flow, firstTeam, actions: [] }; + for (const choice of choices) { + const state = computeVetoState(ctx); + const added = resolveAction(ctx, { team: state.current.team, stepIndex: state.current.index, ...choice }); + ctx.actions = [...ctx.actions, ...added]; + } + return computeVetoState(ctx); +} + +test('BO3 Competitive: kompletter Ablauf mit Seitenwahl und Entscheidungsmap', () => { + const state = play(pools.competitive, 'bo3', 'B', [ + { map: 'de_train' }, // B bannt + { map: 'de_nuke' }, // A bannt + { map: 'de_mirage' }, // B pickt + { side: 'CT' }, // A wählt Seite auf Mirage + { map: 'de_inferno' }, // A pickt + { side: 'T' }, // B wählt Seite auf Inferno + { map: 'de_dust2' }, // B bannt + { map: 'de_anubis' }, // A bannt -> Ancient bleibt + { side: 'CT' }, // B (X) wählt Seite auf der Entscheidungsmap + ]); + assert.equal(state.done, true); + assert.deepEqual(state.series.map((s) => s.map), ['de_mirage', 'de_inferno', 'de_ancient']); + assert.equal(state.series[0].pickedBy, 'B'); + assert.deepEqual(state.series[0].startSides, { A: 'CT', B: 'T' }); + assert.deepEqual(state.series[1].startSides, { B: 'T', A: 'CT' }); + assert.equal(state.series[2].pickedBy, null); + assert.deepEqual(state.series[2].startSides, { B: 'CT', A: 'T' }); + assert.equal(state.maps.find((m) => m.id === 'de_ancient').status, 'decider'); +}); + +test('BO1 Wingman: Entscheidungsmap wird automatisch übernommen', () => { + const state = play(pools.wingman, 'bo1', 'A', [{ map: 'de_inferno' }, { map: 'de_nuke' }, { map: 'de_overpass' }]); + assert.equal(state.done, false); + assert.equal(state.current.type, 'side'); + assert.equal(state.current.team, 'B'); + assert.equal(state.current.map, 'de_vertigo'); +}); + +test('falsches Team, veraltete Schritte und vergebene Maps werden abgelehnt', () => { + const pool = pools.competitive; + const ctx = { maps: pool.maps, flow: pool.formats.bo1.flow, firstTeam: 'A', actions: [] }; + assert.throws(() => resolveAction(ctx, { team: 'B', stepIndex: 0, map: 'de_nuke' }), { code: 'not_your_turn' }); + assert.throws(() => resolveAction(ctx, { team: 'A', stepIndex: 1, map: 'de_nuke' }), { code: 'stale_step' }); + assert.throws(() => resolveAction(ctx, { team: 'A', stepIndex: 0, map: 'de_cache' }), { code: 'invalid_map' }); + ctx.actions = resolveAction(ctx, { team: 'A', stepIndex: 0, map: 'de_nuke' }); + assert.throws(() => resolveAction(ctx, { team: 'B', stepIndex: 1, map: 'de_nuke' }), { code: 'map_unavailable' }); + assert.throws(() => resolveAction(ctx, { team: 'A', stepIndex: 0, map: 'de_train' }), { code: 'stale_step' }); +});