feat!: upgrade to SvelteKit 3 and adapter-bun
Some checks failed
Test and deploy staging / staging (push) Failing after 14s

- run sv migrate sveltekit-3: config moves into vite.config.ts, $lib becomes
  #lib, $env/dynamic becomes $app/env/private via src/env.ts, $app/environment
  becomes $app/env, hook types move to @sveltejs/kit/hooks
- env vars stay optional (the migrator defaulted them to ''), and BANCHO_MODE,
  which it missed, is declared and passed to simulatorEnabled
- handleError only logs and masks unknown errors; Kit 3 now routes 404s and
  error(...) through it too
- json()/text() become Response.json()/new Response(); goto keepFocus/noScroll
  becomes reset: false; invalidateAll becomes refreshAll; page.url is copied
  before mutating
- replace adapter-node with @sveltejs/adapter-bun; prod-server.ts wraps
  Bun.serve to add native WebSockets (bunSockets) for /ws paths, since the
  generated server has no websocket option
- drop the Kit readdir patch: Kit 3 sorts route entries itself
- match view and broadcast pages use the live score modules

BREAKING CHANGE: requires SvelteKit 3, TypeScript 6 and Bun 1.4; the build
output is build/index.js from adapter-bun, started through prod-server.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 22:02:53 +00:00
parent 54444a77f0
commit d5448d9a94
239 changed files with 1650 additions and 1360 deletions

View File

@@ -1,8 +1,6 @@
FROM oven/bun:1.4.0 AS builder
WORKDIR /app
COPY package.json bun.lock ./
# Patched dependencies are applied during install.
COPY patches ./patches
RUN bun install --frozen-lockfile
COPY . .
ENV NODE_ENV=production

112
bun.lock
View File

