Compare commits
99 Commits
0cd1d9d2f6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f1d44de04 | |||
| 2b51448b83 | |||
| e03b374911 | |||
| d056647bbd | |||
| 3ec8803711 | |||
| 7e1007ebf8 | |||
| 998ac07867 | |||
| 971f4110c1 | |||
| f3d407ee21 | |||
| 9e88e99827 | |||
| af0499d354 | |||
| 0784b4b077 | |||
| dda8c4a2ef | |||
| 25f61d456c | |||
| b095ce0d69 | |||
| f9e493cd9d | |||
| cdf878fe9b | |||
| 142455cdb8 | |||
| d3a5d08d6b | |||
| 43268d8d86 | |||
| d11c7780e9 | |||
| 634b953911 | |||
| 4ada13ca9f | |||
| 8caf9ab2f3 | |||
| 48986137db | |||
| 6cc164dfe5 | |||
| ec7e6fd869 | |||
| 01f78e920e | |||
| 3b470787b5 | |||
| ef0f88551d | |||
| f390c21903 | |||
| c7a11e80d3 | |||
| 44ea815f95 | |||
| 00e17d1f28 | |||
| 8372769c9b | |||
| f0dc35610e | |||
| 4e06930796 | |||
| 38a0c9f55a | |||
| 4cfff0eaa5 | |||
| f22dba6134 | |||
| 60a5962519 | |||
| a71308f6f0 | |||
| 611a1bf732 | |||
| c768d7340a | |||
| f6223ae9fa | |||
| 7f5dba6e03 | |||
| 8ee9295b4e | |||
| 094fd1feeb | |||
| d247c2519e | |||
| 302f2e14c0 | |||
| ceba5521dc | |||
| 7cb52291f3 | |||
| aedd3c032a | |||
| b79ddb9679 | |||
| 0703364945 | |||
| e48ee2ca35 | |||
| 9ec0225e4b | |||
| c31f849de3 | |||
| 0019024066 | |||
| 6f1a63e4c9 | |||
| 4516d3743b | |||
| 0561f9350b | |||
| 42f032f67c | |||
| c49b41c64e | |||
| 4489c774e5 | |||
| f9e01f18fd | |||
| aaee0f6b0d | |||
| ae88d0ad59 | |||
| d61d5dfa69 | |||
| 4932b47833 | |||
| 19bbd225b2 | |||
| 2ba74a8773 | |||
| 15d28ef053 | |||
| 518354ae75 | |||
| 5a429eb798 | |||
| 2edffdd7f9 | |||
| eed14f863c | |||
| 08aa68d847 | |||
| 1d11d9becd | |||
| 8a296afd0d | |||
| 1d16badba5 | |||
| d3b61e3735 | |||
| 883b109dad | |||
| 2114084234 | |||
| a587cd66c4 | |||
| 5d527dfc8e | |||
| 59777a79c3 | |||
| d6b0c62646 | |||
| 448c6ee8e6 | |||
| 1b0348de23 | |||
| 7a330c173c | |||
| 8c2d2cefd9 | |||
| f9f5afaec9 | |||
| 63d1893d6c | |||
| 544c27638c | |||
| a26f050688 | |||
| 22bae2aa82 | |||
| 4ee2252dde | |||
| e619a5f1a9 |
@@ -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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" }}>We❤️Eurovision</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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -13,6 +13,15 @@ import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as PlayRoomCodeRouteImport } from './routes/play.$roomCode'
|
||||
import { Route as HostRoomCodeRouteImport } from './routes/host.$roomCode'
|
||||
import { Route as DisplayRoomCodeRouteImport } from './routes/display.$roomCode'
|
||||
import { Route as PlayRoomCodeIndexRouteImport } from './routes/play.$roomCode.index'
|
||||
import { Route as HostRoomCodeIndexRouteImport } from './routes/host.$roomCode.index'
|
||||
import { Route as PlayRoomCodeGameRouteImport } from './routes/play.$roomCode.game'
|
||||
import { Route as PlayRoomCodeBoardRouteImport } from './routes/play.$roomCode.board'
|
||||
import { Route as PlayRoomCodeBingoRouteImport } from './routes/play.$roomCode.bingo'
|
||||
import { Route as HostRoomCodeHostRouteImport } from './routes/host.$roomCode.host'
|
||||
import { Route as HostRoomCodeGameRouteImport } from './routes/host.$roomCode.game'
|
||||
import { Route as HostRoomCodeBoardRouteImport } from './routes/host.$roomCode.board'
|
||||
import { Route as HostRoomCodeBingoRouteImport } from './routes/host.$roomCode.bingo'
|
||||
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
@@ -34,44 +43,147 @@ const DisplayRoomCodeRoute = DisplayRoomCodeRouteImport.update({
|
||||
path: '/display/$roomCode',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PlayRoomCodeIndexRoute = PlayRoomCodeIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => PlayRoomCodeRoute,
|
||||
} as any)
|
||||
const HostRoomCodeIndexRoute = HostRoomCodeIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => HostRoomCodeRoute,
|
||||
} as any)
|
||||
const PlayRoomCodeGameRoute = PlayRoomCodeGameRouteImport.update({
|
||||
id: '/game',
|
||||
path: '/game',
|
||||
getParentRoute: () => PlayRoomCodeRoute,
|
||||
} as any)
|
||||
const PlayRoomCodeBoardRoute = PlayRoomCodeBoardRouteImport.update({
|
||||
id: '/board',
|
||||
path: '/board',
|
||||
getParentRoute: () => PlayRoomCodeRoute,
|
||||
} as any)
|
||||
const PlayRoomCodeBingoRoute = PlayRoomCodeBingoRouteImport.update({
|
||||
id: '/bingo',
|
||||
path: '/bingo',
|
||||
getParentRoute: () => PlayRoomCodeRoute,
|
||||
} as any)
|
||||
const HostRoomCodeHostRoute = HostRoomCodeHostRouteImport.update({
|
||||
id: '/host',
|
||||
path: '/host',
|
||||
getParentRoute: () => HostRoomCodeRoute,
|
||||
} as any)
|
||||
const HostRoomCodeGameRoute = HostRoomCodeGameRouteImport.update({
|
||||
id: '/game',
|
||||
path: '/game',
|
||||
getParentRoute: () => HostRoomCodeRoute,
|
||||
} as any)
|
||||
const HostRoomCodeBoardRoute = HostRoomCodeBoardRouteImport.update({
|
||||
id: '/board',
|
||||
path: '/board',
|
||||
getParentRoute: () => HostRoomCodeRoute,
|
||||
} as any)
|
||||
const HostRoomCodeBingoRoute = HostRoomCodeBingoRouteImport.update({
|
||||
id: '/bingo',
|
||||
path: '/bingo',
|
||||
getParentRoute: () => HostRoomCodeRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/display/$roomCode': typeof DisplayRoomCodeRoute
|
||||
'/host/$roomCode': typeof HostRoomCodeRoute
|
||||
'/play/$roomCode': typeof PlayRoomCodeRoute
|
||||
'/host/$roomCode': typeof HostRoomCodeRouteWithChildren
|
||||
'/play/$roomCode': typeof PlayRoomCodeRouteWithChildren
|
||||
'/host/$roomCode/bingo': typeof HostRoomCodeBingoRoute
|
||||
'/host/$roomCode/board': typeof HostRoomCodeBoardRoute
|
||||
'/host/$roomCode/game': typeof HostRoomCodeGameRoute
|
||||
'/host/$roomCode/host': typeof HostRoomCodeHostRoute
|
||||
'/play/$roomCode/bingo': typeof PlayRoomCodeBingoRoute
|
||||
'/play/$roomCode/board': typeof PlayRoomCodeBoardRoute
|
||||
'/play/$roomCode/game': typeof PlayRoomCodeGameRoute
|
||||
'/host/$roomCode/': typeof HostRoomCodeIndexRoute
|
||||
'/play/$roomCode/': typeof PlayRoomCodeIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/display/$roomCode': typeof DisplayRoomCodeRoute
|
||||
'/host/$roomCode': typeof HostRoomCodeRoute
|
||||
'/play/$roomCode': typeof PlayRoomCodeRoute
|
||||
'/host/$roomCode/bingo': typeof HostRoomCodeBingoRoute
|
||||
'/host/$roomCode/board': typeof HostRoomCodeBoardRoute
|
||||
'/host/$roomCode/game': typeof HostRoomCodeGameRoute
|
||||
'/host/$roomCode/host': typeof HostRoomCodeHostRoute
|
||||
'/play/$roomCode/bingo': typeof PlayRoomCodeBingoRoute
|
||||
'/play/$roomCode/board': typeof PlayRoomCodeBoardRoute
|
||||
'/play/$roomCode/game': typeof PlayRoomCodeGameRoute
|
||||
'/host/$roomCode': typeof HostRoomCodeIndexRoute
|
||||
'/play/$roomCode': typeof PlayRoomCodeIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/display/$roomCode': typeof DisplayRoomCodeRoute
|
||||
'/host/$roomCode': typeof HostRoomCodeRoute
|
||||
'/play/$roomCode': typeof PlayRoomCodeRoute
|
||||
'/host/$roomCode': typeof HostRoomCodeRouteWithChildren
|
||||
'/play/$roomCode': typeof PlayRoomCodeRouteWithChildren
|
||||
'/host/$roomCode/bingo': typeof HostRoomCodeBingoRoute
|
||||
'/host/$roomCode/board': typeof HostRoomCodeBoardRoute
|
||||
'/host/$roomCode/game': typeof HostRoomCodeGameRoute
|
||||
'/host/$roomCode/host': typeof HostRoomCodeHostRoute
|
||||
'/play/$roomCode/bingo': typeof PlayRoomCodeBingoRoute
|
||||
'/play/$roomCode/board': typeof PlayRoomCodeBoardRoute
|
||||
'/play/$roomCode/game': typeof PlayRoomCodeGameRoute
|
||||
'/host/$roomCode/': typeof HostRoomCodeIndexRoute
|
||||
'/play/$roomCode/': typeof PlayRoomCodeIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/display/$roomCode' | '/host/$roomCode' | '/play/$roomCode'
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/display/$roomCode'
|
||||
| '/host/$roomCode'
|
||||
| '/play/$roomCode'
|
||||
| '/host/$roomCode/bingo'
|
||||
| '/host/$roomCode/board'
|
||||
| '/host/$roomCode/game'
|
||||
| '/host/$roomCode/host'
|
||||
| '/play/$roomCode/bingo'
|
||||
| '/play/$roomCode/board'
|
||||
| '/play/$roomCode/game'
|
||||
| '/host/$roomCode/'
|
||||
| '/play/$roomCode/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/display/$roomCode' | '/host/$roomCode' | '/play/$roomCode'
|
||||
to:
|
||||
| '/'
|
||||
| '/display/$roomCode'
|
||||
| '/host/$roomCode/bingo'
|
||||
| '/host/$roomCode/board'
|
||||
| '/host/$roomCode/game'
|
||||
| '/host/$roomCode/host'
|
||||
| '/play/$roomCode/bingo'
|
||||
| '/play/$roomCode/board'
|
||||
| '/play/$roomCode/game'
|
||||
| '/host/$roomCode'
|
||||
| '/play/$roomCode'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/display/$roomCode'
|
||||
| '/host/$roomCode'
|
||||
| '/play/$roomCode'
|
||||
| '/host/$roomCode/bingo'
|
||||
| '/host/$roomCode/board'
|
||||
| '/host/$roomCode/game'
|
||||
| '/host/$roomCode/host'
|
||||
| '/play/$roomCode/bingo'
|
||||
| '/play/$roomCode/board'
|
||||
| '/play/$roomCode/game'
|
||||
| '/host/$roomCode/'
|
||||
| '/play/$roomCode/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
DisplayRoomCodeRoute: typeof DisplayRoomCodeRoute
|
||||
HostRoomCodeRoute: typeof HostRoomCodeRoute
|
||||
PlayRoomCodeRoute: typeof PlayRoomCodeRoute
|
||||
HostRoomCodeRoute: typeof HostRoomCodeRouteWithChildren
|
||||
PlayRoomCodeRoute: typeof PlayRoomCodeRouteWithChildren
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -104,14 +216,115 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof DisplayRoomCodeRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/play/$roomCode/': {
|
||||
id: '/play/$roomCode/'
|
||||
path: '/'
|
||||
fullPath: '/play/$roomCode/'
|
||||
preLoaderRoute: typeof PlayRoomCodeIndexRouteImport
|
||||
parentRoute: typeof PlayRoomCodeRoute
|
||||
}
|
||||
'/host/$roomCode/': {
|
||||
id: '/host/$roomCode/'
|
||||
path: '/'
|
||||
fullPath: '/host/$roomCode/'
|
||||
preLoaderRoute: typeof HostRoomCodeIndexRouteImport
|
||||
parentRoute: typeof HostRoomCodeRoute
|
||||
}
|
||||
'/play/$roomCode/game': {
|
||||
id: '/play/$roomCode/game'
|
||||
path: '/game'
|
||||
fullPath: '/play/$roomCode/game'
|
||||
preLoaderRoute: typeof PlayRoomCodeGameRouteImport
|
||||
parentRoute: typeof PlayRoomCodeRoute
|
||||
}
|
||||
'/play/$roomCode/board': {
|
||||
id: '/play/$roomCode/board'
|
||||
path: '/board'
|
||||
fullPath: '/play/$roomCode/board'
|
||||
preLoaderRoute: typeof PlayRoomCodeBoardRouteImport
|
||||
parentRoute: typeof PlayRoomCodeRoute
|
||||
}
|
||||
'/play/$roomCode/bingo': {
|
||||
id: '/play/$roomCode/bingo'
|
||||
path: '/bingo'
|
||||
fullPath: '/play/$roomCode/bingo'
|
||||
preLoaderRoute: typeof PlayRoomCodeBingoRouteImport
|
||||
parentRoute: typeof PlayRoomCodeRoute
|
||||
}
|
||||
'/host/$roomCode/host': {
|
||||
id: '/host/$roomCode/host'
|
||||
path: '/host'
|
||||
fullPath: '/host/$roomCode/host'
|
||||
preLoaderRoute: typeof HostRoomCodeHostRouteImport
|
||||
parentRoute: typeof HostRoomCodeRoute
|
||||
}
|
||||
'/host/$roomCode/game': {
|
||||
id: '/host/$roomCode/game'
|
||||
path: '/game'
|
||||
fullPath: '/host/$roomCode/game'
|
||||
preLoaderRoute: typeof HostRoomCodeGameRouteImport
|
||||
parentRoute: typeof HostRoomCodeRoute
|
||||
}
|
||||
'/host/$roomCode/board': {
|
||||
id: '/host/$roomCode/board'
|
||||
path: '/board'
|
||||
fullPath: '/host/$roomCode/board'
|
||||
preLoaderRoute: typeof HostRoomCodeBoardRouteImport
|
||||
parentRoute: typeof HostRoomCodeRoute
|
||||
}
|
||||
'/host/$roomCode/bingo': {
|
||||
id: '/host/$roomCode/bingo'
|
||||
path: '/bingo'
|
||||
fullPath: '/host/$roomCode/bingo'
|
||||
preLoaderRoute: typeof HostRoomCodeBingoRouteImport
|
||||
parentRoute: typeof HostRoomCodeRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface HostRoomCodeRouteChildren {
|
||||
HostRoomCodeBingoRoute: typeof HostRoomCodeBingoRoute
|
||||
HostRoomCodeBoardRoute: typeof HostRoomCodeBoardRoute
|
||||
HostRoomCodeGameRoute: typeof HostRoomCodeGameRoute
|
||||
HostRoomCodeHostRoute: typeof HostRoomCodeHostRoute
|
||||
HostRoomCodeIndexRoute: typeof HostRoomCodeIndexRoute
|
||||
}
|
||||
|
||||
const HostRoomCodeRouteChildren: HostRoomCodeRouteChildren = {
|
||||
HostRoomCodeBingoRoute: HostRoomCodeBingoRoute,
|
||||
HostRoomCodeBoardRoute: HostRoomCodeBoardRoute,
|
||||
HostRoomCodeGameRoute: HostRoomCodeGameRoute,
|
||||
HostRoomCodeHostRoute: HostRoomCodeHostRoute,
|
||||
HostRoomCodeIndexRoute: HostRoomCodeIndexRoute,
|
||||
}
|
||||
|
||||
const HostRoomCodeRouteWithChildren = HostRoomCodeRoute._addFileChildren(
|
||||
HostRoomCodeRouteChildren,
|
||||
)
|
||||
|
||||
interface PlayRoomCodeRouteChildren {
|
||||
PlayRoomCodeBingoRoute: typeof PlayRoomCodeBingoRoute
|
||||
PlayRoomCodeBoardRoute: typeof PlayRoomCodeBoardRoute
|
||||
PlayRoomCodeGameRoute: typeof PlayRoomCodeGameRoute
|
||||
PlayRoomCodeIndexRoute: typeof PlayRoomCodeIndexRoute
|
||||
}
|
||||
|
||||
const PlayRoomCodeRouteChildren: PlayRoomCodeRouteChildren = {
|
||||
PlayRoomCodeBingoRoute: PlayRoomCodeBingoRoute,
|
||||
PlayRoomCodeBoardRoute: PlayRoomCodeBoardRoute,
|
||||
PlayRoomCodeGameRoute: PlayRoomCodeGameRoute,
|
||||
PlayRoomCodeIndexRoute: PlayRoomCodeIndexRoute,
|
||||
}
|
||||
|
||||
const PlayRoomCodeRouteWithChildren = PlayRoomCodeRoute._addFileChildren(
|
||||
PlayRoomCodeRouteChildren,
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
DisplayRoomCodeRoute: DisplayRoomCodeRoute,
|
||||
HostRoomCodeRoute: HostRoomCodeRoute,
|
||||
PlayRoomCodeRoute: PlayRoomCodeRoute,
|
||||
HostRoomCodeRoute: HostRoomCodeRouteWithChildren,
|
||||
PlayRoomCodeRoute: PlayRoomCodeRouteWithChildren,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -1,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 })
|
||||
},
|
||||
})
|
||||
@@ -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 })
|
||||
},
|
||||
})
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
}))
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
@@ -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?"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -7,6 +7,5 @@
|
||||
"bingo_full_bonus": 10,
|
||||
"quiz_easy": 5,
|
||||
"quiz_medium": 10,
|
||||
"quiz_hard": 15,
|
||||
"dish_correct": 5
|
||||
"quiz_hard": 15
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
|
||||
@@ -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" }))
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
export * from "./constants"
|
||||
export * from "./game-types"
|
||||
export * from "./room-types"
|
||||
export * from "./ws-messages"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user