Compare commits

...

59 Commits

Author SHA1 Message Date
felixfoertsch 2f1d44de04 add QR code to display view lobby screen
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:59:37 +01:00
felixfoertsch 2b51448b83 change header title to "We❤️Eurovision" with rainbow gradient
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:52:45 +01:00
felixfoertsch e03b374911 regenerate route tree for nested play/host routes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:55:27 +01:00
felixfoertsch d056647bbd restructure host routes: layout with nested game, bingo, board, host children
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 11:54:32 +01:00
felixfoertsch 3ec8803711 restructure play routes: layout with nested game, bingo, board children
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 11:53:54 +01:00
felixfoertsch 7e1007ebf8 share WebSocket send via Zustand store, prevent duplicate connections
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 11:52:05 +01:00
felixfoertsch 998ac07867 add shared tab components: GameTab, BingoTab, BoardTab, HostTab, BingoClaims
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 11:50:57 +01:00
felixfoertsch 971f4110c1 add RoomLayout, BottomNav components; update BingoCard (readonly + redraw), Leaderboard (lobbyMode)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 11:48:09 +01:00
felixfoertsch f3d407ee21 update tap_bingo_square for completion flow, add request_new_bingo_card handler, generate cards on join
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 11:46:21 +01:00
felixfoertsch 9e88e99827 remove unused originalTropes variable in bingo test
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:44:38 +01:00
felixfoertsch af0499d354 add bingo completion tests: tap-only, detection, scoring, redraw, announcements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 11:44:20 +01:00
felixfoertsch 0784b4b077 fix TS errors: remove unused announcedBingo set, prefix unused playerId param
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:42:52 +01:00
felixfoertsch dda8c4a2ef add bingo completion logic: tap-only, card storage, scoring across cards, redraw
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 11:25:30 +01:00
felixfoertsch 25f61d456c add completedBingoCard schema, request_new_bingo_card WS message 2026-03-13 11:23:10 +01:00
felixfoertsch b095ce0d69 fix plan review issues: WebSocket sharing, test signatures, host-tab leaderboard, bingo flow
- add Task 10: store WebSocket send in Zustand, child routes use store
- fix all tapBingoSquare test calls to 2-arg signature (no displayName)
- fix requestNewBingoCard test calls to include displayName
- remove duplicate Leaderboard from host-tab.tsx
- fix bingo completion tests: cards move on redraw, not on detection
- fix addBingoAnnouncement to track announcements via count comparison
- remove use-websocket.ts and room-store.ts from Unchanged Files
- renumber tasks 10-13

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:56:23 +01:00
felixfoertsch f9e493cd9d fix second spec review: tap-only bingo, announcement schema, broadcast pattern
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:51:23 +01:00
felixfoertsch cdf878fe9b fix spec review issues: bingo scoring, data model, edge cases
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:49:43 +01:00
felixfoertsch 142455cdb8 add menu rework design spec
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:47:12 +01:00
felixfoertsch d3a5d08d6b add quiz components to leaderboard, route views
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 07:21:21 +01:00
felixfoertsch 43268d8d86 add QuizBuzzer, QuizHost, QuizDisplay components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 07:18:18 +01:00
felixfoertsch d11c7780e9 fix quiz questions type assertion for JSON import 2026-03-13 07:17:15 +01:00
felixfoertsch 634b953911 add quiz logic to GameManager, WS handler, quiz tests
- implement startQuizQuestion, buzz, judgeQuizAnswer, skipQuizQuestion
- add per-view quiz state builders (player, display, host)
- include quizPoints in leaderboard
- dispatch start_quiz_question, skip_quiz_question, buzz, judge_quiz_answer in WS handler
- override quiz question with full host view (text + answer) for host connections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 07:16:34 +01:00
felixfoertsch 4ada13ca9f add quiz schemas to shared types: QuizQuestion, GameState fields, WS messages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 06:48:21 +01:00
felixfoertsch 8caf9ab2f3 add quiz questions data file (20 Eurovision questions)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 06:48:18 +01:00
felixfoertsch 48986137db show actual results summary on display in scoring/ended
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:18:57 +01:00
felixfoertsch 6cc164dfe5 show scored predictions in player route during scoring/ended
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:18:28 +01:00
felixfoertsch ec7e6fd869 wire ActualResultsForm and prediction results in host route
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:18:15 +01:00
felixfoertsch 01f78e920e add prediction points to leaderboard display and explanation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:17:54 +01:00
felixfoertsch 3b470787b5 show correct/incorrect markers on locked predictions when results are in
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:17:20 +01:00
felixfoertsch ef0f88551d add ActualResultsForm component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:16:58 +01:00
felixfoertsch f390c21903 add submit_actual_results WS handler with validation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:14:43 +01:00
felixfoertsch c7a11e80d3 add prediction scoring to GameManager with tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:14:28 +01:00
felixfoertsch 44ea815f95 add submit_actual_results WS message type
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:12:52 +01:00
felixfoertsch 00e17d1f28 add actual results schema, prediction points to leaderboard and game state
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:12:41 +01:00
felixfoertsch 8372769c9b add prediction scoring implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:45:17 +01:00
felixfoertsch f0dc35610e add prediction scoring design spec
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:40:25 +01:00
felixfoertsch 4e06930796 remove Dish of the Nation from design spec, scoring config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:37:26 +01:00
felixfoertsch 38a0c9f55a fix host UX: revert act, inline open/close voting, larger bingo text, scoring explanation, simplify player list
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:22:35 +01:00
felixfoertsch 4cfff0eaa5 switch deploy from drizzle-kit migrate to push --force
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:12:00 +01:00
felixfoertsch f22dba6134 add jury rounds, jury votes, bingo cards DB tables
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:02:02 +01:00
felixfoertsch 60a5962519 integrate jury display, bingo announcements, leaderboard in display route
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:02:01 +01:00
felixfoertsch a71308f6f0 integrate jury controls, bingo, leaderboard in host route
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:02:00 +01:00
felixfoertsch 611a1bf732 integrate jury voting, bingo tabs in player route
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:01:55 +01:00
felixfoertsch c768d7340a add leaderboard component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:50:33 +01:00
felixfoertsch f6223ae9fa add bingo display component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:50:33 +01:00
felixfoertsch 7f5dba6e03 add bingo card player component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:50:31 +01:00
felixfoertsch 8ee9295b4e add jury display component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:28:35 +01:00
felixfoertsch 094fd1feeb add jury host controls component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:28:34 +01:00
felixfoertsch d247c2519e add jury voting player component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:28:33 +01:00
felixfoertsch 302f2e14c0 handle new jury, bingo WS message types in client
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:28:25 +01:00
felixfoertsch ceba5521dc add jury voting, bingo WS message handlers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:51:32 +01:00
felixfoertsch 7cb52291f3 add getPlayerDisplayNames to RoomManager
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:50:51 +01:00
felixfoertsch aedd3c032a extend GameManager game state with jury, bingo, leaderboard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:50:43 +01:00
felixfoertsch b79ddb9679 add bingo logic to GameManager with tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:50:07 +01:00
felixfoertsch 0703364945 add jury voting logic to GameManager with tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:49:17 +01:00
felixfoertsch e48ee2ca35 add jury voting, bingo WS message types
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:45:35 +01:00
felixfoertsch 9ec0225e4b add jury, bingo, leaderboard schemas to shared game types
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:45:32 +01:00
felixfoertsch c31f849de3 add bingo tropes data file (35 tropes)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:45:27 +01:00
felixfoertsch 0019024066 add Act 2 (jury voting + bingo) implementation plan 2026-03-12 19:42:49 +01:00
55 changed files with 9279 additions and 274 deletions
+312 -83
View File
@@ -16,6 +16,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"qrcode.react": "^4.2.0",
"react": "latest",
"react-dom": "latest",
"tailwind-merge": "^3.5.0",
@@ -96,10 +97,6 @@
"@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
@@ -114,61 +111,67 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
@@ -186,6 +189,12 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="],
"@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
@@ -216,7 +225,37 @@
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm" }, "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "ppc64" }, "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "s390x" }, "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.9", "", { "os": "none", "cpu": "arm64" }, "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.9", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.9", "", { "os": "win32", "cpu": "x64" }, "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
@@ -318,13 +357,7 @@
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.4", "", {}, "sha512-42WoRePf8v690qG8yGRe/YOh+oHni9vUaUUfoqlS91U2scd3a5rkLtVsc6b7z60w3RogH0I00vdrC5AaeiZ18w=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
@@ -340,7 +373,7 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.4", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
"@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="],
@@ -410,7 +443,7 @@
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
@@ -456,29 +489,29 @@
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
@@ -530,18 +563,20 @@
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="],
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
@@ -600,7 +635,7 @@
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"vite": ["vite@8.0.0", "", { "dependencies": { "@oxc-project/runtime": "0.115.0", "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.9", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.0.0-alpha.31", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q=="],
"vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="],
@@ -624,6 +659,8 @@
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
@@ -636,20 +673,28 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tailwindcss/vite/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"@vitest/mocker/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"drizzle-kit/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.9", "", {}, "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw=="],
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"tsx/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
"vitest/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
@@ -694,56 +739,240 @@
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"drizzle-kit/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
"drizzle-kit/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="],
"drizzle-kit/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="],
"drizzle-kit/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="],
"drizzle-kit/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="],
"drizzle-kit/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="],
"drizzle-kit/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="],
"drizzle-kit/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="],
"drizzle-kit/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="],
"drizzle-kit/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="],
"drizzle-kit/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
"drizzle-kit/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@tailwindcss/vite/vite/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
"drizzle-kit/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@vitest/mocker/vite/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
"drizzle-kit/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
"drizzle-kit/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
"drizzle-kit/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
"drizzle-kit/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
"drizzle-kit/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
"drizzle-kit/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
"drizzle-kit/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
"drizzle-kit/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
"drizzle-kit/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
"drizzle-kit/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
"drizzle-kit/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
"drizzle-kit/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
"drizzle-kit/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
"tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
"tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
"tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
"tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
"tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
"tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
"tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
"tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
"tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
"tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
"tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
"vitest/vite/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
"@tailwindcss/vite/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
"@tailwindcss/vite/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
"@tailwindcss/vite/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
"@tailwindcss/vite/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
"@tailwindcss/vite/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
"@tailwindcss/vite/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
"@tailwindcss/vite/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
"@tailwindcss/vite/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
"@tailwindcss/vite/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
"@tailwindcss/vite/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
"@tailwindcss/vite/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
"@tailwindcss/vite/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
"@tailwindcss/vite/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
"@tailwindcss/vite/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
"@tailwindcss/vite/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
"@tailwindcss/vite/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
"@tailwindcss/vite/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
"@tailwindcss/vite/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
"@tailwindcss/vite/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
"@tailwindcss/vite/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
"@tailwindcss/vite/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
"@tailwindcss/vite/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
"@tailwindcss/vite/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
"@tailwindcss/vite/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
"@tailwindcss/vite/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
"@tailwindcss/vite/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
"@vitest/mocker/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
"@vitest/mocker/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
"@vitest/mocker/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
"@vitest/mocker/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
"@vitest/mocker/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
"@vitest/mocker/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
"@vitest/mocker/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
"@vitest/mocker/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
"@vitest/mocker/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
"@vitest/mocker/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
"@vitest/mocker/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
"@vitest/mocker/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
"@vitest/mocker/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
"@vitest/mocker/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
"@vitest/mocker/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
"@vitest/mocker/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
"@vitest/mocker/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
"@vitest/mocker/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
"@vitest/mocker/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
"@vitest/mocker/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
"@vitest/mocker/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
"@vitest/mocker/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
"@vitest/mocker/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
"@vitest/mocker/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
"@vitest/mocker/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
"@vitest/mocker/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
"vitest/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
"vitest/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
"vitest/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
"vitest/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
"vitest/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
"vitest/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
"vitest/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
"vitest/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
"vitest/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
"vitest/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
"vitest/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
"vitest/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
"vitest/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
"vitest/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
"vitest/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
"vitest/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
"vitest/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
"vitest/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
"vitest/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
"vitest/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
"vitest/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
"vitest/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
"vitest/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
"vitest/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
"vitest/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
"vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
}
}
+2 -2
View File
@@ -60,8 +60,8 @@ PORT=$PORT
ENVFILE"
# ── 6. Run migrations ────────────────────────────────────────────────
echo "→ running database migrations..."
ssh "$HOST" "cd ~/$SERVICE_DIR/server && DATABASE_URL=postgresql://localhost:5433/$DB_NAME bun drizzle-kit migrate"
echo "→ pushing database schema..."
ssh "$HOST" "cd ~/$SERVICE_DIR/server && DATABASE_URL=postgresql://localhost:5433/$DB_NAME bun drizzle-kit push --force"
# ── 7. Deploy static client files ────────────────────────────────────
echo "→ deploying client static files..."
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,866 @@
# Prediction Scoring Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Allow the host to enter actual ESC results and score player predictions against them.
**Architecture:** Extend `GameManager` with actual results storage and prediction scoring. Add a new WS message type `submit_actual_results`. Extend `GameState`, `LeaderboardEntry` schemas with prediction data. Add a host-side form and player-side result indicators.
**Tech Stack:** Zod, Hono WebSocket, React, shadcn/ui, Vitest
---
## File Structure
| Action | File | Responsibility |
|--------|------|---------------|
| Modify | `packages/shared/src/game-types.ts` | Add `actualResultsSchema`, `predictionPoints` to leaderboard, `actualResults` to game state |
| Modify | `packages/shared/src/ws-messages.ts` | Add `submit_actual_results` client message |
| Modify | `packages/server/src/games/game-manager.ts` | `setActualResults`, `getPredictionScore`, update `buildLeaderboard` |
| Modify | `packages/server/src/ws/handler.ts` | Handle `submit_actual_results` |
| Modify | `packages/server/tests/game-manager.test.ts` | Tests for prediction scoring |
| Create | `packages/client/src/components/actual-results-form.tsx` | Host form to enter actual ESC results |
| Modify | `packages/client/src/components/predictions-form.tsx` | Show correct/incorrect markers when results are in |
| Modify | `packages/client/src/components/leaderboard.tsx` | Add P: column, update scoring explanation |
| Modify | `packages/client/src/routes/host.$roomCode.tsx` | Show `ActualResultsForm` in scoring/ended |
| Modify | `packages/client/src/routes/play.$roomCode.tsx` | Pass `actualResults` to predictions form in scoring/ended |
| Modify | `packages/client/src/routes/display.$roomCode.tsx` | Show actual results summary |
---
## Chunk 1: Prediction Scoring
### Task 1: Extend shared types
**Files:**
- Modify: `packages/shared/src/game-types.ts`
- [ ] **Step 1: Add `actualResultsSchema` and extend `LeaderboardEntry` and `GameState`**
In `packages/shared/src/game-types.ts`, add after the `Prediction` type block:
```ts
// ─── Actual Results ─────────────────────────────────────────────────
export const actualResultsSchema = z.object({
winner: z.string(),
second: z.string(),
third: z.string(),
last: z.string(),
})
export type ActualResults = z.infer<typeof actualResultsSchema>
```
Update `leaderboardEntrySchema` to add `predictionPoints`:
```ts
export const leaderboardEntrySchema = z.object({
playerId: z.string(),
displayName: z.string(),
juryPoints: z.number(),
bingoPoints: z.number(),
predictionPoints: z.number(),
totalPoints: z.number(),
})
```
Update `gameStateSchema` to add `actualResults`:
```ts
export const gameStateSchema = z.object({
lineup: lineupSchema,
myPrediction: predictionSchema.nullable(),
predictionsLocked: z.boolean(),
predictionSubmitted: z.record(z.string(), z.boolean()),
// Jury
currentJuryRound: juryRoundSchema.nullable(),
juryResults: z.array(juryResultSchema),
myJuryVote: z.number().nullable(),
// Bingo
myBingoCard: bingoCardSchema.nullable(),
bingoAnnouncements: z.array(z.object({
playerId: z.string(),
displayName: z.string(),
})),
// Predictions
actualResults: actualResultsSchema.nullable(),
// Leaderboard
leaderboard: z.array(leaderboardEntrySchema),
})
```
- [ ] **Step 2: Verify build**
Run: `bun run --filter './packages/shared' build 2>&1 || echo 'no build script, check tsc'`
Expected: No type errors in shared package
- [ ] **Step 3: Commit**
```bash
git add packages/shared/src/game-types.ts
git commit -m "add actual results schema, prediction points to leaderboard and game state"
```
### Task 2: Add WS message type
**Files:**
- Modify: `packages/shared/src/ws-messages.ts`
- [ ] **Step 1: Add `submitActualResultsMessage`**
After the `tapBingoSquareMessage` definition, add:
```ts
export const submitActualResultsMessage = z.object({
type: z.literal("submit_actual_results"),
winner: z.string(),
second: z.string(),
third: z.string(),
last: z.string(),
})
```
Add it to the `clientMessage` discriminated union array:
```ts
export const clientMessage = z.discriminatedUnion("type", [
joinRoomMessage,
reconnectMessage,
advanceActMessage,
revertActMessage,
endRoomMessage,
submitPredictionMessage,
openJuryVoteMessage,
closeJuryVoteMessage,
submitJuryVoteMessage,
tapBingoSquareMessage,
submitActualResultsMessage,
])
```
- [ ] **Step 2: Commit**
```bash
git add packages/shared/src/ws-messages.ts
git commit -m "add submit_actual_results WS message type"
```
### Task 3: Add prediction scoring to GameManager — tests first
**Files:**
- Modify: `packages/server/tests/game-manager.test.ts`
- [ ] **Step 1: Write failing tests for prediction scoring**
Add a new `describe("prediction scoring")` block at the end of the test file:
```ts
describe("prediction scoring", () => {
it("returns 0 for all when no actual results set", () => {
const gm = new GameManager()
gm.submitPrediction("p1", "SE", "IT", "FR", "UK")
expect(gm.getPredictionScore("p1")).toBe(0)
})
it("scores correct winner", () => {
const gm = new GameManager()
gm.submitPrediction("p1", "SE", "IT", "FR", "UK")
gm.setActualResults("SE", "CH", "DE", "AL")
expect(gm.getPredictionScore("p1")).toBe(25) // prediction_winner
})
it("scores correct second place", () => {
const gm = new GameManager()
gm.submitPrediction("p1", "XX", "IT", "FR", "UK")
gm.setActualResults("SE", "IT", "DE", "AL")
expect(gm.getPredictionScore("p1")).toBe(10) // prediction_top3
})
it("scores correct third place", () => {
const gm = new GameManager()
gm.submitPrediction("p1", "XX", "YY", "FR", "UK")
gm.setActualResults("SE", "IT", "FR", "AL")
expect(gm.getPredictionScore("p1")).toBe(10) // prediction_top3
})
it("scores correct last place", () => {
const gm = new GameManager()
gm.submitPrediction("p1", "XX", "YY", "ZZ", "UK")
gm.setActualResults("SE", "IT", "FR", "UK")
expect(gm.getPredictionScore("p1")).toBe(15) // prediction_nul_points
})
it("scores perfect prediction (all correct)", () => {
const gm = new GameManager()
gm.submitPrediction("p1", "SE", "IT", "FR", "UK")
gm.setActualResults("SE", "IT", "FR", "UK")
expect(gm.getPredictionScore("p1")).toBe(60) // 25 + 10 + 10 + 15
})
it("scores 0 for all wrong", () => {
const gm = new GameManager()
gm.submitPrediction("p1", "AA", "BB", "CC", "DD")
gm.setActualResults("SE", "IT", "FR", "UK")
expect(gm.getPredictionScore("p1")).toBe(0)
})
it("returns 0 for player with no prediction", () => {
const gm = new GameManager()
gm.setActualResults("SE", "IT", "FR", "UK")
expect(gm.getPredictionScore("p1")).toBe(0)
})
it("getActualResults returns null before setting", () => {
const gm = new GameManager()
expect(gm.getActualResults()).toBeNull()
})
it("getActualResults returns results after setting", () => {
const gm = new GameManager()
gm.setActualResults("SE", "IT", "FR", "UK")
expect(gm.getActualResults()).toEqual({ winner: "SE", second: "IT", third: "FR", last: "UK" })
})
it("setActualResults overwrites previous results", () => {
const gm = new GameManager()
gm.submitPrediction("p1", "SE", "IT", "FR", "UK")
gm.setActualResults("AA", "BB", "CC", "DD")
expect(gm.getPredictionScore("p1")).toBe(0)
gm.setActualResults("SE", "IT", "FR", "UK")
expect(gm.getPredictionScore("p1")).toBe(60)
})
it("prediction points appear in leaderboard", () => {
const gm = new GameManager()
gm.submitPrediction("p1", "SE", "IT", "FR", "UK")
gm.setActualResults("SE", "IT", "FR", "UK")
const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Alice" })
expect(state.leaderboard[0]!.predictionPoints).toBe(60)
expect(state.leaderboard[0]!.totalPoints).toBe(60)
})
it("actualResults included in game state", () => {
const gm = new GameManager()
gm.setActualResults("SE", "IT", "FR", "UK")
const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Alice" })
expect(state.actualResults).toEqual({ winner: "SE", second: "IT", third: "FR", last: "UK" })
})
it("actualResults null in game state when not set", () => {
const gm = new GameManager()
const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Alice" })
expect(state.actualResults).toBeNull()
})
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `bun test`
Expected: New tests FAIL (methods don't exist yet)
### Task 4: Implement prediction scoring in GameManager
**Files:**
- Modify: `packages/server/src/games/game-manager.ts`
- [ ] **Step 1: Add actual results storage and scoring methods**
Add a new private field after `private bingoAnnouncements`:
```ts
// ─── Prediction Scoring ─────────────────────────────────────────
private actualResults: { winner: string; second: string; third: string; last: string } | null = null
```
Add methods after `getBingoScore`:
```ts
setActualResults(winner: string, second: string, third: string, last: string): void {
this.actualResults = { winner, second, third, last }
}
getActualResults(): { winner: string; second: string; third: string; last: string } | null {
return this.actualResults
}
getPredictionScore(playerId: string): number {
if (!this.actualResults) return 0
const prediction = this.predictions.get(playerId)
if (!prediction) return 0
let score = 0
if (prediction.first === this.actualResults.winner) score += scoringConfig.prediction_winner
if (prediction.second === this.actualResults.second) score += scoringConfig.prediction_top3
if (prediction.third === this.actualResults.third) score += scoringConfig.prediction_top3
if (prediction.last === this.actualResults.last) score += scoringConfig.prediction_nul_points
return score
}
```
- [ ] **Step 2: Update `buildLeaderboard` to include prediction points**
Change the `buildLeaderboard` method's return type and body:
```ts
private buildLeaderboard(
playerIds: string[],
displayNames: Record<string, string>,
): { playerId: string; displayName: string; juryPoints: number; bingoPoints: number; predictionPoints: number; totalPoints: number }[] {
return playerIds
.map((id) => {
const juryPoints = this.getJuryScore(id)
const bingoPoints = this.getBingoScore(id)
const predictionPoints = this.getPredictionScore(id)
return {
playerId: id,
displayName: displayNames[id] ?? "Unknown",
juryPoints,
bingoPoints,
predictionPoints,
totalPoints: juryPoints + bingoPoints + predictionPoints,
}
})
.sort((a, b) => b.totalPoints - a.totalPoints)
}
```
- [ ] **Step 3: Add `actualResults` to both game state builder methods**
In `getGameStateForPlayer`, add `actualResults: this.actualResults,` after the `bingoAnnouncements` line.
In `getGameStateForDisplay`, add `actualResults: this.actualResults,` after the `bingoAnnouncements` line.
- [ ] **Step 4: Run tests to verify they pass**
Run: `bun test`
Expected: All tests PASS (60 existing + 13 new = 73)
- [ ] **Step 5: Commit**
```bash
git add packages/server/src/games/game-manager.ts packages/server/tests/game-manager.test.ts
git commit -m "add prediction scoring to GameManager with tests"
```
### Task 5: Add WS handler for submit_actual_results
**Files:**
- Modify: `packages/server/src/ws/handler.ts`
- [ ] **Step 1: Add handler case**
In the `switch (msg.type)` block, add a new case before the closing `}` of the switch (after `tap_bingo_square`):
```ts
case "submit_actual_results": {
if (!sessionId) {
sendError(ws, "Not joined")
return
}
const room = roomManager.getRoom(roomCode)
if (room?.currentAct !== "scoring" && room?.currentAct !== "ended") {
sendError(ws, "Results can only be entered during Scoring or Ended")
return
}
if (!roomManager.isHost(roomCode, sessionId)) {
sendError(ws, "Only the host can enter actual results")
return
}
const gm = roomManager.getGameManager(roomCode)
if (!gm) {
sendError(ws, "Room not found")
return
}
const allPicks = [msg.winner, msg.second, msg.third, msg.last]
for (const code of allPicks) {
if (!gm.isValidCountry(code)) {
sendError(ws, `Invalid country: ${code}`)
return
}
}
if (new Set(allPicks).size !== 4) {
sendError(ws, "All 4 picks must be different countries")
return
}
gm.setActualResults(msg.winner, msg.second, msg.third, msg.last)
broadcastGameStateToAll(roomCode)
break
}
```
- [ ] **Step 2: Run tests and verify client builds**
Run: `bun test`
Expected: All 73 tests pass
Run: `bun run --filter './packages/client' build`
Expected: Build succeeds (client doesn't use the new types yet, but shared types must compile)
- [ ] **Step 3: Commit**
```bash
git add packages/server/src/ws/handler.ts
git commit -m "add submit_actual_results WS handler with validation"
```
### Task 6: Create ActualResultsForm component
**Files:**
- Create: `packages/client/src/components/actual-results-form.tsx`
- [ ] **Step 1: Create the component**
This reuses the same slot-picker pattern as `PredictionsForm`. Create `packages/client/src/components/actual-results-form.tsx`:
```tsx
import { useState } from "react"
import type { Entry, ActualResults } from "@celebrate-esc/shared"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
type SlotKey = "winner" | "second" | "third" | "last"
const SLOTS: { key: SlotKey; label: string }[] = [
{ key: "winner", label: "Winner" },
{ key: "second", label: "2nd Place" },
{ key: "third", label: "3rd Place" },
{ key: "last", label: "Last Place" },
]
function formatEntry(entry: Entry): string {
return `${entry.country.flag} ${entry.artist}${entry.song}`
}
interface ActualResultsFormProps {
entries: Entry[]
existingResults: ActualResults | null
onSubmit: (results: { winner: string; second: string; third: string; last: string }) => void
}
export function ActualResultsForm({ entries, existingResults, onSubmit }: ActualResultsFormProps) {
const [slots, setSlots] = useState<Record<SlotKey, string | null>>(() => {
if (existingResults) {
return {
winner: existingResults.winner,
second: existingResults.second,
third: existingResults.third,
last: existingResults.last,
}
}
return { winner: null, second: null, third: null, last: null }
})
const [pickerForEntry, setPickerForEntry] = useState<string | null>(null)
const assignedCodes = new Set(Object.values(slots).filter(Boolean))
const emptySlots = SLOTS.filter((s) => !slots[s.key])
const allFilled = SLOTS.every((s) => slots[s.key])
function findEntry(code: string): Entry | undefined {
return entries.find((e) => e.country.code === code)
}
function assignToSlot(entryCode: string, slotKey: SlotKey) {
setSlots((prev) => ({ ...prev, [slotKey]: entryCode }))
setPickerForEntry(null)
}
function removeFromSlot(slotKey: SlotKey) {
setSlots((prev) => ({ ...prev, [slotKey]: null }))
}
return (
<Card>
<CardHeader>
<CardTitle>Actual Results</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
{SLOTS.map((slot) => {
const code = slots[slot.key]
const entry = code ? findEntry(code) : null
return (
<div
key={slot.key}
className={`flex items-center justify-between rounded-md border p-2 ${
code ? "border-primary/30 bg-primary/5" : "border-dashed"
}`}
>
<div className="flex items-center gap-2">
<span className="w-20 text-xs font-medium text-muted-foreground">{slot.label}</span>
{entry ? (
<span className="text-sm">{formatEntry(entry)}</span>
) : (
<span className="text-sm text-muted-foreground">Tap an entry below</span>
)}
</div>
{code && (
<button
type="button"
onClick={() => removeFromSlot(slot.key)}
className="text-muted-foreground hover:text-foreground"
aria-label={`Remove ${slot.label}`}
>
</button>
)}
</div>
)
})}
</div>
{allFilled && (
<Button
onClick={() =>
onSubmit({
winner: slots.winner!,
second: slots.second!,
third: slots.third!,
last: slots.last!,
})
}
>
{existingResults ? "Update Results" : "Submit Results"}
</Button>
)}
<div className="flex flex-col gap-1">
<h4 className="text-sm font-medium text-muted-foreground">Entries</h4>
{entries.map((entry) => {
const isAssigned = assignedCodes.has(entry.country.code)
const isPickerOpen = pickerForEntry === entry.country.code
return (
<div key={entry.country.code}>
<button
type="button"
disabled={isAssigned}
onClick={() => {
if (emptySlots.length === 1) {
assignToSlot(entry.country.code, emptySlots[0]!.key)
} else {
setPickerForEntry(isPickerOpen ? null : entry.country.code)
}
}}
className={`w-full rounded-md border px-3 py-2 text-left text-sm transition-colors ${
isAssigned
? "border-transparent bg-muted/50 text-muted-foreground line-through opacity-50"
: isPickerOpen
? "border-primary bg-primary/5"
: "hover:bg-muted"
}`}
>
{formatEntry(entry)}
</button>
{isPickerOpen && !isAssigned && (
<div className="mt-1 ml-4 flex gap-1">
{emptySlots.map((slot) => (
<button
type="button"
key={slot.key}
onClick={() => assignToSlot(entry.country.code, slot.key)}
className="rounded-md border px-2 py-1 text-xs hover:bg-primary hover:text-primary-foreground"
>
{slot.label}
</button>
))}
</div>
)}
</div>
)
})}
</div>
</CardContent>
</Card>
)
}
```
- [ ] **Step 2: Commit**
```bash
git add packages/client/src/components/actual-results-form.tsx
git commit -m "add ActualResultsForm component"
```
### Task 7: Update PredictionsForm to show correct/incorrect markers
**Files:**
- Modify: `packages/client/src/components/predictions-form.tsx`
- [ ] **Step 1: Add `actualResults` prop and markers**
Update the `PredictionsFormProps` interface to add:
```ts
import type { Entry, Prediction, ActualResults } from "@celebrate-esc/shared"
interface PredictionsFormProps {
entries: Entry[]
existingPrediction: Prediction | null
locked: boolean
actualResults?: ActualResults | null
onSubmit: (prediction: { first: string; second: string; third: string; last: string }) => void
}
```
Update the function signature:
```ts
export function PredictionsForm({ entries, existingPrediction, locked, actualResults, onSubmit }: PredictionsFormProps) {
```
In the locked state when `existingPrediction` exists (the block starting at line 67), update the rendered slot items to show correctness. Replace the existing locked-with-prediction return block with:
```tsx
return (
<Card>
<CardHeader>
<CardTitle>Your Predictions {actualResults ? "(scored)" : "(locked)"}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{SLOTS.map((slot) => {
const entry = findEntry(existingPrediction[slot.key])
const isCorrect = actualResults
? slot.key === "first" ? existingPrediction.first === actualResults.winner
: slot.key === "second" ? existingPrediction.second === actualResults.second
: slot.key === "third" ? existingPrediction.third === actualResults.third
: existingPrediction.last === actualResults.last
: null
return (
<div key={slot.key} className="flex items-center gap-2 rounded-md border p-2">
<span className="w-20 text-xs font-medium text-muted-foreground">{slot.label}</span>
<span className="text-sm">{entry ? formatEntry(entry) : "—"}</span>
{isCorrect !== null && (
<span className={isCorrect ? "ml-auto text-green-600" : "ml-auto text-red-500"}>
{isCorrect ? "✓" : "✗"}
</span>
)}
</div>
)
})}
</CardContent>
</Card>
)
```
- [ ] **Step 2: Commit**
```bash
git add packages/client/src/components/predictions-form.tsx
git commit -m "show correct/incorrect markers on locked predictions when results are in"
```
### Task 8: Update Leaderboard component
**Files:**
- Modify: `packages/client/src/components/leaderboard.tsx`
- [ ] **Step 1: Add `resultsEntered` prop and P: column**
Add a `resultsEntered` boolean prop to `LeaderboardProps`:
```ts
interface LeaderboardProps {
entries: LeaderboardEntry[]
resultsEntered?: boolean
}
export function Leaderboard({ entries, resultsEntered }: LeaderboardProps) {
```
In the score display section (the `div` with `gap-3 text-xs`), add `P:` before `J:`. Show `P:?` when results are not yet entered:
```tsx
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span title="Prediction points">P:{resultsEntered ? entry.predictionPoints : "?"}</span>
<span title="Jury points">J:{entry.juryPoints}</span>
<span title="Bingo points">B:{entry.bingoPoints}</span>
<span className="text-sm font-bold text-foreground">{entry.totalPoints}</span>
</div>
```
Update the scoring explanation `ul` to add predictions:
```tsx
<ul className="flex flex-col gap-0.5">
<li><strong>P</strong> = Prediction points 25 for correct winner, 10 each for 2nd/3rd, 15 for last place</li>
<li><strong>J</strong> = Jury points rate each act 1-12, earn up to 5 pts per round for matching the group consensus</li>
<li><strong>B</strong> = Bingo points 2 pts per tapped trope + 10 bonus for a full bingo line</li>
</ul>
```
- [ ] **Step 2: Commit**
```bash
git add packages/client/src/components/leaderboard.tsx
git commit -m "add prediction points to leaderboard display and explanation"
```
**Note for Tasks 9, 10, 11:** All `<Leaderboard>` usages must pass `resultsEntered={!!gameState.actualResults}` or `resultsEntered={!!gameState?.actualResults}`.
### Task 9: Wire up host route
**Files:**
- Modify: `packages/client/src/routes/host.$roomCode.tsx`
- [ ] **Step 1: Import and add ActualResultsForm**
Add import at top:
```ts
import { ActualResultsForm } from "@/components/actual-results-form"
```
In the Host tab's `CardContent`, add the `ActualResultsForm` after the jury host block and before the leaderboard block. Find the line:
```tsx
{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && (
<Leaderboard entries={gameState.leaderboard} />
)}
```
Add before it:
```tsx
{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && (
<ActualResultsForm
entries={gameState.lineup.entries}
existingResults={gameState.actualResults}
onSubmit={(results) => send({ type: "submit_actual_results", ...results })}
/>
)}
```
- [ ] **Step 2: Also pass `actualResults` to PredictionsForm in the Play tab**
In the Play tab, update the predictions block for scoring/ended to show locked predictions with results. Find:
```tsx
{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && (
<Leaderboard entries={gameState.leaderboard} />
)}
```
Add before that block (in the Play tab):
```tsx
{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && gameState.myPrediction && (
<PredictionsForm
entries={gameState.lineup.entries}
existingPrediction={gameState.myPrediction}
locked={true}
actualResults={gameState.actualResults}
onSubmit={() => {}}
/>
)}
```
- [ ] **Step 3: Commit**
```bash
git add packages/client/src/routes/host.$roomCode.tsx
git commit -m "wire ActualResultsForm and prediction results in host route"
```
### Task 10: Wire up player route
**Files:**
- Modify: `packages/client/src/routes/play.$roomCode.tsx`
- [ ] **Step 1: Show scored predictions in scoring/ended**
In the player view, find the scoring act block:
```tsx
{gameState && room.currentAct === "scoring" && (
<Leaderboard entries={gameState.leaderboard} />
)}
```
Add before it:
```tsx
{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && gameState.myPrediction && (
<PredictionsForm
entries={gameState.lineup.entries}
existingPrediction={gameState.myPrediction}
locked={true}
actualResults={gameState.actualResults}
onSubmit={() => {}}
/>
)}
```
- [ ] **Step 2: Commit**
```bash
git add packages/client/src/routes/play.$roomCode.tsx
git commit -m "show scored predictions in player route during scoring/ended"
```
### Task 11: Update display route
**Files:**
- Modify: `packages/client/src/routes/display.$roomCode.tsx`
- [ ] **Step 1: Read current display route**
Read `packages/client/src/routes/display.$roomCode.tsx` to understand current structure.
- [ ] **Step 2: Add actual results summary to display**
When actual results are entered and the act is scoring/ended, show a summary card. The exact placement depends on the current display route structure. Add in the scoring/ended section:
```tsx
{gameState?.actualResults && (
<Card>
<CardHeader>
<CardTitle>Actual Results</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-1 text-sm">
{[
{ label: "Winner", code: gameState.actualResults.winner },
{ label: "2nd", code: gameState.actualResults.second },
{ label: "3rd", code: gameState.actualResults.third },
{ label: "Last", code: gameState.actualResults.last },
].map(({ label, code }) => {
const entry = gameState.lineup.entries.find((e) => e.country.code === code)
return (
<div key={label} className="flex items-center gap-2">
<span className="w-16 text-xs font-medium text-muted-foreground">{label}</span>
<span>{entry ? `${entry.country.flag} ${entry.country.name}` : code}</span>
</div>
)
})}
</CardContent>
</Card>
)}
```
Add this import at the top of the file (these are not currently imported in the display route):
```ts
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
```
- [ ] **Step 3: Commit**
```bash
git add packages/client/src/routes/display.$roomCode.tsx
git commit -m "show actual results summary on display in scoring/ended"
```
### Task 12: Final verification
- [ ] **Step 1: Run all tests**
Run: `bun test`
Expected: All 73 tests pass
- [ ] **Step 2: Build client**
Run: `bun run --filter './packages/client' build`
Expected: Build succeeds with 0 errors
- [ ] **Step 3: Commit any remaining fixes if needed**
File diff suppressed because it is too large Load Diff
@@ -7,7 +7,7 @@
## Goal
A web-based party companion for a group of 5-10 people watching the Eurovision Song Contest together. One host device drives a shared display (TV/projector); all other players join on their phones via a room code. Three acts guide the group through five games across the evening, with all scores feeding into one shared leaderboard.
A web-based party companion for a group of 5-10 people watching the Eurovision Song Contest together. One host device drives a shared display (TV/projector); all other players join on their phones via a room code. Three acts guide the group through four games across the evening, with all scores feeding into one shared leaderboard.
---
@@ -106,14 +106,11 @@ JSON messages with a `type` field discriminator.
| `reconnect` | roomCode, sessionId | Reconnecting |
| `advance_act` | — | Host advances to next act |
| `end_room` | — | Host ends the party |
| `add_dish` | name, correctCountry | Host adds a dish (Act 1) |
| `reveal_dishes` | — | Host reveals dish answers |
| `submit_prediction` | winner, top3[], nulPoints | Player submits predictions |
| `open_jury_vote` | countryCode | Host opens voting for a country |
| `close_jury_vote` | — | Host closes current voting window |
| `submit_jury_vote` | countryCode, rating (1-12) | Player rates an act |
| `tap_bingo_square` | tropeId | Player taps a bingo square |
| `submit_dish_guess` | dishId, guessedCountry | Player guesses a dish's country |
| `start_quiz` | — | Host starts quiz round |
| `next_question` | — | Host advances to next question |
| `buzz_quiz` | — | Player buzzes in |
@@ -134,7 +131,6 @@ JSON messages with a `type` field discriminator.
| `jury_reveal` | "12 points go to..." data | Dramatic reveal on display |
| `bingo_update` | playerId, square tapped | Someone taps a square |
| `bingo_announced` | playerId, displayName | Someone got bingo |
| `dishes_updated` | dish list | Host adds/reveals dishes |
| `quiz_question` | question, options, difficulty | Next quiz question shown |
| `quiz_buzz` | playerId, displayName | Someone buzzed |
| `quiz_result` | playerId, correct, points | Answer judged |
@@ -154,13 +150,6 @@ JSON messages with a `type` field discriminator.
- Scores are hidden until the final leaderboard reveal in Act 3.
- Country selection from the hardcoded ESC 2026 lineup.
### Dish of the Nation (lobby + Act 1)
- Host adds dishes via the Host tab as people arrive (name + correct country). This can start during the lobby phase and continue into Act 1.
- Players see the list of dishes and submit a country guess per dish.
- Host triggers reveal before Act 2 — display shows each dish with the correct country and who guessed right.
- Low-stakes icebreaker.
### Live Jury Voting (Act 2)
- Host taps "Open Voting" after each country's performance.
@@ -189,7 +178,7 @@ JSON messages with a `type` field discriminator.
### Shared Leaderboard
- All five games contribute to one total score per player.
- All four games contribute to one total score per player.
- Leaderboard visible on the display throughout the evening, updated after each scoring event.
- Prediction scores are hidden until the final reveal — the leaderboard shows "??? pts" for predictions until Act 3 ends.
- Final reveal: display shows a dramatic countdown/animation revealing prediction scores and the final standings.
@@ -211,7 +200,6 @@ All values in `packages/server/data/scoring.json`, editable without code changes
"quiz_easy": 5,
"quiz_medium": 10,
"quiz_hard": 15,
"dish_correct": 5
}
```
@@ -279,19 +267,6 @@ PostgreSQL via Drizzle ORM. Lightweight — most queries are "get everything for
- `room_id` (FK → rooms)
- `squares` (jsonb, positionally ordered array of { tropeId, tapped } — index 0-15 maps left-to-right, top-to-bottom on the 4×4 grid)
**dishes**
- `id` (uuid, PK)
- `room_id` (FK → rooms)
- `name` (varchar)
- `correct_country` (varchar)
- `revealed` (boolean)
**dish_guesses**
- `id` (uuid, PK)
- `player_id` (FK → players)
- `dish_id` (FK → dishes)
- `guessed_country` (varchar)
**quiz_rounds**
- `id` (uuid, PK)
- `room_id` (FK → rooms)
@@ -0,0 +1,87 @@
# Prediction Scoring — Design Spec
**Date:** 2026-03-12
**Status:** Approved
---
## Goal
Allow the host to enter actual ESC results, calculate prediction scores for all players, and display them on the leaderboard.
## What Exists
- Players submit predictions (1st, 2nd, 3rd, last place) during lobby/pre-show via `PredictionsForm`
- Predictions lock when host advances to live-event act
- `GameManager` stores predictions in a `Map<string, Prediction>`
- Leaderboard already shows jury (J:) and bingo (B:) points
- DB schema has `actual_winner`, `actual_second`, `actual_third`, `actual_last` columns on `rooms` table (nullable, set when host enters results)
## New Functionality
### 1. Host Enters Actual Results
- New component: `ActualResultsForm` — shown in the Host tab during `scoring` and `ended` acts
- Same country-picker UX as the predictions form (select from lineup, all 4 must be different)
- New WS message: `submit_actual_results` (client → server) with `{ winner, second, third, last }`
- Server stores on the `GameManager` (in-memory) and broadcasts updated game state
- Host can re-submit to correct mistakes (overwrites previous entry)
### 2. Server Scores Predictions
- `GameManager.setActualResults(winner, second, third, last)` — stores the actual results
- `GameManager.getPredictionScore(playerId)` — compares player's prediction to actuals:
- `first` matches `winner`: 25 pts (`scoring.prediction_winner`)
- `second` matches `second`: 10 pts (`scoring.prediction_top3`)
- `third` matches `third`: 10 pts (`scoring.prediction_top3`)
- `last` matches `last`: 15 pts (`scoring.prediction_nul_points`)
- Total possible: 60 pts
- Prediction scores feed into `buildLeaderboard` as a new `predictionPoints` field
### 3. Leaderboard Update
- `LeaderboardEntry` gains `predictionPoints: number`
- Before results are entered: shows `P:?` on the leaderboard
- After results are entered: shows `P:<score>` with actual points
- `totalPoints` includes prediction points (0 if no results entered yet)
### 4. Player View — Results Reveal
- After actual results are entered, each player's `gameState` includes `actualResults: { winner, second, third, last } | null`
- `PredictionsForm` (locked state) gains visual indicators: green checkmark for correct predictions, red X for incorrect
- Players who didn't submit predictions get 0 prediction points
### 5. Display View
- Shows actual results summary when entered
- Shows leaderboard with prediction scores revealed
## WS Messages
**Client → Server:**
| Type | Payload | Guard |
|---|---|---|
| `submit_actual_results` | `winner`, `second`, `third`, `last` (country codes) | Host only, scoring or ended act |
**No new server → client messages** — the existing `game_state` broadcast carries all the data.
## GameState Changes
```ts
// Added to GameState
actualResults: { winner: string; second: string; third: string; last: string } | null
// LeaderboardEntry gains
predictionPoints: number
```
## Scoring Config Values (existing)
```json
{
"prediction_winner": 25,
"prediction_top3": 10,
"prediction_nul_points": 15
}
```
@@ -0,0 +1,308 @@
# Menu Rework — App-Style Navigation
## Goal
Replace the current ad-hoc tab navigation with a native-app-style bottom navigation bar, driven by TanStack Router nested routes. Clean up the host/player split so the host is simply a player with an extra "Host" tab. Add bingo card completion/redraw flow.
## Scope
- In-room views only (`/play/$roomCode`, `/host/$roomCode`)
- Display view (`/display/$roomCode`) unchanged — stays full-screen passive
- Landing page (`/`) unchanged
---
## 1. Route Structure
### Current
```
/play/$roomCode → flat component, all content inline
/host/$roomCode → flat component, all content inline, top tabs (Play | Host)
/display/$roomCode → flat component, passive view
```
### New
```
/play/$roomCode/ → layout route: WebSocket, store, header, bottom nav
/play/$roomCode/game → game content (predictions, jury, quiz)
/play/$roomCode/bingo → bingo card
/play/$roomCode/board → leaderboard + player list
/host/$roomCode/ → layout route: WebSocket, store, header, bottom nav
/host/$roomCode/game → same game content as player
/host/$roomCode/bingo → same bingo card as player
/host/$roomCode/board → same leaderboard as player
/host/$roomCode/host → host-only controls
/display/$roomCode → unchanged
```
- Layout routes handle WebSocket connection, Zustand store hydration, sticky header, and bottom nav rendering.
- Child routes consume room/game state from the Zustand store.
- Default redirect: `/play/$roomCode``/play/$roomCode/game` (same for host).
- Game, Bingo, and Leaderboard tab components are shared between player and host — identical behavior. The host is just a player with an extra tab.
---
## 2. Sticky Header
Replaces the current `RoomHeader` component.
```
┌─────────────────────────────────┐
│ I❤️ESC ABCD 🟢 │
└─────────────────────────────────┘
```
- **Left:** "I❤️ESC" as the app title (text, not emoji — the heart is the Unicode character ❤️)
- **Right:** Room code (monospace, bold) + connection status dot
- Green = `connected`
- Yellow = `connecting`
- Red = `disconnected`
- `position: sticky; top: 0; z-index: 50`
- `padding-top: env(safe-area-inset-top)` for iPhone notch/Dynamic Island
- Act badge removed — the game content makes the current phase obvious
---
## 3. Bottom Navigation Bar
### Layout
```
Player:
┌──────────┬──────────┬──────────────┐
│ Game │ Bingo │ Leaderboard │
└──────────┴──────────┴──────────────┘
Host:
┌────────┬────────┬─────────┬────────┐
│ Game │ Bingo │ Board │ Host │
└────────┴────────┴─────────┴────────┘
```
### Behavior
- `position: fixed; bottom: 0; left: 0; right: 0; z-index: 50`
- `padding-bottom: env(safe-area-inset-bottom)` for iPhone home indicator
- Each tab is a TanStack Router `<Link>` to the nested route
- Active tab: highlighted with primary color
- Inactive tab: muted foreground color
- Icons: iOS-style (SF Symbols aesthetic), rendered as SVG. No emoji.
- Game → gamepad/play icon
- Bingo → grid/squares icon
- Leaderboard/Board → trophy icon
- Host → wrench/settings icon
- Tab label text below each icon
- Main content area needs `padding-bottom` matching the nav bar height to avoid content hiding behind it
### Bingo Tab Availability
- Tab is always visible and always tappable in all acts
- Players can view and familiarize themselves with their bingo card before `live-event`
- Tapping squares to mark them is only enabled during `live-event` act
- Before `live-event`: card is visible but squares are non-interactive (visual only)
---
## 4. Game Tab Content by Act
Identical for player and host. No nested tabs.
| Act | Content |
|-----|---------|
| **Lobby** | Predictions form (editable) |
| **Pre-Show** | Predictions form (editable until locked) |
| **Live Event** | Jury voting (when round is open) or "Waiting for host to open voting..." |
| **Scoring** | Locked predictions (with actual results comparison) + quiz buzzer (when question active) |
| **Ended** | Locked predictions + "Thanks for playing!" |
The nested Jury/Bingo tabs that currently exist inside the Game content during `live-event` are removed — bingo has its own tab, and jury voting is the sole content of the Game tab during live-event.
---
## 5. Bingo Tab Content
### Active Card View
Shows the player's current bingo card. Same `BingoCard` component as today, with one change:
- Before `live-event`: squares are rendered but non-interactive (no `onTap` handler)
- During `live-event`: squares are interactive (tap to mark)
- After `live-event`: card is frozen (same as current behavior)
### Completion Flow (New)
When a player completes a bingo line:
1. **Server detects completion** — on every tap (not untap), the server checks for completed bingo lines. Tapping is tap-only (no toggle/untap) — once a square is marked, it stays marked. When a line is detected, the card is marked as `completed`.
2. **Card stored** — the completed card moves to a `completedBingoCards` array in game state (per player). Contains: `playerId`, `displayName`, `card` (the full card data with marked squares), `completedAt` (ISO 8601 timestamp).
3. **Player sees** — a celebration message ("Bingo!") + "Draw New Card" button.
4. **Redraw** — new WS message `request_new_bingo_card`. Server generates a fresh card with tropes not present on the just-completed card (best effort — if the trope pool is exhausted, duplicates are allowed). The old card is already in `completedBingoCards`.
5. **New card**`myBingoCard` in game state updates to the fresh card. Player can continue playing.
6. **Redraw is only available during `live-event`** — same act gate as tapping squares.
### Scoring Across Multiple Cards
Bingo points accumulate across all cards (completed + active). `getBingoScore()` sums:
- 2 points per tapped square across all completed cards and the active card
- 10 bonus points per completed bingo line (each completed card contributed at least one line; the active card may also have lines)
The `announcedBingo` set is changed to track `playerId:cardIndex` instead of just `playerId`, so multiple bingo announcements per player are possible. The existing `bingoAnnouncedMessage` schema stays unchanged (it carries `playerId` and `displayName`) — the display view shows "Player X got Bingo!" regardless of which card number it is. The `completedBingoCards` array length implicitly tracks the count.
### Data Changes
New Zod schema in `game-types.ts`:
```typescript
export const completedBingoCardSchema = z.object({
playerId: z.string(),
displayName: z.string(),
card: bingoCardSchema,
completedAt: z.string(),
})
```
Added to `gameStateSchema`:
```typescript
completedBingoCards: z.array(completedBingoCardSchema)
```
New WS client message in `ws-messages.ts` (added to `clientMessage` discriminated union):
```typescript
export const requestNewBingoCardMessage = z.object({ type: z.literal("request_new_bingo_card") })
```
Game state changes:
- `myBingoCard` continues to represent the current active card
- `completedBingoCards` is a new array on `GameState` (visible to all — host needs it for verification)
- Display view receives `completedBingoCards` for bingo announcement purposes only (no card detail needed on projector)
---
## 6. Leaderboard Tab Content
Replaces both the current `Leaderboard` and `PlayerList` components as separate UI elements.
- **Lobby** (no scores yet): shows player list with names only — a "who's here" view. The `Leaderboard` component renders a simplified layout: just rank numbers and names, no score columns.
- **All other acts**: full leaderboard table (rank, name, P/J/B/Q breakdown, total points)
- **Scoring explanation**: "How scoring works" box at the bottom (same as current)
The `PlayerList` component is no longer rendered anywhere else. Every player appears in the leaderboard. The leaderboard tab is the single place to see who's in the room.
---
## 7. Host Tab Content
Vertical stack of cards, only visible to the host. This tab contains all host-exclusive controls.
### Always Present
1. **Act Controls** — advance/revert buttons with act-specific labels:
- Lobby → "Start Pre-Show"
- Pre-Show → "Start Live Event"
- Live Event → "Start Scoring"
- Scoring → "End Party"
- Ended → "Back to Scoring" + re-open option
- Revert button available for all acts except lobby
2. **Display View** — explanation of what the display view is ("Project this on a TV for everyone to see") + the display URL (`/display/$roomCode`) + "Copy Link" button
### Conditional (by act)
3. **Jury Host** (live-event) — open/close voting per country (existing `JuryHost` component)
4. **Quiz Host** (scoring) — start question, judge answer, skip (existing `QuizHost` component)
5. **Actual Results Form** (scoring/ended) — enter final placings (existing `ActualResultsForm` component)
6. **Bingo Claims** (new, all acts after live-event starts) — list of completed bingo cards with player name and card preview, for host verification
### Bottom
7. **End Party** button — destructive (red), always available except when already ended. This is the same action as advancing from scoring to ended, but available from any act as a shortcut. The act-specific "End Party" label in Act Controls (scoring → ended) is removed to avoid duplication.
---
## 8. Display View
Unchanged. Full-screen passive view, no bottom nav. Continues to show:
- Lobby: large room code with join instructions
- Pre-Show: prediction submission count
- Live Event: jury display + bingo announcements + leaderboard
- Scoring: quiz display + actual results + leaderboard
- Ended: final results + leaderboard
---
## 9. Components Affected
### New Components
- `BottomNav` — the bottom navigation bar (renders tabs as `<Link>`s, highlights active)
- `RoomLayout` — sticky header + bottom nav + content outlet (used by both layout routes)
- `BingoClaims` — host-only component showing completed bingo cards for verification
### Modified Components
- `RoomHeader` → replaced by new sticky header in `RoomLayout`
- `BingoCard` → add read-only mode (disable tapping before live-event)
- `Leaderboard` → absorb player list display for lobby state
- `PlayerList` → removed from all views (absorbed into leaderboard tab)
### Shared Between Player and Host
- Game tab content (predictions, jury voting, quiz buzzer)
- Bingo tab content (bingo card)
- Leaderboard tab content
### Host-Only
- Host tab content (act controls, display link, jury host, quiz host, actual results, bingo claims)
---
## 10. Server Changes
### Bingo Completion
- `GameManager.tapBingoSquare()` — after marking a square, check for completed lines. If bingo detected:
- Move current card to `completedBingoCards` array
- Award bonus points (already happens)
- Flag card as completed in response
- New method: `GameManager.requestNewBingoCard(playerId)` — generates fresh card, assigns to player
- New WS handler: `request_new_bingo_card` message → calls `requestNewBingoCard`, broadcasts updated state
### Game State Changes
- Add `completedBingoCards` to game state schema (Zod schema + TypeScript type)
- Include in `getGameStateForPlayer` (full card data for host verification) and `getGameStateForDisplay` (for announcements only)
- Update `getBingoScore()` to sum points across all completed cards + active card
- Change `announcedBingo` from `Set<string>` (playerId) to track `playerId:cardIndex` pairs, allowing multiple bingo announcements per player
### WS Handler Changes
- Add `request_new_bingo_card` case to handler, gated to `live-event` act
- Add the message to the `clientMessage` discriminated union in `ws-messages.ts`
- Change `tapBingoSquare` from toggle to tap-only (remove untap behavior)
- Both `tap_bingo_square` and `request_new_bingo_card` responses use the existing pattern: full `game_state` broadcast to all players in the room (consistent with all other state mutations)
### Route Transition Safety
- WebSocket connection and Zustand store are managed in the layout route, not child routes. Navigating between tabs (child routes) does not trigger WS reconnection or store reset.
---
## 11. Migration / URL Compatibility
- Old URLs (`/play/ABCD`, `/host/ABCD`) should redirect to `/play/ABCD/game` and `/host/ABCD/game` respectively
- Implemented via TanStack Router index routes that redirect (e.g., a `/play/$roomCode/` index route with `beforeLoad` that throws `redirect({ to: '/play/$roomCode/game' })`)
---
## 12. Label Naming
- Player bottom nav labels: "Game", "Bingo", "Leaderboard" (3 tabs, enough space for full labels)
- Host bottom nav labels: "Game", "Bingo", "Board", "Host" (4 tabs, "Board" is shortened from "Leaderboard" for space)
- Route paths use short names: `/game`, `/bingo`, `/board`, `/host`
+1
View File
@@ -17,6 +17,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"qrcode.react": "^4.2.0",
"react": "latest",
"react-dom": "latest",
"tailwind-merge": "^3.5.0",
@@ -0,0 +1,159 @@
import { useState } from "react"
import type { Entry, ActualResults } from "@celebrate-esc/shared"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
type SlotKey = "winner" | "second" | "third" | "last"
const SLOTS: { key: SlotKey; label: string }[] = [
{ key: "winner", label: "Winner" },
{ key: "second", label: "2nd Place" },
{ key: "third", label: "3rd Place" },
{ key: "last", label: "Last Place" },
]
function formatEntry(entry: Entry): string {
return `${entry.country.flag} ${entry.artist}${entry.song}`
}
interface ActualResultsFormProps {
entries: Entry[]
existingResults: ActualResults | null
onSubmit: (results: { winner: string; second: string; third: string; last: string }) => void
}
export function ActualResultsForm({ entries, existingResults, onSubmit }: ActualResultsFormProps) {
const [slots, setSlots] = useState<Record<SlotKey, string | null>>(() => {
if (existingResults) {
return {
winner: existingResults.winner,
second: existingResults.second,
third: existingResults.third,
last: existingResults.last,
}
}
return { winner: null, second: null, third: null, last: null }
})
const [pickerForEntry, setPickerForEntry] = useState<string | null>(null)
const assignedCodes = new Set(Object.values(slots).filter(Boolean))
const emptySlots = SLOTS.filter((s) => !slots[s.key])
const allFilled = SLOTS.every((s) => slots[s.key])
function findEntry(code: string): Entry | undefined {
return entries.find((e) => e.country.code === code)
}
function assignToSlot(entryCode: string, slotKey: SlotKey) {
setSlots((prev) => ({ ...prev, [slotKey]: entryCode }))
setPickerForEntry(null)
}
function removeFromSlot(slotKey: SlotKey) {
setSlots((prev) => ({ ...prev, [slotKey]: null }))
}
return (
<Card>
<CardHeader>
<CardTitle>Actual Results</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
{SLOTS.map((slot) => {
const code = slots[slot.key]
const entry = code ? findEntry(code) : null
return (
<div
key={slot.key}
className={`flex items-center justify-between rounded-md border p-2 ${
code ? "border-primary/30 bg-primary/5" : "border-dashed"
}`}
>
<div className="flex items-center gap-2">
<span className="w-20 text-xs font-medium text-muted-foreground">{slot.label}</span>
{entry ? (
<span className="text-sm">{formatEntry(entry)}</span>
) : (
<span className="text-sm text-muted-foreground">Tap an entry below</span>
)}
</div>
{code && (
<button
type="button"
onClick={() => removeFromSlot(slot.key)}
className="text-muted-foreground hover:text-foreground"
aria-label={`Remove ${slot.label}`}
>
</button>
)}
</div>
)
})}
</div>
{allFilled && (
<Button
onClick={() =>
onSubmit({
winner: slots.winner!,
second: slots.second!,
third: slots.third!,
last: slots.last!,
})
}
>
{existingResults ? "Update Results" : "Submit Results"}
</Button>
)}
<div className="flex flex-col gap-1">
<h4 className="text-sm font-medium text-muted-foreground">Entries</h4>
{entries.map((entry) => {
const isAssigned = assignedCodes.has(entry.country.code)
const isPickerOpen = pickerForEntry === entry.country.code
return (
<div key={entry.country.code}>
<button
type="button"
disabled={isAssigned}
onClick={() => {
if (emptySlots.length === 1) {
assignToSlot(entry.country.code, emptySlots[0]!.key)
} else {
setPickerForEntry(isPickerOpen ? null : entry.country.code)
}
}}
className={`w-full rounded-md border px-3 py-2 text-left text-sm transition-colors ${
isAssigned
? "border-transparent bg-muted/50 text-muted-foreground line-through opacity-50"
: isPickerOpen
? "border-primary bg-primary/5"
: "hover:bg-muted"
}`}
>
{formatEntry(entry)}
</button>
{isPickerOpen && !isAssigned && (
<div className="mt-1 ml-4 flex gap-1">
{emptySlots.map((slot) => (
<button
type="button"
key={slot.key}
onClick={() => assignToSlot(entry.country.code, slot.key)}
className="rounded-md border px-2 py-1 text-xs hover:bg-primary hover:text-primary-foreground"
>
{slot.label}
</button>
))}
</div>
)}
</div>
)
})}
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,54 @@
import type { BingoCard as BingoCardType } from "@celebrate-esc/shared"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
interface BingoCardProps {
card: BingoCardType
onTap: (tropeId: string) => void
readonly?: boolean
onRequestNewCard?: () => void
}
export function BingoCard({ card, onTap, readonly, onRequestNewCard }: BingoCardProps) {
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle>Bingo</CardTitle>
{card.hasBingo && (
<Badge variant="default" className="bg-green-600">
BINGO!
</Badge>
)}
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="grid grid-cols-4 gap-1.5">
{card.squares.map((square) => (
<button
key={square.tropeId}
type="button"
onClick={() => !readonly && onTap(square.tropeId)}
disabled={readonly}
className={`flex aspect-square items-center justify-center rounded-md border p-1.5 text-center text-sm leading-snug transition-colors ${
square.tapped
? "border-primary bg-primary/20 font-medium text-primary"
: readonly
? "border-muted text-muted-foreground"
: "border-muted hover:bg-muted/50"
} ${readonly ? "cursor-default" : "cursor-pointer"}`}
>
{square.label}
</button>
))}
</div>
{card.hasBingo && onRequestNewCard && (
<Button onClick={onRequestNewCard} variant="outline" className="w-full">
Draw New Card
</Button>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,44 @@
import type { CompletedBingoCard } from "@celebrate-esc/shared"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface BingoClaimsProps {
completedCards: CompletedBingoCard[]
}
export function BingoClaims({ completedCards }: BingoClaimsProps) {
if (completedCards.length === 0) return null
return (
<Card>
<CardHeader>
<CardTitle>Bingo Claims ({completedCards.length})</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{completedCards.map((claim, i) => (
<div key={`${claim.playerId}-${i}`} className="flex flex-col gap-1.5 border-b pb-3 last:border-0 last:pb-0">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{claim.displayName}</span>
<span className="text-xs text-muted-foreground">
{new Date(claim.completedAt).toLocaleTimeString()}
</span>
</div>
<div className="grid grid-cols-4 gap-0.5">
{claim.card.squares.map((square) => (
<div
key={square.tropeId}
className={`rounded px-1 py-0.5 text-center text-xs ${
square.tapped
? "bg-primary/20 text-primary"
: "bg-muted/50 text-muted-foreground"
}`}
>
{square.label}
</div>
))}
</div>
</div>
))}
</CardContent>
</Card>
)
}
@@ -0,0 +1,18 @@
interface BingoDisplayProps {
announcements: { playerId: string; displayName: string }[]
}
export function BingoDisplay({ announcements }: BingoDisplayProps) {
if (announcements.length === 0) return null
return (
<div className="flex flex-col items-center gap-2">
<p className="text-sm font-medium text-muted-foreground">Bingo Winners</p>
{announcements.map((a, i) => (
<p key={`${a.playerId}-${i}`} className="text-lg font-bold text-green-600">
{a.displayName} got BINGO!
</p>
))}
</div>
)
}
@@ -0,0 +1,31 @@
import type { ClientMessage, GameState, Act } from "@celebrate-esc/shared"
import { BingoCard } from "@/components/bingo-card"
interface BingoTabProps {
currentAct: Act
gameState: GameState
send: (message: ClientMessage) => void
}
export function BingoTab({ currentAct, gameState, send }: BingoTabProps) {
const isLiveEvent = currentAct === "live-event"
if (!gameState.myBingoCard) {
return (
<div className="flex flex-col items-center gap-4 p-4 py-8">
<p className="text-muted-foreground">No bingo card yet. Cards are dealt when you join.</p>
</div>
)
}
return (
<div className="flex flex-col gap-4 p-4">
<BingoCard
card={gameState.myBingoCard}
onTap={(tropeId) => send({ type: "tap_bingo_square", tropeId })}
readonly={!isLiveEvent}
onRequestNewCard={isLiveEvent ? () => send({ type: "request_new_bingo_card" }) : undefined}
/>
</div>
)
}
@@ -0,0 +1,19 @@
import type { GameState, Act } from "@celebrate-esc/shared"
import { Leaderboard } from "@/components/leaderboard"
interface BoardTabProps {
currentAct: Act
gameState: GameState
}
export function BoardTab({ currentAct, gameState }: BoardTabProps) {
return (
<div className="flex flex-col gap-4 p-4">
<Leaderboard
entries={gameState.leaderboard}
resultsEntered={!!gameState.actualResults}
lobbyMode={currentAct === "lobby"}
/>
</div>
)
}
@@ -0,0 +1,96 @@
import { Link, useMatchRoute } from "@tanstack/react-router"
interface BottomNavProps {
basePath: "/play/$roomCode" | "/host/$roomCode"
roomCode: string
isHost: boolean
}
function GameIcon({ active }: { active: boolean }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
<circle cx="12" cy="12" r="10" />
<polygon points="10,8 16,12 10,16" />
</svg>
)
}
function BingoIcon({ active }: { active: boolean }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
)
}
function TrophyIcon({ active }: { active: boolean }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" />
<path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" />
<path d="M4 22h16" />
<path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20 7 22" />
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20 17 22" />
<path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" />
</svg>
)
}
function WrenchIcon({ active }: { active: boolean }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
)
}
interface TabConfig {
to: string
label: string
icon: (props: { active: boolean }) => React.ReactNode
}
export function BottomNav({ basePath, roomCode, isHost }: BottomNavProps) {
const matchRoute = useMatchRoute()
const tabs: TabConfig[] = [
{ to: `${basePath}/game`, label: "Game", icon: GameIcon },
{ to: `${basePath}/bingo`, label: "Bingo", icon: BingoIcon },
{ to: `${basePath}/board`, label: isHost ? "Board" : "Leaderboard", icon: TrophyIcon },
]
if (isHost) {
tabs.push({ to: `${basePath}/host`, label: "Host", icon: WrenchIcon })
}
return (
<nav
className="fixed bottom-0 left-0 right-0 z-50 border-t bg-background"
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
>
<div className="flex">
{tabs.map((tab) => {
const active = !!matchRoute({ to: tab.to, params: { roomCode } })
return (
<Link
key={tab.to}
to={tab.to}
params={{ roomCode }}
className={`flex flex-1 flex-col items-center gap-0.5 py-2 text-xs transition-colors ${
active
? "text-primary"
: "text-muted-foreground"
}`}
>
<tab.icon active={active} />
<span>{tab.label}</span>
</Link>
)
})}
</div>
</nav>
)
}
@@ -0,0 +1,85 @@
import type { ClientMessage, GameState, Act } from "@celebrate-esc/shared"
import { PredictionsForm } from "@/components/predictions-form"
import { JuryVoting } from "@/components/jury-voting"
import { QuizBuzzer } from "@/components/quiz-buzzer"
interface GameTabProps {
currentAct: Act
gameState: GameState
send: (message: ClientMessage) => void
}
export function GameTab({ currentAct, gameState, send }: GameTabProps) {
if (currentAct === "lobby" || currentAct === "pre-show") {
return (
<div className="flex flex-col gap-4 p-4">
<PredictionsForm
entries={gameState.lineup.entries}
existingPrediction={gameState.myPrediction}
locked={gameState.predictionsLocked}
onSubmit={(prediction) => send({ type: "submit_prediction", ...prediction })}
/>
</div>
)
}
if (currentAct === "live-event") {
return (
<div className="flex flex-col gap-4 p-4">
{gameState.currentJuryRound ? (
<JuryVoting
round={gameState.currentJuryRound}
myVote={gameState.myJuryVote}
onVote={(rating) => send({ type: "submit_jury_vote", rating })}
/>
) : (
<div className="py-8 text-center text-muted-foreground">
Waiting for host to open voting...
</div>
)}
</div>
)
}
if (currentAct === "scoring") {
return (
<div className="flex flex-col gap-4 p-4">
{gameState.myPrediction && (
<PredictionsForm
entries={gameState.lineup.entries}
existingPrediction={gameState.myPrediction}
locked={true}
actualResults={gameState.actualResults}
onSubmit={() => {}}
/>
)}
{gameState.currentQuizQuestion && (
<QuizBuzzer
question={gameState.currentQuizQuestion}
buzzStatus={gameState.myQuizBuzzStatus}
onBuzz={() => send({ type: "buzz" })}
/>
)}
</div>
)
}
if (currentAct === "ended") {
return (
<div className="flex flex-col items-center gap-4 p-4 py-8">
<p className="text-lg text-muted-foreground">The party has ended. Thanks for playing!</p>
{gameState.myPrediction && (
<PredictionsForm
entries={gameState.lineup.entries}
existingPrediction={gameState.myPrediction}
locked={true}
actualResults={gameState.actualResults}
onSubmit={() => {}}
/>
)}
</div>
)
}
return null
}
+141
View File
@@ -0,0 +1,141 @@
import { useState } from "react"
import type { ClientMessage, GameState, Act } from "@celebrate-esc/shared"
import { JuryHost } from "@/components/jury-host"
import { QuizHost } from "@/components/quiz-host"
import { ActualResultsForm } from "@/components/actual-results-form"
import { BingoClaims } from "@/components/bingo-claims"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
const nextActLabels: Partial<Record<Act, string>> = {
lobby: "Start Pre-Show",
"pre-show": "Start Live Event",
"live-event": "Start Scoring",
}
const prevActLabels: Partial<Record<Act, string>> = {
"pre-show": "Back to Lobby",
"live-event": "Back to Pre-Show",
scoring: "Back to Live Event",
ended: "Back to Scoring",
}
interface HostTabProps {
roomCode: string
currentAct: Act
gameState: GameState
send: (message: ClientMessage) => void
}
export function HostTab({ roomCode, currentAct, gameState, send }: HostTabProps) {
const [copied, setCopied] = useState(false)
const base = import.meta.env.BASE_URL.replace(/\/$/, "")
const displayUrl = `${window.location.origin}${base}/display/${roomCode}`
function copyDisplayUrl() {
navigator.clipboard.writeText(displayUrl).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
return (
<div className="flex flex-col gap-4 p-4">
{/* Act Controls */}
<Card>
<CardHeader>
<CardTitle>Room Controls</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{currentAct !== "ended" && (
<div className="flex gap-2">
{currentAct !== "lobby" && (
<Button
variant="outline"
onClick={() => send({ type: "revert_act" })}
className="flex-1"
>
{prevActLabels[currentAct] ?? "Back"}
</Button>
)}
{nextActLabels[currentAct] && (
<Button onClick={() => send({ type: "advance_act" })} className="flex-1">
{nextActLabels[currentAct]}
</Button>
)}
</div>
)}
{currentAct === "ended" && (
<Button variant="outline" onClick={() => send({ type: "revert_act" })}>
{prevActLabels[currentAct] ?? "Back"}
</Button>
)}
</CardContent>
</Card>
{/* Display View Link */}
<Card>
<CardHeader>
<CardTitle>Display View</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">
Project this on a TV for everyone to see.
</p>
<div className="flex items-center gap-2">
<code className="flex-1 truncate rounded bg-muted px-2 py-1 text-xs">{displayUrl}</code>
<Button variant="outline" size="sm" onClick={copyDisplayUrl}>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
</CardContent>
</Card>
{/* Jury Host (live-event) */}
{currentAct === "live-event" && (
<JuryHost
entries={gameState.lineup.entries}
currentRound={gameState.currentJuryRound}
results={gameState.juryResults}
onOpenVote={(countryCode) => send({ type: "open_jury_vote", countryCode })}
onCloseVote={() => send({ type: "close_jury_vote" })}
/>
)}
{/* Quiz Host (scoring) */}
{currentAct === "scoring" && (
<QuizHost
question={gameState.currentQuizQuestion}
onStartQuestion={() => send({ type: "start_quiz_question" })}
onJudge={(correct) => send({ type: "judge_quiz_answer", correct })}
onSkip={() => send({ type: "skip_quiz_question" })}
/>
)}
{/* Actual Results Form (scoring/ended) */}
{(currentAct === "scoring" || currentAct === "ended") && (
<ActualResultsForm
entries={gameState.lineup.entries}
existingResults={gameState.actualResults}
onSubmit={(results) => send({ type: "submit_actual_results", ...results })}
/>
)}
{/* Bingo Claims */}
{gameState.completedBingoCards.length > 0 && (
<BingoClaims completedCards={gameState.completedBingoCards} />
)}
{/* End Party (destructive) */}
{currentAct !== "ended" && (
<Button
variant="destructive"
onClick={() => send({ type: "end_room" })}
className="w-full"
>
End Party
</Button>
)}
</div>
)
}
@@ -0,0 +1,73 @@
import type { JuryRound, JuryResult } from "@celebrate-esc/shared"
interface JuryDisplayProps {
currentRound: JuryRound | null
results: JuryResult[]
}
export function JuryDisplay({ currentRound, results }: JuryDisplayProps) {
if (currentRound) {
return (
<div className="flex flex-col items-center gap-4">
<p className="text-lg text-muted-foreground">Now voting</p>
<div className="text-center">
<span className="text-6xl">{currentRound.countryFlag}</span>
<p className="mt-2 text-3xl font-bold">{currentRound.countryName}</p>
</div>
<p className="text-muted-foreground">Rate 1-12 on your phone</p>
</div>
)
}
if (results.length > 0) {
const lastResult = results[results.length - 1]!
const topRated = [...results].sort((a, b) => b.averageRating - a.averageRating)[0]!
return (
<div className="flex flex-col items-center gap-6">
<div className="text-center">
<p className="text-sm font-medium text-muted-foreground">Latest result</p>
<span className="text-4xl">{lastResult.countryFlag}</span>
<p className="mt-2 text-2xl font-bold">{lastResult.countryName}</p>
<p className="text-4xl font-bold text-primary">{lastResult.averageRating}</p>
<p className="text-sm text-muted-foreground">{lastResult.totalVotes} votes</p>
</div>
{results.length > 1 && (
<div className="rounded-lg border-2 border-primary/30 bg-primary/5 p-6 text-center">
<p className="text-lg font-medium text-muted-foreground">
And 12 points go to...
</p>
<p className="mt-2 text-3xl font-bold">
{topRated.countryFlag} {topRated.countryName}
</p>
<p className="text-2xl font-bold text-primary">
{topRated.averageRating} avg
</p>
</div>
)}
<div className="w-full max-w-md">
<p className="mb-2 text-sm font-medium text-muted-foreground">Rankings</p>
{[...results]
.sort((a, b) => b.averageRating - a.averageRating)
.map((r, i) => (
<div key={r.countryCode} className="flex items-center justify-between border-b py-1 text-sm">
<span>
<span className="mr-2 font-bold text-muted-foreground">{i + 1}</span>
{r.countryFlag} {r.countryName}
</span>
<span className="font-medium">{r.averageRating}</span>
</div>
))}
</div>
</div>
)
}
return (
<div className="flex flex-col items-center gap-4 py-12">
<p className="text-2xl text-muted-foreground">Live Event</p>
<p className="text-muted-foreground">Waiting for host to open voting...</p>
</div>
)
}
@@ -0,0 +1,76 @@
import type { Entry, JuryRound, JuryResult } from "@celebrate-esc/shared"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface JuryHostProps {
entries: Entry[]
currentRound: JuryRound | null
results: JuryResult[]
onOpenVote: (countryCode: string) => void
onCloseVote: () => void
}
export function JuryHost({ entries, currentRound, results, onOpenVote, onCloseVote }: JuryHostProps) {
const votedCountries = new Set(results.map((r) => r.countryCode))
return (
<Card>
<CardHeader>
<CardTitle>Jury Voting</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{results.length > 0 && (
<div className="mb-2">
<p className="mb-1 text-sm font-medium text-muted-foreground">
Completed ({results.length})
</p>
{results.map((r) => (
<div key={r.countryCode} className="flex items-center justify-between text-sm">
<span>{r.countryFlag} {r.countryName}</span>
<span className="text-muted-foreground">avg {r.averageRating} ({r.totalVotes} votes)</span>
</div>
))}
</div>
)}
<div className="flex flex-col gap-1">
{entries.map((entry) => {
const voted = votedCountries.has(entry.country.code)
const isVoting = currentRound?.countryCode === entry.country.code
return (
<div
key={entry.country.code}
className={`flex items-center justify-between rounded-md border px-3 py-2 text-sm ${
voted
? "border-transparent bg-muted/50 text-muted-foreground line-through opacity-50"
: isVoting
? "border-primary bg-primary/5"
: ""
}`}
>
<span>{entry.country.flag} {entry.artist} {entry.song}</span>
{!voted && !currentRound && (
<Button
size="sm"
variant="outline"
onClick={() => onOpenVote(entry.country.code)}
>
Open
</Button>
)}
{isVoting && (
<Button
size="sm"
variant="destructive"
onClick={onCloseVote}
>
Close
</Button>
)}
</div>
)
})}
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,57 @@
import { useState } from "react"
import type { JuryRound } from "@celebrate-esc/shared"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface JuryVotingProps {
round: JuryRound
myVote: number | null
onVote: (rating: number) => void
}
export function JuryVoting({ round, myVote, onVote }: JuryVotingProps) {
const [selectedRating, setSelectedRating] = useState<number | null>(myVote)
return (
<Card>
<CardHeader>
<CardTitle className="text-center">
<span className="text-2xl">{round.countryFlag}</span>{" "}
{round.countryName}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<p className="text-center text-sm text-muted-foreground">
Rate this performance (1-12)
</p>
<div className="grid grid-cols-4 gap-2">
{Array.from({ length: 12 }, (_, i) => i + 1).map((rating) => (
<Button
key={rating}
variant={selectedRating === rating ? "default" : "outline"}
size="lg"
onClick={() => setSelectedRating(rating)}
className="text-lg font-bold"
>
{rating}
</Button>
))}
</div>
<Button
onClick={() => {
if (selectedRating) onVote(selectedRating)
}}
disabled={!selectedRating}
className="w-full"
>
{myVote ? "Update Vote" : "Submit Vote"}
</Button>
{myVote && (
<p className="text-center text-sm text-muted-foreground">
Your vote: {myVote}
</p>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,54 @@
import type { LeaderboardEntry } from "@celebrate-esc/shared"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface LeaderboardProps {
entries: LeaderboardEntry[]
resultsEntered?: boolean
lobbyMode?: boolean
}
export function Leaderboard({ entries, resultsEntered, lobbyMode }: LeaderboardProps) {
if (entries.length === 0) return null
return (
<Card>
<CardHeader>
<CardTitle>{lobbyMode ? `Players (${entries.length})` : "Leaderboard"}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
{entries.map((entry, i) => (
<div key={entry.playerId} className="flex items-center justify-between border-b py-1.5 last:border-0">
<div className="flex items-center gap-2">
<span className="w-6 text-center text-sm font-bold text-muted-foreground">
{i + 1}
</span>
<span className="text-sm font-medium">{entry.displayName}</span>
</div>
{!lobbyMode && (
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span title="Prediction points">P:{resultsEntered ? entry.predictionPoints : "?"}</span>
<span title="Jury points">J:{entry.juryPoints}</span>
<span title="Bingo points">B:{entry.bingoPoints}</span>
<span title="Quiz points">Q:{entry.quizPoints}</span>
<span className="text-sm font-bold text-foreground">{entry.totalPoints}</span>
</div>
)}
</div>
))}
</div>
{!lobbyMode && (
<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground">
<p className="mb-1 font-medium">How scoring works</p>
<ul className="flex flex-col gap-0.5">
<li><strong>P</strong> = Prediction points 25 for correct winner, 10 each for 2nd/3rd, 15 for last place</li>
<li><strong>J</strong> = Jury points rate each act 1-12, earn up to 5 pts per round for matching the group consensus</li>
<li><strong>B</strong> = Bingo points 2 pts per tapped trope + 10 bonus for a full bingo line</li>
<li><strong>Q</strong> = Quiz points 5 easy, 10 medium, 15 hard</li>
</ul>
</div>
)}
</CardContent>
</Card>
)
}
@@ -17,7 +17,7 @@ export function PlayerList({ players, mySessionId, predictionSubmitted }: Player
<span
className={`h-2 w-2 rounded-full ${player.connected ? "bg-green-500" : "bg-muted"}`}
/>
<span className={player.sessionId === mySessionId ? "font-bold" : ""}>
<span className={player.sessionId === mySessionId ? "font-bold underline" : ""}>
{player.displayName}
</span>
{player.isHost && (
@@ -25,9 +25,6 @@ export function PlayerList({ players, mySessionId, predictionSubmitted }: Player
Host
</Badge>
)}
{player.sessionId === mySessionId && (
<span className="text-xs text-muted-foreground">(you)</span>
)}
{predictionSubmitted?.[player.id] && (
<span className="text-green-600" title="Prediction submitted"></span>
)}
@@ -1,5 +1,5 @@
import { useState } from "react"
import type { Entry, Prediction } from "@celebrate-esc/shared"
import type { Entry, Prediction, ActualResults } from "@celebrate-esc/shared"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
@@ -20,10 +20,11 @@ interface PredictionsFormProps {
entries: Entry[]
existingPrediction: Prediction | null
locked: boolean
actualResults?: ActualResults | null
onSubmit: (prediction: { first: string; second: string; third: string; last: string }) => void
}
export function PredictionsForm({ entries, existingPrediction, locked, onSubmit }: PredictionsFormProps) {
export function PredictionsForm({ entries, existingPrediction, locked, actualResults, onSubmit }: PredictionsFormProps) {
const [slots, setSlots] = useState<Record<SlotKey, string | null>>(() => {
if (existingPrediction) {
return {
@@ -67,15 +68,26 @@ export function PredictionsForm({ entries, existingPrediction, locked, onSubmit
return (
<Card>
<CardHeader>
<CardTitle>Your Predictions (locked)</CardTitle>
<CardTitle>Your Predictions {actualResults ? "(scored)" : "(locked)"}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{SLOTS.map((slot) => {
const entry = findEntry(existingPrediction[slot.key])
const isCorrect = actualResults
? slot.key === "first" ? existingPrediction.first === actualResults.winner
: slot.key === "second" ? existingPrediction.second === actualResults.second
: slot.key === "third" ? existingPrediction.third === actualResults.third
: existingPrediction.last === actualResults.last
: null
return (
<div key={slot.key} className="flex items-center gap-2 rounded-md border p-2">
<span className="w-20 text-xs font-medium text-muted-foreground">{slot.label}</span>
<span className="text-sm">{entry ? formatEntry(entry) : "—"}</span>
{isCorrect !== null && (
<span className={isCorrect ? "ml-auto text-green-600" : "ml-auto text-red-500"}>
{isCorrect ? "✓" : "✗"}
</span>
)}
</div>
)
})}
@@ -0,0 +1,73 @@
import type { QuizQuestion } from "@celebrate-esc/shared"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface QuizBuzzerProps {
question: QuizQuestion
buzzStatus: "can_buzz" | "already_buzzed" | "excluded" | "waiting" | null
onBuzz: () => void
}
const difficultyColors: Record<string, string> = {
easy: "text-green-600",
medium: "text-yellow-600",
hard: "text-red-600",
}
export function QuizBuzzer({ question, buzzStatus, onBuzz }: QuizBuzzerProps) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Quiz Question {question.index + 1}/{question.total}</span>
<span className={`text-sm font-normal ${difficultyColors[question.difficulty] ?? ""}`}>
{question.difficulty}
</span>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col items-center gap-4">
{question.status === "buzzing" && buzzStatus === "can_buzz" && (
<Button
size="lg"
className="h-24 w-full text-2xl"
onClick={onBuzz}
>
BUZZ!
</Button>
)}
{question.status === "buzzing" && buzzStatus === "excluded" && (
<p className="text-muted-foreground">You are excluded from this question.</p>
)}
{question.status === "judging" && buzzStatus === "already_buzzed" && (
<p className="text-lg font-semibold">You buzzed! Waiting for the host to judge...</p>
)}
{buzzStatus === "waiting" && (
<p className="text-muted-foreground">
{question.buzzerName} buzzed in! Waiting for judgment...
</p>
)}
{question.status === "resolved" && question.wasCorrect && (
<p className="text-lg font-semibold text-green-600">
{question.buzzerName} answered correctly!
</p>
)}
{question.status === "resolved" && question.wasCorrect === null && (
<p className="text-muted-foreground">
Question skipped.
</p>
)}
{question.status === "resolved" && question.wasCorrect === false && (
<p className="text-muted-foreground">
Question resolved no one answered correctly.
</p>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,50 @@
import type { QuizQuestion } from "@celebrate-esc/shared"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface QuizDisplayProps {
question: QuizQuestion
}
const difficultyColors: Record<string, string> = {
easy: "text-green-600",
medium: "text-yellow-600",
hard: "text-red-600",
}
export function QuizDisplay({ question }: QuizDisplayProps) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Quiz Question {question.index + 1}/{question.total}</span>
<span className={`${difficultyColors[question.difficulty] ?? ""}`}>
{question.difficulty}
</span>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-6">
<p className="text-center text-2xl font-semibold">{question.text}</p>
{question.status === "buzzing" && (
<p className="text-center text-xl text-muted-foreground">
Buzz in to answer!
</p>
)}
{question.status === "judging" && question.buzzerName && (
<p className="text-center text-xl font-semibold">
{question.buzzerName} buzzed in!
</p>
)}
{question.status === "resolved" && (
<p className="text-center text-xl">
{question.wasCorrect
? `${question.buzzerName} got it right!`
: "✗ No one got it right."}
</p>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,100 @@
import type { QuizQuestion } from "@celebrate-esc/shared"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface QuizHostProps {
question: QuizQuestion | null
onStartQuestion: () => void
onJudge: (correct: boolean) => void
onSkip: () => void
}
const difficultyColors: Record<string, string> = {
easy: "text-green-600",
medium: "text-yellow-600",
hard: "text-red-600",
}
export function QuizHost({ question, onStartQuestion, onJudge, onSkip }: QuizHostProps) {
if (!question) {
return (
<Card>
<CardHeader>
<CardTitle>Quiz</CardTitle>
</CardHeader>
<CardContent>
<Button onClick={onStartQuestion} className="w-full">
Start Next Question
</Button>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Question {question.index + 1}/{question.total}</span>
<span className={`text-sm font-normal ${difficultyColors[question.difficulty] ?? ""}`}>
{question.difficulty}
</span>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="rounded-md bg-muted p-3">
<p className="font-medium">{question.text}</p>
</div>
<div className="rounded-md border border-dashed p-3">
<p className="text-sm text-muted-foreground">Answer:</p>
<p className="font-medium">{question.answer}</p>
</div>
{question.status === "buzzing" && (
<div className="flex flex-col gap-2">
<p className="text-center text-muted-foreground">Waiting for someone to buzz...</p>
<Button variant="outline" onClick={onSkip} className="w-full">
Skip Question
</Button>
</div>
)}
{question.status === "judging" && question.buzzerName && (
<div className="flex flex-col gap-2">
<p className="text-center font-semibold">
{question.buzzerName} buzzed in!
</p>
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1 border-red-300 text-red-600 hover:bg-red-50"
onClick={() => onJudge(false)}
>
Incorrect
</Button>
<Button
className="flex-1"
onClick={() => onJudge(true)}
>
Correct
</Button>
</div>
</div>
)}
{question.status === "resolved" && (
<div className="flex flex-col gap-2">
<p className="text-center text-muted-foreground">
{question.wasCorrect
? `${question.buzzerName} answered correctly!`
: "No one answered correctly."}
</p>
<Button onClick={onStartQuestion} className="w-full">
Next Question
</Button>
</div>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,32 @@
import { Outlet } from "@tanstack/react-router"
interface RoomLayoutProps {
roomCode: string
connectionStatus: "disconnected" | "connecting" | "connected"
}
export function RoomLayout({ roomCode, connectionStatus }: RoomLayoutProps) {
return (
<div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-50 flex items-center justify-between border-b bg-background px-4 py-3" style={{ paddingTop: "max(0.75rem, env(safe-area-inset-top))" }}>
<span className="text-lg font-bold" style={{ backgroundImage: "linear-gradient(90deg, #e40303, #ff8c00, #ffed00, #008026, #004dff, #750787)", WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent", backgroundClip: "text" }}>WeEurovision</span>
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-bold tracking-widest">{roomCode}</span>
<span
className={`h-2 w-2 rounded-full ${
connectionStatus === "connected"
? "bg-green-500"
: connectionStatus === "connecting"
? "bg-yellow-500"
: "bg-red-500"
}`}
title={connectionStatus}
/>
</div>
</header>
<main className="flex-1 pb-20">
<Outlet />
</main>
</div>
)
}
+11 -1
View File
@@ -30,6 +30,7 @@ export function useWebSocket(roomCode: string) {
reset,
setGameState,
lockPredictions,
setSend,
} = useRoomStore()
const send = useCallback((message: ClientMessage) => {
@@ -38,6 +39,10 @@ export function useWebSocket(roomCode: string) {
}
}, [])
useEffect(() => {
setSend(send)
}, [send, setSend])
useEffect(() => {
const stored = getStoredSession()
const sessionId = stored?.roomCode === roomCode ? stored.sessionId : null
@@ -91,6 +96,11 @@ export function useWebSocket(roomCode: string) {
case "predictions_locked":
lockPredictions()
break
case "jury_vote_opened":
case "jury_vote_closed":
case "bingo_announced":
// State updates arrive via game_state; these are supplementary signals
break
case "error":
console.error("Server error:", msg.message)
break
@@ -105,7 +115,7 @@ export function useWebSocket(roomCode: string) {
ws.close()
reset()
}
}, [roomCode, setRoom, setMySessionId, setConnectionStatus, updatePlayerConnected, addPlayer, setAct, reset, setGameState, lockPredictions])
}, [roomCode, setRoom, setMySessionId, setConnectionStatus, updatePlayerConnected, addPlayer, setAct, reset, setGameState, lockPredictions, setSend])
return { send }
}
+225 -12
View File
@@ -13,6 +13,15 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as PlayRoomCodeRouteImport } from './routes/play.$roomCode'
import { Route as HostRoomCodeRouteImport } from './routes/host.$roomCode'
import { Route as DisplayRoomCodeRouteImport } from './routes/display.$roomCode'
import { Route as PlayRoomCodeIndexRouteImport } from './routes/play.$roomCode.index'
import { Route as HostRoomCodeIndexRouteImport } from './routes/host.$roomCode.index'
import { Route as PlayRoomCodeGameRouteImport } from './routes/play.$roomCode.game'
import { Route as PlayRoomCodeBoardRouteImport } from './routes/play.$roomCode.board'
import { Route as PlayRoomCodeBingoRouteImport } from './routes/play.$roomCode.bingo'
import { Route as HostRoomCodeHostRouteImport } from './routes/host.$roomCode.host'
import { Route as HostRoomCodeGameRouteImport } from './routes/host.$roomCode.game'
import { Route as HostRoomCodeBoardRouteImport } from './routes/host.$roomCode.board'
import { Route as HostRoomCodeBingoRouteImport } from './routes/host.$roomCode.bingo'
const IndexRoute = IndexRouteImport.update({
id: '/',
@@ -34,44 +43,147 @@ const DisplayRoomCodeRoute = DisplayRoomCodeRouteImport.update({
path: '/display/$roomCode',
getParentRoute: () => rootRouteImport,
} as any)
const PlayRoomCodeIndexRoute = PlayRoomCodeIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => PlayRoomCodeRoute,
} as any)
const HostRoomCodeIndexRoute = HostRoomCodeIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => HostRoomCodeRoute,
} as any)
const PlayRoomCodeGameRoute = PlayRoomCodeGameRouteImport.update({
id: '/game',
path: '/game',
getParentRoute: () => PlayRoomCodeRoute,
} as any)
const PlayRoomCodeBoardRoute = PlayRoomCodeBoardRouteImport.update({
id: '/board',
path: '/board',
getParentRoute: () => PlayRoomCodeRoute,
} as any)
const PlayRoomCodeBingoRoute = PlayRoomCodeBingoRouteImport.update({
id: '/bingo',
path: '/bingo',
getParentRoute: () => PlayRoomCodeRoute,
} as any)
const HostRoomCodeHostRoute = HostRoomCodeHostRouteImport.update({
id: '/host',
path: '/host',
getParentRoute: () => HostRoomCodeRoute,
} as any)
const HostRoomCodeGameRoute = HostRoomCodeGameRouteImport.update({
id: '/game',
path: '/game',
getParentRoute: () => HostRoomCodeRoute,
} as any)
const HostRoomCodeBoardRoute = HostRoomCodeBoardRouteImport.update({
id: '/board',
path: '/board',
getParentRoute: () => HostRoomCodeRoute,
} as any)
const HostRoomCodeBingoRoute = HostRoomCodeBingoRouteImport.update({
id: '/bingo',
path: '/bingo',
getParentRoute: () => HostRoomCodeRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/display/$roomCode': typeof DisplayRoomCodeRoute
'/host/$roomCode': typeof HostRoomCodeRoute
'/play/$roomCode': typeof PlayRoomCodeRoute
'/host/$roomCode': typeof HostRoomCodeRouteWithChildren
'/play/$roomCode': typeof PlayRoomCodeRouteWithChildren
'/host/$roomCode/bingo': typeof HostRoomCodeBingoRoute
'/host/$roomCode/board': typeof HostRoomCodeBoardRoute
'/host/$roomCode/game': typeof HostRoomCodeGameRoute
'/host/$roomCode/host': typeof HostRoomCodeHostRoute
'/play/$roomCode/bingo': typeof PlayRoomCodeBingoRoute
'/play/$roomCode/board': typeof PlayRoomCodeBoardRoute
'/play/$roomCode/game': typeof PlayRoomCodeGameRoute
'/host/$roomCode/': typeof HostRoomCodeIndexRoute
'/play/$roomCode/': typeof PlayRoomCodeIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/display/$roomCode': typeof DisplayRoomCodeRoute
'/host/$roomCode': typeof HostRoomCodeRoute
'/play/$roomCode': typeof PlayRoomCodeRoute
'/host/$roomCode/bingo': typeof HostRoomCodeBingoRoute
'/host/$roomCode/board': typeof HostRoomCodeBoardRoute
'/host/$roomCode/game': typeof HostRoomCodeGameRoute
'/host/$roomCode/host': typeof HostRoomCodeHostRoute
'/play/$roomCode/bingo': typeof PlayRoomCodeBingoRoute
'/play/$roomCode/board': typeof PlayRoomCodeBoardRoute
'/play/$roomCode/game': typeof PlayRoomCodeGameRoute
'/host/$roomCode': typeof HostRoomCodeIndexRoute
'/play/$roomCode': typeof PlayRoomCodeIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/display/$roomCode': typeof DisplayRoomCodeRoute
'/host/$roomCode': typeof HostRoomCodeRoute
'/play/$roomCode': typeof PlayRoomCodeRoute
'/host/$roomCode': typeof HostRoomCodeRouteWithChildren
'/play/$roomCode': typeof PlayRoomCodeRouteWithChildren
'/host/$roomCode/bingo': typeof HostRoomCodeBingoRoute
'/host/$roomCode/board': typeof HostRoomCodeBoardRoute
'/host/$roomCode/game': typeof HostRoomCodeGameRoute
'/host/$roomCode/host': typeof HostRoomCodeHostRoute
'/play/$roomCode/bingo': typeof PlayRoomCodeBingoRoute
'/play/$roomCode/board': typeof PlayRoomCodeBoardRoute
'/play/$roomCode/game': typeof PlayRoomCodeGameRoute
'/host/$roomCode/': typeof HostRoomCodeIndexRoute
'/play/$roomCode/': typeof PlayRoomCodeIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/display/$roomCode' | '/host/$roomCode' | '/play/$roomCode'
fullPaths:
| '/'
| '/display/$roomCode'
| '/host/$roomCode'
| '/play/$roomCode'
| '/host/$roomCode/bingo'
| '/host/$roomCode/board'
| '/host/$roomCode/game'
| '/host/$roomCode/host'
| '/play/$roomCode/bingo'
| '/play/$roomCode/board'
| '/play/$roomCode/game'
| '/host/$roomCode/'
| '/play/$roomCode/'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/display/$roomCode' | '/host/$roomCode' | '/play/$roomCode'
to:
| '/'
| '/display/$roomCode'
| '/host/$roomCode/bingo'
| '/host/$roomCode/board'
| '/host/$roomCode/game'
| '/host/$roomCode/host'
| '/play/$roomCode/bingo'
| '/play/$roomCode/board'
| '/play/$roomCode/game'
| '/host/$roomCode'
| '/play/$roomCode'
id:
| '__root__'
| '/'
| '/display/$roomCode'
| '/host/$roomCode'
| '/play/$roomCode'
| '/host/$roomCode/bingo'
| '/host/$roomCode/board'
| '/host/$roomCode/game'
| '/host/$roomCode/host'
| '/play/$roomCode/bingo'
| '/play/$roomCode/board'
| '/play/$roomCode/game'
| '/host/$roomCode/'
| '/play/$roomCode/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
DisplayRoomCodeRoute: typeof DisplayRoomCodeRoute
HostRoomCodeRoute: typeof HostRoomCodeRoute
PlayRoomCodeRoute: typeof PlayRoomCodeRoute
HostRoomCodeRoute: typeof HostRoomCodeRouteWithChildren
PlayRoomCodeRoute: typeof PlayRoomCodeRouteWithChildren
}
declare module '@tanstack/react-router' {
@@ -104,14 +216,115 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DisplayRoomCodeRouteImport
parentRoute: typeof rootRouteImport
}
'/play/$roomCode/': {
id: '/play/$roomCode/'
path: '/'
fullPath: '/play/$roomCode/'
preLoaderRoute: typeof PlayRoomCodeIndexRouteImport
parentRoute: typeof PlayRoomCodeRoute
}
'/host/$roomCode/': {
id: '/host/$roomCode/'
path: '/'
fullPath: '/host/$roomCode/'
preLoaderRoute: typeof HostRoomCodeIndexRouteImport
parentRoute: typeof HostRoomCodeRoute
}
'/play/$roomCode/game': {
id: '/play/$roomCode/game'
path: '/game'
fullPath: '/play/$roomCode/game'
preLoaderRoute: typeof PlayRoomCodeGameRouteImport
parentRoute: typeof PlayRoomCodeRoute
}
'/play/$roomCode/board': {
id: '/play/$roomCode/board'
path: '/board'
fullPath: '/play/$roomCode/board'
preLoaderRoute: typeof PlayRoomCodeBoardRouteImport
parentRoute: typeof PlayRoomCodeRoute
}
'/play/$roomCode/bingo': {
id: '/play/$roomCode/bingo'
path: '/bingo'
fullPath: '/play/$roomCode/bingo'
preLoaderRoute: typeof PlayRoomCodeBingoRouteImport
parentRoute: typeof PlayRoomCodeRoute
}
'/host/$roomCode/host': {
id: '/host/$roomCode/host'
path: '/host'
fullPath: '/host/$roomCode/host'
preLoaderRoute: typeof HostRoomCodeHostRouteImport
parentRoute: typeof HostRoomCodeRoute
}
'/host/$roomCode/game': {
id: '/host/$roomCode/game'
path: '/game'
fullPath: '/host/$roomCode/game'
preLoaderRoute: typeof HostRoomCodeGameRouteImport
parentRoute: typeof HostRoomCodeRoute
}
'/host/$roomCode/board': {
id: '/host/$roomCode/board'
path: '/board'
fullPath: '/host/$roomCode/board'
preLoaderRoute: typeof HostRoomCodeBoardRouteImport
parentRoute: typeof HostRoomCodeRoute
}
'/host/$roomCode/bingo': {
id: '/host/$roomCode/bingo'
path: '/bingo'
fullPath: '/host/$roomCode/bingo'
preLoaderRoute: typeof HostRoomCodeBingoRouteImport
parentRoute: typeof HostRoomCodeRoute
}
}
}
interface HostRoomCodeRouteChildren {
HostRoomCodeBingoRoute: typeof HostRoomCodeBingoRoute
HostRoomCodeBoardRoute: typeof HostRoomCodeBoardRoute
HostRoomCodeGameRoute: typeof HostRoomCodeGameRoute
HostRoomCodeHostRoute: typeof HostRoomCodeHostRoute
HostRoomCodeIndexRoute: typeof HostRoomCodeIndexRoute
}
const HostRoomCodeRouteChildren: HostRoomCodeRouteChildren = {
HostRoomCodeBingoRoute: HostRoomCodeBingoRoute,
HostRoomCodeBoardRoute: HostRoomCodeBoardRoute,
HostRoomCodeGameRoute: HostRoomCodeGameRoute,
HostRoomCodeHostRoute: HostRoomCodeHostRoute,
HostRoomCodeIndexRoute: HostRoomCodeIndexRoute,
}
const HostRoomCodeRouteWithChildren = HostRoomCodeRoute._addFileChildren(
HostRoomCodeRouteChildren,
)
interface PlayRoomCodeRouteChildren {
PlayRoomCodeBingoRoute: typeof PlayRoomCodeBingoRoute
PlayRoomCodeBoardRoute: typeof PlayRoomCodeBoardRoute
PlayRoomCodeGameRoute: typeof PlayRoomCodeGameRoute
PlayRoomCodeIndexRoute: typeof PlayRoomCodeIndexRoute
}
const PlayRoomCodeRouteChildren: PlayRoomCodeRouteChildren = {
PlayRoomCodeBingoRoute: PlayRoomCodeBingoRoute,
PlayRoomCodeBoardRoute: PlayRoomCodeBoardRoute,
PlayRoomCodeGameRoute: PlayRoomCodeGameRoute,
PlayRoomCodeIndexRoute: PlayRoomCodeIndexRoute,
}
const PlayRoomCodeRouteWithChildren = PlayRoomCodeRoute._addFileChildren(
PlayRoomCodeRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
DisplayRoomCodeRoute: DisplayRoomCodeRoute,
HostRoomCodeRoute: HostRoomCodeRoute,
PlayRoomCodeRoute: PlayRoomCodeRoute,
HostRoomCodeRoute: HostRoomCodeRouteWithChildren,
PlayRoomCodeRoute: PlayRoomCodeRouteWithChildren,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
@@ -1,10 +1,15 @@
import { useState } from "react"
import { QRCodeSVG } from "qrcode.react"
import { createFileRoute } from "@tanstack/react-router"
import { useWebSocket } from "@/hooks/use-websocket"
import { useRoomStore } from "@/stores/room-store"
import { PlayerList } from "@/components/player-list"
import { JuryDisplay } from "@/components/jury-display"
import { BingoDisplay } from "@/components/bingo-display"
import { Leaderboard } from "@/components/leaderboard"
import { QuizDisplay } from "@/components/quiz-display"
import { RoomHeader } from "@/components/room-header"
import { ACT_LABELS } from "@celebrate-esc/shared"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
export const Route = createFileRoute("/display/$roomCode")({
component: DisplayView,
@@ -40,15 +45,77 @@ function DisplayView() {
</div>
)}
{room.currentAct !== "lobby" && room.currentAct !== "ended" && room.currentAct !== "pre-show" && (
<div className="flex flex-col items-center gap-4 py-12">
<p className="text-2xl text-muted-foreground">{ACT_LABELS[room.currentAct]}</p>
{room.currentAct === "live-event" && gameState && (
<div className="flex flex-col items-center gap-8">
<JuryDisplay
currentRound={gameState.currentJuryRound}
results={gameState.juryResults}
/>
<BingoDisplay announcements={gameState.bingoAnnouncements} />
<Leaderboard entries={gameState.leaderboard} resultsEntered={!!gameState?.actualResults} />
</div>
)}
{room.currentAct === "scoring" && gameState && (
<div className="flex flex-col items-center gap-8">
<p className="text-2xl text-muted-foreground">Scoring</p>
{gameState.currentQuizQuestion && (
<QuizDisplay question={gameState.currentQuizQuestion} />
)}
{gameState?.actualResults && (
<Card>
<CardHeader>
<CardTitle>Actual Results</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-1 text-sm">
{[
{ label: "Winner", code: gameState.actualResults.winner },
{ label: "2nd", code: gameState.actualResults.second },
{ label: "3rd", code: gameState.actualResults.third },
{ label: "Last", code: gameState.actualResults.last },
].map(({ label, code }) => {
const entry = gameState.lineup.entries.find((e) => e.country.code === code)
return (
<div key={label} className="flex items-center gap-2">
<span className="w-16 text-xs font-medium text-muted-foreground">{label}</span>
<span>{entry ? `${entry.country.flag} ${entry.country.name}` : code}</span>
</div>
)
})}
</CardContent>
</Card>
)}
<Leaderboard entries={gameState.leaderboard} resultsEntered={!!gameState?.actualResults} />
</div>
)}
{room.currentAct === "ended" && (
<div className="flex flex-col items-center gap-4 py-12">
<p className="text-2xl text-muted-foreground">The party has ended. Thanks for playing!</p>
{gameState?.actualResults && (
<Card>
<CardHeader>
<CardTitle>Actual Results</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-1 text-sm">
{[
{ label: "Winner", code: gameState.actualResults.winner },
{ label: "2nd", code: gameState.actualResults.second },
{ label: "3rd", code: gameState.actualResults.third },
{ label: "Last", code: gameState.actualResults.last },
].map(({ label, code }) => {
const entry = gameState.lineup.entries.find((e) => e.country.code === code)
return (
<div key={label} className="flex items-center gap-2">
<span className="w-16 text-xs font-medium text-muted-foreground">{label}</span>
<span>{entry ? `${entry.country.flag} ${entry.country.name}` : code}</span>
</div>
)
})}
</CardContent>
</Card>
)}
{gameState && <Leaderboard entries={gameState.leaderboard} resultsEntered={!!gameState?.actualResults} />}
</div>
)}
@@ -92,6 +159,7 @@ function LobbyDisplay({ roomCode }: { roomCode: string }) {
<>Tap the code to copy</>
)}
</p>
<QRCodeSVG value={joinUrl} size={192} level="M" />
<p className="text-muted-foreground">
Go to <span className="font-mono font-medium">{joinUrl}</span>
</p>
@@ -0,0 +1,15 @@
import { createFileRoute } from "@tanstack/react-router"
import { useRoomStore } from "@/stores/room-store"
import { BingoTab } from "@/components/bingo-tab"
export const Route = createFileRoute("/host/$roomCode/bingo")({
component: HostBingo,
})
function HostBingo() {
const { room, gameState, send } = useRoomStore()
if (!room || !gameState) return null
return <BingoTab currentAct={room.currentAct} gameState={gameState} send={send} />
}
@@ -0,0 +1,15 @@
import { createFileRoute } from "@tanstack/react-router"
import { useRoomStore } from "@/stores/room-store"
import { BoardTab } from "@/components/board-tab"
export const Route = createFileRoute("/host/$roomCode/board")({
component: HostBoard,
})
function HostBoard() {
const { room, gameState } = useRoomStore()
if (!room || !gameState) return null
return <BoardTab currentAct={room.currentAct} gameState={gameState} />
}
@@ -0,0 +1,15 @@
import { createFileRoute } from "@tanstack/react-router"
import { useRoomStore } from "@/stores/room-store"
import { GameTab } from "@/components/game-tab"
export const Route = createFileRoute("/host/$roomCode/game")({
component: HostGame,
})
function HostGame() {
const { room, gameState, send } = useRoomStore()
if (!room || !gameState) return null
return <GameTab currentAct={room.currentAct} gameState={gameState} send={send} />
}
@@ -0,0 +1,16 @@
import { createFileRoute } from "@tanstack/react-router"
import { useRoomStore } from "@/stores/room-store"
import { HostTab } from "@/components/host-tab"
export const Route = createFileRoute("/host/$roomCode/host")({
component: HostControls,
})
function HostControls() {
const { roomCode } = Route.useParams()
const { room, gameState, send } = useRoomStore()
if (!room || !gameState) return null
return <HostTab roomCode={roomCode} currentAct={room.currentAct} gameState={gameState} send={send} />
}
@@ -0,0 +1,7 @@
import { createFileRoute, redirect } from "@tanstack/react-router"
export const Route = createFileRoute("/host/$roomCode/")({
beforeLoad: ({ params }) => {
throw redirect({ to: "/host/$roomCode/game", params })
},
})
+10 -85
View File
@@ -1,29 +1,17 @@
import { createFileRoute } from "@tanstack/react-router"
import { useWebSocket } from "@/hooks/use-websocket"
import { useRoomStore } from "@/stores/room-store"
import { PlayerList } from "@/components/player-list"
import { PredictionsForm } from "@/components/predictions-form"
import { RoomHeader } from "@/components/room-header"
import { Button } from "@/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import type { Act } from "@celebrate-esc/shared"
import { RoomLayout } from "@/components/room-layout"
import { BottomNav } from "@/components/bottom-nav"
export const Route = createFileRoute("/host/$roomCode")({
component: HostView,
component: HostLayout,
})
const nextActLabels: Partial<Record<Act, string>> = {
lobby: "Start Pre-Show",
"pre-show": "Start Live Event",
"live-event": "Start Scoring",
scoring: "End Party",
}
function HostView() {
function HostLayout() {
const { roomCode } = Route.useParams()
const { send } = useWebSocket(roomCode)
const { room, mySessionId, connectionStatus, gameState } = useRoomStore()
useWebSocket(roomCode)
const { room, connectionStatus } = useRoomStore()
if (!room) {
return (
@@ -36,72 +24,9 @@ function HostView() {
}
return (
<div className="flex min-h-screen flex-col">
<RoomHeader roomCode={roomCode} currentAct={room.currentAct} connectionStatus={connectionStatus} />
<Tabs defaultValue="host" className="flex-1">
<TabsList className="w-full rounded-none">
<TabsTrigger value="play" className="flex-1">
Play
</TabsTrigger>
<TabsTrigger value="host" className="flex-1">
Host
</TabsTrigger>
</TabsList>
<TabsContent value="play" className="p-4">
{gameState && room.currentAct !== "ended" && (
<div className="flex flex-col gap-4">
<PredictionsForm
entries={gameState.lineup.entries}
existingPrediction={gameState.myPrediction}
locked={gameState.predictionsLocked}
onSubmit={(prediction) =>
send({ type: "submit_prediction", ...prediction })
}
/>
</div>
)}
<PlayerList
players={room.players}
mySessionId={mySessionId}
predictionSubmitted={gameState?.predictionSubmitted}
/>
</TabsContent>
<TabsContent value="host" className="p-4">
<div className="flex flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>Room Controls</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{room.currentAct !== "ended" && (
<Button onClick={() => send({ type: "advance_act" })} className="w-full">
{nextActLabels[room.currentAct] ?? "Next"}
</Button>
)}
{room.currentAct !== "ended" && (
<Button
variant="destructive"
onClick={() => send({ type: "end_room" })}
className="w-full"
>
End Party
</Button>
)}
{room.currentAct === "ended" && (
<p className="text-center text-muted-foreground">
The party has ended. Thanks for playing!
</p>
)}
</CardContent>
</Card>
<PlayerList
players={room.players}
mySessionId={mySessionId}
predictionSubmitted={gameState?.predictionSubmitted}
/>
</div>
</TabsContent>
</Tabs>
</div>
<>
<RoomLayout roomCode={roomCode} connectionStatus={connectionStatus} />
<BottomNav basePath="/host/$roomCode" roomCode={roomCode} isHost={true} />
</>
)
}
@@ -0,0 +1,15 @@
import { createFileRoute } from "@tanstack/react-router"
import { useRoomStore } from "@/stores/room-store"
import { BingoTab } from "@/components/bingo-tab"
export const Route = createFileRoute("/play/$roomCode/bingo")({
component: PlayBingo,
})
function PlayBingo() {
const { room, gameState, send } = useRoomStore()
if (!room || !gameState) return null
return <BingoTab currentAct={room.currentAct} gameState={gameState} send={send} />
}
@@ -0,0 +1,15 @@
import { createFileRoute } from "@tanstack/react-router"
import { useRoomStore } from "@/stores/room-store"
import { BoardTab } from "@/components/board-tab"
export const Route = createFileRoute("/play/$roomCode/board")({
component: PlayBoard,
})
function PlayBoard() {
const { room, gameState } = useRoomStore()
if (!room || !gameState) return null
return <BoardTab currentAct={room.currentAct} gameState={gameState} />
}
@@ -0,0 +1,15 @@
import { createFileRoute } from "@tanstack/react-router"
import { useRoomStore } from "@/stores/room-store"
import { GameTab } from "@/components/game-tab"
export const Route = createFileRoute("/play/$roomCode/game")({
component: PlayGame,
})
function PlayGame() {
const { room, gameState, send } = useRoomStore()
if (!room || !gameState) return null
return <GameTab currentAct={room.currentAct} gameState={gameState} send={send} />
}
@@ -0,0 +1,7 @@
import { createFileRoute, redirect } from "@tanstack/react-router"
export const Route = createFileRoute("/play/$roomCode/")({
beforeLoad: ({ params }) => {
throw redirect({ to: "/play/$roomCode/game", params })
},
})
+9 -41
View File
@@ -2,20 +2,19 @@ import { useEffect, useRef, useState } from "react"
import { createFileRoute } from "@tanstack/react-router"
import { useWebSocket } from "@/hooks/use-websocket"
import { useRoomStore } from "@/stores/room-store"
import { PlayerList } from "@/components/player-list"
import { PredictionsForm } from "@/components/predictions-form"
import { RoomHeader } from "@/components/room-header"
import { RoomLayout } from "@/components/room-layout"
import { BottomNav } from "@/components/bottom-nav"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
export const Route = createFileRoute("/play/$roomCode")({
component: PlayerView,
component: PlayLayout,
})
function PlayerView() {
function PlayLayout() {
const { roomCode } = Route.useParams()
const { send } = useWebSocket(roomCode)
const { room, mySessionId, connectionStatus, gameState } = useRoomStore()
const { room, mySessionId, connectionStatus } = useRoomStore()
const joinSentRef = useRef(false)
const [manualName, setManualName] = useState("")
@@ -80,40 +79,9 @@ function PlayerView() {
}
return (
<div className="flex min-h-screen flex-col">
<RoomHeader roomCode={roomCode} currentAct={room.currentAct} connectionStatus={connectionStatus} />
<div className="flex-1 p-4">
{room.currentAct === "lobby" && !gameState && (
<div className="flex flex-col items-center gap-4 py-8">
<p className="text-lg text-muted-foreground">Waiting for the host to start...</p>
</div>
)}
{gameState && room.currentAct !== "ended" && (
<div className="flex flex-col gap-4">
<PredictionsForm
entries={gameState.lineup.entries}
existingPrediction={gameState.myPrediction}
locked={gameState.predictionsLocked}
onSubmit={(prediction) =>
send({ type: "submit_prediction", ...prediction })
}
/>
</div>
)}
{room.currentAct === "ended" && (
<div className="flex flex-col items-center gap-4 py-8">
<p className="text-lg text-muted-foreground">The party has ended. Thanks for playing!</p>
</div>
)}
<PlayerList
players={room.players}
mySessionId={mySessionId}
predictionSubmitted={gameState?.predictionSubmitted}
/>
</div>
</div>
<>
<RoomLayout roomCode={roomCode} connectionStatus={connectionStatus} />
<BottomNav basePath="/play/$roomCode" roomCode={roomCode} isHost={false} />
</>
)
}
+9 -2
View File
@@ -1,11 +1,12 @@
import { create } from "zustand"
import type { RoomState, Player, GameState } from "@celebrate-esc/shared"
import type { RoomState, Player, GameState, ClientMessage } from "@celebrate-esc/shared"
interface RoomStore {
room: RoomState | null
mySessionId: string | null
connectionStatus: "disconnected" | "connecting" | "connected"
gameState: GameState | null
send: (message: ClientMessage) => void
setRoom: (room: RoomState) => void
setMySessionId: (sessionId: string) => void
@@ -15,14 +16,18 @@ interface RoomStore {
setAct: (act: RoomState["currentAct"]) => void
setGameState: (gameState: GameState) => void
lockPredictions: () => void
setSend: (send: (message: ClientMessage) => void) => void
reset: () => void
}
const noop = () => {}
export const useRoomStore = create<RoomStore>((set) => ({
room: null,
mySessionId: null,
connectionStatus: "disconnected",
gameState: null,
send: noop,
setRoom: (room) => set({ room }),
setMySessionId: (sessionId) => set({ mySessionId: sessionId }),
@@ -67,5 +72,7 @@ export const useRoomStore = create<RoomStore>((set) => ({
}
}),
reset: () => set({ room: null, mySessionId: null, connectionStatus: "disconnected", gameState: null }),
setSend: (send) => set({ send }),
reset: () => set({ room: null, mySessionId: null, connectionStatus: "disconnected", gameState: null, send: noop }),
}))
+37
View File
@@ -0,0 +1,37 @@
[
{ "id": "key-change", "label": "Key change" },
{ "id": "pyrotechnics", "label": "Pyrotechnics on stage" },
{ "id": "wind-machine", "label": "Wind machine" },
{ "id": "costume-change", "label": "Costume change mid-song" },
{ "id": "barefoot", "label": "Performer is barefoot" },
{ "id": "rain-on-stage", "label": "Rain/water effect on stage" },
{ "id": "flag-waving", "label": "Flag waving in audience" },
{ "id": "crowd-singalong", "label": "Crowd sings along" },
{ "id": "heart-hands", "label": "Heart-shaped hand gesture" },
{ "id": "political-message", "label": "Political/peace message" },
{ "id": "prop-on-stage", "label": "Unusual prop on stage" },
{ "id": "dancer-lift", "label": "Dancer does a lift" },
{ "id": "whistle-note", "label": "Whistle note / extreme high note" },
{ "id": "spoken-word", "label": "Spoken word section" },
{ "id": "guitar-solo", "label": "Guitar solo" },
{ "id": "ethnic-instrument", "label": "Traditional/ethnic instrument" },
{ "id": "glitter-outfit", "label": "Glitter/sequin outfit" },
{ "id": "backup-choir", "label": "Dramatic backup choir" },
{ "id": "confetti", "label": "Confetti drop" },
{ "id": "led-floor", "label": "LED floor visuals" },
{ "id": "love-ballad", "label": "Slow love ballad" },
{ "id": "rap-section", "label": "Rap section in song" },
{ "id": "audience-clap", "label": "Audience clap-along" },
{ "id": "mirror-outfit", "label": "Mirror/reflective outfit" },
{ "id": "trampolines", "label": "Trampolines or acrobatics" },
{ "id": "drone-camera", "label": "Drone camera shot" },
{ "id": "language-switch", "label": "Song switches language mid-song" },
{ "id": "standing-ovation", "label": "Standing ovation" },
{ "id": "host-joke-flop", "label": "Host joke falls flat" },
{ "id": "technical-glitch", "label": "Technical glitch on screen" },
{ "id": "twelve-points", "label": "Commentator says \"douze points\"" },
{ "id": "cry-on-stage", "label": "Performer cries on stage" },
{ "id": "country-shoutout", "label": "Shoutout to their country" },
{ "id": "dramatic-pause", "label": "Dramatic pause before last note" },
{ "id": "staging-surprise", "label": "Staging surprise/reveal" }
]
+122
View File
@@ -0,0 +1,122 @@
[
{
"id": "q01",
"text": "Which country has won the Eurovision Song Contest the most times?",
"answer": "Sweden (7 wins)",
"difficulty": "easy"
},
{
"id": "q02",
"text": "In what year was the first Eurovision Song Contest held?",
"answer": "1956",
"difficulty": "easy"
},
{
"id": "q03",
"text": "Which city hosted the first Eurovision Song Contest?",
"answer": "Lugano, Switzerland",
"difficulty": "medium"
},
{
"id": "q04",
"text": "ABBA won Eurovision in 1974 with which song?",
"answer": "Waterloo",
"difficulty": "easy"
},
{
"id": "q05",
"text": "Which country represented by Loreen won Eurovision twice, in 2012 and 2023?",
"answer": "Sweden",
"difficulty": "easy"
},
{
"id": "q06",
"text": "Which country hosted Eurovision the most times?",
"answer": "United Kingdom (8 times)",
"difficulty": "hard"
},
{
"id": "q07",
"text": "Which non-European countries have competed in Eurovision? Name at least two.",
"answer": "Australia, Israel, Morocco (1980 only)",
"difficulty": "medium"
},
{
"id": "q08",
"text": "What is the 'Big Five' in Eurovision?",
"answer": "France, Germany, Italy, Spain, United Kingdom — they automatically qualify for the Grand Final",
"difficulty": "easy"
},
{
"id": "q09",
"text": "Which Eurovision winner went on to win a Grammy Award?",
"answer": "Celine Dion (won Eurovision 1988 for Switzerland)",
"difficulty": "medium"
},
{
"id": "q10",
"text": "What is 'nul points' in Eurovision?",
"answer": "Receiving zero points from all voters — the ultimate last place",
"difficulty": "easy"
},
{
"id": "q11",
"text": "In 2006, Finnish monster rock band Lordi won Eurovision with which song?",
"answer": "Hard Rock Hallelujah",
"difficulty": "medium"
},
{
"id": "q12",
"text": "Which country has participated in Eurovision the most times without ever winning?",
"answer": "Malta (or Cyprus — both have never won despite many entries)",
"difficulty": "hard"
},
{
"id": "q13",
"text": "What voting system replaced the traditional jury-only voting in 1997?",
"answer": "Televoting (public phone voting)",
"difficulty": "medium"
},
{
"id": "q14",
"text": "Which Eurovision host country famously had a stage invasion during the 2018 performance by SuRie?",
"answer": "United Kingdom (SuRie was performing for UK in Lisbon when a man grabbed her microphone)",
"difficulty": "hard"
},
{
"id": "q15",
"text": "What is the maximum number of points one country can award to another in the current voting system?",
"answer": "12 points (douze points)",
"difficulty": "easy"
},
{
"id": "q16",
"text": "Conchita Wurst won Eurovision 2014 for which country?",
"answer": "Austria",
"difficulty": "easy"
},
{
"id": "q17",
"text": "Which instrument is traditionally NOT allowed to be played live on the Eurovision stage?",
"answer": "All instruments — until 2021 live instruments were banned; only vocals were live. Since 2023, live instruments are allowed again.",
"difficulty": "hard"
},
{
"id": "q18",
"text": "The Eurovision Song Contest is organized by which broadcasting union?",
"answer": "European Broadcasting Union (EBU)",
"difficulty": "medium"
},
{
"id": "q19",
"text": "Which song holds the record for the most points ever scored in a Eurovision Grand Final?",
"answer": "Stefania by Kalush Orchestra (Ukraine, 2022) with 631 points",
"difficulty": "hard"
},
{
"id": "q20",
"text": "How many songs competed in the very first Eurovision in 1956?",
"answer": "14 songs from 7 countries (each country sent 2 songs)",
"difficulty": "hard"
}
]
+1 -2
View File
@@ -7,6 +7,5 @@
"bingo_full_bonus": 10,
"quiz_easy": 5,
"quiz_medium": 10,
"quiz_hard": 15,
"dish_correct": 5
"quiz_hard": 15
}
+39 -1
View File
@@ -1,4 +1,4 @@
import { boolean, pgEnum, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core"
import { boolean, integer, jsonb, pgEnum, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core"
export const actEnum = pgEnum("act", ["lobby", "pre-show", "live-event", "scoring", "ended"])
@@ -44,3 +44,41 @@ export const predictions = pgTable("predictions", {
third: varchar("third").notNull(),
last: varchar("last").notNull(),
})
// ─── Jury Voting ────────────────────────────────────────────────────
export const juryRoundStatusEnum = pgEnum("jury_round_status", ["open", "closed"])
export const juryRounds = pgTable("jury_rounds", {
id: uuid("id").primaryKey().defaultRandom(),
roomId: uuid("room_id")
.notNull()
.references(() => rooms.id),
countryCode: varchar("country_code").notNull(),
status: juryRoundStatusEnum("status").notNull().default("open"),
openedAt: timestamp("opened_at").notNull().defaultNow(),
})
export const juryVotes = pgTable("jury_votes", {
id: uuid("id").primaryKey().defaultRandom(),
playerId: uuid("player_id")
.notNull()
.references(() => players.id),
juryRoundId: uuid("jury_round_id")
.notNull()
.references(() => juryRounds.id),
rating: integer("rating").notNull(),
})
// ─── Bingo ──────────────────────────────────────────────────────────
export const bingoCards = pgTable("bingo_cards", {
id: uuid("id").primaryKey().defaultRandom(),
playerId: uuid("player_id")
.notNull()
.references(() => players.id),
roomId: uuid("room_id")
.notNull()
.references(() => rooms.id),
squares: jsonb("squares").notNull(),
})
@@ -0,0 +1,269 @@
import { describe, it, expect, beforeEach } from "bun:test"
import { GameManager } from "../game-manager"
describe("Bingo", () => {
let gm: GameManager
beforeEach(() => {
gm = new GameManager()
})
describe("generateBingoCards", () => {
it("should create a 16-square card for each player", () => {
gm.generateBingoCards(["p1", "p2"])
const card1 = gm.getBingoCard("p1")
const card2 = gm.getBingoCard("p2")
expect(card1).not.toBeNull()
expect(card1!.squares).toHaveLength(16)
expect(card1!.hasBingo).toBe(false)
expect(card2).not.toBeNull()
expect(card2!.squares).toHaveLength(16)
})
it("should return null for unknown player", () => {
expect(gm.getBingoCard("unknown")).toBeNull()
})
})
describe("tapBingoSquare", () => {
beforeEach(() => {
gm.generateBingoCards(["p1"])
})
it("should mark a square as tapped", () => {
const card = gm.getBingoCard("p1")!
const tropeId = card.squares[0]!.tropeId
const result = gm.tapBingoSquare("p1", tropeId)
expect(result).toHaveProperty("success", true)
expect(card.squares[0]!.tapped).toBe(true)
})
it("should be tap-only (not toggle)", () => {
const card = gm.getBingoCard("p1")!
const tropeId = card.squares[0]!.tropeId
gm.tapBingoSquare("p1", tropeId)
expect(card.squares[0]!.tapped).toBe(true)
// Tap again — should stay tapped
const result = gm.tapBingoSquare("p1", tropeId)
expect(result).toHaveProperty("success", true)
expect(card.squares[0]!.tapped).toBe(true)
})
it("should error for unknown player", () => {
const result = gm.tapBingoSquare("unknown", "trope1")
expect(result).toHaveProperty("error")
})
it("should error for trope not on card", () => {
const result = gm.tapBingoSquare("p1", "nonexistent-trope")
expect(result).toHaveProperty("error")
})
})
describe("bingo detection", () => {
beforeEach(() => {
gm.generateBingoCards(["p1"])
})
it("should detect a completed row", () => {
const card = gm.getBingoCard("p1")!
// Tap first row (indices 0-3)
for (let i = 0; i < 4; i++) {
const result = gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
if (i < 3) {
expect((result as any).hasBingo).toBe(false)
} else {
expect((result as any).hasBingo).toBe(true)
expect((result as any).isNewBingo).toBe(true)
}
}
})
it("should detect a completed column", () => {
const card = gm.getBingoCard("p1")!
// Tap first column (indices 0, 4, 8, 12)
for (const i of [0, 4, 8, 12]) {
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
expect(card.hasBingo).toBe(true)
})
it("should detect a completed diagonal", () => {
const card = gm.getBingoCard("p1")!
// Tap main diagonal (indices 0, 5, 10, 15)
for (const i of [0, 5, 10, 15]) {
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
expect(card.hasBingo).toBe(true)
})
it("should detect anti-diagonal", () => {
const card = gm.getBingoCard("p1")!
// Tap anti-diagonal (indices 3, 6, 9, 12)
for (const i of [3, 6, 9, 12]) {
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
expect(card.hasBingo).toBe(true)
})
})
describe("bingo completion flow", () => {
beforeEach(() => {
gm.generateBingoCards(["p1"])
})
it("should NOT move card to completedBingoCards on bingo detection (only on redraw)", () => {
const card = gm.getBingoCard("p1")!
// Complete first row — sets hasBingo but does NOT move card
for (let i = 0; i < 4; i++) {
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
expect(card.hasBingo).toBe(true)
expect(gm.getCompletedBingoCards()).toHaveLength(0)
})
it("should move card to completedBingoCards on requestNewBingoCard", () => {
const card = gm.getBingoCard("p1")!
// Complete first row
for (let i = 0; i < 4; i++) {
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
// Redraw moves the completed card
gm.requestNewBingoCard("p1", "Player 1")
const completed = gm.getCompletedBingoCards()
expect(completed).toHaveLength(1)
expect(completed[0]!.playerId).toBe("p1")
expect(completed[0]!.displayName).toBe("Player 1")
expect(completed[0]!.card.hasBingo).toBe(true)
expect(completed[0]!.completedAt).toBeTruthy()
})
})
describe("requestNewBingoCard", () => {
beforeEach(() => {
gm.generateBingoCards(["p1"])
})
it("should error if card has no bingo", () => {
const result = gm.requestNewBingoCard("p1", "Player 1")
expect(result).toHaveProperty("error")
})
it("should generate a fresh card after bingo", () => {
const card = gm.getBingoCard("p1")!
// Complete first row
for (let i = 0; i < 4; i++) {
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
const result = gm.requestNewBingoCard("p1", "Player 1")
expect(result).toHaveProperty("success", true)
const newCard = gm.getBingoCard("p1")!
expect(newCard.hasBingo).toBe(false)
expect(newCard.squares.every((s) => !s.tapped)).toBe(true)
expect(newCard.squares).toHaveLength(16)
})
})
describe("getBingoScore — accumulation across cards", () => {
beforeEach(() => {
gm.generateBingoCards(["p1"])
})
it("should score tapped squares on active card", () => {
const card = gm.getBingoCard("p1")!
gm.tapBingoSquare("p1", card.squares[0]!.tropeId)
gm.tapBingoSquare("p1", card.squares[1]!.tropeId)
// 2 tapped squares * 2 points = 4
expect(gm.getBingoScore("p1")).toBe(4)
})
it("should include bingo bonus on completed card", () => {
const card = gm.getBingoCard("p1")!
// Complete first row (4 squares)
for (let i = 0; i < 4; i++) {
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
// 4 tapped * 2 = 8, plus 10 bonus = 18
expect(gm.getBingoScore("p1")).toBe(18)
})
it("should accumulate scores across completed + new card", () => {
const card = gm.getBingoCard("p1")!
// Complete first row (4 squares) — triggers completion
for (let i = 0; i < 4; i++) {
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
// Request new card
gm.requestNewBingoCard("p1", "Player 1")
const newCard = gm.getBingoCard("p1")!
// Tap 2 squares on new card
gm.tapBingoSquare("p1", newCard.squares[0]!.tropeId)
gm.tapBingoSquare("p1", newCard.squares[1]!.tropeId)
// Old card: 4 tapped * 2 = 8 + 10 bonus = 18
// New card: 2 tapped * 2 = 4
// Total: 22
expect(gm.getBingoScore("p1")).toBe(22)
})
it("should return 0 for unknown player", () => {
expect(gm.getBingoScore("unknown")).toBe(0)
})
})
describe("addBingoAnnouncement — multiple per player", () => {
beforeEach(() => {
gm.generateBingoCards(["p1"])
})
it("should announce first bingo", () => {
const card = gm.getBingoCard("p1")!
for (let i = 0; i < 4; i++) {
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
const isNew = gm.addBingoAnnouncement("p1", "Player 1")
expect(isNew).toBe(true)
})
it("should not re-announce same bingo", () => {
const card = gm.getBingoCard("p1")!
for (let i = 0; i < 4; i++) {
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
gm.addBingoAnnouncement("p1", "Player 1")
const isNew = gm.addBingoAnnouncement("p1", "Player 1")
expect(isNew).toBe(false)
})
it("should announce second bingo after redraw", () => {
const card = gm.getBingoCard("p1")!
for (let i = 0; i < 4; i++) {
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
gm.addBingoAnnouncement("p1", "Player 1")
// Redraw
gm.requestNewBingoCard("p1", "Player 1")
const newCard = gm.getBingoCard("p1")!
// Complete first row of new card
for (let i = 0; i < 4; i++) {
gm.tapBingoSquare("p1", newCard.squares[i]!.tropeId)
}
const isNew = gm.addBingoAnnouncement("p1", "Player 1")
expect(isNew).toBe(true)
expect(gm.getBingoAnnouncements()).toHaveLength(2)
})
})
describe("game state includes completedBingoCards", () => {
it("should include completedBingoCards in player game state", () => {
gm.generateBingoCards(["p1"])
const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Player 1" })
expect(state.completedBingoCards).toEqual([])
})
it("should include completedBingoCards in display game state", () => {
gm.generateBingoCards(["p1"])
const state = gm.getGameStateForDisplay(["p1"], { p1: "Player 1" })
expect(state.completedBingoCards).toEqual([])
})
})
})
@@ -0,0 +1,164 @@
import { describe, it, expect, beforeEach } from "bun:test"
import { GameManager } from "../game-manager"
describe("GameManager Quiz", () => {
let gm: GameManager
beforeEach(() => {
gm = new GameManager()
})
describe("startQuizQuestion", () => {
it("starts the first question", () => {
const result = gm.startQuizQuestion()
expect("error" in result).toBe(false)
if (!("error" in result)) {
expect(result.questionIndex).toBe(0)
}
const round = gm.getCurrentQuizRound()
expect(round).not.toBeNull()
expect(round!.status).toBe("buzzing")
})
it("returns error if a question is already active", () => {
gm.startQuizQuestion()
const result = gm.startQuizQuestion()
expect(result).toEqual({ error: "A quiz question is already active — skip it first" })
})
it("advances to next question after resolving", () => {
gm.startQuizQuestion()
gm.buzz("p1")
gm.judgeQuizAnswer(true)
const result = gm.startQuizQuestion()
expect("error" in result).toBe(false)
if (!("error" in result)) {
expect(result.questionIndex).toBe(1)
}
})
})
describe("buzz", () => {
it("sets the buzzer player", () => {
gm.startQuizQuestion()
const result = gm.buzz("p1")
expect("error" in result).toBe(false)
const round = gm.getCurrentQuizRound()
expect(round!.status).toBe("judging")
expect(round!.buzzerPlayerId).toBe("p1")
})
it("returns error if no question active", () => {
const result = gm.buzz("p1")
expect(result).toEqual({ error: "No quiz question active" })
})
it("returns error if not in buzzing status", () => {
gm.startQuizQuestion()
gm.buzz("p1")
const result = gm.buzz("p2")
expect(result).toEqual({ error: "Buzzing is not open" })
})
it("returns error if player is excluded", () => {
gm.startQuizQuestion()
gm.buzz("p1")
gm.judgeQuizAnswer(false)
const result = gm.buzz("p1")
expect(result).toEqual({ error: "You are excluded from this question" })
})
})
describe("judgeQuizAnswer", () => {
it("awards points on correct answer", () => {
gm.startQuizQuestion()
gm.buzz("p1")
const result = gm.judgeQuizAnswer(true)
expect("error" in result).toBe(false)
const round = gm.getCurrentQuizRound()
expect(round!.status).toBe("resolved")
expect(round!.wasCorrect).toBe(true)
expect(gm.getQuizScore("p1")).toBeGreaterThan(0)
})
it("excludes player and reopens buzzing on incorrect", () => {
gm.startQuizQuestion()
gm.buzz("p1")
gm.judgeQuizAnswer(false)
const round = gm.getCurrentQuizRound()
expect(round!.status).toBe("buzzing")
expect(round!.buzzerPlayerId).toBeNull()
expect(round!.wasCorrect).toBeNull()
expect(gm.getQuizBuzzStatus("p1")).toBe("excluded")
})
it("returns error if no one has buzzed", () => {
gm.startQuizQuestion()
const result = gm.judgeQuizAnswer(true)
expect(result).toEqual({ error: "No one has buzzed yet" })
})
})
describe("skipQuizQuestion", () => {
it("resolves the current question without awarding points", () => {
gm.startQuizQuestion()
const result = gm.skipQuizQuestion()
expect("error" in result).toBe(false)
const round = gm.getCurrentQuizRound()
expect(round!.status).toBe("resolved")
expect(round!.wasCorrect).toBeNull()
})
it("returns error when no question active", () => {
const result = gm.skipQuizQuestion()
expect(result).toEqual({ error: "No quiz question active" })
})
it("allows starting next question after skip", () => {
gm.startQuizQuestion()
gm.skipQuizQuestion()
const result = gm.startQuizQuestion()
expect("error" in result).toBe(false)
})
})
describe("getQuizBuzzStatus", () => {
it("returns null when no question active", () => {
expect(gm.getQuizBuzzStatus("p1")).toBeNull()
})
it("returns can_buzz for non-excluded player during buzzing", () => {
gm.startQuizQuestion()
expect(gm.getQuizBuzzStatus("p1")).toBe("can_buzz")
})
it("returns already_buzzed for the buzzer during judging", () => {
gm.startQuizQuestion()
gm.buzz("p1")
expect(gm.getQuizBuzzStatus("p1")).toBe("already_buzzed")
})
it("returns excluded for incorrectly judged player", () => {
gm.startQuizQuestion()
gm.buzz("p1")
gm.judgeQuizAnswer(false)
expect(gm.getQuizBuzzStatus("p1")).toBe("excluded")
})
it("returns waiting for non-buzzer players during judging", () => {
gm.startQuizQuestion()
gm.buzz("p1")
expect(gm.getQuizBuzzStatus("p2")).toBe("waiting")
})
})
describe("scoring", () => {
it("awards points based on difficulty", () => {
gm.startQuizQuestion()
gm.buzz("p1")
gm.judgeQuizAnswer(true)
const score = gm.getQuizScore("p1")
expect(score).toBeGreaterThan(0)
})
})
})
+470 -3
View File
@@ -1,5 +1,12 @@
import type { Prediction, GameState, Lineup } from "@celebrate-esc/shared"
import type { Prediction, GameState, Lineup, JuryRound, JuryResult, QuizQuestion, CompletedBingoCard } from "@celebrate-esc/shared"
import lineupData from "../../data/esc-2025.json"
import scoringConfig from "../../data/scoring.json"
import tropesData from "../../data/bingo-tropes.json"
import quizQuestionsData from "../../data/quiz-questions.json"
const quizQuestions = quizQuestionsData as { id: string; text: string; answer: string; difficulty: "easy" | "medium" | "hard" }[]
const tropes: { id: string; label: string }[] = tropesData
const lineup: Lineup = lineupData as Lineup
const countryCodes = new Set(lineup.entries.map((e) => e.country.code))
@@ -7,6 +14,7 @@ const countryCodes = new Set(lineup.entries.map((e) => e.country.code))
export class GameManager {
private predictions = new Map<string, Prediction>() // playerId → prediction
private locked = false
private actualResults: { winner: string; second: string; third: string; last: string } | null = null
getLineup(): Lineup {
return lineup
@@ -60,6 +68,415 @@ export class GameManager {
return this.predictions.has(playerId)
}
// ─── Jury Voting ────────────────────────────────────────────────
private currentJuryRound: {
id: string
countryCode: string
countryName: string
countryFlag: string
votes: Map<string, number>
} | null = null
private juryResults: JuryResult[] = []
private juryScores = new Map<string, number>()
openJuryRound(
countryCode: string,
countryName: string,
countryFlag: string,
): { success: true } | { error: string } {
if (this.currentJuryRound) return { error: "A jury round is already open" }
this.currentJuryRound = {
id: crypto.randomUUID(),
countryCode,
countryName,
countryFlag,
votes: new Map(),
}
return { success: true }
}
submitJuryVote(playerId: string, rating: number): { success: true } | { error: string } {
if (!this.currentJuryRound) return { error: "No jury round is open" }
if (rating < 1 || rating > 12) return { error: "Rating must be between 1 and 12" }
this.currentJuryRound.votes.set(playerId, rating)
return { success: true }
}
getPlayerJuryVote(playerId: string): number | null {
if (!this.currentJuryRound) return null
return this.currentJuryRound.votes.get(playerId) ?? null
}
closeJuryRound(): JuryResult | { error: string } {
if (!this.currentJuryRound) return { error: "No jury round is open" }
const round = this.currentJuryRound
const votes = Array.from(round.votes.values())
const averageRating = votes.length > 0
? Math.round((votes.reduce((a, b) => a + b, 0) / votes.length) * 10) / 10
: 0
const maxPts = scoringConfig.jury_max_per_round
for (const [playerId, rating] of round.votes) {
const diff = Math.abs(rating - averageRating)
const pts = Math.max(0, maxPts - Math.round(diff))
this.juryScores.set(playerId, (this.juryScores.get(playerId) ?? 0) + pts)
}
const result: JuryResult = {
countryCode: round.countryCode,
countryName: round.countryName,
countryFlag: round.countryFlag,
averageRating,
totalVotes: votes.length,
}
this.juryResults.push(result)
this.currentJuryRound = null
return result
}
getCurrentJuryRound(): JuryRound | null {
if (!this.currentJuryRound) return null
return {
id: this.currentJuryRound.id,
countryCode: this.currentJuryRound.countryCode,
countryName: this.currentJuryRound.countryName,
countryFlag: this.currentJuryRound.countryFlag,
status: "open",
}
}
getJuryResults(): JuryResult[] {
return this.juryResults
}
getJuryScore(playerId: string): number {
return this.juryScores.get(playerId) ?? 0
}
// ─── Bingo ──────────────────────────────────────────────────────
private bingoCards = new Map<string, {
squares: { tropeId: string; label: string; tapped: boolean }[]
hasBingo: boolean
}>()
private bingoAnnouncements: { playerId: string; displayName: string }[] = []
private completedBingoCards: CompletedBingoCard[] = []
generateBingoCards(playerIds: string[]): void {
for (const playerId of playerIds) {
if (this.bingoCards.has(playerId)) continue
const shuffled = [...tropes].sort(() => Math.random() - 0.5)
const selected = shuffled.slice(0, 16)
this.bingoCards.set(playerId, {
squares: selected.map((t) => ({
tropeId: t.id,
label: t.label,
tapped: false,
})),
hasBingo: false,
})
}
}
generateBingoCardForPlayer(playerId: string): void {
if (this.bingoCards.has(playerId)) return
const shuffled = [...tropes].sort(() => Math.random() - 0.5)
const selected = shuffled.slice(0, 16)
this.bingoCards.set(playerId, {
squares: selected.map((t) => ({ tropeId: t.id, label: t.label, tapped: false })),
hasBingo: false,
})
}
getBingoCard(playerId: string): { squares: { tropeId: string; label: string; tapped: boolean }[]; hasBingo: boolean } | null {
return this.bingoCards.get(playerId) ?? null
}
tapBingoSquare(playerId: string, tropeId: string): { success: true; hasBingo: boolean; isNewBingo: boolean } | { error: string } {
const card = this.bingoCards.get(playerId)
if (!card) return { error: "No bingo card found" }
const square = card.squares.find((s) => s.tropeId === tropeId)
if (!square) return { error: "Trope not on your card" }
if (square.tapped) return { success: true, hasBingo: card.hasBingo, isNewBingo: false }
square.tapped = true
const hadBingo = card.hasBingo
card.hasBingo = this.checkBingo(card.squares)
const isNewBingo = card.hasBingo && !hadBingo
return { success: true, hasBingo: card.hasBingo, isNewBingo }
}
private checkBingo(squares: { tapped: boolean }[]): boolean {
for (let row = 0; row < 4; row++) {
if (squares[row * 4]!.tapped && squares[row * 4 + 1]!.tapped && squares[row * 4 + 2]!.tapped && squares[row * 4 + 3]!.tapped) return true
}
for (let col = 0; col < 4; col++) {
if (squares[col]!.tapped && squares[col + 4]!.tapped && squares[col + 8]!.tapped && squares[col + 12]!.tapped) return true
}
if (squares[0]!.tapped && squares[5]!.tapped && squares[10]!.tapped && squares[15]!.tapped) return true
if (squares[3]!.tapped && squares[6]!.tapped && squares[9]!.tapped && squares[12]!.tapped) return true
return false
}
addBingoAnnouncement(playerId: string, displayName: string): boolean {
// Count how many bingos this player already announced
const count = this.bingoAnnouncements.filter((a) => a.playerId === playerId).length
// Count how many bingo-detected cards this player has (completed + current if hasBingo)
const completedCount = this.completedBingoCards.filter((c) => c.playerId === playerId).length
const activeCard = this.bingoCards.get(playerId)
const totalBingos = completedCount + (activeCard?.hasBingo ? 1 : 0)
// Only announce if there are more bingos than announcements
if (count >= totalBingos) return false
this.bingoAnnouncements.push({ playerId, displayName })
return true
}
getBingoAnnouncements(): { playerId: string; displayName: string }[] {
return this.bingoAnnouncements
}
getBingoScore(playerId: string): number {
let totalTapped = 0
let totalBonuses = 0
// Count completed cards (moved here on redraw)
const completed = this.completedBingoCards.filter((c) => c.playerId === playerId)
for (const c of completed) {
totalTapped += c.card.squares.filter((s) => s.tapped).length
totalBonuses += scoringConfig.bingo_full_bonus
}
// Count active card (never overlaps with completed — card moves on redraw)
const activeCard = this.bingoCards.get(playerId)
if (activeCard) {
totalTapped += activeCard.squares.filter((s) => s.tapped).length
if (activeCard.hasBingo) totalBonuses += scoringConfig.bingo_full_bonus
}
return totalTapped * scoringConfig.bingo_per_square + totalBonuses
}
requestNewBingoCard(playerId: string, displayName: string): { success: true } | { error: string } {
const currentCard = this.bingoCards.get(playerId)
if (!currentCard || !currentCard.hasBingo) {
return { error: "No completed bingo card to replace" }
}
// Move current card to completedBingoCards
this.completedBingoCards.push({
playerId,
displayName,
card: { squares: currentCard.squares.map((s) => ({ ...s })), hasBingo: true },
completedAt: new Date().toISOString(),
})
// Generate new card excluding tropes from the just-completed card
const excludeIds = new Set(currentCard.squares.map((s) => s.tropeId))
const available = tropes.filter((t) => !excludeIds.has(t.id))
const pool = available.length >= 16 ? available : tropes
const shuffled = [...pool].sort(() => Math.random() - 0.5)
const selected = shuffled.slice(0, 16)
this.bingoCards.set(playerId, {
squares: selected.map((t) => ({ tropeId: t.id, label: t.label, tapped: false })),
hasBingo: false,
})
return { success: true }
}
getCompletedBingoCards(): CompletedBingoCard[] {
return this.completedBingoCards
}
// ─── Quiz ────────────────────────────────────────────────────────
private currentQuizRound: {
questionIndex: number
question: { id: string; text: string; answer: string; difficulty: "easy" | "medium" | "hard" }
status: "buzzing" | "judging" | "resolved"
buzzerPlayerId: string | null
excludedPlayers: Set<string>
wasCorrect: boolean | null
} | null = null
private quizQuestionIndex = 0
private quizScores = new Map<string, number>()
skipQuizQuestion(): { success: true } | { error: string } {
if (!this.currentQuizRound) return { error: "No quiz question active" }
if (this.currentQuizRound.status === "resolved") return { error: "Question already resolved" }
this.currentQuizRound.status = "resolved"
this.currentQuizRound.wasCorrect = null
this.currentQuizRound.buzzerPlayerId = null
return { success: true }
}
startQuizQuestion(): { questionIndex: number } | { error: string } {
if (this.currentQuizRound && this.currentQuizRound.status !== "resolved") {
return { error: "A quiz question is already active — skip it first" }
}
if (this.quizQuestionIndex >= quizQuestions.length) {
return { error: "No more questions available" }
}
const question = quizQuestions[this.quizQuestionIndex]!
this.currentQuizRound = {
questionIndex: this.quizQuestionIndex,
question,
status: "buzzing",
buzzerPlayerId: null,
excludedPlayers: new Set(),
wasCorrect: null,
}
const index = this.quizQuestionIndex
this.quizQuestionIndex++
return { questionIndex: index }
}
buzz(playerId: string): { success: true } | { error: string } {
if (!this.currentQuizRound) return { error: "No quiz question active" }
if (this.currentQuizRound.status !== "buzzing") return { error: "Buzzing is not open" }
if (this.currentQuizRound.excludedPlayers.has(playerId)) return { error: "You are excluded from this question" }
this.currentQuizRound.buzzerPlayerId = playerId
this.currentQuizRound.status = "judging"
return { success: true }
}
judgeQuizAnswer(correct: boolean): { success: true } | { error: string } {
if (!this.currentQuizRound) return { error: "No quiz question active" }
if (this.currentQuizRound.status !== "judging" || !this.currentQuizRound.buzzerPlayerId) {
return { error: "No one has buzzed yet" }
}
const playerId = this.currentQuizRound.buzzerPlayerId
if (correct) {
const difficulty = this.currentQuizRound.question.difficulty
const points = difficulty === "easy"
? scoringConfig.quiz_easy
: difficulty === "medium"
? scoringConfig.quiz_medium
: scoringConfig.quiz_hard
this.quizScores.set(playerId, (this.quizScores.get(playerId) ?? 0) + points)
this.currentQuizRound.status = "resolved"
this.currentQuizRound.wasCorrect = true
} else {
this.currentQuizRound.excludedPlayers.add(playerId)
this.currentQuizRound.buzzerPlayerId = null
this.currentQuizRound.wasCorrect = null
this.currentQuizRound.status = "buzzing"
}
return { success: true }
}
getCurrentQuizRound(): {
questionIndex: number
status: "buzzing" | "judging" | "resolved"
difficulty: "easy" | "medium" | "hard"
text: string
answer: string
buzzerPlayerId: string | null
wasCorrect: boolean | null
} | null {
if (!this.currentQuizRound) return null
return {
questionIndex: this.currentQuizRound.questionIndex,
status: this.currentQuizRound.status,
difficulty: this.currentQuizRound.question.difficulty,
text: this.currentQuizRound.question.text,
answer: this.currentQuizRound.question.answer,
buzzerPlayerId: this.currentQuizRound.buzzerPlayerId,
wasCorrect: this.currentQuizRound.wasCorrect,
}
}
getQuizBuzzStatus(playerId: string): "can_buzz" | "already_buzzed" | "excluded" | "waiting" | null {
if (!this.currentQuizRound) return null
if (this.currentQuizRound.status === "resolved") return null
if (this.currentQuizRound.excludedPlayers.has(playerId)) return "excluded"
if (this.currentQuizRound.buzzerPlayerId === playerId) return "already_buzzed"
if (this.currentQuizRound.status === "judging") return "waiting"
return "can_buzz"
}
getQuizScore(playerId: string): number {
return this.quizScores.get(playerId) ?? 0
}
getTotalQuizQuestions(): number {
return quizQuestions.length
}
private buildQuizQuestionForPlayer(
_playerId: string,
displayNames: Record<string, string>,
): QuizQuestion | null {
const round = this.currentQuizRound
if (!round) return null
return {
index: round.questionIndex,
total: quizQuestions.length,
difficulty: round.question.difficulty,
text: "",
answer: "",
status: round.status,
buzzerPlayerId: round.buzzerPlayerId,
buzzerName: round.buzzerPlayerId ? (displayNames[round.buzzerPlayerId] ?? "Unknown") : null,
wasCorrect: round.wasCorrect,
}
}
private buildQuizQuestionForDisplay(
displayNames: Record<string, string>,
): QuizQuestion | null {
const round = this.currentQuizRound
if (!round) return null
return {
index: round.questionIndex,
total: quizQuestions.length,
difficulty: round.question.difficulty,
text: round.question.text,
answer: "",
status: round.status,
buzzerPlayerId: round.buzzerPlayerId,
buzzerName: round.buzzerPlayerId ? (displayNames[round.buzzerPlayerId] ?? "Unknown") : null,
wasCorrect: round.wasCorrect,
}
}
buildQuizQuestionForHost(
displayNames: Record<string, string>,
): QuizQuestion | null {
const round = this.currentQuizRound
if (!round) return null
return {
index: round.questionIndex,
total: quizQuestions.length,
difficulty: round.question.difficulty,
text: round.question.text,
answer: round.question.answer,
status: round.status,
buzzerPlayerId: round.buzzerPlayerId,
buzzerName: round.buzzerPlayerId ? (displayNames[round.buzzerPlayerId] ?? "Unknown") : null,
wasCorrect: round.wasCorrect,
}
}
// ─── Prediction Scoring ─────────────────────────────────────────
setActualResults(winner: string, second: string, third: string, last: string): void {
this.actualResults = { winner, second, third, last }
}
getActualResults(): { winner: string; second: string; third: string; last: string } | null {
return this.actualResults
}
getPredictionScore(playerId: string): number {
if (!this.actualResults) return 0
const prediction = this.predictions.get(playerId)
if (!prediction) return 0
let score = 0
if (prediction.first === this.actualResults.winner) score += scoringConfig.prediction_winner
if (prediction.second === this.actualResults.second) score += scoringConfig.prediction_top3
if (prediction.third === this.actualResults.third) score += scoringConfig.prediction_top3
if (prediction.last === this.actualResults.last) score += scoringConfig.prediction_nul_points
return score
}
// ─── State for client ───────────────────────────────────────────
private buildPredictionSubmitted(playerIds: string[]): Record<string, boolean> {
@@ -70,21 +487,71 @@ export class GameManager {
return result
}
getGameStateForPlayer(playerId: string, allPlayerIds: string[]): GameState {
getGameStateForPlayer(
playerId: string,
allPlayerIds: string[],
displayNames?: Record<string, string>,
): GameState {
return {
lineup,
myPrediction: this.getPrediction(playerId),
predictionsLocked: this.locked,
predictionSubmitted: this.buildPredictionSubmitted(allPlayerIds),
currentJuryRound: this.getCurrentJuryRound(),
juryResults: this.juryResults,
myJuryVote: this.getPlayerJuryVote(playerId),
myBingoCard: this.getBingoCard(playerId),
bingoAnnouncements: this.bingoAnnouncements,
completedBingoCards: this.completedBingoCards,
currentQuizQuestion: this.buildQuizQuestionForPlayer(playerId, displayNames ?? {}),
myQuizBuzzStatus: this.getQuizBuzzStatus(playerId),
actualResults: this.actualResults,
leaderboard: this.buildLeaderboard(allPlayerIds, displayNames ?? {}),
}
}
getGameStateForDisplay(allPlayerIds: string[]): GameState {
getGameStateForDisplay(
allPlayerIds: string[],
displayNames?: Record<string, string>,
): GameState {
return {
lineup,
myPrediction: null,
predictionsLocked: this.locked,
predictionSubmitted: this.buildPredictionSubmitted(allPlayerIds),
currentJuryRound: this.getCurrentJuryRound(),
juryResults: this.juryResults,
myJuryVote: null,
myBingoCard: null,
bingoAnnouncements: this.bingoAnnouncements,
completedBingoCards: this.completedBingoCards,
currentQuizQuestion: this.buildQuizQuestionForDisplay(displayNames ?? {}),
myQuizBuzzStatus: null,
actualResults: this.actualResults,
leaderboard: this.buildLeaderboard(allPlayerIds, displayNames ?? {}),
}
}
private buildLeaderboard(
playerIds: string[],
displayNames: Record<string, string>,
): { playerId: string; displayName: string; juryPoints: number; bingoPoints: number; predictionPoints: number; quizPoints: number; totalPoints: number }[] {
return playerIds
.map((id) => {
const juryPoints = this.getJuryScore(id)
const bingoPoints = this.getBingoScore(id)
const predictionPoints = this.getPredictionScore(id)
const quizPoints = this.getQuizScore(id)
return {
playerId: id,
displayName: displayNames[id] ?? "Unknown",
juryPoints,
bingoPoints,
predictionPoints,
quizPoints,
totalPoints: juryPoints + bingoPoints + predictionPoints + quizPoints,
}
})
.sort((a, b) => b.totalPoints - a.totalPoints)
}
}
+22
View File
@@ -87,6 +87,18 @@ export class RoomManager {
return { newAct: nextAct }
}
revertAct(code: string, sessionId: string): { newAct: Act } | { error: string } {
const room = this.rooms.get(code)
if (!room) return { error: "Room not found" }
if (room.hostSessionId !== sessionId) return { error: "Only the host can revert acts" }
if (room.currentAct === "lobby") return { error: "Already at the first act" }
const currentIndex = ACTS.indexOf(room.currentAct)
const prevAct = ACTS[currentIndex - 1]!
room.currentAct = prevAct
return { newAct: prevAct }
}
endRoom(code: string, sessionId: string): { success: true } | { error: string } {
const room = this.rooms.get(code)
if (!room) return { error: "Room not found" }
@@ -154,6 +166,16 @@ export class RoomManager {
return Array.from(room.players.values()).map((p) => p.id)
}
getPlayerDisplayNames(code: string): Record<string, string> {
const room = this.rooms.get(code)
if (!room) return {}
const result: Record<string, string> = {}
for (const player of room.players.values()) {
result[player.id] = player.displayName
}
return result
}
getPlayerIdBySession(code: string, sessionId: string): string | null {
const room = this.rooms.get(code)
if (!room) return null
+337 -4
View File
@@ -47,7 +47,15 @@ function sendGameState(ws: WSContext, roomCode: string, sessionId: string) {
if (!gm || !playerId) return
const allPlayerIds = roomManager.getAllPlayerIds(roomCode)
const gameState = gm.getGameStateForPlayer(playerId, allPlayerIds)
const displayNames = roomManager.getPlayerDisplayNames(roomCode)
const gameState = gm.getGameStateForPlayer(playerId, allPlayerIds, displayNames)
// If this player is host, override quiz question with full host view
if (roomManager.isHost(roomCode, sessionId)) {
const hostQuiz = gm.buildQuizQuestionForHost(displayNames)
gameState.currentQuizQuestion = hostQuiz
}
sendTo(ws, { type: "game_state", gameState })
}
@@ -56,7 +64,8 @@ function sendDisplayGameState(ws: WSContext, roomCode: string) {
if (!gm) return
const allPlayerIds = roomManager.getAllPlayerIds(roomCode)
const gameState = gm.getGameStateForDisplay(allPlayerIds)
const displayNames = roomManager.getPlayerDisplayNames(roomCode)
const gameState = gm.getGameStateForDisplay(allPlayerIds, displayNames)
sendTo(ws, { type: "game_state", gameState })
}
@@ -167,6 +176,15 @@ export function registerWebSocketRoutes() {
})
sendGameState(ws, roomCode, result.sessionId)
// Generate bingo card for new player immediately
{
const gmForCard = roomManager.getGameManager(roomCode)
if (gmForCard) {
const playerIdForCard = roomManager.getPlayerIdBySession(roomCode, result.sessionId)
if (playerIdForCard) gmForCard.generateBingoCardForPlayer(playerIdForCard)
}
}
const room = roomManager.getRoom(roomCode)!
const newPlayer = room.players.find((p) => p.sessionId === sessionId)!
broadcast(roomCode, {
@@ -211,18 +229,39 @@ export function registerWebSocketRoutes() {
type: "act_changed",
newAct: result.newAct,
})
// Lock predictions when moving from pre-show to live-event
// Lock predictions and generate bingo cards when entering live-event
if (result.newAct === "live-event") {
const gm = roomManager.getGameManager(roomCode)
if (gm) {
gm.lockPredictions()
broadcast(roomCode, { type: "predictions_locked" })
const allPlayerIds = roomManager.getAllPlayerIds(roomCode)
gm.generateBingoCards(allPlayerIds)
broadcastGameStateToAll(roomCode)
}
}
break
}
case "end_room": {
case "revert_act": {
if (!sessionId) {
sendError(ws, "Not joined")
return
}
const result = roomManager.revertAct(roomCode, sessionId)
if ("error" in result) {
sendError(ws, result.error)
return
}
broadcast(roomCode, {
type: "act_changed",
newAct: result.newAct,
})
broadcastGameStateToAll(roomCode)
break
}
case "end_room": {
if (!sessionId) {
sendError(ws, "Not joined")
return
@@ -258,6 +297,300 @@ export function registerWebSocketRoutes() {
broadcastGameStateToAll(roomCode)
break
}
case "open_jury_vote": {
if (!sessionId) {
sendError(ws, "Not joined")
return
}
const room = roomManager.getRoom(roomCode)
if (room?.currentAct !== "live-event") {
sendError(ws, "Jury voting is only available during Live Event")
return
}
if (!roomManager.isHost(roomCode, sessionId)) {
sendError(ws, "Only the host can open jury voting")
return
}
const gm = roomManager.getGameManager(roomCode)
if (!gm) {
sendError(ws, "Room not found")
return
}
const entry = gm.getLineup().entries.find((e) => e.country.code === msg.countryCode)
if (!entry) {
sendError(ws, "Invalid country code")
return
}
const result = gm.openJuryRound(entry.country.code, entry.country.name, entry.country.flag)
if ("error" in result) {
sendError(ws, result.error)
return
}
const round = gm.getCurrentJuryRound()!
broadcast(roomCode, {
type: "jury_vote_opened",
roundId: round.id,
countryCode: round.countryCode,
countryName: round.countryName,
countryFlag: round.countryFlag,
})
broadcastGameStateToAll(roomCode)
break
}
case "close_jury_vote": {
if (!sessionId) {
sendError(ws, "Not joined")
return
}
if (!roomManager.isHost(roomCode, sessionId)) {
sendError(ws, "Only the host can close jury voting")
return
}
const gm = roomManager.getGameManager(roomCode)
if (!gm) {
sendError(ws, "Room not found")
return
}
const result = gm.closeJuryRound()
if ("error" in result) {
sendError(ws, result.error)
return
}
broadcast(roomCode, {
type: "jury_vote_closed",
countryCode: result.countryCode,
countryName: result.countryName,
countryFlag: result.countryFlag,
averageRating: result.averageRating,
totalVotes: result.totalVotes,
})
broadcastGameStateToAll(roomCode)
break
}
case "submit_jury_vote": {
if (!sessionId) {
sendError(ws, "Not joined")
return
}
if (roomManager.getRoom(roomCode)?.currentAct !== "live-event") {
sendError(ws, "Jury voting is only available during Live Event")
return
}
const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId)
const gm = roomManager.getGameManager(roomCode)
if (!playerId || !gm) {
sendError(ws, "Room not found")
return
}
const result = gm.submitJuryVote(playerId, msg.rating)
if ("error" in result) {
sendError(ws, result.error)
return
}
sendGameState(ws, roomCode, sessionId)
break
}
case "submit_actual_results": {
if (!sessionId) {
sendError(ws, "Not joined")
return
}
const room = roomManager.getRoom(roomCode)
if (room?.currentAct !== "scoring" && room?.currentAct !== "ended") {
sendError(ws, "Results can only be entered during Scoring or Ended")
return
}
if (!roomManager.isHost(roomCode, sessionId)) {
sendError(ws, "Only the host can enter actual results")
return
}
const gm = roomManager.getGameManager(roomCode)
if (!gm) {
sendError(ws, "Room not found")
return
}
const allPicks = [msg.winner, msg.second, msg.third, msg.last]
for (const code of allPicks) {
if (!gm.isValidCountry(code)) {
sendError(ws, `Invalid country: ${code}`)
return
}
}
if (new Set(allPicks).size !== 4) {
sendError(ws, "All 4 picks must be different countries")
return
}
gm.setActualResults(msg.winner, msg.second, msg.third, msg.last)
broadcastGameStateToAll(roomCode)
break
}
case "tap_bingo_square": {
if (!sessionId) {
sendError(ws, "Not joined")
return
}
if (roomManager.getRoom(roomCode)?.currentAct !== "live-event") {
sendError(ws, "Bingo is only available during Live Event")
return
}
const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId)
const gm = roomManager.getGameManager(roomCode)
if (!playerId || !gm) {
sendError(ws, "Room not found")
return
}
const result = gm.tapBingoSquare(playerId, msg.tropeId)
if ("error" in result) {
sendError(ws, result.error)
return
}
sendGameState(ws, roomCode, sessionId)
if (result.isNewBingo) {
const room = roomManager.getRoom(roomCode)
const player = room?.players.find((p) => p.sessionId === sessionId)
const displayName = player?.displayName ?? "Unknown"
const isNew = gm.addBingoAnnouncement(playerId, displayName)
if (isNew) {
broadcast(roomCode, {
type: "bingo_announced",
playerId,
displayName,
})
broadcastGameStateToAll(roomCode)
}
}
break
}
case "request_new_bingo_card": {
if (!sessionId) {
sendError(ws, "Not joined")
return
}
if (roomManager.getRoom(roomCode)?.currentAct !== "live-event") {
sendError(ws, "New bingo cards are only available during Live Event")
return
}
const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId)
const gm = roomManager.getGameManager(roomCode)
if (!playerId || !gm) {
sendError(ws, "Room not found")
return
}
const room = roomManager.getRoom(roomCode)
const player = room?.players.find((p) => p.sessionId === sessionId)
const displayName = player?.displayName ?? "Unknown"
const result = gm.requestNewBingoCard(playerId, displayName)
if ("error" in result) {
sendError(ws, result.error)
return
}
broadcastGameStateToAll(roomCode)
break
}
case "start_quiz_question": {
if (!sessionId) {
sendError(ws, "Not joined")
return
}
const room = roomManager.getRoom(roomCode)
if (room?.currentAct !== "scoring") {
sendError(ws, "Quiz is only available during Scoring")
return
}
if (!roomManager.isHost(roomCode, sessionId)) {
sendError(ws, "Only the host can start quiz questions")
return
}
const gm = roomManager.getGameManager(roomCode)
if (!gm) {
sendError(ws, "Room not found")
return
}
const result = gm.startQuizQuestion()
if ("error" in result) {
sendError(ws, result.error)
return
}
broadcastGameStateToAll(roomCode)
break
}
case "skip_quiz_question": {
if (!sessionId) {
sendError(ws, "Not joined")
return
}
if (!roomManager.isHost(roomCode, sessionId)) {
sendError(ws, "Only the host can skip quiz questions")
return
}
const gm = roomManager.getGameManager(roomCode)
if (!gm) {
sendError(ws, "Room not found")
return
}
const result = gm.skipQuizQuestion()
if ("error" in result) {
sendError(ws, result.error)
return
}
broadcastGameStateToAll(roomCode)
break
}
case "buzz": {
if (!sessionId) {
sendError(ws, "Not joined")
return
}
if (roomManager.getRoom(roomCode)?.currentAct !== "scoring") {
sendError(ws, "Quiz is only available during Scoring")
return
}
const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId)
const gm = roomManager.getGameManager(roomCode)
if (!playerId || !gm) {
sendError(ws, "Room not found")
return
}
const result = gm.buzz(playerId)
if ("error" in result) {
sendError(ws, result.error)
return
}
broadcastGameStateToAll(roomCode)
break
}
case "judge_quiz_answer": {
if (!sessionId) {
sendError(ws, "Not joined")
return
}
if (!roomManager.isHost(roomCode, sessionId)) {
sendError(ws, "Only the host can judge quiz answers")
return
}
const gm = roomManager.getGameManager(roomCode)
if (!gm) {
sendError(ws, "Room not found")
return
}
const result = gm.judgeQuizAnswer(msg.correct)
if ("error" in result) {
sendError(ws, result.error)
return
}
broadcastGameStateToAll(roomCode)
break
}
}
},
+352
View File
@@ -72,6 +72,124 @@ describe("GameManager", () => {
})
})
describe("bingo", () => {
it("generates a bingo card with 16 unique squares", () => {
gm.generateBingoCards(["p1", "p2"])
const card = gm.getBingoCard("p1")
expect(card).not.toBeNull()
expect(card!.squares).toHaveLength(16)
expect(card!.hasBingo).toBe(false)
const ids = card!.squares.map((s) => s.tropeId)
expect(new Set(ids).size).toBe(16)
})
it("generates different cards for different players", () => {
gm.generateBingoCards(["p1", "p2"])
const card1 = gm.getBingoCard("p1")!
const card2 = gm.getBingoCard("p2")!
const ids1 = card1.squares.map((s) => s.tropeId).sort()
const ids2 = card2.squares.map((s) => s.tropeId).sort()
expect(ids1).not.toEqual(ids2)
})
it("taps a bingo square", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
const tropeId = card.squares[0]!.tropeId
const result = gm.tapBingoSquare("p1", tropeId)
expect(result).toMatchObject({ success: true, hasBingo: false })
expect(gm.getBingoCard("p1")!.squares[0]!.tapped).toBe(true)
})
it("rejects tap on unknown trope", () => {
gm.generateBingoCards(["p1"])
const result = gm.tapBingoSquare("p1", "nonexistent")
expect(result).toEqual({ error: "Trope not on your card" })
})
it("rejects tap when no card exists", () => {
const result = gm.tapBingoSquare("p1", "key-change")
expect(result).toEqual({ error: "No bingo card found" })
})
it("does not untap a square (tap is one-way)", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
const tropeId = card.squares[0]!.tropeId
gm.tapBingoSquare("p1", tropeId)
gm.tapBingoSquare("p1", tropeId)
// Second tap is idempotent — square stays tapped
expect(gm.getBingoCard("p1")!.squares[0]!.tapped).toBe(true)
})
it("detects bingo on a completed row", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
let result: any
for (let i = 0; i < 4; i++) {
result = gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
expect(result).toMatchObject({ success: true, hasBingo: true })
expect(gm.getBingoCard("p1")!.hasBingo).toBe(true)
})
it("detects bingo on a completed column", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
let result: any
for (const i of [0, 4, 8, 12]) {
result = gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
expect(result).toMatchObject({ success: true, hasBingo: true })
})
it("detects bingo on a diagonal", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
let result: any
for (const i of [0, 5, 10, 15]) {
result = gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
expect(result).toMatchObject({ success: true, hasBingo: true })
})
it("bingo persists after re-tapping a completing square (tap is one-way)", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
for (let i = 0; i < 4; i++) {
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
expect(gm.getBingoCard("p1")!.hasBingo).toBe(true)
// Re-tapping is a no-op — bingo stays
gm.tapBingoSquare("p1", card.squares[0]!.tropeId)
expect(gm.getBingoCard("p1")!.hasBingo).toBe(true)
})
it("does not duplicate bingo announcements on re-bingo", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
for (let i = 0; i < 4; i++) {
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
expect(gm.addBingoAnnouncement("p1", "Player 1")).toBe(true)
gm.tapBingoSquare("p1", card.squares[0]!.tropeId) // untap
gm.tapBingoSquare("p1", card.squares[0]!.tropeId) // re-tap → re-bingo
expect(gm.addBingoAnnouncement("p1", "Player 1")).toBe(false)
expect(gm.getBingoAnnouncements()).toHaveLength(1)
})
it("computes bingo score", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
for (let i = 0; i < 4; i++) {
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
gm.tapBingoSquare("p1", card.squares[4]!.tropeId)
// 5 tapped * 2 + 10 bingo bonus = 20
expect(gm.getBingoScore("p1")).toBe(20)
})
})
describe("getGameStateForPlayer", () => {
it("includes only the requesting player's prediction", () => {
gm.submitPrediction("p1", "SE", "DE", "IT", "GB")
@@ -87,6 +205,240 @@ describe("GameManager", () => {
})
})
describe("jury voting", () => {
it("opens a jury round", () => {
const result = gm.openJuryRound("SE", "Sweden", "🇸🇪")
expect(result).toEqual({ success: true })
const round = gm.getCurrentJuryRound()
expect(round).not.toBeNull()
expect(round!.countryCode).toBe("SE")
expect(round!.status).toBe("open")
})
it("rejects opening when a round is already open", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
const result = gm.openJuryRound("DE", "Germany", "🇩🇪")
expect(result).toEqual({ error: "A jury round is already open" })
})
it("accepts a valid jury vote", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
const result = gm.submitJuryVote("p1", 8)
expect(result).toEqual({ success: true })
})
it("rejects jury vote when no round is open", () => {
const result = gm.submitJuryVote("p1", 8)
expect(result).toEqual({ error: "No jury round is open" })
})
it("rejects jury vote outside 1-12 range", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
const result = gm.submitJuryVote("p1", 0)
expect(result).toEqual({ error: "Rating must be between 1 and 12" })
})
it("allows overwriting a jury vote in the same round", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
gm.submitJuryVote("p1", 8)
gm.submitJuryVote("p1", 10)
const result = gm.closeJuryRound()
expect("averageRating" in result && result.averageRating).toBe(10)
})
it("closes a jury round and computes average", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
gm.submitJuryVote("p1", 8)
gm.submitJuryVote("p2", 10)
const result = gm.closeJuryRound()
expect(result).toMatchObject({
countryCode: "SE",
averageRating: 9,
totalVotes: 2,
})
})
it("rejects close when no round is open", () => {
const result = gm.closeJuryRound()
expect(result).toEqual({ error: "No jury round is open" })
})
it("handles closing a round with zero votes", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
const result = gm.closeJuryRound()
expect(result).toMatchObject({
countryCode: "SE",
averageRating: 0,
totalVotes: 0,
})
})
it("accumulates results across rounds", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
gm.submitJuryVote("p1", 10)
gm.closeJuryRound()
gm.openJuryRound("DE", "Germany", "🇩🇪")
gm.submitJuryVote("p1", 6)
gm.closeJuryRound()
expect(gm.getJuryResults()).toHaveLength(2)
})
it("computes jury scores based on closeness to average", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
gm.submitJuryVote("p1", 10) // avg will be 10, diff=0, score=5
gm.submitJuryVote("p2", 10) // diff=0, score=5
gm.closeJuryRound()
expect(gm.getJuryScore("p1")).toBe(5)
expect(gm.getJuryScore("p2")).toBe(5)
gm.openJuryRound("DE", "Germany", "🇩🇪")
gm.submitJuryVote("p1", 4) // avg=(4+10)/2=7, diff=3, score=2
gm.submitJuryVote("p2", 10) // diff=3, score=2
gm.closeJuryRound()
expect(gm.getJuryScore("p1")).toBe(7) // 5+2
expect(gm.getJuryScore("p2")).toBe(7) // 5+2
})
it("returns the player's current vote for a round", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
expect(gm.getPlayerJuryVote("p1")).toBeNull()
gm.submitJuryVote("p1", 7)
expect(gm.getPlayerJuryVote("p1")).toBe(7)
})
})
describe("getGameStateForPlayer (with jury + bingo)", () => {
it("includes jury round state", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
gm.submitJuryVote("p1", 8)
const state = gm.getGameStateForPlayer("p1", ["p1"])
expect(state.currentJuryRound).not.toBeNull()
expect(state.currentJuryRound!.countryCode).toBe("SE")
expect(state.myJuryVote).toBe(8)
})
it("includes bingo card", () => {
gm.generateBingoCards(["p1"])
const state = gm.getGameStateForPlayer("p1", ["p1"])
expect(state.myBingoCard).not.toBeNull()
expect(state.myBingoCard!.squares).toHaveLength(16)
})
it("includes leaderboard with jury + bingo scores", () => {
gm.generateBingoCards(["p1"])
gm.openJuryRound("SE", "Sweden", "🇸🇪")
gm.submitJuryVote("p1", 8)
gm.closeJuryRound()
const card = gm.getBingoCard("p1")!
gm.tapBingoSquare("p1", card.squares[0]!.tropeId)
const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Player 1" })
expect(state.leaderboard).toHaveLength(1)
expect(state.leaderboard[0]!.juryPoints).toBe(5) // solo voter = exact match
expect(state.leaderboard[0]!.bingoPoints).toBe(2) // 1 tapped * 2
expect(state.leaderboard[0]!.totalPoints).toBe(7)
})
})
describe("prediction scoring", () => {
it("returns 0 for all when no actual results set", () => {
const gm = new GameManager()
gm.submitPrediction("p1", "SE", "IT", "FR", "GB")
expect(gm.getPredictionScore("p1")).toBe(0)
})
it("scores correct winner", () => {
const gm = new GameManager()
gm.submitPrediction("p1", "SE", "IT", "FR", "GB")
gm.setActualResults("SE", "CH", "DE", "AL")
expect(gm.getPredictionScore("p1")).toBe(25)
})
it("scores correct second place", () => {
const gm = new GameManager()
gm.submitPrediction("p1", "NO", "IT", "FR", "GB")
gm.setActualResults("SE", "IT", "DE", "AL")
expect(gm.getPredictionScore("p1")).toBe(10)
})
it("scores correct third place", () => {
const gm = new GameManager()
gm.submitPrediction("p1", "NO", "DK", "FR", "GB")
gm.setActualResults("SE", "IT", "FR", "AL")
expect(gm.getPredictionScore("p1")).toBe(10)
})
it("scores correct last place", () => {
const gm = new GameManager()
gm.submitPrediction("p1", "NO", "DK", "FI", "GB")
gm.setActualResults("SE", "IT", "FR", "GB")
expect(gm.getPredictionScore("p1")).toBe(15)
})
it("scores perfect prediction (all correct)", () => {
const gm = new GameManager()
gm.submitPrediction("p1", "SE", "IT", "FR", "GB")
gm.setActualResults("SE", "IT", "FR", "GB")
expect(gm.getPredictionScore("p1")).toBe(60)
})
it("scores 0 for all wrong", () => {
const gm = new GameManager()
gm.submitPrediction("p1", "NO", "DK", "FI", "EE")
gm.setActualResults("SE", "IT", "FR", "GB")
expect(gm.getPredictionScore("p1")).toBe(0)
})
it("returns 0 for player with no prediction", () => {
const gm = new GameManager()
gm.setActualResults("SE", "IT", "FR", "GB")
expect(gm.getPredictionScore("p1")).toBe(0)
})
it("getActualResults returns null before setting", () => {
const gm = new GameManager()
expect(gm.getActualResults()).toBeNull()
})
it("getActualResults returns results after setting", () => {
const gm = new GameManager()
gm.setActualResults("SE", "IT", "FR", "GB")
expect(gm.getActualResults()).toEqual({ winner: "SE", second: "IT", third: "FR", last: "GB" })
})
it("setActualResults overwrites previous results", () => {
const gm = new GameManager()
gm.submitPrediction("p1", "SE", "IT", "FR", "GB")
gm.setActualResults("NO", "DK", "FI", "EE")
expect(gm.getPredictionScore("p1")).toBe(0)
gm.setActualResults("SE", "IT", "FR", "GB")
expect(gm.getPredictionScore("p1")).toBe(60)
})
it("prediction points appear in leaderboard", () => {
const gm = new GameManager()
gm.submitPrediction("p1", "SE", "IT", "FR", "GB")
gm.setActualResults("SE", "IT", "FR", "GB")
const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Alice" })
expect(state.leaderboard[0]!.predictionPoints).toBe(60)
expect(state.leaderboard[0]!.totalPoints).toBe(60)
})
it("actualResults included in game state", () => {
const gm = new GameManager()
gm.setActualResults("SE", "IT", "FR", "GB")
const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Alice" })
expect(state.actualResults).toEqual({ winner: "SE", second: "IT", third: "FR", last: "GB" })
})
it("actualResults null in game state when not set", () => {
const gm = new GameManager()
const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Alice" })
expect(state.actualResults).toBeNull()
})
})
describe("getGameStateForDisplay", () => {
it("returns null myPrediction", () => {
gm.submitPrediction("p1", "SE", "DE", "IT", "GB")
+106
View File
@@ -37,6 +37,95 @@ export const predictionSchema = z.object({
export type Prediction = z.infer<typeof predictionSchema>
// ─── Actual Results ─────────────────────────────────────────────────
export const actualResultsSchema = z.object({
winner: z.string(),
second: z.string(),
third: z.string(),
last: z.string(),
})
export type ActualResults = z.infer<typeof actualResultsSchema>
// ─── Jury Voting ────────────────────────────────────────────────────
export const juryRoundSchema = z.object({
id: z.string(),
countryCode: z.string(),
countryName: z.string(),
countryFlag: z.string(),
status: z.enum(["open", "closed"]),
})
export type JuryRound = z.infer<typeof juryRoundSchema>
export const juryResultSchema = z.object({
countryCode: z.string(),
countryName: z.string(),
countryFlag: z.string(),
averageRating: z.number(),
totalVotes: z.number(),
})
export type JuryResult = z.infer<typeof juryResultSchema>
// ─── Bingo ──────────────────────────────────────────────────────────
export const bingoSquareSchema = z.object({
tropeId: z.string(),
label: z.string(),
tapped: z.boolean(),
})
export type BingoSquare = z.infer<typeof bingoSquareSchema>
export const bingoCardSchema = z.object({
squares: z.array(bingoSquareSchema).length(16),
hasBingo: z.boolean(),
})
export type BingoCard = z.infer<typeof bingoCardSchema>
export const completedBingoCardSchema = z.object({
playerId: z.string(),
displayName: z.string(),
card: bingoCardSchema,
completedAt: z.string(),
})
export type CompletedBingoCard = z.infer<typeof completedBingoCardSchema>
// ─── Quiz ────────────────────────────────────────────────────────
export const quizQuestionSchema = z.object({
index: z.number(),
total: z.number(),
difficulty: z.enum(["easy", "medium", "hard"]),
text: z.string(),
answer: z.string(),
status: z.enum(["buzzing", "judging", "resolved"]),
buzzerPlayerId: z.string().nullable(),
buzzerName: z.string().nullable(),
wasCorrect: z.boolean().nullable(),
})
export type QuizQuestion = z.infer<typeof quizQuestionSchema>
// ─── Scoring ────────────────────────────────────────────────────────
export const leaderboardEntrySchema = z.object({
playerId: z.string(),
displayName: z.string(),
juryPoints: z.number(),
bingoPoints: z.number(),
predictionPoints: z.number(),
quizPoints: z.number(),
totalPoints: z.number(),
})
export type LeaderboardEntry = z.infer<typeof leaderboardEntrySchema>
// ─── Game State (sent to clients) ───────────────────────────────────
export const gameStateSchema = z.object({
@@ -44,6 +133,23 @@ export const gameStateSchema = z.object({
myPrediction: predictionSchema.nullable(),
predictionsLocked: z.boolean(),
predictionSubmitted: z.record(z.string(), z.boolean()),
// Jury
currentJuryRound: juryRoundSchema.nullable(),
juryResults: z.array(juryResultSchema),
myJuryVote: z.number().nullable(),
// Bingo
myBingoCard: bingoCardSchema.nullable(),
bingoAnnouncements: z.array(z.object({
playerId: z.string(),
displayName: z.string(),
})),
completedBingoCards: z.array(completedBingoCardSchema),
// Quiz
currentQuizQuestion: quizQuestionSchema.nullable(),
myQuizBuzzStatus: z.enum(["can_buzz", "already_buzzed", "excluded", "waiting"]).nullable(),
actualResults: actualResultsSchema.nullable(),
// Leaderboard
leaderboard: z.array(leaderboardEntrySchema),
})
export type GameState = z.infer<typeof gameStateSchema>
+89
View File
@@ -19,6 +19,10 @@ export const advanceActMessage = z.object({
type: z.literal("advance_act"),
})
export const revertActMessage = z.object({
type: z.literal("revert_act"),
})
export const endRoomMessage = z.object({
type: z.literal("end_room"),
})
@@ -31,12 +35,71 @@ export const submitPredictionMessage = z.object({
last: z.string(),
})
export const openJuryVoteMessage = z.object({
type: z.literal("open_jury_vote"),
countryCode: z.string(),
})
export const closeJuryVoteMessage = z.object({
type: z.literal("close_jury_vote"),
})
export const submitJuryVoteMessage = z.object({
type: z.literal("submit_jury_vote"),
rating: z.number().int().min(1).max(12),
})
export const tapBingoSquareMessage = z.object({
type: z.literal("tap_bingo_square"),
tropeId: z.string(),
})
export const requestNewBingoCardMessage = z.object({
type: z.literal("request_new_bingo_card"),
})
export const submitActualResultsMessage = z.object({
type: z.literal("submit_actual_results"),
winner: z.string(),
second: z.string(),
third: z.string(),
last: z.string(),
})
export const startQuizQuestionMessage = z.object({
type: z.literal("start_quiz_question"),
})
export const buzzMessage = z.object({
type: z.literal("buzz"),
})
export const judgeQuizAnswerMessage = z.object({
type: z.literal("judge_quiz_answer"),
correct: z.boolean(),
})
export const skipQuizQuestionMessage = z.object({
type: z.literal("skip_quiz_question"),
})
export const clientMessage = z.discriminatedUnion("type", [
joinRoomMessage,
reconnectMessage,
advanceActMessage,
revertActMessage,
endRoomMessage,
submitPredictionMessage,
openJuryVoteMessage,
closeJuryVoteMessage,
submitJuryVoteMessage,
tapBingoSquareMessage,
requestNewBingoCardMessage,
submitActualResultsMessage,
startQuizQuestionMessage,
buzzMessage,
judgeQuizAnswerMessage,
skipQuizQuestionMessage,
])
export type ClientMessage = z.infer<typeof clientMessage>
@@ -87,6 +150,29 @@ export const predictionsLockedMessage = z.object({
type: z.literal("predictions_locked"),
})
export const juryVoteOpenedMessage = z.object({
type: z.literal("jury_vote_opened"),
roundId: z.string(),
countryCode: z.string(),
countryName: z.string(),
countryFlag: z.string(),
})
export const juryVoteClosedMessage = z.object({
type: z.literal("jury_vote_closed"),
countryCode: z.string(),
countryName: z.string(),
countryFlag: z.string(),
averageRating: z.number(),
totalVotes: z.number(),
})
export const bingoAnnouncedMessage = z.object({
type: z.literal("bingo_announced"),
playerId: z.string(),
displayName: z.string(),
})
export const serverMessage = z.discriminatedUnion("type", [
roomStateMessage,
playerJoinedMessage,
@@ -97,6 +183,9 @@ export const serverMessage = z.discriminatedUnion("type", [
errorMessage,
gameStateMessage,
predictionsLockedMessage,
juryVoteOpenedMessage,
juryVoteClosedMessage,
bingoAnnouncedMessage,
])
export type ServerMessage = z.infer<typeof serverMessage>