@@ -17,8 +17,9 @@
"@lucide/svelte": "^1.48.0",
"@playwright/test": "^1.63.0",
"@sentry/sveltekit": "^10.75.3",
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.70.3",
"@sveltejs/adapter-bun": "^1.0.0",
"@sveltejs/kit": "^3.0.0",
"@sveltejs/load-config": "^0.2.2",
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3",
@@ -51,7 +52,7 @@
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "~5.8.3",
"typescript": "^6.0.0",
"typescript-eslint": "^8.70.1",
"vite": "^8.3.1",
"vite-plugin-devtools-json": "^1.1.0",
@@ -59,9 +60,6 @@
},
},
},
"patchedDependencies": {
"@sveltejs/kit@2.70.3": "patches/@sveltejs%2Fkit@2.70.3.patch",
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
@@ -281,8 +279,6 @@
"@lucide/svelte": ["@lucide/svelte@1.48.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-bLimVDjehxu3fpu9XEUpqEkBk2sJAHAT2sVcLHhlM70EdNsXuj9ZnYENWv5xgw3+05h4i5TirAxTiTgnZZf8gw=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
"@noble/ciphers": ["@noble/ciphers@2.4.0", "", {}, "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw=="],
"@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="],
@@ -343,66 +339,6 @@
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@29.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-ZaOxZceP7SOUW7Lqw5IRVweSQYWaeIPnXIGLiB690EBA3FGJTO40EEr2L5yZplJWsgTCogILRSpcAe7+U0Otdg=="],
"@rollup/plugin-json": ["@rollup/plugin-json@6.1.0", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA=="],
"@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@16.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg=="],
"@rollup/plugin-replace": ["@rollup/plugin-replace@6.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "magic-string": "^0.30.3" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA=="],
"@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.63.5", "", { "os": "android", "cpu": "arm" }, "sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.63.5", "", { "os": "android", "cpu": "arm64" }, "sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.63.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.63.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.63.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.63.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.63.5", "", { "os": "linux", "cpu": "arm" }, "sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.63.5", "", { "os": "linux", "cpu": "arm" }, "sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.63.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.63.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.63.5", "", { "os": "linux", "cpu": "none" }, "sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.63.5", "", { "os": "linux", "cpu": "none" }, "sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.63.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.63.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.63.5", "", { "os": "linux", "cpu": "none" }, "sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.63.5", "", { "os": "linux", "cpu": "none" }, "sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.63.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.63.5", "", { "os": "linux", "cpu": "x64" }, "sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.63.5", "", { "os": "linux", "cpu": "x64" }, "sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.63.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.63.5", "", { "os": "none", "cpu": "arm64" }, "sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.63.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.63.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.63.5", "", { "os": "win32", "cpu": "x64" }, "sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.63.5", "", { "os": "win32", "cpu": "x64" }, "sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw=="],
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
"@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="],
@@ -467,9 +403,9 @@
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.13", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ=="],
"@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.5.7", "", { "dependencies": { "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "@rollup/plugin-replace": "^6.0.3", "rollup": "^4.59.0" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-uOfc9eVlI3A37RRSaKcgrheBYPrfJwC9VMqDp8x/O6tlKdcLLvHThSWD0KNIbjQ/d+7bwLGx3vx6aowAcRfd2g=="],
"@sveltejs/adapter-bun": ["@sveltejs/adapter-bun@1.0.0", "", { "dependencies": { "bun-types": "^1.3.14" }, "peerDependencies": { "@sveltejs/kit": "^3.0.0-next.0" } }, "sha512-CUfFNxrWrKk/0VqSM1DrXCAwpY3tWocaFwBohuBeyxKx07859shLqPdT9oo00lDti9PvVwhQMDS6bKQylTyv0g=="],
"@sveltejs/kit": ["@sveltejs/kit@2.70.3", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg=="],
"@sveltejs/kit": ["@sveltejs/kit@3.1.0", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1", "@standard-schema/spec": "^1.1.0", "cookie": "^2.0.1", "devalue": "^5.9.4", "esm-env": "^1.2.2", "magic-string": "^1.2.3", "mrmime": "^2.0.1", "sirv": "^3.0.2" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0", "svelte": "^5.57.1", "typescript": "^6.0.0", "vite": "^8.0.12" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-JTWlq2HQlkqx6vIoxhC0R6sc4WVqbQoRyEmr5YlqKmiYbzY1U3awtSWTmjMLlS056a49AmkQR6rnNANXIE8B0Q=="],
"@sveltejs/load-config": ["@sveltejs/load-config@0.2.3", "", {}, "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ=="],
@@ -515,8 +451,6 @@
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
@@ -527,8 +461,6 @@
"@types/node": ["@types/node@25.9.8", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-VfMrScDmMhUJQmd5hArdQnFvK0OIeD36uN2Va1FcpYaLB/BgkgM8Ulc50XtYISPMz7APJ30+N0T5EM0jlcdfRw=="],
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.70.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.70.1", "@typescript-eslint/type-utils": "8.70.1", "@typescript-eslint/utils": "8.70.1", "@typescript-eslint/visitor-keys": "8.70.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.70.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA=="],
@@ -643,8 +575,6 @@
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
"compare-func": ["compare-func@2.0.0", "", { "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" } }, "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA=="],
"conventional-changelog-angular": ["conventional-changelog-angular@8.3.1", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg=="],
@@ -655,7 +585,7 @@
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
"cookie": ["cookie@2.0.1", "", {}, "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w=="],
"cosmiconfig": ["cosmiconfig@9.0.2", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg=="],
@@ -707,8 +637,6 @@
"error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
"es-toolkit": ["es-toolkit@1.52.0", "", {}, "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA=="],
@@ -741,7 +669,7 @@
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
@@ -773,8 +701,6 @@
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
@@ -801,8 +727,6 @@
"hashery": ["hashery@1.5.1", "", { "dependencies": { "hookified": "^1.15.0" } }, "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ=="],
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
"hls.js": ["hls.js@1.7.3", "", {}, "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A=="],
@@ -829,16 +753,12 @@
"is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
"is-core-module": ["is-core-module@2.17.0", "", { "dependencies": { "hasown": "^2.0.4" } }, "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
"is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
@@ -869,8 +789,6 @@
"keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"known-css-properties": ["known-css-properties@0.37.0", "", {}, "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="],
"kysely": ["kysely@0.28.17", "", {}, "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q=="],
@@ -979,8 +897,6 @@
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
@@ -1043,8 +959,6 @@
"require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="],
"resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
@@ -1055,8 +969,6 @@
"rolldown": ["rolldown@1.2.11", "", { "dependencies": { "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.11", "@rolldown/binding-android-arm64": "1.2.11", "@rolldown/binding-darwin-arm64": "1.2.11", "@rolldown/binding-darwin-x64": "1.2.11", "@rolldown/binding-freebsd-x64": "1.2.11", "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", "@rolldown/binding-linux-arm64-gnu": "1.2.11", "@rolldown/binding-linux-arm64-musl": "1.2.11", "@rolldown/binding-linux-ppc64-gnu": "1.2.11", "@rolldown/binding-linux-s390x-gnu": "1.2.11", "@rolldown/binding-linux-x64-gnu": "1.2.11", "@rolldown/binding-linux-x64-musl": "1.2.11", "@rolldown/binding-openharmony-arm64": "1.2.11", "@rolldown/binding-win32-arm64-msvc": "1.2.11", "@rolldown/binding-win32-x64-msvc": "1.2.11" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw=="],
"rollup": ["rollup@4.63.5", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.5", "@rollup/rollup-android-arm64": "4.63.5", "@rollup/rollup-darwin-arm64": "4.63.5", "@rollup/rollup-darwin-x64": "4.63.5", "@rollup/rollup-freebsd-arm64": "4.63.5", "@rollup/rollup-freebsd-x64": "4.63.5", "@rollup/rollup-linux-arm-gnueabihf": "4.63.5", "@rollup/rollup-linux-arm-musleabihf": "4.63.5", "@rollup/rollup-linux-arm64-gnu": "4.63.5", "@rollup/rollup-linux-arm64-musl": "4.63.5", "@rollup/rollup-linux-loong64-gnu": "4.63.5", "@rollup/rollup-linux-loong64-musl": "4.63.5", "@rollup/rollup-linux-ppc64-gnu": "4.63.5", "@rollup/rollup-linux-ppc64-musl": "4.63.5", "@rollup/rollup-linux-riscv64-gnu": "4.63.5", "@rollup/rollup-linux-riscv64-musl": "4.63.5", "@rollup/rollup-linux-s390x-gnu": "4.63.5", "@rollup/rollup-linux-x64-gnu": "4.63.5", "@rollup/rollup-linux-x64-musl": "4.63.5", "@rollup/rollup-openbsd-x64": "4.63.5", "@rollup/rollup-openharmony-arm64": "4.63.5", "@rollup/rollup-win32-arm64-msvc": "4.63.5", "@rollup/rollup-win32-ia32-msvc": "4.63.5", "@rollup/rollup-win32-x64-gnu": "4.63.5", "@rollup/rollup-win32-x64-msvc": "4.63.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw=="],
"rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="],
"runed": ["runed@0.35.1", "", { "dependencies": { "dequal": "^2.0.3", "esm-env": "^1.0.0", "lz-string": "^1.5.0" }, "peerDependencies": { "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0" }, "optionalPeers": ["@sveltejs/kit"] }, "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q=="],
@@ -1111,8 +1023,6 @@
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"svelte": ["svelte@5.57.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@jridgewell/sourcemap-codec": "^1.6.0", "@sveltejs/acorn-typescript": "^1.0.13", "@types/estree": "^1.0.9", "acorn": "^8.18.0", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.9.2", "esm-env": "^1.2.1", "esrap": "^2.3.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA=="],
"svelte-check": ["svelte-check@4.7.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.3", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw=="],
@@ -1161,7 +1071,7 @@
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"typescript-eslint": ["typescript-eslint@8.70.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.70.1", "@typescript-eslint/parser": "8.70.1", "@typescript-eslint/typescript-estree": "8.70.1", "@typescript-eslint/utils": "8.70.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA=="],
@@ -1231,7 +1141,7 @@
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
"@sveltejs/kit/magic-string": ["magic-string@1.4.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g=="],
"@sveltejs/vite-plugin-svelte/magic-string": ["magic-string@1.4.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g=="],
@@ -1253,8 +1163,6 @@
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.10", "", {}, "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q=="],
"@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"better-call/@better-fetch/fetch": ["@better-fetch/fetch@1.3.2", "", {}, "sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow=="],
"bun-types/@types/node": ["@types/node@26.6.3", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg=="],

View File

@@ -6,7 +6,9 @@ import svelte from 'eslint-plugin-svelte';
import { defineConfig } from 'eslint/config';
import globals from 'globals';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
import { loadConfig } from '@sveltejs/load-config';
const svelteConfig = (await loadConfig('./', { traverse: false }))?.config;
const gitignorePath = path.resolve(import.meta.dirname, '.gitignore');

View File

@@ -18,6 +18,7 @@
"test:e2e": "playwright test",
"test:streamer": "bun test streamer/",
"streamer": "bun streamer/agent.ts",
"broadcast:build": "bun build streamer/windows/cli.ts --compile --minify --target=bun-windows-x64 --outfile dist/vash-broadcast.exe",
"streamer:build": "bun build streamer/agent.ts --compile --minify --target=bun-windows-x64 --outfile dist/vash-streamer.exe && bun build streamer/agent.ts --compile --minify --target=bun-linux-x64 --outfile dist/vash-streamer",
"db:seed": "bun scripts/seed-dev.ts",
"db:push": "drizzle-kit push",
@@ -34,8 +35,9 @@
"@lucide/svelte": "^1.48.0",
"@playwright/test": "^1.63.0",
"@sentry/sveltekit": "^10.75.3",
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.70.3",
"@sveltejs/adapter-bun": "^1.0.0",
"@sveltejs/kit": "^3.0.0",
"@sveltejs/load-config": "^0.2.2",
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3",
@@ -68,7 +70,7 @@
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "~5.8.3",
"typescript": "^6.0.0",
"typescript-eslint": "^8.70.1",
"vite": "^8.3.1",
"vite-plugin-devtools-json": "^1.1.0",
@@ -88,7 +90,8 @@
"discord.js": "^14.27.0",
"zod": "^4.6.5"
},
"patchedDependencies": {
"@sveltejs/kit@2.70.3": "patches/@sveltejs%2Fkit@2.70.3.patch"
"imports": {
"#lib": "./src/lib/index.js",
"#lib/*": "./src/lib/*"
}
}

View File

@@ -1,15 +0,0 @@
diff --git a/src/core/sync/create_manifest_data/index.js b/src/core/sync/create_manifest_data/index.js
index 6e97903682f448a9c95a01527530bd0bdb545174..d233d9c10da549f31bb4d4ff48204bb9f9b4a9e9 100644
--- a/src/core/sync/create_manifest_data/index.js
+++ b/src/core/sync/create_manifest_data/index.js
@@ -222,7 +222,9 @@ function create_routes_and_nodes(cwd, config, fallback) {
// We can't use withFileTypes because of a NodeJs bug which returns wrong results
// with isDirectory() in case of symlinks: https://github.com/nodejs/node/issues/30646
- const files = fs.readdirSync(dir).map((name) => ({
+ // Sorted so route nodes get the same numbers under every runtime: Node
+ // returns directory entries sorted, Bun returns them in on-disk order.
+ const files = fs.readdirSync(dir).sort().map((name) => ({
is_dir: fs.statSync(path.join(dir, name)).isDirectory(),
name
}));

View File

@@ -1,11 +1,13 @@
import { createServer } from 'node:http';
import { handler } from './build/handler.js';
import { attachSockets } from './src/lib/server/ws-attach.ts';
/**
* Production entry: the server adapter-bun generates, plus the app's WebSockets.
* The generated server calls Bun.serve itself and has no websocket option, so
* Bun.serve is wrapped before it loads to upgrade /ws paths first. Everything
* else (static assets, HOST/PORT/*_HEADER env, graceful shutdown) is the adapter's.
*/
import { bunSockets } from './src/lib/server/ws-attach.ts';
const port = Number(process.env.PORT || 3000);
const host = process.env.HOST || '0.0.0.0';
const server = createServer(handler);
await attachSockets(server);
server.listen(port, host, () => {
console.log(`Vash Esports listening on ${host}:${port}`);
});
const serve = Bun.serve;
Bun.serve = ((options: Parameters<typeof serve>[0] & { fetch: Parameters<typeof bunSockets>[0] }) =>
serve({ ...options, ...bunSockets(options.fetch) })) as typeof serve;
await import('./build/index.js');

4
src/app.d.ts vendored
View File

@@ -1,7 +1,7 @@
// src/app.d.ts
import type { Session } from 'better-auth/minimal';
import type { user } from '$lib/server/db/auth.schema';
import type { ConnectionProfile } from '$lib/auth-identity';
import type { user } from '#lib/server/db/auth.schema.js';
import type { ConnectionProfile } from '#lib/auth-identity.js';
declare global {
namespace App {

41
src/env.ts Normal file
View File

@@ -0,0 +1,41 @@
import { defineEnvVars } from '@sveltejs/kit/env';
/** Every variable is optional and read at startup, like the old `$env/dynamic` modules. */
const optional = (input: string | undefined) => input;
export const variables = defineEnvVars({
PUBLIC_UMAMI_URL: { public: true, schema: optional },
PUBLIC_UMAMI_WEBSITE_ID: { public: true, schema: optional },
ORIGIN: { schema: optional },
APP_ENV: { schema: optional },
RESEND_API_KEY: { schema: optional },
EMAIL_FROM: { schema: optional },
OSU_CLIENT_ID: { schema: optional },
OSU_CLIENT_SECRET: { schema: optional },
DISCORD_CLIENT_ID: { schema: optional },
DISCORD_CLIENT_SECRET: { schema: optional },
GOOGLE_CLIENT_ID: { schema: optional },
GOOGLE_CLIENT_SECRET: { schema: optional },
BETTER_AUTH_SECRET: { schema: optional },
R2_PUBLIC_URL: { schema: optional },
STREAM_KEY_SECRET: { schema: optional },
R2_ACCESS_KEY_ID: { schema: optional },
R2_SECRET_ACCESS_KEY: { schema: optional },
R2_ACCOUNT_ID: { schema: optional },
R2_BUCKET: { schema: optional },
ROOT_ADMIN_USER_ID: { schema: optional },
BANCHO_MODE: { schema: optional },
OSU_IRC_ACCOUNTS: { schema: optional },
OSU_IRC_USERNAME: { schema: optional },
OSU_IRC_PASSWORD: { schema: optional },
OSU_IRC_HOST: { schema: optional },
OSU_IRC_PORT: { schema: optional },
DISCORD_BOT_CLIENT_ID: { schema: optional },
DISCORD_BOT_TOKEN: { schema: optional },
DISCORD_GUILD_ID: { schema: optional },
DISCORD_MATCH_CHANNEL_ID: { schema: optional },
DISCORD_STREAM_CHANNEL_ID: { schema: optional },
DATABASE_URL: { schema: optional },
DB_URL: { schema: optional },
LOG_SQL: { schema: optional }
});

View File

@@ -1,6 +1,7 @@
import type { HandleClientError } from '@sveltejs/kit/hooks';
// src/hooks.client.ts
import * as Sentry from '@sentry/sveltekit';
import type { HandleClientError } from '@sveltejs/kit';
Sentry.init({
dsn: import.meta.env.PUBLIC_SENTRY_DSN,

View File

@@ -1,9 +1,9 @@
// src/hooks.server.ts
import type { Handle, HandleServerError } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import { building, dev } from '$app/environment';
import { sequence, type Handle, type HandleServerError } from '@sveltejs/kit/hooks';
import { building, dev } from '$app/env';
import * as Sentry from '@sentry/sveltekit';
import { appEnv, titlePrefix } from '$lib/server/app-env';
import { appEnv, titlePrefix } from '#lib/server/app-env.js';
if (!building) {
Sentry.init({
@@ -16,16 +16,16 @@ if (!building) {
});
}
import { auth } from '$lib/server/auth';
import { auth } from '#lib/server/auth.js';
import { svelteKitHandler } from 'better-auth/svelte-kit';
import { db } from '$lib/server/db';
import { user as userTable } from '$lib/server/db/auth.schema';
import { db } from '#lib/server/db/index.js';
import { user as userTable } from '#lib/server/db/auth.schema.js';
import { eq } from 'drizzle-orm';
import { ensureRootAdminRole } from '$lib/server/permissions';
import { checkRateLimit } from '$lib/server/rate-limit';
import { log } from '$lib/server/logger';
import { metrics } from '$lib/server/metrics';
import { startServices } from '$lib/server/services';
import { ensureRootAdminRole } from '#lib/server/permissions/index.js';
import { checkRateLimit } from '#lib/server/rate-limit.js';
import { log } from '#lib/server/logger.js';
import { metrics } from '#lib/server/metrics.js';
import { startServices } from '#lib/server/services.js';
if (!building) {
startServices().catch((err) => log.http.fatal({ err }, 'background services failed to start'));
@@ -135,7 +135,10 @@ const handleAuth: Handle = async ({ event, resolve }) => {
export const handle: Handle = sequence(Sentry.sentryHandle(), handleRequestLogging, handleAuth);
export const handleError: HandleServerError = Sentry.handleErrorWithSentry(({ error, event }) => {
export const handleError: HandleServerError = Sentry.handleErrorWithSentry((input) => {
// Expected errors (error(...), 404s, validation) keep their own status and message.
if (input.kind !== 'unknown') return;
const { error, event } = input;
const err = error instanceof Error ? error : new Error(String(error));
log.http.error(
{

View File

@@ -2,9 +2,9 @@
* What an overlay receives for its channel: the layout, the scene to show,
* and everything its components draw from. One payload, pushed on every change.
*/
import type { LiveMatch } from '$lib/live';
import type { MatchView } from '$lib/match-view';
import type { Crowd } from '$lib/predictions';
import type { LiveMatch } from '#lib/live.js';
import type { MatchView } from '#lib/match-view.js';
import type { Crowd } from '#lib/predictions.js';
import type { Layout, SceneId } from './layout';
export type ChannelMode = 'auto' | 'match' | 'tournament';

View File

@@ -2,7 +2,7 @@
* A made-up match in progress, so the layout editor can show every component
* even when nothing is live.
*/
import type { MatchView, PlayerView, SideView, SlotView } from '$lib/match-view';
import type { MatchView, PlayerView, SideView, SlotView } from '#lib/match-view.js';
import type { ChannelPayload } from './channel';
import type { Layout, SceneId } from './layout';

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'vitest';
import type { LiveMatch } from '$lib/live';
import type { LiveMatch } from '#lib/live.js';
import { interest, pickMatch, RESULT_LINGER_MS } from './director';
function live(

View File

@@ -5,7 +5,7 @@
* for it to be seen, and otherwise goes where it is most exciting: tournament
* matches first, then close series near match point, tiebreakers, and crowds.
*/
import { DECIDED, type LiveMatch } from '$lib/live';
import { DECIDED, type LiveMatch } from '#lib/live.js';
import type { ChannelMode } from './channel';
export const RESULT_LINGER_MS = 30_000;

View File

@@ -5,7 +5,7 @@
* layout editor. Coordinates are pixels on a 1920×1080 stage; the overlay
* scales the stage to whatever size the browser source is.
*/
import type { MatchView, Phase } from '$lib/match-view';
import type { MatchView, Phase } from '#lib/match-view.js';
export const STAGE = { width: 1920, height: 1080 } as const;

View File

@@ -1,9 +1,9 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { hoursToRanges, type TimeRange } from '$lib/availability';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import { Label } from '#lib/components/ui/label/index.js';
import { hoursToRanges, type TimeRange } from '#lib/availability.js';
import { Clock3, Plus, Trash2, LocateFixed } from '@lucide/svelte';
const {
timezone,

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { gradientFor, initials } from '$lib/format';
import { gradientFor, initials } from '#lib/format.js';
/** A picture, or the initials on a gradient that is always the same for the same name. */
const {

View File

@@ -2,7 +2,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/state';
import type { Snippet } from 'svelte';
import type { TableMeta } from '$lib/server/table';
import type { TableMeta } from '#lib/server/table.js';
interface Column {
key: string;
@@ -55,7 +55,7 @@
});
function updateParams(updates: Record<string, string>) {
const url = new URL(page.url);
const url = new URL(page.url.href);
for (const [key, value] of Object.entries(updates)) {
if (value) {
url.searchParams.set(key, value);
@@ -67,7 +67,7 @@
if (!('page' in updates)) {
url.searchParams.set('page', '1');
}
goto(url.toString(), { keepFocus: true, noScroll: true });
goto(url.toString(), { reset: false });
}
function handleSearch() {

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import Avatar from '$lib/components/Avatar.svelte';
import Avatar from '#lib/components/Avatar.svelte';
import { page } from '$app/state';
import { enhance } from '$app/forms';

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import Avatar from '$lib/components/Avatar.svelte';
import { LIVE_STATES, type MatchSummary } from '$lib/match-summary';
import { timeAgo } from '$lib/format';
import Avatar from '#lib/components/Avatar.svelte';
import { LIVE_STATES, type MatchSummary } from '#lib/match-summary.js';
import { timeAgo } from '#lib/format.js';
import Ban from '@lucide/svelte/icons/ban';
import Crown from '@lucide/svelte/icons/crown';
import Disc3 from '@lucide/svelte/icons/disc-3';

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { SIDE_COLORS, initials } from '$lib/components/match/theme';
import { SIDE_COLORS, initials } from '#lib/components/match/theme.js';
import {
POINTS,
crowdShare,
@@ -9,7 +9,7 @@
type Crowd,
type PredictionResult,
type PredictionState
} from '$lib/predictions';
} from '#lib/predictions.js';
import Check from '@lucide/svelte/icons/check';
import Lock from '@lucide/svelte/icons/lock';
import Sparkles from '@lucide/svelte/icons/sparkles';

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import Avatar from '$lib/components/Avatar.svelte';
import Avatar from '#lib/components/Avatar.svelte';
import { enhance } from '$app/forms';
import { page } from '$app/state';

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import Avatar from '$lib/components/Avatar.svelte';
import Avatar from '#lib/components/Avatar.svelte';
import { goto } from '$app/navigation';
interface SearchResult {

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import { queue, joinQueue, leaveQueue } from '$lib/stores/queue.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import { queue, joinQueue, leaveQueue } from '#lib/stores/queue.svelte.js';
import { Button } from '#lib/components/ui/button/index.js';
const user = $derived(page.data?.user);
const activeMatch = $derived(page.data?.activeMatch);

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import { SIDE_COLORS, modColor } from '$lib/components/match/theme';
import { SIDE_COLORS, modColor } from '#lib/components/match/theme.js';
import { numberFormat, type OverlayProps } from './context';
import Panel from './Panel.svelte';

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { Tween } from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
import { SIDE_COLORS } from '$lib/components/match/theme';
import { SIDE_COLORS } from '#lib/components/match/theme.js';
import { numberFormat, type OverlayProps } from './context';
const { data, props, h }: OverlayProps = $props();

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { flip } from 'svelte/animate';
import { SIDE_COLORS, modColor } from '$lib/components/match/theme';
import { SIDE_COLORS, modColor } from '#lib/components/match/theme.js';
import type { OverlayProps } from './context';
const { data, props, w, h }: OverlayProps = $props();

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { modColor } from '$lib/components/match/theme';
import { modColor } from '#lib/components/match/theme.js';
import type { OverlayProps } from './context';
const { data, props, h, accent }: OverlayProps = $props();

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import type { Component } from 'svelte';
import { fade } from 'svelte/transition';
import type { ChannelPayload } from '$lib/broadcast/channel';
import { stageOf, type ComponentType, type SceneId } from '$lib/broadcast/layout';
import type { ChannelPayload } from '#lib/broadcast/channel.js';
import { stageOf, type ComponentType, type SceneId } from '#lib/broadcast/layout.js';
import type { OverlayProps } from './context';
import Scoreboard from './Scoreboard.svelte';
import LiveScore from './LiveScore.svelte';

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import { SIDE_COLORS, flagUrl, initials } from '$lib/components/match/theme';
import { SIDE_COLORS, flagUrl, initials } from '#lib/components/match/theme.js';
import type { OverlayProps } from './context';
const { data }: OverlayProps = $props();

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { SIDE_COLORS } from '$lib/components/match/theme';
import { crowdShare } from '$lib/predictions';
import { SIDE_COLORS } from '#lib/components/match/theme.js';
import { crowdShare } from '#lib/predictions.js';
import type { OverlayProps } from './context';
import Panel from './Panel.svelte';

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import Overlay from './Overlay.svelte';
import { demoChannel, demoPayload } from '$lib/broadcast/demo';
import type { Layout, SceneId } from '$lib/broadcast/layout';
import { demoChannel, demoPayload } from '#lib/broadcast/demo.js';
import type { Layout, SceneId } from '#lib/broadcast/layout.js';
/** A small live render of a layout with the demo match; the checkerboard is where OBS shows through. */
const { layout, scene = 'gameplay' }: { layout: Layout; scene?: SceneId } = $props();

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { PHASE_LABELS } from '$lib/match-view';
import { SIDE_COLORS, initials } from '$lib/components/match/theme';
import { PHASE_LABELS } from '#lib/match-view.js';
import { SIDE_COLORS, initials } from '#lib/components/match/theme.js';
import type { OverlayProps } from './context';
const { data, props, h, accent }: OverlayProps = $props();

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { SIDE_COLORS } from '$lib/components/match/theme';
import { DECIDED } from '$lib/live';
import { SIDE_COLORS } from '#lib/components/match/theme.js';
import { DECIDED } from '#lib/live.js';
import type { OverlayProps } from './context';
const { data, props, accent }: OverlayProps = $props();

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { SIDE_COLORS } from '$lib/components/match/theme';
import { SIDE_COLORS } from '#lib/components/match/theme.js';
import type { OverlayProps } from './context';
const { data, now, h, accent }: OverlayProps = $props();

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { scale } from 'svelte/transition';
import { elasticOut } from 'svelte/easing';
import { SIDE_COLORS, initials } from '$lib/components/match/theme';
import { SIDE_COLORS, initials } from '#lib/components/match/theme.js';
import type { OverlayProps } from './context';
const { data, props, h, accent }: OverlayProps = $props();

View File

@@ -1,5 +1,5 @@
import type { ChannelPayload } from '$lib/broadcast/channel';
import type { PlacedComponent } from '$lib/broadcast/layout';
import type { ChannelPayload } from '#lib/broadcast/channel.js';
import type { PlacedComponent } from '#lib/broadcast/layout.js';
/** What every overlay component gets. */
export interface OverlayProps {

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { PHASE_LABELS } from '$lib/match-view';
import { DECIDED, type LiveMatch } from '$lib/live';
import { SIDE_COLORS, initials, modColor } from '$lib/components/match/theme';
import { PHASE_LABELS } from '#lib/match-view.js';
import { DECIDED, type LiveMatch } from '#lib/live.js';
import { SIDE_COLORS, initials, modColor } from '#lib/components/match/theme.js';
import Eye from '@lucide/svelte/icons/eye';
import Crown from '@lucide/svelte/icons/crown';

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import type { SlotView } from '$lib/match-view';
import * as Dialog from '#lib/components/ui/dialog/index.js';
import { Button } from '#lib/components/ui/button/index.js';
import type { SlotView } from '#lib/match-view.js';
import type { MatchLive } from './live.svelte';
import MapCard from './MapCard.svelte';
import CountdownRing from './CountdownRing.svelte';
@@ -61,7 +61,7 @@
<Dialog.Root bind:open>
<Dialog.Content
class="max-h-[92dvh] overflow-y-auto border-2 sm:max-w-3xl"
class="max-h-[92dvh] overflow-y-auto border-2 sm:max-w-4xl"
style="border-color:{colors.ring}"
>
<Dialog.Header>
@@ -70,7 +70,7 @@
<Dialog.Title class="text-2xl font-black tracking-tight" style="color:{colors.text}"
>{title}</Dialog.Title
>
<Dialog.Description>
<Dialog.Description class="text-base">
{#if prompt.kind === 'choose'}
{view.rules.bansPerTeam > 0
? 'Whoever picks first bans second.'

View File

@@ -60,8 +60,10 @@
</svg>
{#if label}
<span
class="absolute font-mono font-semibold tabular-nums {size < 48 && text.length > 2
? 'text-[9px]'
class="absolute font-mono font-semibold tabular-nums {size >= 48
? 'text-base'
: size < 40 && text.length > 2
? 'text-[10px]'
: 'text-xs'} {urgent ? 'text-red-300' : 'text-zinc-200'}">{text}</span
>
{/if}

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import type { SideView, SlotView } from '$lib/match-view';
import type { SideView, SlotView } from '#lib/match-view.js';
import { SIDE_COLORS, formatLength, modColor } from './theme';
import Lock from '@lucide/svelte/icons/lock';
import Trophy from '@lucide/svelte/icons/trophy';
@@ -35,7 +35,7 @@
const banned = $derived(slot.status === 'banned');
const played = $derived(slot.status === 'played');
const locked = $derived(slot.status === 'locked');
const heights = { sm: 'h-[68px]', md: 'h-[92px]', lg: 'h-[132px]' };
const heights = { sm: 'h-[68px]', md: 'h-[112px]', lg: 'h-[112px]' };
</script>
<button
@@ -90,12 +90,12 @@
<div class="relative flex h-full flex-col justify-between p-2.5 {banned ? 'opacity-60' : ''}">
<div class="flex items-start justify-between gap-2">
<span
class="rounded-md px-1.5 py-0.5 font-mono text-[11px] font-black tracking-wide shadow"
class="rounded-md px-1.5 py-0.5 font-mono text-sm font-black tracking-wide shadow"
style="background:{color};color:{slot.category === 'TB' ? '#18181b' : '#0a0a0a'}"
>{slot.label}</span
>
<div
class="flex items-center gap-1 rounded-md bg-black/50 px-1.5 py-0.5 text-[11px] font-semibold text-zinc-100 backdrop-blur"
class="flex items-center gap-1 rounded-md bg-black/50 px-1.5 py-0.5 text-sm font-semibold text-zinc-100 backdrop-blur"
>
{#if locked}<Lock class="size-3" />{/if}
{#if slot.starRating}<Star
@@ -105,19 +105,21 @@
</div>
<div class="min-w-0">
<div
class="truncate text-sm leading-tight font-semibold text-white {size === 'sm'
? 'text-xs'
: ''}"
class="truncate leading-tight font-semibold text-white {size === 'lg'
? 'text-xl'
: size === 'md'
? 'text-base'
: 'text-sm'}"
>
{slot.title ?? `Beatmap #${slot.beatmapId}`}
</div>
{#if size !== 'sm'}
<div class="truncate text-[11px] text-zinc-300/80">
<div class="truncate text-sm text-zinc-300">
{slot.artist ?? ''}{#if slot.version}<span class="text-zinc-400">
[{slot.version}]</span
>{/if}
</div>
<div class="mt-1 flex items-center gap-2.5 text-[10px] text-zinc-400">
<div class="mt-1 flex items-center gap-2.5 text-xs text-zinc-300">
{#if slot.bpm}<span class="flex items-center gap-0.5"
><Music class="size-2.5" />{Math.round(slot.bpm)}</span
>{/if}
@@ -152,7 +154,7 @@
{/if}
{#if played}
<div
class="absolute right-2 bottom-2 flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-bold uppercase"
class="absolute right-2 bottom-2 flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs font-bold uppercase"
style="background:{winner ? SIDE_COLORS[winner.color].solid : '#3f3f46'};color:white"
>
<Trophy class="size-3" />{winner ? winner.name.slice(0, 14) : 'Draw'}

View File

@@ -24,7 +24,7 @@
{#if view.maps.length}
<section class={embedded ? 'p-2' : 'rounded-2xl border border-white/8 bg-zinc-950/60 p-3 sm:p-4'}>
<h2 class="mb-3 text-sm font-semibold tracking-wide text-zinc-200 uppercase">Maps played</h2>
<h2 class="mb-3 text-base font-semibold tracking-wide text-zinc-200 uppercase">Maps played</h2>
<ol class="space-y-2">
{#each [...view.maps].reverse() as map (map.index)}
{@const slot = live.slot(map.slotId)}
@@ -43,29 +43,29 @@
onclick={() => (open.has(map.index) ? open.delete(map.index) : open.add(map.index))}
>
<div class="flex min-w-0 items-center gap-2">
<span class="w-4 shrink-0 text-center text-[11px] text-zinc-500 tabular-nums"
<span class="w-4 shrink-0 text-center text-sm text-zinc-400 tabular-nums"
>{map.index + 1}</span
>
<span
class="shrink-0 font-mono text-xs font-black"
class="shrink-0 font-mono text-sm font-black"
style="color:{slot ? modColor(slot.category) : '#fff'}">{slot?.label ?? 'Map'}</span
>
<span class="min-w-0 flex-1 truncate text-sm font-medium"
<span class="min-w-0 flex-1 truncate text-base font-medium"
>{slot?.title ?? `Beatmap #${slot?.beatmapId ?? ''}`}</span
>
<ChevronDown
class="size-4 shrink-0 text-zinc-500 transition {open.has(map.index)
class="size-4 shrink-0 text-zinc-400 transition {open.has(map.index)
? 'rotate-180'
: ''}"
/>
</div>
<div class="mt-2 flex items-center justify-between gap-2 pl-6 text-xs">
<div class="mt-2 flex items-center justify-between gap-2 pl-6 text-sm">
<span
class="min-w-0 truncate font-semibold"
style="color:{winner ? SIDE_COLORS[winner.color].text : '#a1a1aa'}"
>{winner?.name ?? 'Draw'}{winner ? ' won' : ''}</span
>
<span class="shrink-0 font-mono text-[11px] tabular-nums"
<span class="shrink-0 font-mono text-sm tabular-nums"
><span style="color:{SIDE_COLORS.red.text}"
>{formatScore(red, view.rules.winCondition)}</span
><span class="px-1 text-zinc-600">–</span><span
@@ -82,12 +82,12 @@
{#if open.has(map.index)}
<div transition:slide={{ duration: 180 }} class="border-t border-white/5 px-3 py-2">
{#if map.scores.length}
<p class="mb-1 text-[10px] font-semibold tracking-wide text-zinc-500 uppercase">
<p class="mb-1 text-xs font-semibold tracking-wide text-zinc-400 uppercase">
Player scores
</p>
{#each map.scores as score (score.playerId)}
{@const side = live.side(score.side)}
<div class="flex items-center gap-2 border-t border-white/5 py-1.5 text-xs">
<div class="flex items-center gap-2 border-t border-white/5 py-1.5 text-sm">
<span
class="size-2 shrink-0 rounded-full"
style="background:{side ? SIDE_COLORS[side.color].solid : '#71717a'}"
@@ -96,7 +96,7 @@
<a
href="/users/{score.userId}"
class="block truncate font-medium hover:underline">{name(score.playerId)}</a
><span class="text-[10px] text-zinc-500"
><span class="text-xs text-zinc-400"
>{score.accuracy ? `${score.accuracy.toFixed(2)}%` : '–'} · {score.combo
? `${score.combo}x`
: '–'} · {score.mods.filter((mod) => mod !== 'NF').join('') ||
@@ -110,15 +110,15 @@
</div>
{/each}
{:else}
<p class="text-xs text-zinc-500">
<p class="text-sm text-zinc-400">
No per-player scores were recorded for this map.
</p>
{/if}
{#if isReferee}
<div
class="mt-2 flex flex-wrap items-center gap-1.5 border-t border-white/5 pt-2 text-xs"
class="mt-2 flex flex-wrap items-center gap-1.5 border-t border-white/5 pt-2 text-sm"
>
<span class="w-full text-zinc-500">Correct winner:</span>
<span class="w-full text-zinc-400">Correct winner:</span>
{#each view.sides as side (side.id)}
<button
type="button"

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import Avatar from '$lib/components/Avatar.svelte';
import Avatar from '#lib/components/Avatar.svelte';
import Disc3 from '@lucide/svelte/icons/disc-3';
import { SvelteSet } from 'svelte/reactivity';
import type { SlotView } from '$lib/match-view';
import type { SlotView } from '#lib/match-view.js';
import type { MatchLive } from './live.svelte';
import MapCard from './MapCard.svelte';
import { CATEGORY_NAMES, categoryRank, modColor } from './theme';
@@ -62,13 +62,13 @@
<span>
<h2 class="text-sm font-semibold tracking-wide text-zinc-200 uppercase">Map pool</h2>
{#if view.pool}
<span class="block text-xs text-zinc-500 group-hover:text-zinc-300"
<span class="block text-sm text-zinc-400 group-hover:text-zinc-300"
>{view.pool.name}{view.pool.owner ? ` · from ${view.pool.owner.name}` : ''}</span
>
{/if}
</span>
</a>
<div class="flex gap-3 text-[11px] text-zinc-500">
<div class="flex gap-3 text-sm text-zinc-400">
<span><b class="text-zinc-300">{counts.open}</b> open</span>
<span><b class="text-zinc-300">{counts.banned}</b> banned</span>
<span><b class="text-zinc-300">{counts.played}</b> played</span>
@@ -89,7 +89,7 @@
<span class="font-mono text-sm font-black" style="color:{modColor(category)}"
>{category}</span
>
<span class="hidden text-[10px] leading-tight text-zinc-500 sm:block"
<span class="hidden text-xs leading-tight text-zinc-400 sm:block"
>{CATEGORY_NAMES[category] ?? ''}</span
>
</div>
@@ -101,9 +101,9 @@
{#each slots as slot (slot.id)}
{#if slot.status === 'live'}
<div
class="flex items-center justify-center gap-2 rounded-xl border-2 border-dashed text-center text-xs font-semibold {compact
class="flex items-center justify-center gap-2 rounded-xl border-2 border-dashed text-center text-sm font-semibold {compact
? 'h-[68px]'
: 'h-[92px]'}"
: 'h-[112px]'}"
style="border-color:{modColor(slot.category)}66;color:{modColor(slot.category)}"
>
<Radio class="size-4 animate-pulse" />

View File

@@ -2,7 +2,7 @@
import { Tween } from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
import { onMount } from 'svelte';
import type { MapView } from '$lib/match-view';
import type { MapView } from '#lib/match-view.js';
import type { MatchLive } from './live.svelte';
import { SIDE_COLORS, formatScore, modColor } from './theme';

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import type { MapView, MatchView } from '$lib/match-view';
import type { MapView, MatchView } from '#lib/match-view.js';
import { MatchLive } from './live.svelte';
import Scoreboard from './Scoreboard.svelte';
import MapPool from './MapPool.svelte';
@@ -15,10 +15,10 @@
import NowPlaying from './NowPlaying.svelte';
import StageFinal from './StageFinal.svelte';
import MapReveal from './MapReveal.svelte';
import PredictionCard from '$lib/components/PredictionCard.svelte';
import type { PredictionResult, PredictionState } from '$lib/predictions';
import PredictionCard from '#lib/components/PredictionCard.svelte';
import type { PredictionResult, PredictionState } from '#lib/predictions.js';
import { blinkTitle, chime, notify } from './attention';
import { Button } from '$lib/components/ui/button/index.js';
import { Button } from '#lib/components/ui/button/index.js';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Bell from '@lucide/svelte/icons/bell';
import BellOff from '@lucide/svelte/icons/bell-off';
@@ -174,7 +174,10 @@
};
if (v.phase === 'playing')
return v.live?.startedAt
? { label: 'Map in progress', detail: 'Scores and progress update as players finish.' }
? {
label: 'Map in progress',
detail: 'Follow live scores, accuracy and combo while the map plays.'
}
: { label: 'Ready up in osu!', detail: 'The selected map is loading. Get ready to play.' };
return { label: 'Match in progress', detail: 'Updates appear here as they happen.' };
});
@@ -195,16 +198,19 @@
<meta name="twitter:card" content="summary_large_image" />
</svelte:head>
<div class="mx-auto flex max-w-[1600px] flex-col gap-3 lg:h-[calc(100dvh-6.5rem)] lg:min-h-[680px]">
<header class="flex min-h-10 flex-wrap items-center gap-2">
<div
data-match-dashboard
class="mx-auto flex max-w-[1680px] flex-col gap-3 lg:h-[calc(100dvh-6.5rem)] lg:min-h-[680px]"
>
<header class="flex min-h-10 shrink-0 flex-wrap items-center gap-2">
<a
href="/matches"
class="grid size-9 place-items-center rounded-lg border border-white/10 text-zinc-400 transition hover:bg-white/5 hover:text-white"
aria-label="All matches"><ArrowLeft class="size-4" /></a
>
<div class="min-w-0 flex-1">
<h1 class="truncate text-base font-bold tracking-tight sm:text-lg">{v.name ?? title}</h1>
<div class="flex flex-wrap items-center gap-x-2 text-xs text-zinc-500">
<h1 class="truncate text-lg font-bold tracking-tight sm:text-xl">{v.name ?? title}</h1>
<div class="flex flex-wrap items-center gap-x-2 text-sm text-zinc-400">
{#if v.tournament}
<a
href="/tournaments/{v.tournament.id}"
@@ -219,7 +225,7 @@
</div>
</div>
<span
class="hidden items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium sm:flex {decided ||
class="hidden items-center gap-1.5 rounded-full border px-2.5 py-1 text-sm font-medium sm:flex {decided ||
live.connected
? 'border-emerald-400/20 bg-emerald-400/5 text-emerald-300'
: 'border-amber-400/30 bg-amber-400/10 text-amber-300'}"
@@ -261,11 +267,11 @@
</Button>
<details class="relative z-20">
<summary
class="flex h-8 cursor-pointer list-none items-center gap-1 rounded-lg border border-white/10 px-2.5 text-xs text-zinc-300 hover:bg-white/5"
class="flex h-8 cursor-pointer list-none items-center gap-1 rounded-lg border border-white/10 px-2.5 text-sm text-zinc-300 hover:bg-white/5"
>Details <ChevronDown class="size-3" /></summary
>
<div
class="absolute right-0 mt-2 w-64 rounded-xl border border-white/10 bg-zinc-900 p-3 text-xs shadow-2xl"
class="absolute right-0 mt-2 w-64 rounded-xl border border-white/10 bg-zinc-900 p-3 text-sm shadow-2xl"
>
<p class="mb-2 font-semibold text-white">Match details</p>
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-zinc-400">
@@ -301,14 +307,14 @@
<Scoreboard {live} />
<div class="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1fr)_23rem]">
<div class="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1fr)_26rem]">
<section
class="relative flex min-h-[25rem] flex-col overflow-hidden rounded-2xl border border-white/10 bg-gradient-to-b from-zinc-900/90 to-zinc-950 lg:min-h-0"
>
<div
class="flex flex-wrap items-center justify-between gap-2 border-b border-white/8 px-4 py-3 sm:px-5"
class="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-white/8 px-4 py-3 sm:px-5"
>
<div class="flex items-center gap-2 text-xs font-semibold text-zinc-300">
<div class="flex items-center gap-2 text-base font-semibold text-zinc-200">
<span
class="size-2 rounded-full {live.myTurn
? 'animate-pulse bg-amber-300'
@@ -318,18 +324,20 @@
<nav aria-label="Match progress" class="flex items-center gap-1.5">
{#each steps as step, index}
<span
class="rounded-full px-2 py-0.5 text-[10px] font-semibold {index === activeStep
class="rounded-full px-2 py-0.5 text-xs font-semibold {index === activeStep
? 'bg-white/10 text-white'
: index < activeStep
? 'text-emerald-300'
: 'text-zinc-600'}">{index < activeStep ? '✓ ' : ''}{step}</span
: 'text-zinc-500'}">{index < activeStep ? '✓ ' : ''}{step}</span
>
{/each}
</nav>
</div>
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 py-4 sm:px-6 sm:py-5">
<div data-match-stage class="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 py-3 sm:px-5">
<div class="my-auto w-full">
<p class="mb-4 text-center text-xs text-zinc-500">{guidance.detail}</p>
{#if v.phase !== 'playing'}<p class="mb-3 text-center text-base text-zinc-300">
{guidance.detail}
</p>{/if}
{#if v.paused && !decided}
<div
transition:fade
@@ -361,8 +369,8 @@
{/key}
</div>
</div>
<div class="border-t border-white/8 bg-black/20 px-3 py-2.5 sm:px-4">
<div class="mb-2 flex items-center justify-between text-[11px] font-semibold text-zinc-400">
<div class="shrink-0 border-t border-white/8 bg-black/20 px-3 py-2.5 sm:px-4">
<div class="mb-2 flex items-center justify-between text-sm font-semibold text-zinc-400">
<span>MAP JOURNEY</span><span>{v.maps.length} played · first to {v.rules.winsNeeded}</span
>
</div>
@@ -373,14 +381,14 @@
{@const slot = map ? live.slot(map.slotId) : isLive ? live.slot(v.live?.slotId) : null}
{@const winner = map ? live.side(map.winner) : null}
<div
class="flex min-w-24 flex-1 basis-24 items-center gap-2 rounded-lg border px-2 py-1.5 text-xs {isLive
class="flex min-w-24 flex-1 basis-24 items-center gap-2 rounded-lg border px-2 py-1.5 text-sm {isLive
? 'border-emerald-400/40 bg-emerald-400/10'
: map
? 'border-white/12 bg-white/5'
: 'border-white/5 bg-white/[0.02] text-zinc-600'}"
: 'border-white/5 bg-white/[0.02] text-zinc-500'}"
>
<span
class="grid size-6 shrink-0 place-items-center rounded-md bg-black/30 font-mono text-[10px] font-bold"
class="grid size-6 shrink-0 place-items-center rounded-md bg-black/30 font-mono text-xs font-bold"
style={slot ? `color:${modColor(slot.category)}` : ''}
>{slot?.label ?? index + 1}</span
>
@@ -416,10 +424,10 @@
role="tab"
aria-selected={panel === 'activity'}
onclick={() => (panel = 'activity')}
class="flex min-w-0 flex-1 items-center justify-center gap-1 rounded-lg px-2 py-2 text-xs font-semibold transition {panel ===
class="flex min-w-0 flex-1 items-center justify-center gap-1 rounded-lg px-2 py-2 text-sm font-semibold transition {panel ===
'activity'
? 'bg-white/10 text-white'
: 'text-zinc-500 hover:bg-white/5 hover:text-zinc-200'}"
: 'text-zinc-400 hover:bg-white/5 hover:text-zinc-200'}"
><List class="size-3.5" />Activity</button
>
<button
@@ -427,10 +435,10 @@
role="tab"
aria-selected={panel === 'pool'}
onclick={() => (panel = 'pool')}
class="flex min-w-0 flex-1 items-center justify-center gap-1 rounded-lg px-2 py-2 text-xs font-semibold transition {panel ===
class="flex min-w-0 flex-1 items-center justify-center gap-1 rounded-lg px-2 py-2 text-sm font-semibold transition {panel ===
'pool'
? 'bg-white/10 text-white'
: 'text-zinc-500 hover:bg-white/5 hover:text-zinc-200'}"
: 'text-zinc-400 hover:bg-white/5 hover:text-zinc-200'}"
><Layers class="size-3.5" />Maps</button
>
<button
@@ -438,10 +446,10 @@
role="tab"
aria-selected={panel === 'results'}
onclick={() => (panel = 'results')}
class="flex min-w-0 flex-1 items-center justify-center gap-1 rounded-lg px-2 py-2 text-xs font-semibold transition {panel ===
class="flex min-w-0 flex-1 items-center justify-center gap-1 rounded-lg px-2 py-2 text-sm font-semibold transition {panel ===
'results'
? 'bg-white/10 text-white'
: 'text-zinc-500 hover:bg-white/5 hover:text-zinc-200'}"
: 'text-zinc-400 hover:bg-white/5 hover:text-zinc-200'}"
><Trophy class="size-3.5" />Results</button
>
{#if prediction}<button
@@ -449,10 +457,10 @@
role="tab"
aria-selected={panel === 'predictions'}
onclick={() => (panel = 'predictions')}
class="flex min-w-0 flex-1 items-center justify-center gap-1 rounded-lg px-2 py-2 text-xs font-semibold transition {panel ===
class="flex min-w-0 flex-1 items-center justify-center gap-1 rounded-lg px-2 py-2 text-sm font-semibold transition {panel ===
'predictions'
? 'bg-white/10 text-white'
: 'text-zinc-500 hover:bg-white/5 hover:text-zinc-200'}"
: 'text-zinc-400 hover:bg-white/5 hover:text-zinc-200'}"
><Sparkles class="size-3.5" />Picks</button
>{/if}
</div>
@@ -464,7 +472,7 @@
{:else if panel === 'results'}
<div class="h-full overflow-y-auto p-2">
{#if v.maps.length}<MapHistory {live} {isReferee} embedded />{:else}<p
class="px-3 py-12 text-center text-sm text-zinc-500"
class="px-3 py-12 text-center text-sm text-zinc-400"
>
Map results will appear here after the first play.
</p>{/if}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { tick } from 'svelte';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import type { LogLine } from '$lib/match-view';
import * as Tabs from '#lib/components/ui/tabs/index.js';
import type { LogLine } from '#lib/match-view.js';
import type { MatchLive } from './live.svelte';
import { SIDE_COLORS } from './theme';
import Ban from '@lucide/svelte/icons/ban';
@@ -66,9 +66,9 @@
{#each events as line, index (line.t + ':' + line.text + ':' + index)}
{@const Icon = icons[line.kind] ?? Info}
{@const side = live.side(line.side)}
<li class="flex gap-2 text-[13px] leading-snug {index === 0 ? 'vash-rise' : ''}">
<li class="flex gap-2 text-base leading-snug {index === 0 ? 'vash-rise' : ''}">
<span
class="mt-0.5 grid size-5 shrink-0 place-items-center rounded-md"
class="mt-0.5 grid size-6 shrink-0 place-items-center rounded-md"
style="background:{side
? SIDE_COLORS[side.color].soft
: 'rgb(255 255 255 / 0.06)'};color:{side
@@ -77,12 +77,12 @@
? '#fcd34d'
: '#a1a1aa'}"
>
<Icon class="size-3" />
<Icon class="size-3.5" />
</span>
<span class="flex-1 {line.kind === 'warn' ? 'text-amber-200' : 'text-zinc-300'}"
>{line.text}</span
>
<span class="shrink-0 font-mono text-[10px] text-zinc-600">{time(line.t)}</span>
<span class="shrink-0 font-mono text-xs text-zinc-400">{time(line.t)}</span>
</li>
{/each}
</ol>
@@ -92,14 +92,14 @@
bind:this={chatBox}
onscroll={() =>
chatBox && (stick = chatBox.scrollHeight - chatBox.scrollTop - chatBox.clientHeight < 40)}
class="h-full max-h-[28rem] overflow-y-auto px-3 pb-3 font-mono text-[12px] leading-relaxed lg:max-h-none"
class="h-full max-h-[28rem] overflow-y-auto px-3 pb-3 font-mono text-sm leading-relaxed lg:max-h-none"
>
{#if view.chat.length === 0}
<p class="py-8 text-center font-sans text-sm text-zinc-500">No messages yet.</p>
{/if}
{#each view.chat as line, index (line.t + ':' + index)}
<div class="break-words">
<span class="text-zinc-600">{time(line.t)}</span>
<span class="text-zinc-400">{time(line.t)}</span>
<span
class="font-semibold {line.role === 'bancho'
? 'text-pink-400'

View File

@@ -4,6 +4,7 @@
import type { MatchLive } from './live.svelte';
import MapCard from './MapCard.svelte';
import { SIDE_COLORS, formatScore, modColor, playerHref } from './theme';
import { liveTotal, scoreIsFresh } from '#lib/match-scores.js';
import { receiveCard, sendCard } from './transitions';
import Radio from '@lucide/svelte/icons/radio';
import Hourglass from '@lucide/svelte/icons/hourglass';
@@ -18,15 +19,11 @@
const speed = $derived(
slot.mods.includes('DT') || slot.mods.includes('NC') ? 1.5 : slot.mods.includes('HT') ? 0.75 : 1
);
const players = $derived(
view.sides
.flatMap((side) => side.players.map((player) => ({ player, side })))
.filter(({ player }) => player.live)
);
const reported = $derived(
Math.max(0, ...players.map(({ player }) => player.live?.progress ?? 0))
);
const players = $derived(view.sides.flatMap((side) => side.players));
const reporting = $derived(players.filter((player) => player.live));
const fresh = $derived(reporting.filter((player) => scoreIsFresh(player.live, live.now)));
const stale = $derived(reporting.length > 0 && fresh.length === 0);
const reported = $derived(Math.max(0, ...reporting.map((player) => player.live?.progress ?? 0)));
const progress = $derived(
!started
? 0
@@ -34,53 +31,60 @@
? reported
: Math.min(1, (live.now - current.startedAt!) / (((slot.lengthSec ?? 180) * 1000) / speed))
);
const totals = $derived(
view.sides.map((side) =>
side.players.reduce((sum, player) => sum + (player.live?.score ?? 0), 0)
const totals = $derived(view.sides.map((side) => liveTotal(side, view.rules.winCondition)));
const values = [
new Tween(0, { duration: 250, easing: cubicOut }),
new Tween(0, { duration: 250, easing: cubicOut })
];
$effect(() => {
values[0].target = totals[0] ?? 0;
values[1].target = totals[1] ?? 0;
});
const both = $derived(totals[0] != null && totals[1] != null);
const share = $derived(
both && values[0].current + values[1].current > 0
? values[0].current / (values[0].current + values[1].current)
: 0.5
);
const complete = $derived(
view.sides.every(
(side) =>
side.players.filter((player) => scoreIsFresh(player.live, live.now)).length >=
view.rules.teamSize
)
);
const red = new Tween(0, { duration: 700, easing: cubicOut });
const blue = new Tween(0, { duration: 700, easing: cubicOut });
$effect(() => {
red.target = totals[0];
blue.target = totals[1];
});
const share = $derived(
red.current + blue.current > 0 ? red.current / (red.current + blue.current) : 0.5
);
const leading = $derived(
totals[0] === totals[1] ? null : totals[0] > totals[1] ? view.sides[0] : view.sides[1]
complete && both && totals[0] !== totals[1] ? view.sides[totals[0]! > totals[1]! ? 0 : 1] : null
);
</script>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2 text-sm font-semibold">
<div class="flex flex-col gap-2" data-match-playing>
<div class="flex flex-wrap items-center justify-between gap-2 text-base">
<div class="flex items-center gap-2 font-semibold">
{#if started}
<span class="flex items-center gap-1.5 text-emerald-300"
><Radio class="size-4 animate-pulse" /> Live</span
>
{:else if starting}
<span class="text-amber-300">Starting…</span>
<span class={stale || !reporting.length ? 'text-amber-300' : 'text-emerald-300'}>
<Radio class="mr-1.5 inline size-4" />{stale
? 'Score feed interrupted'
: reporting.length
? 'Live scores'
: 'Waiting for live scores'}
</span>
{:else if starting}<span class="text-amber-300">Starting…</span>
{:else}
<span class="flex items-center gap-1.5 text-zinc-300"
><Hourglass class="size-4" /> Ready up in osu!</span
<span class="text-zinc-200"><Hourglass class="mr-1.5 inline size-4" />Ready up in osu!</span
>
{#if view.deadlineAt}<span class="text-xs text-zinc-500"
{#if view.deadlineAt}<span class="text-sm text-zinc-400"
>force start in {live.secondsLeft()}s</span
>{/if}
{/if}
</div>
<div class="text-xs text-zinc-400">
{#if current.tiebreaker}
<span class="font-bold text-white">Tiebreaker</span>
{:else if picker}
Picked by <span class="font-semibold" style="color:{SIDE_COLORS[picker.color].text}"
>{picker.name}</span
>
{/if}
{#if current.attempt > 1}<span class="ml-2 rounded bg-amber-400/15 px-1.5 text-amber-300"
>replay #{current.attempt}</span
<div class="text-sm text-zinc-300">
{#if current.tiebreaker}<span class="font-bold text-white">Tiebreaker</span>
{:else if picker}Picked by <span
class="font-semibold"
style="color:{SIDE_COLORS[picker.color].text}">{picker.name}</span
>{/if}
{#if current.attempt > 1}<span class="ml-2 text-amber-300">replay #{current.attempt}</span
>{/if}
</div>
</div>
@@ -98,61 +102,87 @@
></div>
</div>
</div>
<div class="flex flex-wrap gap-1.5 text-[11px]">
{#each slot.mods as mod (mod)}<span class="rounded bg-white/10 px-1.5 py-0.5 font-semibold"
<div class="flex flex-wrap items-center gap-2 text-sm">
{#each slot.mods as mod (mod)}<span class="rounded bg-white/10 px-2 py-0.5 font-semibold"
>{mod}</span
>{/each}
{#if slot.freemod}<span
class="rounded bg-emerald-400/15 px-1.5 py-0.5 font-semibold text-emerald-300">Freemod</span
class="rounded bg-emerald-400/15 px-2 py-0.5 font-semibold text-emerald-300">Freemod</span
>{/if}
<span class="rounded bg-white/5 px-1.5 py-0.5 text-zinc-400"
>{view.rules.winCondition.replace('_', ' ').toUpperCase()}</span
<span class="text-zinc-400"
>{view.rules.winCondition.replace('_', ' ').toUpperCase()} · provisional scores</span
>
{#if started}<span class="ml-auto text-zinc-400 tabular-nums"
>{fresh.length}/{view.rules.teamSize * 2} players reporting</span
>{/if}
</div>
{#if players.length}
<div class="space-y-2">
<div class="flex h-9 overflow-hidden rounded-lg text-xs font-bold">
<div
class="flex items-center justify-start px-2.5 transition-all duration-700"
style="width:{share * 100}%;background:{SIDE_COLORS.red.solid}"
{#if started}
<div class="grid gap-3 sm:grid-cols-2">
{#each view.sides as side, index (side.id)}
{@const colors = SIDE_COLORS[side.color]}
<section
class="min-w-0 overflow-hidden rounded-xl border"
style="border-color:{colors.ring};background:{colors.soft}"
aria-label="{side.name} live scores"
>
{formatScore(red.current, view.rules.winCondition)}
</div>
<div
class="flex flex-1 items-center justify-end px-2.5"
style="background:{SIDE_COLORS.blue.solid}"
<header class="flex items-center justify-between gap-3 px-3 py-2">
<span class="min-w-0 truncate text-base font-semibold" style="color:{colors.text}"
>{side.name}</span
>
{formatScore(blue.current, view.rules.winCondition)}
</div>
</div>
{#if leading}
<p class="text-center text-xs text-zinc-400">
<span style="color:{SIDE_COLORS[leading.color].text}" class="font-semibold"
>{leading.name}</span
<span class="shrink-0 text-2xl font-black tabular-nums xl:text-3xl" data-team-live-score
>{totals[index] == null
? '—'
: formatScore(values[index].current, view.rules.winCondition)}</span
>
leads by
{formatScore(Math.abs(totals[0] - totals[1]), view.rules.winCondition)}
</p>
{/if}
<div class="grid gap-1.5 sm:grid-cols-2">
{#each players as { player, side } (player.id)}
<div class="flex items-center gap-2 rounded-lg bg-white/5 px-2 py-1.5 text-xs">
<span class="h-6 w-1 rounded" style="background:{SIDE_COLORS[side.color].solid}"></span>
<a href={playerHref(player)} class="flex-1 truncate hover:underline"
</header>
<div class="bg-black/25">
{#each side.players as player (player.id)}
{@const freshPlayer = scoreIsFresh(player.live, live.now)}
<div class="flex min-h-14 items-center gap-3 border-t border-white/5 px-3 py-1">
<div class="min-w-0 flex-1">
<a
href={playerHref(player)}
class="block truncate text-base font-semibold hover:underline"
>{player.osuName ?? player.name}</a
>
<span class="text-zinc-400 tabular-nums">{player.live!.accuracy.toFixed(2)}%</span>
<span class="text-zinc-400 tabular-nums">{player.live!.combo}x</span>
<span class="w-20 text-right font-semibold tabular-nums"
>{player.live!.score.toLocaleString('en-US')}</span
<div
class="text-sm tabular-nums {freshPlayer ? 'text-zinc-300' : 'text-amber-200'}"
>
{#if player.live}{player.live.accuracy.toFixed(2)}% · {player.live
.combo}x{#if !freshPlayer}
· delayed{/if}
{:else}Waiting for score{/if}
</div>
</div>
<span class="shrink-0 text-lg font-bold tabular-nums" data-player-live-score
>{player.live ? player.live.score.toLocaleString('en-US') : '—'}</span
>
</div>
{/each}
</div>
</section>
{/each}
</div>
{:else if started}
<p class="text-center text-xs text-zinc-500">Scores appear the moment the map ends.</p>
{#if both}
<div class="flex h-2 overflow-hidden rounded-full bg-white/5" aria-hidden="true">
<div
class="transition-[width] duration-250"
style="width:{share * 100}%;background:{SIDE_COLORS.red.solid}"
></div>
<div class="flex-1" style="background:{SIDE_COLORS.blue.solid}"></div>
</div>
{/if}
<p class="text-center text-sm text-zinc-300">
{#if stale}Showing the last received scores. Waiting for the score feed to reconnect.
{:else if !reporting.length}Waiting for live score updates. Final results still arrive when
the map ends.
{:else if leading}<span class="font-semibold" style="color:{SIDE_COLORS[leading.color].text}"
>{leading.name}</span
>
leads by {formatScore(Math.abs(totals[0]! - totals[1]!), view.rules.winCondition)}
{:else if complete}Scores are tied.
{:else}Waiting for the remaining players’ live scores.{/if}
</p>
{/if}
</div>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import * as Sheet from '$lib/components/ui/sheet/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as Sheet from '#lib/components/ui/sheet/index.js';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import type { MatchLive } from './live.svelte';
import { SIDE_COLORS } from './theme';
import Shield from '@lucide/svelte/icons/shield';

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { PHASE_LABELS, type SideView } from '$lib/match-view';
import { PHASE_LABELS, type SideView } from '#lib/match-view.js';
import type { MatchLive } from './live.svelte';
import { SIDE_COLORS, flagUrl, initials, playerHref, sideHref } from './theme';
import CountdownRing from './CountdownRing.svelte';
@@ -79,11 +79,11 @@
<div class="min-w-0 flex-1">
<a
href={sideHref(side)}
class="block truncate text-base font-bold tracking-tight hover:underline sm:text-xl"
class="block truncate text-lg font-bold tracking-tight hover:underline sm:text-2xl"
>{side.name}</a
>
<div
class="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-zinc-400 {align ===
class="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-zinc-400 {align ===
'right'
? 'justify-end'
: ''}"
@@ -159,26 +159,26 @@
</div>
{/snippet}
<section class="flex items-stretch gap-2 sm:gap-3">
<section class="flex shrink-0 items-stretch gap-2 sm:gap-3">
{@render team(red, 'left')}
<div class="flex w-28 shrink-0 flex-col items-center justify-center gap-1.5 sm:w-44">
<div class="flex w-28 shrink-0 flex-col items-center justify-center gap-1.5 sm:w-56">
<div class="flex items-baseline gap-2 font-black tabular-nums sm:gap-3">
{#key red.points}<span
class="vash-pop text-4xl sm:text-6xl"
class="vash-pop text-4xl sm:text-7xl"
style="color:{SIDE_COLORS.red.text}">{red.points}</span
>{/key}
<span class="text-xl text-zinc-600 sm:text-3xl">:</span>
{#key blue.points}<span
class="vash-pop text-4xl sm:text-6xl"
class="vash-pop text-4xl sm:text-7xl"
style="color:{SIDE_COLORS.blue.text}">{blue.points}</span
>{/key}
</div>
<div class="text-[11px] tracking-wide text-zinc-500 uppercase">
<div class="text-sm tracking-wide text-zinc-400 uppercase">
First to {needed} · BO{view.rules.bestOf}
</div>
<div class="flex items-center gap-2">
<span
class="flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-semibold {view.paused
class="flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-sm font-semibold {view.paused
? 'border-amber-400/40 bg-amber-400/10 text-amber-300'
: liveNow
? 'border-emerald-400/40 bg-emerald-400/10 text-emerald-300'
@@ -193,7 +193,7 @@
deadline={view.deadlineAt}
now={live.now}
total={totalMs}
size={36}
size={52}
/>{/if}
</div>
</div>

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import * as Sheet from '$lib/components/ui/sheet/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import * as Sheet from '#lib/components/ui/sheet/index.js';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import { Switch } from '#lib/components/ui/switch/index.js';
import type { MatchLive } from './live.svelte';
import { SIDE_COLORS } from './theme';
import FlaskConical from '@lucide/svelte/icons/flask-conical';

View File

@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import type { MatchLive } from './live.svelte';
import { SIDE_COLORS, modColor, sideHref } from './theme';
import Avatar from '$lib/components/Avatar.svelte';
import Avatar from '#lib/components/Avatar.svelte';
import Trophy from '@lucide/svelte/icons/trophy';
import CircleX from '@lucide/svelte/icons/circle-x';
@@ -29,13 +29,13 @@
<div class="flex flex-col items-center gap-4 py-2 text-center">
{#if cancelled}
<CircleX class="size-14 text-zinc-500" />
<CircleX class="size-14 text-zinc-400" />
<h3 class="text-2xl font-black">Match cancelled</h3>
<p class="max-w-md text-sm text-zinc-400">{view.endReason ?? 'This match was cancelled.'}</p>
<p class="max-w-md text-base text-zinc-400">{view.endReason ?? 'This match was cancelled.'}</p>
{:else if winner}
{@const colors = SIDE_COLORS[winner.color]}
{@const loser = view.sides.find((side) => side.id !== winner.id)!}
<div class="text-xs font-bold tracking-[0.3em] text-zinc-400 uppercase">Victory</div>
<div class="text-sm font-bold tracking-[0.3em] text-zinc-400 uppercase">Victory</div>
<div class="flex items-center gap-6 sm:gap-10">
{#each [winner, loser] as side (side.id)}
{@const sideColors = SIDE_COLORS[side.color]}
@@ -90,7 +90,7 @@
{@const slot = live.slot(map.slotId)}
{@const side = live.side(map.winner)}
<span
class="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs"
class="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-sm"
>
<span class="font-mono font-bold" style="color:{slot ? modColor(slot.category) : '#fff'}"
>{slot?.label}</span
@@ -104,6 +104,6 @@
</div>
{/if}
{#if view.phase === 'closing'}
<p class="text-xs text-zinc-500">The lobby closes shortly. GG!</p>
<p class="text-sm text-zinc-400">The lobby closes shortly. GG!</p>
{/if}
</div>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { MatchLive } from './live.svelte';
import { SIDE_COLORS, flagUrl, initials, playerHref, sideHref } from './theme';
import { Button } from '$lib/components/ui/button/index.js';
import { Button } from '#lib/components/ui/button/index.js';
import CheckCircle from '@lucide/svelte/icons/circle-check';
import Loader from '@lucide/svelte/icons/loader-circle';
import Send from '@lucide/svelte/icons/send';
@@ -15,8 +15,8 @@
<div class="flex flex-col gap-5">
<div class="text-center">
{#if created}
<h3 class="text-xl font-bold">Getting a lobby ready</h3>
<p class="mt-1 text-sm text-zinc-400">
<h3 class="text-2xl font-bold">Getting a lobby ready</h3>
<p class="mt-1 text-base text-zinc-400">
{view.scheduledAt && Date.parse(view.scheduledAt) > live.now
? `The lobby opens at ${new Date(view.scheduledAt).toLocaleString()}.`
: 'Waiting for a free osu! lobby slot. This usually takes seconds.'}
@@ -30,8 +30,8 @@
>
{/if}
{:else}
<h3 class="text-xl font-bold">Join the lobby</h3>
<p class="mt-1 text-sm text-zinc-400">
<h3 class="text-2xl font-bold">Join the lobby</h3>
<p class="mt-1 text-base text-zinc-400">
Accept the invite in osu! (check your chat). Each team needs {view.rules.teamSize} player{view
.rules.teamSize === 1
? ''
@@ -65,7 +65,7 @@
<a href={sideHref(side)} class="font-semibold hover:underline" style="color:{colors.text}"
>{side.name}</a
>
<span class="text-xs text-zinc-400 tabular-nums"
<span class="text-sm text-zinc-400 tabular-nums"
>{Math.min(present, view.rules.teamSize)}/{view.rules.teamSize}</span
>
</div>
@@ -80,13 +80,13 @@
<img
src={player.avatarUrl}
alt=""
class="size-7 rounded-full object-cover {player.inLobby
class="size-8 rounded-full object-cover {player.inLobby
? ''
: 'opacity-50 grayscale'}"
/>
{:else}
<span
class="grid size-7 place-items-center rounded-full bg-zinc-800 text-[10px] font-bold"
class="grid size-8 place-items-center rounded-full bg-zinc-800 text-[10px] font-bold"
>{initials(player.name)}</span
>
{/if}
@@ -97,14 +97,14 @@
/>{/if}
<a
href={playerHref(player)}
class="flex-1 truncate text-sm hover:underline {player.inLobby
class="flex-1 truncate text-base hover:underline {player.inLobby
? 'text-white'
: 'text-zinc-400'}">{player.osuName ?? player.name}</a
>
{#if player.inLobby}
<CheckCircle class="size-4 text-emerald-400" />
{:else if !created}
<Loader class="size-4 animate-spin text-zinc-500" />
<Loader class="size-4 animate-spin text-zinc-400" />
{/if}
</li>
{/each}

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import type { SideView } from '$lib/match-view';
import type { SideView } from '#lib/match-view.js';
import type { MatchLive } from './live.svelte';
import { SIDE_COLORS } from './theme';
import { Button } from '$lib/components/ui/button/index.js';
import { Button } from '#lib/components/ui/button/index.js';
import Dice from '@lucide/svelte/icons/dices';
const { live }: { live: MatchLive } = $props();
@@ -53,7 +53,7 @@
{@const colors = SIDE_COLORS[side.color]}
{@const won = leader?.id === side.id && !rolling[side.id]}
<div class="flex flex-col items-center gap-3">
<div class="text-sm font-semibold" style="color:{colors.text}">{side.name}</div>
<div class="text-base font-semibold" style="color:{colors.text}">{side.name}</div>
<div
class="relative grid size-28 place-items-center rounded-3xl border-2 transition-all duration-500 sm:size-36 {rolling[
side.id
@@ -75,12 +75,12 @@
<Dice class="size-12 text-zinc-600" />
{/if}
{#if side.serverRoll && !rolling[side.id]}
<span class="absolute -bottom-2 rounded bg-zinc-800 px-1.5 text-[10px] text-zinc-400"
<span class="absolute -bottom-2 rounded bg-zinc-800 px-1.5 text-xs text-zinc-400"
>auto-rolled</span
>
{/if}
</div>
{#if won}<span class="vash-pop text-xs font-bold tracking-widest text-amber-300 uppercase"
{#if won}<span class="vash-pop text-sm font-bold tracking-widest text-amber-300 uppercase"
>High roll</span
>{/if}
</div>
@@ -88,10 +88,10 @@
<div class="flex flex-col items-center gap-5">
<div class="text-center">
<h3 class="text-xl font-bold">
<h3 class="text-2xl font-bold">
{view.rollFor === 'tiebreaker' ? 'Tiebreaker roll' : 'Roll for order'}
</h3>
<p class="mt-1 text-sm text-zinc-400">
<p class="mt-1 text-base text-zinc-400">
{view.rollFor === 'tiebreaker'
? 'The higher roll picks the tiebreaker.'
: view.rules.bansPerTeam > 0

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { MatchLive } from './live.svelte';
import { SIDE_COLORS, modColor } from './theme';
import { Button } from '$lib/components/ui/button/index.js';
import { Button } from '#lib/components/ui/button/index.js';
import Ban from '@lucide/svelte/icons/ban';
import Crosshair from '@lucide/svelte/icons/crosshair';
import Scale from '@lucide/svelte/icons/scale';
@@ -50,7 +50,7 @@
class="size-6 text-white"
/>{:else}<Crosshair class="size-6 text-white" />{/if}
</div>
<div class="text-2xl font-black tracking-tight sm:text-3xl">
<div class="text-2xl font-black tracking-tight sm:text-4xl">
{#if mine}
<span style="color:{colors.text}">Your turn</span>
{:else}
@@ -58,7 +58,7 @@
<span class="text-zinc-300">{verb}</span>
{/if}
</div>
<p class="text-sm text-zinc-400">
<p class="text-base text-zinc-300">
{#if kind === 'choose'}
{actor.name} won the roll. {choiceHint}
{:else if kind === 'ban'}
@@ -74,7 +74,7 @@
{#if mine}
<Button
size="lg"
class="vash-glow mt-2 h-11 px-7 font-bold"
class="vash-glow mt-2 h-12 px-7 text-lg font-bold"
style="--glow:{colors.solid}"
onclick={onact}
>
@@ -96,7 +96,7 @@
{@const slot = live.slot(line.slotId)}
{#if side && slot}
<span
class="vash-rise flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold"
class="vash-rise flex items-center gap-1 rounded-full border px-2 py-0.5 text-sm font-semibold"
style="border-color:{SIDE_COLORS[side.color].ring};background:{SIDE_COLORS[side.color]
.soft}"
>

View File

@@ -1,7 +1,7 @@
import { toast } from 'svelte-sonner';
import type { MatchCommand, MatchView, SideView } from '$lib/match-view';
import type { Crowd } from '$lib/predictions';
import { connectTopics } from '$lib/topic-socket';
import type { MatchCommand, MatchView, SideView } from '#lib/match-view.js';
import type { Crowd } from '#lib/predictions.js';
import { connectTopics } from '#lib/topic-socket.js';
type SimCommand = Record<string, unknown> & { type: string };

View File

@@ -1,4 +1,4 @@
import type { SideColor } from '$lib/match-view';
import type { SideColor } from '#lib/match-view.js';
/** One color per mod pool. Tiebreakers are white, as on every tournament sheet. */
const MOD_COLORS: Record<string, string> = {

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { toast } from 'svelte-sonner';
import { renderBBCode } from '$lib/bbcode';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
import { renderBBCode } from '#lib/bbcode.js';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import { Textarea } from '#lib/components/ui/textarea/index.js';
import Palette from '@lucide/svelte/icons/palette';
const {

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { flagUrl, initials } from '$lib/components/match/theme';
import { flagUrl, initials } from '#lib/components/match/theme.js';
import Crown from '@lucide/svelte/icons/crown';
interface Member {

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import Avatar from '$lib/components/Avatar.svelte';
import { gradientFor } from '$lib/format';
import Avatar from '#lib/components/Avatar.svelte';
import { gradientFor } from '#lib/format.js';
import CalendarDays from '@lucide/svelte/icons/calendar-days';
const {

View File

@@ -23,7 +23,7 @@
</script>
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLAnchorAttributes } from 'svelte/elements';
let {

View File

@@ -1,6 +1,6 @@
<script lang="ts" module>
import { type VariantProps, tv } from 'tailwind-variants';
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
export const buttonVariants = tv({

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import XIcon from '@lucide/svelte/icons/x';
import { Button } from '$lib/components/ui/button/index.js';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import { Button } from '#lib/components/ui/button/index.js';
import { cn, type WithoutChildrenOrChild } from '#lib/utils.js';
import * as Dialog from './index.js';
import DialogPortal from './dialog-portal.svelte';
import type { Snippet } from 'svelte';

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '#lib/utils.js';
let {
ref = $bindable(null),

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import { Button } from '$lib/components/ui/button/index.js';
import { cn, type WithElementRef } from '$lib/utils.js';
import { Button } from '#lib/components/ui/button/index.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '#lib/utils.js';
let {
ref = $bindable(null),

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '#lib/utils.js';
let {
ref = $bindable(null),

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from 'svelte/elements';
type InputType = Exclude<HTMLInputTypeAttribute, 'file'>;

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Label as LabelPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '#lib/utils.js';
let {
ref = $bindable(null),

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '#lib/utils.js';
let {
ref = $bindable(null),

View File

@@ -5,8 +5,8 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from 'bits-ui';
import XIcon from '@lucide/svelte/icons/x';
import { Button } from '$lib/components/ui/button/index.js';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import { Button } from '#lib/components/ui/button/index.js';
import { cn, type WithoutChildrenOrChild } from '#lib/utils.js';
import SheetOverlay from './sheet-overlay.svelte';
import SheetPortal from './sheet-portal.svelte';
import type { Snippet } from 'svelte';

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '#lib/utils.js';
let {
ref = $bindable(null),

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '#lib/utils.js';
let {
ref = $bindable(null),

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '#lib/utils.js';
let {
ref = $bindable(null),

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef, type WithoutChildren } from '$lib/utils.js';
import { cn, type WithElementRef, type WithoutChildren } from '#lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Switch as SwitchPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import { cn, type WithoutChildrenOrChild } from '#lib/utils.js';
let {
ref = $bindable(null),

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '#lib/utils.js';
let {
ref = $bindable(null),

View File

@@ -19,7 +19,7 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '#lib/utils.js';
let {
ref = $bindable(null),

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '#lib/utils.js';
let {
ref = $bindable(null),

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '#lib/utils.js';
let {
ref = $bindable(null),

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef, type WithoutChildren } from '$lib/utils.js';
import { cn, type WithElementRef, type WithoutChildren } from '#lib/utils.js';
import type { HTMLTextareaAttributes } from 'svelte/elements';
let {

View File

@@ -2,7 +2,7 @@
* One live match as the Live page and stream overlays show it: small enough
* to send for every match on every change.
*/
import type { MatchView, Phase, SideColor } from '$lib/match-view';
import type { MatchView, Phase, SideColor } from '#lib/match-view.js';
export interface LiveSide {
id: string;

View File

@@ -3,7 +3,7 @@
* every change over the websocket. Everything a viewer needs to draw the page
* is in here; the client never refetches the page to update.
*/
import type { LogLine, Pause, Phase, Prompt, WinCondition } from '$lib/server/osu/procedure';
import type { LogLine, Pause, Phase, Prompt, WinCondition } from '#lib/server/osu/procedure.js';
export type { LogLine, Phase, Prompt };
@@ -14,6 +14,8 @@ export interface LiveScore {
accuracy: number;
combo: number;
progress: number;
/** Server receipt time, so an interrupted score feed is visible to spectators. */
updatedAt?: number;
}
export interface PlayerView {

View File

@@ -4,7 +4,7 @@ import { account, user, session, verification } from './db/auth.schema';
import { playerRating } from './db/schema';
import { and, eq, gt, inArray, sql } from 'drizzle-orm';
import { ACTIVE_MATCH_STATES } from './match/types';
import { newAuthIdentity } from '$lib/auth-identity';
import { newAuthIdentity } from '#lib/auth-identity.js';
export const recoveryCookie = 'vash-account-recovery';
const identifier = (token: string) =>
@@ -12,9 +12,7 @@ const identifier = (token: string) =>
export async function startRecovery(userId: string) {
const token = randomBytes(32).toString('hex');
await db
.insert(verification)
.values({
await db.insert(verification).values({
id: crypto.randomUUID(),
identifier: identifier(token),
value: userId,

View File

@@ -1,12 +1,12 @@
import { env } from '$env/dynamic/private';
import { APP_ENV, ORIGIN } from '$app/env/private';
export type AppEnv = 'dev' | 'staging' | 'prod';
/** APP_ENV wins; otherwise the public origin tells the three deployments apart. */
export function appEnv(): AppEnv {
const explicit = env.APP_ENV?.toLowerCase();
const explicit = APP_ENV?.toLowerCase();
if (explicit === 'dev' || explicit === 'staging' || explicit === 'prod') return explicit;
const origin = env.ORIGIN ?? '';
const origin = ORIGIN ?? '';
if (origin.includes('esports-staging.')) return 'staging';
if (
origin.includes('esports-dev.') ||

View File

@@ -1,16 +1,20 @@
import { env } from '$env/dynamic/private';
import { contactEmail } from '$lib/auth-identity';
import { RESEND_API_KEY, EMAIL_FROM } from '$app/env/private';
import { contactEmail } from '#lib/auth-identity.js';
export const emailDeliveryEnabled = !!(env.RESEND_API_KEY && env.EMAIL_FROM);
export const emailDeliveryEnabled = !!(RESEND_API_KEY && EMAIL_FROM);
export async function sendAuthEmail(to: string, subject: string, url: string) {
if (!contactEmail(to)) return;
if (!emailDeliveryEnabled) throw new Error('Email delivery is not configured');
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: { Authorization: `Bearer ${env.RESEND_API_KEY}`, 'Content-Type': 'application/json' },
headers: {
Authorization: `Bearer ${RESEND_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
from: env.EMAIL_FROM,
from: EMAIL_FROM,
to: [to],
subject,
text: `${subject}\n\n${url}\n\nIf you did not request this, you can ignore this email.`

View File

@@ -12,8 +12,7 @@ vi.mock('$app/server', () => ({ getRequestEvent: () => ({ locals: context.locals
vi.mock('better-auth/svelte-kit', () => ({
sveltekitCookies: () => ({ id: 'integration-cookies' })
}));
vi.mock('$env/dynamic/private', () => ({
env: {
vi.mock('$app/env/private', () => ({
ORIGIN: 'http://localhost:3102',
BETTER_AUTH_SECRET: 'test-secret-with-at-least-32-characters',
OSU_CLIENT_ID: 'test',
@@ -24,7 +23,6 @@ vi.mock('$env/dynamic/private', () => ({
GOOGLE_CLIENT_SECRET: 'test',
RESEND_API_KEY: 'test',
EMAIL_FROM: 'test@example.com'
}
}));
vi.mock('./auth-mail', () => ({
emailDeliveryEnabled: true,
@@ -32,7 +30,7 @@ vi.mock('./auth-mail', () => ({
context.mail.push({ to, url });
}
}));
vi.mock('$lib/server/db', async () => {
vi.mock('#lib/server/db/index.js', async () => {
const url = process.env.AUTH_TEST_DATABASE_URL;
if (!url || !new URL(url).pathname.startsWith('/esports_auth_test'))
throw new Error('An isolated esports_auth_test database is required');
@@ -89,7 +87,7 @@ describe.skipIf(!enabled)('Better Auth with the actual Postgres schema', () => {
);
}
beforeAll(async () => {
const module = (await import('$lib/server/db')) as unknown as {
const module = (await import('#lib/server/db/index.js')) as unknown as {
db: typeof db;
testConnection: typeof connection;
};

View File

@@ -3,21 +3,32 @@ import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { sveltekitCookies } from 'better-auth/svelte-kit';
import { genericOAuth, customSession } from 'better-auth/plugins';
import { APIError, createAuthMiddleware } from 'better-auth/api';
import { env } from '$env/dynamic/private';
import {
OSU_CLIENT_ID,
OSU_CLIENT_SECRET,
DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET,
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
ORIGIN,
BETTER_AUTH_SECRET
} from '$app/env/private';
import { getRequestEvent } from '$app/server';
import { and, eq, isNull } from 'drizzle-orm';
import { db } from './db';
import { account, user as userTable } from './db/auth.schema';
import { playerRating } from './db/schema';
import { rankToElo } from './match/rating-math';
import { contactEmail, newAuthIdentity, type ConnectionProfile } from '$lib/auth-identity';
import { contactEmail, newAuthIdentity, type ConnectionProfile } from '#lib/auth-identity.js';
import { emailDeliveryEnabled, sendAuthEmail } from './auth-mail';
import { adoptProviderPicture } from './profile-picture';
export const configuredProviders = {
osu: !!(env.OSU_CLIENT_ID && env.OSU_CLIENT_SECRET),
discord: !!(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET),
google: !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET)
osu: !!(OSU_CLIENT_ID && OSU_CLIENT_SECRET),
discord: !!(DISCORD_CLIENT_ID && DISCORD_CLIENT_SECRET),
google: !!(GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET)
};
/** Request-local profile data survives the callback without a shared pending-user cache. */
@@ -89,8 +100,8 @@ async function syncConnection(link: typeof account.$inferInsert) {
}
export const auth = betterAuth({
baseURL: env.ORIGIN,
secret: env.BETTER_AUTH_SECRET,
baseURL: ORIGIN,
secret: BETTER_AUTH_SECRET,
database: drizzleAdapter(db, { provider: 'pg' }),
user: {
fields: { email: 'authEmail' },
@@ -182,15 +193,15 @@ export const auth = betterAuth({
? [
{
providerId: 'osu',
clientId: env.OSU_CLIENT_ID!,
clientSecret: env.OSU_CLIENT_SECRET!,
clientId: OSU_CLIENT_ID!,
clientSecret: OSU_CLIENT_SECRET!,
authorizationUrl: 'https://osu.ppy.sh/oauth/authorize',
tokenUrl: 'https://osu.ppy.sh/oauth/token',
userInfoUrl: 'https://osu.ppy.sh/api/v2/me/osu',
scopes: ['identify', 'public'],
pkce: false,
authentication: 'post' as const,
redirectURI: `${env.ORIGIN}/api/auth/oauth2/callback/osu`,
redirectURI: `${ORIGIN}/api/auth/oauth2/callback/osu`,
mapProfileToUser: (profile: Record<string, any>) =>
oauthUser('osu', String(profile.id), {
username: profile.username,
@@ -207,14 +218,14 @@ export const auth = betterAuth({
? [
{
providerId: 'discord',
clientId: env.DISCORD_CLIENT_ID!,
clientSecret: env.DISCORD_CLIENT_SECRET!,
clientId: DISCORD_CLIENT_ID!,
clientSecret: DISCORD_CLIENT_SECRET!,
authorizationUrl: 'https://discord.com/api/oauth2/authorize',
tokenUrl: 'https://discord.com/api/oauth2/token',
userInfoUrl: 'https://discord.com/api/users/@me',
scopes: ['identify'],
pkce: false,
redirectURI: `${env.ORIGIN}/api/auth/oauth2/callback/discord`,
redirectURI: `${ORIGIN}/api/auth/oauth2/callback/discord`,
mapProfileToUser: (profile: Record<string, any>) =>
oauthUser('discord', String(profile.id), {
username: profile.username,
@@ -231,13 +242,13 @@ export const auth = betterAuth({
? [
{
providerId: 'google',
clientId: env.GOOGLE_CLIENT_ID!,
clientSecret: env.GOOGLE_CLIENT_SECRET!,
clientId: GOOGLE_CLIENT_ID!,
clientSecret: GOOGLE_CLIENT_SECRET!,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo',
scopes: ['openid', 'email', 'profile'],
pkce: true,
redirectURI: `${env.ORIGIN}/api/auth/oauth2/callback/google`,
redirectURI: `${ORIGIN}/api/auth/oauth2/callback/google`,
mapProfileToUser: (profile: Record<string, any>) =>
oauthUser('google', String(profile.sub ?? profile.id), {
username: profile.name,

View File

@@ -1,5 +1,5 @@
import * as z from 'zod';
import { COMPONENTS, SCENES, type ComponentType } from '$lib/broadcast/layout';
import { COMPONENTS, SCENES, type ComponentType } from '#lib/broadcast/layout.js';
/** https URLs or paths on this site, nothing that could close a CSS url("…"). */
const safeUrl = z

View File

@@ -9,7 +9,7 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { and, asc, eq, gt, inArray, isNotNull, isNull, lt, sql } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import { db } from '$lib/server/db';
import { db } from '#lib/server/db/index.js';
import {
broadcastChannel,
broadcastSession,
@@ -17,25 +17,33 @@ import {
tournament,
tournamentMatch,
tournamentRound
} from '$lib/server/db/schema';
import { hub, publish, setSnapshot } from '$lib/server/hub';
import { log } from '$lib/server/logger';
import { publicUrl } from '$lib/server/storage/r2';
import { on } from '$lib/server/events';
import { singleton } from '$lib/server/singleton';
import { socketEndpoints } from '$lib/server/ws-attach';
import { matchesIfRunning } from '$lib/server/match/service';
import { predictionState } from '$lib/server/predictions';
import type { ExternalScore } from '$lib/server/match/runtime';
import { summarize, DECIDED, type LiveMatch } from '$lib/live';
import type { MatchView } from '$lib/match-view';
import type { Crowd } from '$lib/predictions';
import { sceneFor, type Layout } from '$lib/broadcast/layout';
import { pickMatch } from '$lib/broadcast/director';
import type { ChannelMode, ChannelPayload, UpcomingMatch } from '$lib/broadcast/channel';
} from '#lib/server/db/schema.js';
import { hub, publish, setSnapshot } from '#lib/server/hub.js';
import { log } from '#lib/server/logger.js';
import { publicUrl } from '#lib/server/storage/r2.js';
import { on } from '#lib/server/events.js';
import { singleton } from '#lib/server/singleton.js';
import { socketEndpoints } from '#lib/server/ws-attach.js';
import { matchesIfRunning } from '#lib/server/match/service.js';
import { predictionState } from '#lib/server/predictions.js';
import type { ExternalScore } from '#lib/server/match/runtime.js';
import { summarize, DECIDED, type LiveMatch } from '#lib/live.js';
import type { MatchView } from '#lib/match-view.js';
import type { Crowd } from '#lib/predictions.js';
import { sceneFor, type Layout } from '#lib/broadcast/layout.js';
import { pickMatch } from '#lib/broadcast/director.js';
import type { ChannelMode, ChannelPayload, UpcomingMatch } from '#lib/broadcast/channel.js';
import { parseWorkerStatus, type WorkerStatus } from '#lib/broadcast/worker.js';
import { agentScoresSchema } from '#lib/broadcast/scores.js';
type ChannelRow = typeof broadcastChannel.$inferSelect;
interface Agent {
send(data: string): void;
/** What an unattended broadcast worker last said it is doing. */
worker: WorkerStatus | null;
}
interface ChannelState {
row: ChannelRow;
matchId: string | null;
@@ -45,7 +53,7 @@ interface ChannelState {
stored: MatchView | null;
payload: ChannelPayload | null;
publishTimer: ReturnType<typeof setTimeout> | null;
agents: Set<{ send(data: string): void }>;
agents: Set<Agent>;
/** The broadcast on air right now, for chapters. */
session: { id: string; startedAt: number } | null;
}
@@ -108,10 +116,14 @@ export function agentCount(channelId: string) {
/** The match a channel shows right now, and its scene. */
export function channelStatus(channelId: string) {
const channel = state.channels.get(channelId);
let worker: WorkerStatus | null = null;
for (const agent of channel?.agents ?? [])
if (agent.worker && (!worker || agent.worker.at > worker.at)) worker = agent.worker;
return {
matchId: channel?.matchId ?? null,
scene: channel?.payload?.scene ?? 'idle',
agents: channel?.agents.size ?? 0
agents: channel?.agents.size ?? 0,
worker
};
}
@@ -415,12 +427,6 @@ function tellAgents(channel: ChannelState) {
for (const agent of channel.agents) agent.send(message);
}
interface TosuFrame {
type: 'tosu';
clients: { osuId: number; score: number; accuracy: number; combo: number }[];
progress: number | null;
}
/** Hooks channels into match updates and registers /ws/agent. Called on every boot. */
export function startBroadcasts() {
on('match', 'broadcast', onMatchUpdate);
@@ -434,16 +440,19 @@ function registerAgentSocket() {
maxPayload: 64 * 1024,
open(peer) {
let channel: ChannelState | null = null;
const agent = { send: peer.send };
const agent: Agent = { send: peer.send, worker: null };
return {
message(text) {
const message = JSON.parse(text) as {
message(raw) {
let message: {
type?: string;
channelId?: string;
token?: string;
clients?: TosuFrame['clients'];
progress?: number | null;
};
try {
message = JSON.parse(raw);
} catch {
return;
}
if (!channel) {
const candidate =
message.type === 'hello' && message.channelId
@@ -470,17 +479,31 @@ function registerAgentSocket() {
tellAgents(channel);
return;
}
if (message.type === 'tosu' && channel.matchId && Array.isArray(message.clients)) {
const scores: ExternalScore[] = message.clients
.filter((client) => Number.isFinite(client.osuId) && client.osuId > 0)
.map((client) => ({
osuId: client.osuId,
score: Math.max(0, Number(client.score) || 0),
accuracy: Math.max(0, Math.min(100, Number(client.accuracy) || 0)),
combo: Math.max(0, Number(client.combo) || 0),
progress: Math.max(0, Math.min(1, Number(message.progress) || 0))
if (message.type === 'tosu' && channel.matchId) {
const parsed = agentScoresSchema.safeParse(message);
if (!parsed.success) return;
const frame = parsed.data;
// A queued frame must not follow a director cut into a different match.
if (frame.matchId !== undefined && frame.matchId !== channel.matchId) return;
const scores: ExternalScore[] = frame.clients.map((client) => ({
...client,
progress: frame.progress ?? 0
}));
matchesIfRunning()?.feedLive(channel.matchId, scores);
const accepted =
matchesIfRunning()?.feedLive(channel.matchId, scores, frame.beatmapId) ?? 0;
peer.send(JSON.stringify({ type: 'scores', matchId: channel.matchId, accepted }));
} else if (message.type === 'status') {
const before = agent.worker?.state;
agent.worker = parseWorkerStatus(message as Record<string, unknown>);
if (agent.worker.state !== before)
log.broadcast.info(
{
channel: channel.row.name,
state: agent.worker.state,
detail: agent.worker.detail
},
'broadcast worker state'
);
}
},
close() {

View File

@@ -10,7 +10,7 @@ import {
uniqueIndex
} from 'drizzle-orm/pg-core';
import { jsonb } from './jsonb';
import type { ConnectionProfile } from '$lib/auth-identity';
import type { ConnectionProfile } from '#lib/auth-identity.js';
export const user = pgTable('user', {
id: text('id').primaryKey(),

Some files were not shown because too many files have changed in this diff Show More