Compare commits

...

99 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
felixfoertsch 6f1a63e4c9 fix stale act names in room-service.ts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:54:18 +01:00
felixfoertsch 4516d3743b delete dish components
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:46:57 +01:00
felixfoertsch 0561f9350b update routes: remove dish UI, update act refs, add copy-to-clipboard on lobby display
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:43:58 +01:00
felixfoertsch 42f032f67c use ACT_LABELS from shared constants in room header
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:41:49 +01:00
felixfoertsch c49b41c64e add prediction submission checkmark to player list
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:40:23 +01:00
felixfoertsch 4489c774e5 rewrite predictions form as tap-to-assign with 4 ranked slots
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:39:47 +01:00
felixfoertsch f9e01f18fd simplify room store, remove dish message handlers from websocket hook
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:38:53 +01:00
felixfoertsch aaee0f6b0d fix room manager test: update act names to match new constants
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:20:33 +01:00
felixfoertsch ae88d0ad59 rewrite game manager tests for new prediction model
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:30:36 +01:00
felixfoertsch d61d5dfa69 replace getPlayerLookup with getAllPlayerIds in room manager
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:14:28 +01:00
felixfoertsch 4932b47833 update WS handler: remove dish handlers, broadcast prediction checkmarks, lock on live-event
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:13:36 +01:00
felixfoertsch 19bbd225b2 simplify game service: remove dish persistence, update prediction columns
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:12:43 +01:00
felixfoertsch 2ba74a8773 update DB schema: rename acts, update prediction columns, remove dish/jury/bingo/quiz tables
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:12:21 +01:00
felixfoertsch 15d28ef053 rewrite game manager: ordered predictions, predictionSubmitted, remove dishes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:10:35 +01:00
felixfoertsch 518354ae75 replace esc-2026 country-only data with esc-2025 full entries (flag, artist, song)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:07:06 +01:00
felixfoertsch 5a429eb798 remove dish WS messages, update prediction message to first/second/third/last
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:05:49 +01:00
felixfoertsch 2edffdd7f9 rewrite game types: entry model with flag/artist/song, ordered predictions, remove dishes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:05:25 +01:00
felixfoertsch eed14f863c update acts to pre-show/live-event/scoring, add ACT_LABELS, remove unused constants
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:03:17 +01:00
felixfoertsch 08aa68d847 add implementation plan for issue #1 fixes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:54:41 +01:00
felixfoertsch 1d11d9becd fix spec review issues: add missing files, clarify DB migration, use Zod schemas
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:05:42 +01:00
felixfoertsch 8a296afd0d add design spec for issue #1 fixes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:03:06 +01:00
felixfoertsch 1d16badba5 add plan docs for foundation + act1 games 2026-03-12 12:29:32 +01:00
felixfoertsch d3b61e3735 rename celebrate-esc to esc in deploy script, server log 2026-03-12 12:29:31 +01:00
felixfoertsch 883b109dad fix WS handler test to drain game_state messages after connect 2026-03-12 12:26:04 +01:00
felixfoertsch 2114084234 fix host dish visibility, show correct countries to host
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:18:58 +01:00
felixfoertsch a587cd66c4 add game status to display view
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:18:26 +01:00
felixfoertsch 5d527dfc8e add game UI to host view
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:18:05 +01:00
felixfoertsch 59777a79c3 add game UI to player view
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:17:29 +01:00
felixfoertsch d6b0c62646 add game manager unit tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:16:07 +01:00
felixfoertsch 448c6ee8e6 add WS handlers for predictions, dishes game messages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:15:31 +01:00
felixfoertsch 1b0348de23 add game service for DB persistence of predictions, dishes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:13:57 +01:00
felixfoertsch 7a330c173c handle game WS messages in client hook
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:13:43 +01:00
felixfoertsch 8c2d2cefd9 wire game manager into room manager
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:13:40 +01:00
felixfoertsch f9f5afaec9 add game manager for predictions, dishes in-memory state
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:13:07 +01:00
felixfoertsch 63d1893d6c add dish UI components (player list, host controls, results)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:13:06 +01:00
felixfoertsch 544c27638c add game state to zustand store
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:12:50 +01:00
felixfoertsch a26f050688 add predictions form component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:12:28 +01:00
felixfoertsch 22bae2aa82 add WS message types for predictions, dishes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:10:52 +01:00
felixfoertsch 4ee2252dde add shared game types for predictions, dishes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:10:24 +01:00
felixfoertsch e619a5f1a9 add ESC 2026 country lineup data
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:10:02 +01:00
68 changed files with 17590 additions and 341 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=="],
}
}
+18 -18
View File
@@ -1,13 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
# celebrate-esc deploy script — idempotent, can be re-run safely
# esc deploy script — idempotent, can be re-run safely
# Target: Uberspace 8 (serve.uber.space)
HOST="serve"
SERVICE_DIR="services/celebrate-esc"
STATIC_DIR="/var/www/virtual/serve/html/celebrate-esc"
DB_NAME="celebrate_esc"
SERVICE_DIR="services/esc"
STATIC_DIR="/var/www/virtual/serve/html/esc"
DB_NAME="esc"
PORT=3006
echo "=== celebrate-esc deploy ==="
@@ -19,7 +19,7 @@ ssh "$HOST" "createdb -h localhost -p 5433 $DB_NAME 2>/dev/null || true"
# ── 2. Build client locally ──────────────────────────────────────────
echo "→ building client..."
cd packages/client
VITE_BASE="/celebrate-esc/" bun run build
VITE_BASE="/esc/" bun run build
cd ../..
# ── 3. Sync server code ─────────────────────────────────────────────
@@ -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..."
@@ -71,10 +71,10 @@ rsync -az --delete packages/client/dist/ "$HOST:$STATIC_DIR/"
# Create .htaccess for SPA routing
ssh "$HOST" "cat > $STATIC_DIR/.htaccess << 'HTACCESS'
RewriteEngine On
RewriteBase /celebrate-esc/
RewriteBase /esc/
# Don't rewrite API requests — handled by web backend
RewriteCond %{REQUEST_URI} ^/celebrate-esc/api [NC]
RewriteCond %{REQUEST_URI} ^/esc/api [NC]
RewriteRule . - [L]
RewriteCond %{REQUEST_FILENAME} !-f
@@ -84,14 +84,14 @@ HTACCESS"
# ── 8. Create systemd service ────────────────────────────────────────
echo "→ setting up systemd service..."
ssh "$HOST" "cat > ~/.config/systemd/user/celebrate-esc.service << 'UNIT'
ssh "$HOST" "cat > ~/.config/systemd/user/esc.service << 'UNIT'
[Unit]
Description=celebrate-esc API server
Description=esc API server
After=postgresql.service
[Service]
Type=simple
WorkingDirectory=%h/services/celebrate-esc/server
WorkingDirectory=%h/services/esc/server
ExecStart=/usr/bin/bun run --env-file=../.env src/index.ts
Restart=on-failure
RestartSec=5
@@ -101,18 +101,18 @@ WantedBy=default.target
UNIT
systemctl --user daemon-reload
systemctl --user enable celebrate-esc.service
systemctl --user restart celebrate-esc.service"
systemctl --user enable esc.service
systemctl --user restart esc.service"
# ── 9. Set up web backend routing ────────────────────────────────────
echo "→ configuring web backend routing..."
ssh "$HOST" "uberspace web backend add /celebrate-esc/api PORT $PORT --remove-prefix --force 2>/dev/null || true"
ssh "$HOST" "uberspace web backend add /esc/api PORT $PORT --remove-prefix --force 2>/dev/null || true"
# ── 10. Verify ────────────────────────────────────────────────────────
echo "→ verifying deployment..."
sleep 2
ssh "$HOST" "systemctl --user status celebrate-esc.service --no-pager | head -5"
ssh "$HOST" "systemctl --user status esc.service --no-pager | head -5"
echo ""
echo "=== deploy complete ==="
echo "Frontend: https://serve.uber.space/celebrate-esc/"
echo "API: https://serve.uber.space/celebrate-esc/api/health"
echo "Frontend: https://serve.uber.space/esc/"
echo "API: https://serve.uber.space/esc/api/health"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
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,174 @@
# Issue #1 Fixes — Design Spec
**Goal:** Address all items in Gitea issue #1 — rework predictions to use full ESC entries with tap-to-assign UI, remove Dish of the Nation, rename acts, add player submission indicators, and make lobby code copyable.
**Source:** https://git.felixfoertsch.de/felixfoertsch/esc/issues/1
---
## 1. Entry Data Model
Replace the current country-only lineup with full ESC entries (shown as Zod schemas, matching codebase conventions):
```ts
const entrySchema = z.object({
country: z.object({ code: z.string(), name: z.string(), flag: z.string() }),
artist: z.string(),
song: z.string(),
})
const lineupSchema = z.object({
year: z.number(),
entries: z.array(entrySchema),
})
```
Note: The existing `countrySchema` gains a `flag` field — this is a breaking change to the country object shape throughout the codebase.
The data file changes from `esc-2026.json` to `esc-2025.json` using real ESC 2025 entries for testing. Each entry includes the flag emoji in the data file.
Display format everywhere: `🇩🇪 Abor — Süden`
## 2. Prediction Model
Replace the current `{ predictedWinner, top3[], nulPointsPick }` with 4 ordered picks:
```ts
type Prediction = {
playerId: string
first: string // country code
second: string // country code
third: string // country code
last: string // country code
}
```
**Validation:** All 4 picks must be distinct country codes from the lineup.
**Scoring model** (for future implementation):
- Any of 1st/2nd/3rd picks landing in the actual top 3 → points
- 1st pick matching actual winner → bonus points
- Last pick matching actual last place → bonus points
## 3. Prediction UI — Tap-to-Assign
**Top section — 4 slot cards:**
- "1st Place", "2nd Place", "3rd Place", "Last Place"
- Empty slots show placeholder text
- Filled slots show the entry (flag + artist + song) with a tap-to-remove action
**Bottom section — scrollable entry list:**
- All entries from the lineup
- Already-assigned entries are dimmed/disabled
- Tapping an unassigned entry shows a popover with only unfilled slot options (1st/2nd/3rd/Last)
- After selecting a slot, the entry fills that slot, the popover closes, and the entry dims in the list
**Submit button:** Appears when all 4 slots are filled. After submission or when predictions are locked, the UI becomes read-only showing assigned entries in their slots.
**Locked state:** When advancing past Pre-Show, predictions lock. The form shows the player's submitted picks (or "Not submitted" if they missed the window).
## 4. Remove Dish of the Nation
Strip the entire Dish of the Nation feature:
**Server:**
- Remove dish WS message handlers from `handler.ts`
- Remove dish methods from `GameManager`
- Remove dish persistence from `GameService`
- Remove dish DB tables (`dishes`, `dish_guesses`) from schema
**Client:**
- Delete `dish-list.tsx`, `dish-host.tsx`, `dish-results.tsx`
- Remove dish state slices and actions from `room-store.ts`
- Remove dish WS message handlers from `use-websocket.ts`
- Remove dish UI from routes (`play`, `host`, `display`)
**Shared:**
- Remove dish schemas from `game-types.ts`
- Remove dish WS message types from `ws-messages.ts`
## 5. Player List — Prediction Checkmark
Add prediction submission status to the game state broadcast:
- Shared: Add `predictionSubmitted: Map<playerId, boolean>` (or equivalent) to `gameStateSchema` in `game-types.ts`. This lives on the game state, not the player schema, since it's game-specific data.
- Server: When building game state in `GameManager.getGameStateForPlayer()` / `getGameStateForDisplay()`, include which players have submitted predictions.
- Client: `player-list.tsx` reads from game state and renders a checkmark icon (✓) next to player names that have submitted predictions.
- Visible on all views (play, host, display).
## 6. Acts Naming
Rename internal act identifiers and add display names:
```ts
const ACTS = ["lobby", "pre-show", "live-event", "scoring", "ended"] as const
```
| Internal ID | Display Name | Timing Intent |
|----------------|---------------|----------------------------------|
| `lobby` | Lobby | Waiting room, players join |
| `pre-show` | Pre-Show | Before broadcast, predictions |
| `live-event` | Live Event | During broadcast |
| `scoring` | Scoring | After results, leaderboard |
| `ended` | Ended | Party over |
Host control buttons: "Start Pre-Show", "Start Live Event", "Start Scoring", "End Party".
Predictions lock when advancing from Pre-Show to Live Event (previously act1 → act2).
**DB migration:** The Postgres `actEnum` must be updated from `["lobby", "act1", "act2", "act3", "ended"]` to `["lobby", "pre-show", "live-event", "scoring", "ended"]`. Since there is no production data to preserve, drop and recreate the enum (via Drizzle `push` or a migration). The `predictions` table columns also change from `predictedWinner/top3/nulPointsPick` to `first/second/third/last` — same approach, drop and recreate.
## 7. Lobby Code — Copy to Clipboard
On the display view (and anywhere the room code is shown prominently):
- Wrap the room code in a tappable/clickable element
- On click: `navigator.clipboard.writeText(roomCode)`
- Show brief "Copied!" feedback (tooltip or temporary text swap)
- Style to indicate interactivity (cursor pointer, subtle hover state)
## Data Flow Changes
### Prediction Flow (updated)
1. Client taps entry → selects slot → slot fills in UI
2. Client fills all 4 slots → submits `submit_prediction` with `{ first, second, third, last }`
3. Server validates: all 4 distinct, all valid country codes
4. Server stores in GameManager, persists to DB
5. Server broadcasts updated game state (includes `hasSubmittedPrediction` per player)
6. All clients update player list checkmarks
### Act Progression (updated names)
```
lobby → pre-show → live-event → scoring → ended
```
Predictions lock on `pre-show → live-event` transition.
## Files Affected
### Modified
- `packages/shared/src/game-types.ts` — entry/lineup schemas, prediction model, add predictionSubmitted to game state
- `packages/shared/src/ws-messages.ts` — remove dish messages, update prediction message
- `packages/shared/src/constants.ts` — act names
- `packages/server/data/` — replace `esc-2026.json` with `esc-2025.json`
- `packages/server/src/games/game-manager.ts` — remove dish logic, update prediction logic
- `packages/server/src/games/game-service.ts` — remove dish persistence, update prediction columns
- `packages/server/src/rooms/room-manager.ts` — act name references
- `packages/server/src/ws/handler.ts` — remove dish handlers, update prediction handler
- `packages/server/src/db/schema.ts` — remove dish tables, update prediction columns, update actEnum values
- `packages/server/tests/game-manager.test.ts` — rewrite for new model
- `packages/server/tests/ws-handler.test.ts` — update for changed messages
- `packages/client/src/stores/room-store.ts` — remove dish state, update game state shape
- `packages/client/src/hooks/use-websocket.ts` — remove dish handlers
- `packages/client/src/components/predictions-form.tsx` — rewrite as tap-to-assign
- `packages/client/src/components/player-list.tsx` — add prediction checkmark
- `packages/client/src/routes/play.$roomCode.tsx` — remove dish UI
- `packages/client/src/routes/host.$roomCode.tsx` — remove dish UI
- `packages/client/src/routes/display.$roomCode.tsx` — remove dish UI, add copy-to-clipboard
### Deleted
- `packages/client/src/components/dish-list.tsx`
- `packages/client/src/components/dish-host.tsx`
- `packages/client/src/components/dish-results.tsx`
### Created
- `packages/server/data/esc-2025.json` — full ESC 2025 entry data
@@ -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>
)
}
@@ -4,9 +4,10 @@ import type { Player } from "@celebrate-esc/shared"
interface PlayerListProps {
players: Player[]
mySessionId: string | null
predictionSubmitted?: Record<string, boolean>
}
export function PlayerList({ players, mySessionId }: PlayerListProps) {
export function PlayerList({ players, mySessionId, predictionSubmitted }: PlayerListProps) {
return (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-medium text-muted-foreground">Players ({players.length})</h3>
@@ -16,7 +17,7 @@ export function PlayerList({ players, mySessionId }: PlayerListProps) {
<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 && (
@@ -24,8 +25,8 @@ export function PlayerList({ players, mySessionId }: PlayerListProps) {
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>
)}
</li>
))}
@@ -0,0 +1,239 @@
import { useState } from "react"
import type { Entry, Prediction, ActualResults } from "@celebrate-esc/shared"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
type SlotKey = "first" | "second" | "third" | "last"
const SLOTS: { key: SlotKey; label: string }[] = [
{ key: "first", label: "1st Place" },
{ 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 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, actualResults, onSubmit }: PredictionsFormProps) {
const [slots, setSlots] = useState<Record<SlotKey, string | null>>(() => {
if (existingPrediction) {
return {
first: existingPrediction.first,
second: existingPrediction.second,
third: existingPrediction.third,
last: existingPrediction.last,
}
}
return { first: 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 }))
}
if (locked) {
if (!existingPrediction) {
return (
<Card>
<CardContent className="py-6 text-center text-muted-foreground">
Predictions are locked. You didn't submit one in time.
</CardContent>
</Card>
)
}
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>
)
}
// Already submitted — show read-only with option to change
if (existingPrediction && allFilled) {
const hasChanges = SLOTS.some((s) => slots[s.key] !== existingPrediction[s.key])
if (!hasChanges) {
return (
<Card>
<CardHeader>
<CardTitle>Your Predictions (submitted)</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{SLOTS.map((slot) => {
const entry = findEntry(existingPrediction[slot.key])
return (
<div key={slot.key} className="flex items-center justify-between rounded-md border p-2">
<div className="flex items-center gap-2">
<span className="w-20 text-xs font-medium text-muted-foreground">{slot.label}</span>
<span className="text-sm">{entry ? formatEntry(entry) : "—"}</span>
</div>
<button
type="button"
onClick={() => removeFromSlot(slot.key)}
className="text-xs text-muted-foreground hover:text-foreground"
>
change
</button>
</div>
)
})}
</CardContent>
</Card>
)
}
}
return (
<Card>
<CardHeader>
<CardTitle>Predictions</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{/* Slot cards */}
<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>
{/* Submit button */}
{allFilled && (
<Button
onClick={() =>
onSubmit({
first: slots.first!,
second: slots.second!,
third: slots.third!,
last: slots.last!,
})
}
>
{existingPrediction ? "Update Prediction" : "Submit Prediction"}
</Button>
)}
{/* Entry list */}
<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,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>
)
}
@@ -1,4 +1,5 @@
import { Badge } from "@/components/ui/badge"
import { ACT_LABELS } from "@celebrate-esc/shared"
import type { Act } from "@celebrate-esc/shared"
interface RoomHeaderProps {
@@ -7,20 +8,12 @@ interface RoomHeaderProps {
connectionStatus: "disconnected" | "connecting" | "connected"
}
const actLabels: Record<Act, string> = {
lobby: "Lobby",
act1: "Act 1",
act2: "Act 2",
act3: "Act 3",
ended: "Ended",
}
export function RoomHeader({ roomCode, currentAct, connectionStatus }: RoomHeaderProps) {
return (
<div className="flex items-center justify-between border-b p-4">
<div className="flex items-center gap-3">
<span className="font-mono text-2xl font-bold tracking-widest">{roomCode}</span>
<Badge variant="outline">{actLabels[currentAct]}</Badge>
<Badge variant="outline">{ACT_LABELS[currentAct]}</Badge>
</div>
<span
className={`h-2 w-2 rounded-full ${
@@ -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>
)
}
+19 -2
View File
@@ -28,6 +28,9 @@ export function useWebSocket(roomCode: string) {
addPlayer,
setAct,
reset,
setGameState,
lockPredictions,
setSend,
} = useRoomStore()
const send = useCallback((message: ClientMessage) => {
@@ -36,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
@@ -64,7 +71,6 @@ export function useWebSocket(roomCode: string) {
setMySessionId(msg.sessionId)
storeSession(roomCode, msg.sessionId)
} else if (sessionId) {
// Reconnected with stored session
setMySessionId(sessionId)
}
break
@@ -84,6 +90,17 @@ export function useWebSocket(roomCode: string) {
case "room_ended":
setAct("ended")
break
case "game_state":
setGameState(msg.gameState)
break
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
@@ -98,7 +115,7 @@ export function useWebSocket(roomCode: string) {
ws.close()
reset()
}
}, [roomCode, setRoom, setMySessionId, setConnectionStatus, updatePlayerConnected, addPlayer, setAct, reset])
}, [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)
+122 -10
View File
@@ -1,8 +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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
export const Route = createFileRoute("/display/$roomCode")({
component: DisplayView,
@@ -11,7 +18,7 @@ export const Route = createFileRoute("/display/$roomCode")({
function DisplayView() {
const { roomCode } = Route.useParams()
useWebSocket(roomCode)
const { room, connectionStatus } = useRoomStore()
const { room, connectionStatus, gameState } = useRoomStore()
if (!room) {
return (
@@ -28,29 +35,134 @@ function DisplayView() {
<RoomHeader roomCode={roomCode} currentAct={room.currentAct} connectionStatus={connectionStatus} />
<div className="flex flex-1 flex-col items-center justify-center gap-8 p-8">
{room.currentAct === "lobby" && <LobbyDisplay roomCode={roomCode} />}
<PlayerList players={room.players} mySessionId={null} />
{room.currentAct === "pre-show" && gameState && (
<div className="flex flex-col items-center gap-4 py-12">
<p className="text-2xl text-muted-foreground">Pre-Show Predictions</p>
<p className="text-lg text-muted-foreground">
{Object.values(gameState.predictionSubmitted).filter(Boolean).length} / {Object.keys(gameState.predictionSubmitted).length} predictions submitted
</p>
</div>
)}
{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>
)}
<PlayerList
players={room.players}
mySessionId={null}
predictionSubmitted={gameState?.predictionSubmitted}
/>
</div>
</div>
)
}
function LobbyDisplay({ roomCode }: { roomCode: string }) {
const joinUrl = `${window.location.origin}/play/${roomCode}`
const [copied, setCopied] = useState(false)
const base = import.meta.env.BASE_URL.replace(/\/$/, "")
const joinUrl = `${window.location.origin}${base}/play/${roomCode}`
function copyCode() {
navigator.clipboard.writeText(roomCode).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
return (
<div className="flex flex-col items-center gap-6">
<h2 className="text-2xl text-muted-foreground">Join the party!</h2>
<div className="rounded-lg border-4 border-dashed border-muted p-8">
<button
type="button"
onClick={copyCode}
className="cursor-pointer rounded-lg border-4 border-dashed border-muted p-8 transition-colors hover:border-primary/50"
title="Click to copy room code"
>
<span className="font-mono text-8xl font-bold tracking-[0.3em]">{roomCode}</span>
</div>
</button>
<p className="text-muted-foreground">
{copied ? (
<span className="font-medium text-green-600">Copied!</span>
) : (
<>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>
<p className="text-sm text-muted-foreground">or scan the QR code</p>
{/* QR code will be added in Plan 5 (polish) */}
<div className="flex h-48 w-48 items-center justify-center rounded-lg border-2 border-dashed border-muted">
<span className="text-sm text-muted-foreground">QR code</span>
</div>
</div>
)
}
@@ -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 -65
View File
@@ -1,28 +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 { 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 Act 1",
act1: "Start Act 2",
act2: "Start Act 3",
act3: "End Party",
}
function HostView() {
function HostLayout() {
const { roomCode } = Route.useParams()
const { send } = useWebSocket(roomCode)
const { room, mySessionId, connectionStatus } = useRoomStore()
useWebSocket(roomCode)
const { room, connectionStatus } = useRoomStore()
if (!room) {
return (
@@ -35,53 +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">
<PlayerList players={room.players} mySessionId={mySessionId} />
{/* Game UI will be added in later plans */}
</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} />
</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 })
},
})
+8 -24
View File
@@ -2,23 +2,22 @@ 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 { 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 } = useRoomStore()
const joinSentRef = useRef(false)
const [manualName, setManualName] = useState("")
// Auto-send join_room when connected for the first time (no existing session)
useEffect(() => {
if (connectionStatus !== "connected" || mySessionId || joinSentRef.current) return
@@ -40,8 +39,6 @@ function PlayerView() {
)
}
// Fallback: if no stored display name and no session (e.g., direct URL access),
// show a name input form
if (!mySessionId && connectionStatus === "connected" && !joinSentRef.current) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-4">
@@ -82,22 +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" && (
<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>
)}
{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>
)}
{/* Game UI will be added in later plans */}
<PlayerList players={room.players} mySessionId={mySessionId} />
</div>
</div>
<>
<RoomLayout roomCode={roomCode} connectionStatus={connectionStatus} />
<BottomNav basePath="/play/$roomCode" roomCode={roomCode} isHost={false} />
</>
)
}
+23 -3
View File
@@ -1,10 +1,12 @@
import { create } from "zustand"
import type { RoomState, Player } 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
@@ -12,13 +14,20 @@ interface RoomStore {
updatePlayerConnected: (playerId: string, connected: boolean) => void
addPlayer: (player: Player) => void
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 }),
@@ -38,7 +47,6 @@ export const useRoomStore = create<RoomStore>((set) => ({
addPlayer: (player) =>
set((state) => {
if (!state.room) return state
// Avoid duplicates
if (state.room.players.some((p) => p.id === player.id)) return state
return {
room: {
@@ -54,5 +62,17 @@ export const useRoomStore = create<RoomStore>((set) => ({
return { room: { ...state.room, currentAct: act } }
}),
reset: () => set({ room: null, mySessionId: null, connectionStatus: "disconnected" }),
setGameState: (gameState) => set({ gameState }),
lockPredictions: () =>
set((state) => {
if (!state.gameState) return state
return {
gameState: { ...state.gameState, predictionsLocked: true },
}
}),
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" }
]
+190
View File
@@ -0,0 +1,190 @@
{
"year": 2025,
"entries": [
{
"country": { "code": "AL", "name": "Albania", "flag": "🇦🇱" },
"artist": "Shkodra Elektronike",
"song": "Zjerm"
},
{
"country": { "code": "AM", "name": "Armenia", "flag": "🇦🇲" },
"artist": "Parg",
"song": "Survivor"
},
{
"country": { "code": "AU", "name": "Australia", "flag": "🇦🇺" },
"artist": "Go-Jo",
"song": "Milkshake Man"
},
{
"country": { "code": "AT", "name": "Austria", "flag": "🇦🇹" },
"artist": "JJ",
"song": "Wasted Love"
},
{
"country": { "code": "AZ", "name": "Azerbaijan", "flag": "🇦🇿" },
"artist": "Mamagama",
"song": "Run With U"
},
{
"country": { "code": "BE", "name": "Belgium", "flag": "🇧🇪" },
"artist": "Red Sebastian",
"song": "Strobe Lights"
},
{
"country": { "code": "HR", "name": "Croatia", "flag": "🇭🇷" },
"artist": "Marko Bošnjak",
"song": "Poison Cake"
},
{
"country": { "code": "CY", "name": "Cyprus", "flag": "🇨🇾" },
"artist": "Theo Evan",
"song": "Shh"
},
{
"country": { "code": "CZ", "name": "Czechia", "flag": "🇨🇿" },
"artist": "Adonxs",
"song": "Kiss Kiss Goodbye"
},
{
"country": { "code": "DK", "name": "Denmark", "flag": "🇩🇰" },
"artist": "Sissal",
"song": "Hallucination"
},
{
"country": { "code": "EE", "name": "Estonia", "flag": "🇪🇪" },
"artist": "Tommy Cash",
"song": "Espresso Macchiato"
},
{
"country": { "code": "FI", "name": "Finland", "flag": "🇫🇮" },
"artist": "Erika Vikman",
"song": "Ich komme"
},
{
"country": { "code": "FR", "name": "France", "flag": "🇫🇷" },
"artist": "Louane",
"song": "Maman"
},
{
"country": { "code": "GE", "name": "Georgia", "flag": "🇬🇪" },
"artist": "Mariam Shengelia",
"song": "Freedom"
},
{
"country": { "code": "DE", "name": "Germany", "flag": "🇩🇪" },
"artist": "Abor & Tynna",
"song": "Baller"
},
{
"country": { "code": "GR", "name": "Greece", "flag": "🇬🇷" },
"artist": "Klavdia",
"song": "Asteromáta"
},
{
"country": { "code": "IS", "name": "Iceland", "flag": "🇮🇸" },
"artist": "Væb",
"song": "Róa"
},
{
"country": { "code": "IE", "name": "Ireland", "flag": "🇮🇪" },
"artist": "Emmy",
"song": "Laika Party"
},
{
"country": { "code": "IL", "name": "Israel", "flag": "🇮🇱" },
"artist": "Yuval Raphael",
"song": "New Day Will Rise"
},
{
"country": { "code": "IT", "name": "Italy", "flag": "🇮🇹" },
"artist": "Lucio Corsi",
"song": "Volevo essere un duro"
},
{
"country": { "code": "LV", "name": "Latvia", "flag": "🇱🇻" },
"artist": "Tautumeitas",
"song": "Bur man laimi"
},
{
"country": { "code": "LT", "name": "Lithuania", "flag": "🇱🇹" },
"artist": "Katarsis",
"song": "Tavo akys"
},
{
"country": { "code": "LU", "name": "Luxembourg", "flag": "🇱🇺" },
"artist": "Laura Thorn",
"song": "La poupée monte le son"
},
{
"country": { "code": "MT", "name": "Malta", "flag": "🇲🇹" },
"artist": "Miriana Conte",
"song": "Serving"
},
{
"country": { "code": "ME", "name": "Montenegro", "flag": "🇲🇪" },
"artist": "Nina Žižić",
"song": "Dobrodošli"
},
{
"country": { "code": "NL", "name": "Netherlands", "flag": "🇳🇱" },
"artist": "Claude",
"song": "C'est La Vie"
},
{
"country": { "code": "NO", "name": "Norway", "flag": "🇳🇴" },
"artist": "Kyle Alessandro",
"song": "Lighter"
},
{
"country": { "code": "PL", "name": "Poland", "flag": "🇵🇱" },
"artist": "Justyna Steczkowska",
"song": "Gaja"
},
{
"country": { "code": "PT", "name": "Portugal", "flag": "🇵🇹" },
"artist": "Napa",
"song": "Deslocado"
},
{
"country": { "code": "SM", "name": "San Marino", "flag": "🇸🇲" },
"artist": "Gabry Ponte",
"song": "Tutta l'Italia"
},
{
"country": { "code": "RS", "name": "Serbia", "flag": "🇷🇸" },
"artist": "Princ",
"song": "Mila"
},
{
"country": { "code": "SI", "name": "Slovenia", "flag": "🇸🇮" },
"artist": "Klemen",
"song": "How Much Time Do We Have Left"
},
{
"country": { "code": "ES", "name": "Spain", "flag": "🇪🇸" },
"artist": "Melody",
"song": "Esa diva"
},
{
"country": { "code": "SE", "name": "Sweden", "flag": "🇸🇪" },
"artist": "KAJ",
"song": "Bara bada bastu"
},
{
"country": { "code": "CH", "name": "Switzerland", "flag": "🇨🇭" },
"artist": "Zoë Më",
"song": "Voyage"
},
{
"country": { "code": "UA", "name": "Ukraine", "flag": "🇺🇦" },
"artist": "Ziferblat",
"song": "Bird of Pray"
},
{
"country": { "code": "GB", "name": "United Kingdom", "flag": "🇬🇧" },
"artist": "Remember Monday",
"song": "What The Hell Just Happened?"
}
]
}
+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
}
+11 -56
View File
@@ -1,8 +1,6 @@
import { boolean, integer, jsonb, pgEnum, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core"
export const actEnum = pgEnum("act", ["lobby", "act1", "act2", "act3", "ended"])
export const juryRoundStatusEnum = pgEnum("jury_round_status", ["open", "closed"])
export const quizRoundStatusEnum = pgEnum("quiz_round_status", ["showing", "buzzing", "judging", "resolved"])
export const actEnum = pgEnum("act", ["lobby", "pre-show", "live-event", "scoring", "ended"])
// ─── Room System ────────────────────────────────────────────────────
@@ -31,7 +29,7 @@ export const players = pgTable("players", {
joinedAt: timestamp("joined_at").notNull().defaultNow(),
})
// ─── Predictions (Plan 2) ──────────────────────────────────────────
// ─── Predictions ────────────────────────────────────────────────────
export const predictions = pgTable("predictions", {
id: uuid("id").primaryKey().defaultRandom(),
@@ -41,12 +39,15 @@ export const predictions = pgTable("predictions", {
roomId: uuid("room_id")
.notNull()
.references(() => rooms.id),
predictedWinner: varchar("predicted_winner").notNull(),
top3: jsonb("top_3").notNull().$type<string[]>(),
nulPointsPick: varchar("nul_points_pick").notNull(),
first: varchar("first").notNull(),
second: varchar("second").notNull(),
third: varchar("third").notNull(),
last: varchar("last").notNull(),
})
// ─── Jury Voting (Plan 3) ──────────────────────────────────────────
// ─── Jury Voting ────────────────────────────────────────────────────
export const juryRoundStatusEnum = pgEnum("jury_round_status", ["open", "closed"])
export const juryRounds = pgTable("jury_rounds", {
id: uuid("id").primaryKey().defaultRandom(),
@@ -69,7 +70,7 @@ export const juryVotes = pgTable("jury_votes", {
rating: integer("rating").notNull(),
})
// ─── Bingo (Plan 3) ────────────────────────────────────────────────
// ─── Bingo ──────────────────────────────────────────────────────────
export const bingoCards = pgTable("bingo_cards", {
id: uuid("id").primaryKey().defaultRandom(),
@@ -79,51 +80,5 @@ export const bingoCards = pgTable("bingo_cards", {
roomId: uuid("room_id")
.notNull()
.references(() => rooms.id),
squares: jsonb("squares").notNull().$type<{ tropeId: string; tapped: boolean }[]>(),
})
// ─── Dishes (Plan 2) ───────────────────────────────────────────────
export const dishes = pgTable("dishes", {
id: uuid("id").primaryKey().defaultRandom(),
roomId: uuid("room_id")
.notNull()
.references(() => rooms.id),
name: varchar("name", { length: 100 }).notNull(),
correctCountry: varchar("correct_country").notNull(),
revealed: boolean("revealed").notNull().default(false),
})
export const dishGuesses = pgTable("dish_guesses", {
id: uuid("id").primaryKey().defaultRandom(),
playerId: uuid("player_id")
.notNull()
.references(() => players.id),
dishId: uuid("dish_id")
.notNull()
.references(() => dishes.id),
guessedCountry: varchar("guessed_country").notNull(),
})
// ─── Quiz (Plan 4) ─────────────────────────────────────────────────
export const quizRounds = pgTable("quiz_rounds", {
id: uuid("id").primaryKey().defaultRandom(),
roomId: uuid("room_id")
.notNull()
.references(() => rooms.id),
questionId: varchar("question_id").notNull(),
status: quizRoundStatusEnum("status").notNull().default("showing"),
})
export const quizAnswers = pgTable("quiz_answers", {
id: uuid("id").primaryKey().defaultRandom(),
playerId: uuid("player_id")
.notNull()
.references(() => players.id),
quizRoundId: uuid("quiz_round_id")
.notNull()
.references(() => quizRounds.id),
buzzedAt: timestamp("buzzed_at").notNull().defaultNow(),
correct: boolean("correct"),
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)
})
})
})
+557
View File
@@ -0,0 +1,557 @@
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))
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
}
isValidCountry(code: string): boolean {
return countryCodes.has(code)
}
// ─── Predictions ────────────────────────────────────────────────
arePredictionsLocked(): boolean {
return this.locked
}
lockPredictions(): void {
this.locked = true
}
submitPrediction(
playerId: string,
first: string,
second: string,
third: string,
last: string,
): { success: true } | { error: string } {
if (this.locked) return { error: "Predictions are locked" }
const allPicks = [first, second, third, last]
for (const code of allPicks) {
if (!this.isValidCountry(code)) return { error: `Invalid country: ${code}` }
}
if (new Set(allPicks).size !== 4) {
return { error: "All 4 picks must be different countries" }
}
this.predictions.set(playerId, { playerId, first, second, third, last })
return { success: true }
}
getPrediction(playerId: string): Prediction | null {
return this.predictions.get(playerId) ?? null
}
getAllPredictions(): Map<string, Prediction> {
return this.predictions
}
hasPrediction(playerId: string): boolean {
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> {
const result: Record<string, boolean> = {}
for (const id of playerIds) {
result[id] = this.predictions.has(id)
}
return result
}
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[],
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)
}
}
+29
View File
@@ -0,0 +1,29 @@
import { eq, and } from "drizzle-orm"
import type { Database } from "../db/client"
import { predictions } from "../db/schema"
export class GameService {
constructor(private db: Database) {}
async persistPrediction(data: {
playerId: string
roomId: string
first: string
second: string
third: string
last: string
}) {
// Delete existing prediction for this player+room, then insert
await this.db
.delete(predictions)
.where(and(eq(predictions.playerId, data.playerId), eq(predictions.roomId, data.roomId)))
await this.db.insert(predictions).values({
playerId: data.playerId,
roomId: data.roomId,
first: data.first,
second: data.second,
third: data.third,
last: data.last,
})
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { env } from "./env"
registerWebSocketRoutes()
const server = serve({ fetch: app.fetch, port: env.PORT }, (info) => {
console.log(`celebrate-esc server running on http://localhost:${info.port}`)
console.log(`esc server running on http://localhost:${info.port}`)
})
injectWebSocket(server)
+42
View File
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto"
import { ACTS, MAX_PLAYERS, ROOM_CODE_CHARS, ROOM_CODE_LENGTH, ROOM_EXPIRY_HOURS } from "@celebrate-esc/shared"
import type { Act, RoomState } from "@celebrate-esc/shared"
import { GameManager } from "../games/game-manager"
interface InternalPlayer {
id: string
@@ -18,6 +19,7 @@ interface InternalRoom {
players: Map<string, InternalPlayer> // sessionId -> player
createdAt: Date
expiresAt: Date
gameManager: GameManager
}
export class RoomManager {
@@ -44,6 +46,7 @@ export class RoomManager {
players: new Map([[sessionId, host]]),
createdAt: now,
expiresAt: new Date(now.getTime() + ROOM_EXPIRY_HOURS * 60 * 60 * 1000),
gameManager: new GameManager(),
}
this.rooms.set(code, room)
@@ -84,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" }
@@ -140,6 +155,33 @@ export class RoomManager {
return room?.hostSessionId === sessionId
}
getGameManager(code: string): GameManager | null {
const room = this.rooms.get(code)
return room?.gameManager ?? null
}
getAllPlayerIds(code: string): string[] {
const room = this.rooms.get(code)
if (!room) return []
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
return room.players.get(sessionId)?.id ?? null
}
/** Clear all rooms -- used in tests */
reset(): void {
this.rooms.clear()
+3 -3
View File
@@ -17,14 +17,14 @@ export class RoomService {
.values({
id: room.id,
code: room.code,
currentAct: room.currentAct as "lobby" | "act1" | "act2" | "act3" | "ended",
currentAct: room.currentAct as "lobby" | "pre-show" | "live-event" | "scoring" | "ended",
hostSessionId: room.hostSessionId,
expiresAt: room.expiresAt,
})
.onConflictDoUpdate({
target: rooms.id,
set: {
currentAct: room.currentAct as "lobby" | "act1" | "act2" | "act3" | "ended",
currentAct: room.currentAct as "lobby" | "pre-show" | "live-event" | "scoring" | "ended",
},
})
}
@@ -56,7 +56,7 @@ export class RoomService {
async updateRoomAct(roomId: string, act: string) {
await this.db
.update(rooms)
.set({ currentAct: act as "lobby" | "act1" | "act2" | "act3" | "ended" })
.set({ currentAct: act as "lobby" | "pre-show" | "live-event" | "scoring" | "ended" })
.where(eq(rooms.id, roomId))
}
+404 -5
View File
@@ -41,6 +41,50 @@ function sendError(ws: WSContext, message: string) {
sendTo(ws, { type: "error", message })
}
function sendGameState(ws: WSContext, roomCode: string, sessionId: string) {
const gm = roomManager.getGameManager(roomCode)
const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId)
if (!gm || !playerId) return
const allPlayerIds = roomManager.getAllPlayerIds(roomCode)
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 })
}
function sendDisplayGameState(ws: WSContext, roomCode: string) {
const gm = roomManager.getGameManager(roomCode)
if (!gm) return
const allPlayerIds = roomManager.getAllPlayerIds(roomCode)
const displayNames = roomManager.getPlayerDisplayNames(roomCode)
const gameState = gm.getGameStateForDisplay(allPlayerIds, displayNames)
sendTo(ws, { type: "game_state", gameState })
}
function broadcastGameStateToAll(roomCode: string) {
const conns = roomConnections.get(roomCode)
if (!conns) return
for (const conn of conns) {
try {
if (conn.sessionId) {
sendGameState(conn.ws, roomCode, conn.sessionId)
} else {
sendDisplayGameState(conn.ws, roomCode)
}
} catch {
// Connection may be closed
}
}
}
let registered = false
export function registerWebSocketRoutes() {
@@ -66,7 +110,6 @@ export function registerWebSocketRoutes() {
connection = { ws, sessionId }
getConnections(roomCode).add(connection)
// If sessionId provided, attempt reconnect
if (sessionId) {
const result = roomManager.reconnectPlayer(roomCode, sessionId)
if ("error" in result) {
@@ -79,17 +122,18 @@ export function registerWebSocketRoutes() {
type: "room_state",
room: roomManager.getRoom(roomCode)!,
})
sendGameState(ws, roomCode, sessionId)
broadcast(roomCode, {
type: "player_reconnected",
playerId: result.playerId,
})
}
} else {
// Passive viewer (display) or player about to send join_room
sendTo(ws, {
type: "room_state",
room: roomManager.getRoom(roomCode)!,
})
sendDisplayGameState(ws, roomCode)
}
},
@@ -125,14 +169,22 @@ export function registerWebSocketRoutes() {
if (connection) connection.sessionId = sessionId
roomManager.setPlayerConnected(roomCode, sessionId, true)
// Send room state with session ID to the new player
sendTo(ws, {
type: "room_state",
room: roomManager.getRoom(roomCode)!,
sessionId: result.sessionId,
})
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)
}
}
// Broadcast player joined to everyone
const room = roomManager.getRoom(roomCode)!
const newPlayer = room.players.find((p) => p.sessionId === sessionId)!
broadcast(roomCode, {
@@ -155,6 +207,7 @@ export function registerWebSocketRoutes() {
type: "room_state",
room: roomManager.getRoom(roomCode)!,
})
sendGameState(ws, roomCode, msg.sessionId)
broadcast(roomCode, {
type: "player_reconnected",
playerId: result.playerId,
@@ -176,10 +229,39 @@ export function registerWebSocketRoutes() {
type: "act_changed",
newAct: result.newAct,
})
// 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
@@ -192,6 +274,323 @@ export function registerWebSocketRoutes() {
broadcast(roomCode, { type: "room_ended" })
break
}
case "submit_prediction": {
if (!sessionId) {
sendError(ws, "Not joined")
return
}
const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId)
const gm = roomManager.getGameManager(roomCode)
if (!playerId || !gm) {
sendError(ws, "Room not found")
return
}
const result = gm.submitPrediction(playerId, msg.first, msg.second, msg.third, msg.last)
if ("error" in result) {
sendError(ws, result.error)
return
}
// Broadcast game state to all so everyone sees the checkmark update
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
}
}
},
+455
View File
@@ -0,0 +1,455 @@
import { describe, it, expect, beforeEach } from "vitest"
import { GameManager } from "../src/games/game-manager"
describe("GameManager", () => {
let gm: GameManager
beforeEach(() => {
gm = new GameManager()
})
describe("lineup", () => {
it("returns the ESC 2025 lineup", () => {
const lineup = gm.getLineup()
expect(lineup.year).toBe(2025)
expect(lineup.entries.length).toBeGreaterThan(20)
expect(lineup.entries[0]).toHaveProperty("country")
expect(lineup.entries[0]).toHaveProperty("artist")
expect(lineup.entries[0]).toHaveProperty("song")
expect(lineup.entries[0]?.country).toHaveProperty("flag")
})
it("validates country codes", () => {
expect(gm.isValidCountry("DE")).toBe(true)
expect(gm.isValidCountry("XX")).toBe(false)
})
})
describe("predictions", () => {
it("accepts a valid prediction", () => {
const result = gm.submitPrediction("p1", "SE", "DE", "IT", "GB")
expect(result).toEqual({ success: true })
expect(gm.getPrediction("p1")).toEqual({
playerId: "p1",
first: "SE",
second: "DE",
third: "IT",
last: "GB",
})
})
it("rejects prediction with invalid country", () => {
const result = gm.submitPrediction("p1", "XX", "DE", "IT", "GB")
expect(result).toEqual({ error: "Invalid country: XX" })
})
it("rejects duplicate picks", () => {
const result = gm.submitPrediction("p1", "SE", "SE", "IT", "GB")
expect(result).toEqual({ error: "All 4 picks must be different countries" })
})
it("rejects last same as first", () => {
const result = gm.submitPrediction("p1", "SE", "DE", "IT", "SE")
expect(result).toEqual({ error: "All 4 picks must be different countries" })
})
it("allows overwriting a prediction", () => {
gm.submitPrediction("p1", "SE", "DE", "IT", "GB")
gm.submitPrediction("p1", "NO", "DE", "IT", "GB")
expect(gm.getPrediction("p1")?.first).toBe("NO")
})
it("rejects prediction when locked", () => {
gm.lockPredictions()
const result = gm.submitPrediction("p1", "SE", "DE", "IT", "GB")
expect(result).toEqual({ error: "Predictions are locked" })
})
it("tracks prediction submission status", () => {
gm.submitPrediction("p1", "SE", "DE", "IT", "GB")
expect(gm.hasPrediction("p1")).toBe(true)
expect(gm.hasPrediction("p2")).toBe(false)
})
})
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")
gm.submitPrediction("p2", "NO", "DE", "IT", "GB")
const state = gm.getGameStateForPlayer("p1", ["p1", "p2"])
expect(state.myPrediction?.first).toBe("SE")
})
it("includes predictionSubmitted for all players", () => {
gm.submitPrediction("p1", "SE", "DE", "IT", "GB")
const state = gm.getGameStateForPlayer("p1", ["p1", "p2"])
expect(state.predictionSubmitted).toEqual({ p1: true, p2: false })
})
})
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")
const state = gm.getGameStateForDisplay(["p1"])
expect(state.myPrediction).toBeNull()
})
it("includes predictionSubmitted", () => {
gm.submitPrediction("p1", "SE", "DE", "IT", "GB")
const state = gm.getGameStateForDisplay(["p1", "p2"])
expect(state.predictionSubmitted).toEqual({ p1: true, p2: false })
})
})
})
+1 -1
View File
@@ -96,7 +96,7 @@ describe("RoomManager", () => {
const room = manager.getRoom(code)!
const hostSession = room.hostSessionId
const expectedSequence: Act[] = ["act1", "act2", "act3", "ended"]
const expectedSequence: Act[] = ["pre-show", "live-event", "scoring", "ended"]
for (const expected of expectedSequence) {
const result = manager.advanceAct(code, hostSession)
expect(result).toEqual({ newAct: expected })
+20 -6
View File
@@ -21,6 +21,20 @@ function waitForMessage(ws: WebSocket): Promise<unknown> {
})
}
/** Consume messages until one with the given type arrives */
function waitForMessageType(ws: WebSocket, type: string): Promise<unknown> {
return new Promise((resolve) => {
function handler(event: MessageEvent) {
const msg = JSON.parse(event.data as string) as { type: string }
if (msg.type === type) {
ws.removeEventListener("message", handler)
resolve(msg)
}
}
ws.addEventListener("message", handler)
})
}
function waitForOpen(ws: WebSocket): Promise<void> {
return new Promise((resolve) => {
if (ws.readyState === WebSocket.OPEN) {
@@ -75,19 +89,19 @@ describe("WebSocket handler", () => {
})
const { data } = (await res.json()) as { data: { code: string; sessionId: string } }
// Connect host
// Connect host — consumes room_state + game_state from onOpen
const hostWs = new WebSocket(`ws://localhost:${port}/ws/${data.code}?sessionId=${data.sessionId}`)
await waitForOpen(hostWs)
await waitForMessage(hostWs) // room_state
await waitForMessageType(hostWs, "game_state") // drain initial messages
// Connect player (no sessionId)
// Connect player (no sessionId — passive until join_room)
const playerWs = new WebSocket(`ws://localhost:${port}/ws/${data.code}`)
await waitForOpen(playerWs)
await waitForMessage(playerWs) // initial room_state
await waitForMessageType(playerWs, "game_state") // drain initial messages
// Set up listeners BEFORE sending to avoid race conditions
const playerMsgPromise = waitForMessage(playerWs)
const hostMsgPromise = waitForMessage(hostWs)
const playerMsgPromise = waitForMessageType(playerWs, "room_state")
const hostMsgPromise = waitForMessageType(hostWs, "player_joined")
// Player sends join_room
playerWs.send(JSON.stringify({ type: "join_room", displayName: "Player 1" }))
+8 -8
View File
@@ -5,13 +5,13 @@ export const ROOM_EXPIRY_HOURS = 12
/** Characters used for room codes — excludes I/O/0/1 to avoid confusion */
export const ROOM_CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
export const ACTS = ["lobby", "act1", "act2", "act3", "ended"] as const
export const ACTS = ["lobby", "pre-show", "live-event", "scoring", "ended"] as const
export type Act = (typeof ACTS)[number]
/** Rating range for jury voting (Eurovision convention: 1-12) */
export const JURY_RATING_MIN = 1
export const JURY_RATING_MAX = 12
/** Bingo grid dimensions */
export const BINGO_GRID_SIZE = 4
export const BINGO_TOTAL_SQUARES = BINGO_GRID_SIZE * BINGO_GRID_SIZE
export const ACT_LABELS: Record<Act, string> = {
lobby: "Lobby",
"pre-show": "Pre-Show",
"live-event": "Live Event",
scoring: "Scoring",
ended: "Ended",
}
+155
View File
@@ -0,0 +1,155 @@
import { z } from "zod"
// ─── Entry Lineup ───────────────────────────────────────────────────
export const countrySchema = z.object({
code: z.string(),
name: z.string(),
flag: z.string(),
})
export type Country = z.infer<typeof countrySchema>
export const entrySchema = z.object({
country: countrySchema,
artist: z.string(),
song: z.string(),
})
export type Entry = z.infer<typeof entrySchema>
export const lineupSchema = z.object({
year: z.number(),
entries: z.array(entrySchema),
})
export type Lineup = z.infer<typeof lineupSchema>
// ─── Predictions ────────────────────────────────────────────────────
export const predictionSchema = z.object({
playerId: z.string().uuid(),
first: z.string(),
second: z.string(),
third: z.string(),
last: z.string(),
})
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({
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(),
})),
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>
+1
View File
@@ -1,3 +1,4 @@
export * from "./constants"
export * from "./game-types"
export * from "./room-types"
export * from "./ws-messages"
+110 -2
View File
@@ -1,5 +1,6 @@
import { z } from "zod"
import { ACTS } from "./constants"
import { gameStateSchema } from "./game-types"
import { playerSchema, roomStateSchema } from "./room-types"
// ─── Client → Server ───────────────────────────────────────────────
@@ -18,16 +19,87 @@ 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"),
})
/** Union of all client → server messages (room system only — games add more) */
export const submitPredictionMessage = z.object({
type: z.literal("submit_prediction"),
first: z.string(),
second: z.string(),
third: z.string(),
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>
@@ -69,7 +141,38 @@ export const errorMessage = z.object({
message: z.string(),
})
/** Union of all server → client messages (room system only) */
export const gameStateMessage = z.object({
type: z.literal("game_state"),
gameState: gameStateSchema,
})
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,
@@ -78,6 +181,11 @@ export const serverMessage = z.discriminatedUnion("type", [
actChangedMessage,
roomEndedMessage,
errorMessage,
gameStateMessage,
predictionsLockedMessage,
juryVoteOpenedMessage,
juryVoteClosedMessage,
bingoAnnouncedMessage,
])
export type ServerMessage = z.infer<typeof serverMessage>