Compare commits
59 Commits
6f1a63e4c9
...
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 |
@@ -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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,8 +60,8 @@ PORT=$PORT
|
||||
ENVFILE"
|
||||
|
||||
# ── 6. Run migrations ────────────────────────────────────────────────
|
||||
echo "→ running database migrations..."
|
||||
ssh "$HOST" "cd ~/$SERVICE_DIR/server && DATABASE_URL=postgresql://localhost:5433/$DB_NAME bun drizzle-kit migrate"
|
||||
echo "→ pushing database schema..."
|
||||
ssh "$HOST" "cd ~/$SERVICE_DIR/server && DATABASE_URL=postgresql://localhost:5433/$DB_NAME bun drizzle-kit push --force"
|
||||
|
||||
# ── 7. Deploy static client files ────────────────────────────────────
|
||||
echo "→ deploying client static files..."
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,866 @@
|
||||
# Prediction Scoring Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Allow the host to enter actual ESC results and score player predictions against them.
|
||||
|
||||
**Architecture:** Extend `GameManager` with actual results storage and prediction scoring. Add a new WS message type `submit_actual_results`. Extend `GameState`, `LeaderboardEntry` schemas with prediction data. Add a host-side form and player-side result indicators.
|
||||
|
||||
**Tech Stack:** Zod, Hono WebSocket, React, shadcn/ui, Vitest
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Action | File | Responsibility |
|
||||
|--------|------|---------------|
|
||||
| Modify | `packages/shared/src/game-types.ts` | Add `actualResultsSchema`, `predictionPoints` to leaderboard, `actualResults` to game state |
|
||||
| Modify | `packages/shared/src/ws-messages.ts` | Add `submit_actual_results` client message |
|
||||
| Modify | `packages/server/src/games/game-manager.ts` | `setActualResults`, `getPredictionScore`, update `buildLeaderboard` |
|
||||
| Modify | `packages/server/src/ws/handler.ts` | Handle `submit_actual_results` |
|
||||
| Modify | `packages/server/tests/game-manager.test.ts` | Tests for prediction scoring |
|
||||
| Create | `packages/client/src/components/actual-results-form.tsx` | Host form to enter actual ESC results |
|
||||
| Modify | `packages/client/src/components/predictions-form.tsx` | Show correct/incorrect markers when results are in |
|
||||
| Modify | `packages/client/src/components/leaderboard.tsx` | Add P: column, update scoring explanation |
|
||||
| Modify | `packages/client/src/routes/host.$roomCode.tsx` | Show `ActualResultsForm` in scoring/ended |
|
||||
| Modify | `packages/client/src/routes/play.$roomCode.tsx` | Pass `actualResults` to predictions form in scoring/ended |
|
||||
| Modify | `packages/client/src/routes/display.$roomCode.tsx` | Show actual results summary |
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Prediction Scoring
|
||||
|
||||
### Task 1: Extend shared types
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/shared/src/game-types.ts`
|
||||
|
||||
- [ ] **Step 1: Add `actualResultsSchema` and extend `LeaderboardEntry` and `GameState`**
|
||||
|
||||
In `packages/shared/src/game-types.ts`, add after the `Prediction` type block:
|
||||
|
||||
```ts
|
||||
// ─── Actual Results ─────────────────────────────────────────────────
|
||||
|
||||
export const actualResultsSchema = z.object({
|
||||
winner: z.string(),
|
||||
second: z.string(),
|
||||
third: z.string(),
|
||||
last: z.string(),
|
||||
})
|
||||
|
||||
export type ActualResults = z.infer<typeof actualResultsSchema>
|
||||
```
|
||||
|
||||
Update `leaderboardEntrySchema` to add `predictionPoints`:
|
||||
|
||||
```ts
|
||||
export const leaderboardEntrySchema = z.object({
|
||||
playerId: z.string(),
|
||||
displayName: z.string(),
|
||||
juryPoints: z.number(),
|
||||
bingoPoints: z.number(),
|
||||
predictionPoints: z.number(),
|
||||
totalPoints: z.number(),
|
||||
})
|
||||
```
|
||||
|
||||
Update `gameStateSchema` to add `actualResults`:
|
||||
|
||||
```ts
|
||||
export const gameStateSchema = z.object({
|
||||
lineup: lineupSchema,
|
||||
myPrediction: predictionSchema.nullable(),
|
||||
predictionsLocked: z.boolean(),
|
||||
predictionSubmitted: z.record(z.string(), z.boolean()),
|
||||
// Jury
|
||||
currentJuryRound: juryRoundSchema.nullable(),
|
||||
juryResults: z.array(juryResultSchema),
|
||||
myJuryVote: z.number().nullable(),
|
||||
// Bingo
|
||||
myBingoCard: bingoCardSchema.nullable(),
|
||||
bingoAnnouncements: z.array(z.object({
|
||||
playerId: z.string(),
|
||||
displayName: z.string(),
|
||||
})),
|
||||
// Predictions
|
||||
actualResults: actualResultsSchema.nullable(),
|
||||
// Leaderboard
|
||||
leaderboard: z.array(leaderboardEntrySchema),
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify build**
|
||||
|
||||
Run: `bun run --filter './packages/shared' build 2>&1 || echo 'no build script, check tsc'`
|
||||
Expected: No type errors in shared package
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/shared/src/game-types.ts
|
||||
git commit -m "add actual results schema, prediction points to leaderboard and game state"
|
||||
```
|
||||
|
||||
### Task 2: Add WS message type
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/shared/src/ws-messages.ts`
|
||||
|
||||
- [ ] **Step 1: Add `submitActualResultsMessage`**
|
||||
|
||||
After the `tapBingoSquareMessage` definition, add:
|
||||
|
||||
```ts
|
||||
export const submitActualResultsMessage = z.object({
|
||||
type: z.literal("submit_actual_results"),
|
||||
winner: z.string(),
|
||||
second: z.string(),
|
||||
third: z.string(),
|
||||
last: z.string(),
|
||||
})
|
||||
```
|
||||
|
||||
Add it to the `clientMessage` discriminated union array:
|
||||
|
||||
```ts
|
||||
export const clientMessage = z.discriminatedUnion("type", [
|
||||
joinRoomMessage,
|
||||
reconnectMessage,
|
||||
advanceActMessage,
|
||||
revertActMessage,
|
||||
endRoomMessage,
|
||||
submitPredictionMessage,
|
||||
openJuryVoteMessage,
|
||||
closeJuryVoteMessage,
|
||||
submitJuryVoteMessage,
|
||||
tapBingoSquareMessage,
|
||||
submitActualResultsMessage,
|
||||
])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/shared/src/ws-messages.ts
|
||||
git commit -m "add submit_actual_results WS message type"
|
||||
```
|
||||
|
||||
### Task 3: Add prediction scoring to GameManager — tests first
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/server/tests/game-manager.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for prediction scoring**
|
||||
|
||||
Add a new `describe("prediction scoring")` block at the end of the test file:
|
||||
|
||||
```ts
|
||||
describe("prediction scoring", () => {
|
||||
it("returns 0 for all when no actual results set", () => {
|
||||
const gm = new GameManager()
|
||||
gm.submitPrediction("p1", "SE", "IT", "FR", "UK")
|
||||
expect(gm.getPredictionScore("p1")).toBe(0)
|
||||
})
|
||||
|
||||
it("scores correct winner", () => {
|
||||
const gm = new GameManager()
|
||||
gm.submitPrediction("p1", "SE", "IT", "FR", "UK")
|
||||
gm.setActualResults("SE", "CH", "DE", "AL")
|
||||
expect(gm.getPredictionScore("p1")).toBe(25) // prediction_winner
|
||||
})
|
||||
|
||||
it("scores correct second place", () => {
|
||||
const gm = new GameManager()
|
||||
gm.submitPrediction("p1", "XX", "IT", "FR", "UK")
|
||||
gm.setActualResults("SE", "IT", "DE", "AL")
|
||||
expect(gm.getPredictionScore("p1")).toBe(10) // prediction_top3
|
||||
})
|
||||
|
||||
it("scores correct third place", () => {
|
||||
const gm = new GameManager()
|
||||
gm.submitPrediction("p1", "XX", "YY", "FR", "UK")
|
||||
gm.setActualResults("SE", "IT", "FR", "AL")
|
||||
expect(gm.getPredictionScore("p1")).toBe(10) // prediction_top3
|
||||
})
|
||||
|
||||
it("scores correct last place", () => {
|
||||
const gm = new GameManager()
|
||||
gm.submitPrediction("p1", "XX", "YY", "ZZ", "UK")
|
||||
gm.setActualResults("SE", "IT", "FR", "UK")
|
||||
expect(gm.getPredictionScore("p1")).toBe(15) // prediction_nul_points
|
||||
})
|
||||
|
||||
it("scores perfect prediction (all correct)", () => {
|
||||
const gm = new GameManager()
|
||||
gm.submitPrediction("p1", "SE", "IT", "FR", "UK")
|
||||
gm.setActualResults("SE", "IT", "FR", "UK")
|
||||
expect(gm.getPredictionScore("p1")).toBe(60) // 25 + 10 + 10 + 15
|
||||
})
|
||||
|
||||
it("scores 0 for all wrong", () => {
|
||||
const gm = new GameManager()
|
||||
gm.submitPrediction("p1", "AA", "BB", "CC", "DD")
|
||||
gm.setActualResults("SE", "IT", "FR", "UK")
|
||||
expect(gm.getPredictionScore("p1")).toBe(0)
|
||||
})
|
||||
|
||||
it("returns 0 for player with no prediction", () => {
|
||||
const gm = new GameManager()
|
||||
gm.setActualResults("SE", "IT", "FR", "UK")
|
||||
expect(gm.getPredictionScore("p1")).toBe(0)
|
||||
})
|
||||
|
||||
it("getActualResults returns null before setting", () => {
|
||||
const gm = new GameManager()
|
||||
expect(gm.getActualResults()).toBeNull()
|
||||
})
|
||||
|
||||
it("getActualResults returns results after setting", () => {
|
||||
const gm = new GameManager()
|
||||
gm.setActualResults("SE", "IT", "FR", "UK")
|
||||
expect(gm.getActualResults()).toEqual({ winner: "SE", second: "IT", third: "FR", last: "UK" })
|
||||
})
|
||||
|
||||
it("setActualResults overwrites previous results", () => {
|
||||
const gm = new GameManager()
|
||||
gm.submitPrediction("p1", "SE", "IT", "FR", "UK")
|
||||
gm.setActualResults("AA", "BB", "CC", "DD")
|
||||
expect(gm.getPredictionScore("p1")).toBe(0)
|
||||
gm.setActualResults("SE", "IT", "FR", "UK")
|
||||
expect(gm.getPredictionScore("p1")).toBe(60)
|
||||
})
|
||||
|
||||
it("prediction points appear in leaderboard", () => {
|
||||
const gm = new GameManager()
|
||||
gm.submitPrediction("p1", "SE", "IT", "FR", "UK")
|
||||
gm.setActualResults("SE", "IT", "FR", "UK")
|
||||
const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Alice" })
|
||||
expect(state.leaderboard[0]!.predictionPoints).toBe(60)
|
||||
expect(state.leaderboard[0]!.totalPoints).toBe(60)
|
||||
})
|
||||
|
||||
it("actualResults included in game state", () => {
|
||||
const gm = new GameManager()
|
||||
gm.setActualResults("SE", "IT", "FR", "UK")
|
||||
const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Alice" })
|
||||
expect(state.actualResults).toEqual({ winner: "SE", second: "IT", third: "FR", last: "UK" })
|
||||
})
|
||||
|
||||
it("actualResults null in game state when not set", () => {
|
||||
const gm = new GameManager()
|
||||
const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Alice" })
|
||||
expect(state.actualResults).toBeNull()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `bun test`
|
||||
Expected: New tests FAIL (methods don't exist yet)
|
||||
|
||||
### Task 4: Implement prediction scoring in GameManager
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/server/src/games/game-manager.ts`
|
||||
|
||||
- [ ] **Step 1: Add actual results storage and scoring methods**
|
||||
|
||||
Add a new private field after `private bingoAnnouncements`:
|
||||
|
||||
```ts
|
||||
// ─── Prediction Scoring ─────────────────────────────────────────
|
||||
private actualResults: { winner: string; second: string; third: string; last: string } | null = null
|
||||
```
|
||||
|
||||
Add methods after `getBingoScore`:
|
||||
|
||||
```ts
|
||||
setActualResults(winner: string, second: string, third: string, last: string): void {
|
||||
this.actualResults = { winner, second, third, last }
|
||||
}
|
||||
|
||||
getActualResults(): { winner: string; second: string; third: string; last: string } | null {
|
||||
return this.actualResults
|
||||
}
|
||||
|
||||
getPredictionScore(playerId: string): number {
|
||||
if (!this.actualResults) return 0
|
||||
const prediction = this.predictions.get(playerId)
|
||||
if (!prediction) return 0
|
||||
|
||||
let score = 0
|
||||
if (prediction.first === this.actualResults.winner) score += scoringConfig.prediction_winner
|
||||
if (prediction.second === this.actualResults.second) score += scoringConfig.prediction_top3
|
||||
if (prediction.third === this.actualResults.third) score += scoringConfig.prediction_top3
|
||||
if (prediction.last === this.actualResults.last) score += scoringConfig.prediction_nul_points
|
||||
return score
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `buildLeaderboard` to include prediction points**
|
||||
|
||||
Change the `buildLeaderboard` method's return type and body:
|
||||
|
||||
```ts
|
||||
private buildLeaderboard(
|
||||
playerIds: string[],
|
||||
displayNames: Record<string, string>,
|
||||
): { playerId: string; displayName: string; juryPoints: number; bingoPoints: number; predictionPoints: number; totalPoints: number }[] {
|
||||
return playerIds
|
||||
.map((id) => {
|
||||
const juryPoints = this.getJuryScore(id)
|
||||
const bingoPoints = this.getBingoScore(id)
|
||||
const predictionPoints = this.getPredictionScore(id)
|
||||
return {
|
||||
playerId: id,
|
||||
displayName: displayNames[id] ?? "Unknown",
|
||||
juryPoints,
|
||||
bingoPoints,
|
||||
predictionPoints,
|
||||
totalPoints: juryPoints + bingoPoints + predictionPoints,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.totalPoints - a.totalPoints)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `actualResults` to both game state builder methods**
|
||||
|
||||
In `getGameStateForPlayer`, add `actualResults: this.actualResults,` after the `bingoAnnouncements` line.
|
||||
|
||||
In `getGameStateForDisplay`, add `actualResults: this.actualResults,` after the `bingoAnnouncements` line.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `bun test`
|
||||
Expected: All tests PASS (60 existing + 13 new = 73)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/server/src/games/game-manager.ts packages/server/tests/game-manager.test.ts
|
||||
git commit -m "add prediction scoring to GameManager with tests"
|
||||
```
|
||||
|
||||
### Task 5: Add WS handler for submit_actual_results
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/server/src/ws/handler.ts`
|
||||
|
||||
- [ ] **Step 1: Add handler case**
|
||||
|
||||
In the `switch (msg.type)` block, add a new case before the closing `}` of the switch (after `tap_bingo_square`):
|
||||
|
||||
```ts
|
||||
case "submit_actual_results": {
|
||||
if (!sessionId) {
|
||||
sendError(ws, "Not joined")
|
||||
return
|
||||
}
|
||||
const room = roomManager.getRoom(roomCode)
|
||||
if (room?.currentAct !== "scoring" && room?.currentAct !== "ended") {
|
||||
sendError(ws, "Results can only be entered during Scoring or Ended")
|
||||
return
|
||||
}
|
||||
if (!roomManager.isHost(roomCode, sessionId)) {
|
||||
sendError(ws, "Only the host can enter actual results")
|
||||
return
|
||||
}
|
||||
const gm = roomManager.getGameManager(roomCode)
|
||||
if (!gm) {
|
||||
sendError(ws, "Room not found")
|
||||
return
|
||||
}
|
||||
const allPicks = [msg.winner, msg.second, msg.third, msg.last]
|
||||
for (const code of allPicks) {
|
||||
if (!gm.isValidCountry(code)) {
|
||||
sendError(ws, `Invalid country: ${code}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (new Set(allPicks).size !== 4) {
|
||||
sendError(ws, "All 4 picks must be different countries")
|
||||
return
|
||||
}
|
||||
gm.setActualResults(msg.winner, msg.second, msg.third, msg.last)
|
||||
broadcastGameStateToAll(roomCode)
|
||||
break
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests and verify client builds**
|
||||
|
||||
Run: `bun test`
|
||||
Expected: All 73 tests pass
|
||||
|
||||
Run: `bun run --filter './packages/client' build`
|
||||
Expected: Build succeeds (client doesn't use the new types yet, but shared types must compile)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/server/src/ws/handler.ts
|
||||
git commit -m "add submit_actual_results WS handler with validation"
|
||||
```
|
||||
|
||||
### Task 6: Create ActualResultsForm component
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/client/src/components/actual-results-form.tsx`
|
||||
|
||||
- [ ] **Step 1: Create the component**
|
||||
|
||||
This reuses the same slot-picker pattern as `PredictionsForm`. Create `packages/client/src/components/actual-results-form.tsx`:
|
||||
|
||||
```tsx
|
||||
import { useState } from "react"
|
||||
import type { Entry, ActualResults } from "@celebrate-esc/shared"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
type SlotKey = "winner" | "second" | "third" | "last"
|
||||
|
||||
const SLOTS: { key: SlotKey; label: string }[] = [
|
||||
{ key: "winner", label: "Winner" },
|
||||
{ key: "second", label: "2nd Place" },
|
||||
{ key: "third", label: "3rd Place" },
|
||||
{ key: "last", label: "Last Place" },
|
||||
]
|
||||
|
||||
function formatEntry(entry: Entry): string {
|
||||
return `${entry.country.flag} ${entry.artist} — ${entry.song}`
|
||||
}
|
||||
|
||||
interface ActualResultsFormProps {
|
||||
entries: Entry[]
|
||||
existingResults: ActualResults | null
|
||||
onSubmit: (results: { winner: string; second: string; third: string; last: string }) => void
|
||||
}
|
||||
|
||||
export function ActualResultsForm({ entries, existingResults, onSubmit }: ActualResultsFormProps) {
|
||||
const [slots, setSlots] = useState<Record<SlotKey, string | null>>(() => {
|
||||
if (existingResults) {
|
||||
return {
|
||||
winner: existingResults.winner,
|
||||
second: existingResults.second,
|
||||
third: existingResults.third,
|
||||
last: existingResults.last,
|
||||
}
|
||||
}
|
||||
return { winner: null, second: null, third: null, last: null }
|
||||
})
|
||||
const [pickerForEntry, setPickerForEntry] = useState<string | null>(null)
|
||||
|
||||
const assignedCodes = new Set(Object.values(slots).filter(Boolean))
|
||||
const emptySlots = SLOTS.filter((s) => !slots[s.key])
|
||||
const allFilled = SLOTS.every((s) => slots[s.key])
|
||||
|
||||
function findEntry(code: string): Entry | undefined {
|
||||
return entries.find((e) => e.country.code === code)
|
||||
}
|
||||
|
||||
function assignToSlot(entryCode: string, slotKey: SlotKey) {
|
||||
setSlots((prev) => ({ ...prev, [slotKey]: entryCode }))
|
||||
setPickerForEntry(null)
|
||||
}
|
||||
|
||||
function removeFromSlot(slotKey: SlotKey) {
|
||||
setSlots((prev) => ({ ...prev, [slotKey]: null }))
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Actual Results</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{SLOTS.map((slot) => {
|
||||
const code = slots[slot.key]
|
||||
const entry = code ? findEntry(code) : null
|
||||
return (
|
||||
<div
|
||||
key={slot.key}
|
||||
className={`flex items-center justify-between rounded-md border p-2 ${
|
||||
code ? "border-primary/30 bg-primary/5" : "border-dashed"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-20 text-xs font-medium text-muted-foreground">{slot.label}</span>
|
||||
{entry ? (
|
||||
<span className="text-sm">{formatEntry(entry)}</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">Tap an entry below</span>
|
||||
)}
|
||||
</div>
|
||||
{code && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFromSlot(slot.key)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label={`Remove ${slot.label}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{allFilled && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
onSubmit({
|
||||
winner: slots.winner!,
|
||||
second: slots.second!,
|
||||
third: slots.third!,
|
||||
last: slots.last!,
|
||||
})
|
||||
}
|
||||
>
|
||||
{existingResults ? "Update Results" : "Submit Results"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">Entries</h4>
|
||||
{entries.map((entry) => {
|
||||
const isAssigned = assignedCodes.has(entry.country.code)
|
||||
const isPickerOpen = pickerForEntry === entry.country.code
|
||||
return (
|
||||
<div key={entry.country.code}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isAssigned}
|
||||
onClick={() => {
|
||||
if (emptySlots.length === 1) {
|
||||
assignToSlot(entry.country.code, emptySlots[0]!.key)
|
||||
} else {
|
||||
setPickerForEntry(isPickerOpen ? null : entry.country.code)
|
||||
}
|
||||
}}
|
||||
className={`w-full rounded-md border px-3 py-2 text-left text-sm transition-colors ${
|
||||
isAssigned
|
||||
? "border-transparent bg-muted/50 text-muted-foreground line-through opacity-50"
|
||||
: isPickerOpen
|
||||
? "border-primary bg-primary/5"
|
||||
: "hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{formatEntry(entry)}
|
||||
</button>
|
||||
{isPickerOpen && !isAssigned && (
|
||||
<div className="mt-1 ml-4 flex gap-1">
|
||||
{emptySlots.map((slot) => (
|
||||
<button
|
||||
type="button"
|
||||
key={slot.key}
|
||||
onClick={() => assignToSlot(entry.country.code, slot.key)}
|
||||
className="rounded-md border px-2 py-1 text-xs hover:bg-primary hover:text-primary-foreground"
|
||||
>
|
||||
{slot.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/client/src/components/actual-results-form.tsx
|
||||
git commit -m "add ActualResultsForm component"
|
||||
```
|
||||
|
||||
### Task 7: Update PredictionsForm to show correct/incorrect markers
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/client/src/components/predictions-form.tsx`
|
||||
|
||||
- [ ] **Step 1: Add `actualResults` prop and markers**
|
||||
|
||||
Update the `PredictionsFormProps` interface to add:
|
||||
|
||||
```ts
|
||||
import type { Entry, Prediction, ActualResults } from "@celebrate-esc/shared"
|
||||
|
||||
interface PredictionsFormProps {
|
||||
entries: Entry[]
|
||||
existingPrediction: Prediction | null
|
||||
locked: boolean
|
||||
actualResults?: ActualResults | null
|
||||
onSubmit: (prediction: { first: string; second: string; third: string; last: string }) => void
|
||||
}
|
||||
```
|
||||
|
||||
Update the function signature:
|
||||
|
||||
```ts
|
||||
export function PredictionsForm({ entries, existingPrediction, locked, actualResults, onSubmit }: PredictionsFormProps) {
|
||||
```
|
||||
|
||||
In the locked state when `existingPrediction` exists (the block starting at line 67), update the rendered slot items to show correctness. Replace the existing locked-with-prediction return block with:
|
||||
|
||||
```tsx
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your Predictions {actualResults ? "(scored)" : "(locked)"}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{SLOTS.map((slot) => {
|
||||
const entry = findEntry(existingPrediction[slot.key])
|
||||
const isCorrect = actualResults
|
||||
? slot.key === "first" ? existingPrediction.first === actualResults.winner
|
||||
: slot.key === "second" ? existingPrediction.second === actualResults.second
|
||||
: slot.key === "third" ? existingPrediction.third === actualResults.third
|
||||
: existingPrediction.last === actualResults.last
|
||||
: null
|
||||
return (
|
||||
<div key={slot.key} className="flex items-center gap-2 rounded-md border p-2">
|
||||
<span className="w-20 text-xs font-medium text-muted-foreground">{slot.label}</span>
|
||||
<span className="text-sm">{entry ? formatEntry(entry) : "—"}</span>
|
||||
{isCorrect !== null && (
|
||||
<span className={isCorrect ? "ml-auto text-green-600" : "ml-auto text-red-500"}>
|
||||
{isCorrect ? "✓" : "✗"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/client/src/components/predictions-form.tsx
|
||||
git commit -m "show correct/incorrect markers on locked predictions when results are in"
|
||||
```
|
||||
|
||||
### Task 8: Update Leaderboard component
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/client/src/components/leaderboard.tsx`
|
||||
|
||||
- [ ] **Step 1: Add `resultsEntered` prop and P: column**
|
||||
|
||||
Add a `resultsEntered` boolean prop to `LeaderboardProps`:
|
||||
|
||||
```ts
|
||||
interface LeaderboardProps {
|
||||
entries: LeaderboardEntry[]
|
||||
resultsEntered?: boolean
|
||||
}
|
||||
|
||||
export function Leaderboard({ entries, resultsEntered }: LeaderboardProps) {
|
||||
```
|
||||
|
||||
In the score display section (the `div` with `gap-3 text-xs`), add `P:` before `J:`. Show `P:?` when results are not yet entered:
|
||||
|
||||
```tsx
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span title="Prediction points">P:{resultsEntered ? entry.predictionPoints : "?"}</span>
|
||||
<span title="Jury points">J:{entry.juryPoints}</span>
|
||||
<span title="Bingo points">B:{entry.bingoPoints}</span>
|
||||
<span className="text-sm font-bold text-foreground">{entry.totalPoints}</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
Update the scoring explanation `ul` to add predictions:
|
||||
|
||||
```tsx
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
<li><strong>P</strong> = Prediction points — 25 for correct winner, 10 each for 2nd/3rd, 15 for last place</li>
|
||||
<li><strong>J</strong> = Jury points — rate each act 1-12, earn up to 5 pts per round for matching the group consensus</li>
|
||||
<li><strong>B</strong> = Bingo points — 2 pts per tapped trope + 10 bonus for a full bingo line</li>
|
||||
</ul>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/client/src/components/leaderboard.tsx
|
||||
git commit -m "add prediction points to leaderboard display and explanation"
|
||||
```
|
||||
|
||||
**Note for Tasks 9, 10, 11:** All `<Leaderboard>` usages must pass `resultsEntered={!!gameState.actualResults}` or `resultsEntered={!!gameState?.actualResults}`.
|
||||
|
||||
### Task 9: Wire up host route
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/client/src/routes/host.$roomCode.tsx`
|
||||
|
||||
- [ ] **Step 1: Import and add ActualResultsForm**
|
||||
|
||||
Add import at top:
|
||||
|
||||
```ts
|
||||
import { ActualResultsForm } from "@/components/actual-results-form"
|
||||
```
|
||||
|
||||
In the Host tab's `CardContent`, add the `ActualResultsForm` after the jury host block and before the leaderboard block. Find the line:
|
||||
|
||||
```tsx
|
||||
{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && (
|
||||
<Leaderboard entries={gameState.leaderboard} />
|
||||
)}
|
||||
```
|
||||
|
||||
Add before it:
|
||||
|
||||
```tsx
|
||||
{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && (
|
||||
<ActualResultsForm
|
||||
entries={gameState.lineup.entries}
|
||||
existingResults={gameState.actualResults}
|
||||
onSubmit={(results) => send({ type: "submit_actual_results", ...results })}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Also pass `actualResults` to PredictionsForm in the Play tab**
|
||||
|
||||
In the Play tab, update the predictions block for scoring/ended to show locked predictions with results. Find:
|
||||
|
||||
```tsx
|
||||
{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && (
|
||||
<Leaderboard entries={gameState.leaderboard} />
|
||||
)}
|
||||
```
|
||||
|
||||
Add before that block (in the Play tab):
|
||||
|
||||
```tsx
|
||||
{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && gameState.myPrediction && (
|
||||
<PredictionsForm
|
||||
entries={gameState.lineup.entries}
|
||||
existingPrediction={gameState.myPrediction}
|
||||
locked={true}
|
||||
actualResults={gameState.actualResults}
|
||||
onSubmit={() => {}}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/client/src/routes/host.$roomCode.tsx
|
||||
git commit -m "wire ActualResultsForm and prediction results in host route"
|
||||
```
|
||||
|
||||
### Task 10: Wire up player route
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/client/src/routes/play.$roomCode.tsx`
|
||||
|
||||
- [ ] **Step 1: Show scored predictions in scoring/ended**
|
||||
|
||||
In the player view, find the scoring act block:
|
||||
|
||||
```tsx
|
||||
{gameState && room.currentAct === "scoring" && (
|
||||
<Leaderboard entries={gameState.leaderboard} />
|
||||
)}
|
||||
```
|
||||
|
||||
Add before it:
|
||||
|
||||
```tsx
|
||||
{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && gameState.myPrediction && (
|
||||
<PredictionsForm
|
||||
entries={gameState.lineup.entries}
|
||||
existingPrediction={gameState.myPrediction}
|
||||
locked={true}
|
||||
actualResults={gameState.actualResults}
|
||||
onSubmit={() => {}}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/client/src/routes/play.$roomCode.tsx
|
||||
git commit -m "show scored predictions in player route during scoring/ended"
|
||||
```
|
||||
|
||||
### Task 11: Update display route
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/client/src/routes/display.$roomCode.tsx`
|
||||
|
||||
- [ ] **Step 1: Read current display route**
|
||||
|
||||
Read `packages/client/src/routes/display.$roomCode.tsx` to understand current structure.
|
||||
|
||||
- [ ] **Step 2: Add actual results summary to display**
|
||||
|
||||
When actual results are entered and the act is scoring/ended, show a summary card. The exact placement depends on the current display route structure. Add in the scoring/ended section:
|
||||
|
||||
```tsx
|
||||
{gameState?.actualResults && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Actual Results</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-1 text-sm">
|
||||
{[
|
||||
{ label: "Winner", code: gameState.actualResults.winner },
|
||||
{ label: "2nd", code: gameState.actualResults.second },
|
||||
{ label: "3rd", code: gameState.actualResults.third },
|
||||
{ label: "Last", code: gameState.actualResults.last },
|
||||
].map(({ label, code }) => {
|
||||
const entry = gameState.lineup.entries.find((e) => e.country.code === code)
|
||||
return (
|
||||
<div key={label} className="flex items-center gap-2">
|
||||
<span className="w-16 text-xs font-medium text-muted-foreground">{label}</span>
|
||||
<span>{entry ? `${entry.country.flag} ${entry.country.name}` : code}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
```
|
||||
|
||||
Add this import at the top of the file (these are not currently imported in the display route):
|
||||
|
||||
```ts
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/client/src/routes/display.$roomCode.tsx
|
||||
git commit -m "show actual results summary on display in scoring/ended"
|
||||
```
|
||||
|
||||
### Task 12: Final verification
|
||||
|
||||
- [ ] **Step 1: Run all tests**
|
||||
|
||||
Run: `bun test`
|
||||
Expected: All 73 tests pass
|
||||
|
||||
- [ ] **Step 2: Build client**
|
||||
|
||||
Run: `bun run --filter './packages/client' build`
|
||||
Expected: Build succeeds with 0 errors
|
||||
|
||||
- [ ] **Step 3: Commit any remaining fixes if needed**
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@
|
||||
|
||||
## Goal
|
||||
|
||||
A web-based party companion for a group of 5-10 people watching the Eurovision Song Contest together. One host device drives a shared display (TV/projector); all other players join on their phones via a room code. Three acts guide the group through five games across the evening, with all scores feeding into one shared leaderboard.
|
||||
A web-based party companion for a group of 5-10 people watching the Eurovision Song Contest together. One host device drives a shared display (TV/projector); all other players join on their phones via a room code. Three acts guide the group through four games across the evening, with all scores feeding into one shared leaderboard.
|
||||
|
||||
---
|
||||
|
||||
@@ -106,14 +106,11 @@ JSON messages with a `type` field discriminator.
|
||||
| `reconnect` | roomCode, sessionId | Reconnecting |
|
||||
| `advance_act` | — | Host advances to next act |
|
||||
| `end_room` | — | Host ends the party |
|
||||
| `add_dish` | name, correctCountry | Host adds a dish (Act 1) |
|
||||
| `reveal_dishes` | — | Host reveals dish answers |
|
||||
| `submit_prediction` | winner, top3[], nulPoints | Player submits predictions |
|
||||
| `open_jury_vote` | countryCode | Host opens voting for a country |
|
||||
| `close_jury_vote` | — | Host closes current voting window |
|
||||
| `submit_jury_vote` | countryCode, rating (1-12) | Player rates an act |
|
||||
| `tap_bingo_square` | tropeId | Player taps a bingo square |
|
||||
| `submit_dish_guess` | dishId, guessedCountry | Player guesses a dish's country |
|
||||
| `start_quiz` | — | Host starts quiz round |
|
||||
| `next_question` | — | Host advances to next question |
|
||||
| `buzz_quiz` | — | Player buzzes in |
|
||||
@@ -134,7 +131,6 @@ JSON messages with a `type` field discriminator.
|
||||
| `jury_reveal` | "12 points go to..." data | Dramatic reveal on display |
|
||||
| `bingo_update` | playerId, square tapped | Someone taps a square |
|
||||
| `bingo_announced` | playerId, displayName | Someone got bingo |
|
||||
| `dishes_updated` | dish list | Host adds/reveals dishes |
|
||||
| `quiz_question` | question, options, difficulty | Next quiz question shown |
|
||||
| `quiz_buzz` | playerId, displayName | Someone buzzed |
|
||||
| `quiz_result` | playerId, correct, points | Answer judged |
|
||||
@@ -154,13 +150,6 @@ JSON messages with a `type` field discriminator.
|
||||
- Scores are hidden until the final leaderboard reveal in Act 3.
|
||||
- Country selection from the hardcoded ESC 2026 lineup.
|
||||
|
||||
### Dish of the Nation (lobby + Act 1)
|
||||
|
||||
- Host adds dishes via the Host tab as people arrive (name + correct country). This can start during the lobby phase and continue into Act 1.
|
||||
- Players see the list of dishes and submit a country guess per dish.
|
||||
- Host triggers reveal before Act 2 — display shows each dish with the correct country and who guessed right.
|
||||
- Low-stakes icebreaker.
|
||||
|
||||
### Live Jury Voting (Act 2)
|
||||
|
||||
- Host taps "Open Voting" after each country's performance.
|
||||
@@ -189,7 +178,7 @@ JSON messages with a `type` field discriminator.
|
||||
|
||||
### Shared Leaderboard
|
||||
|
||||
- All five games contribute to one total score per player.
|
||||
- All four games contribute to one total score per player.
|
||||
- Leaderboard visible on the display throughout the evening, updated after each scoring event.
|
||||
- Prediction scores are hidden until the final reveal — the leaderboard shows "??? pts" for predictions until Act 3 ends.
|
||||
- Final reveal: display shows a dramatic countdown/animation revealing prediction scores and the final standings.
|
||||
@@ -211,7 +200,6 @@ All values in `packages/server/data/scoring.json`, editable without code changes
|
||||
"quiz_easy": 5,
|
||||
"quiz_medium": 10,
|
||||
"quiz_hard": 15,
|
||||
"dish_correct": 5
|
||||
}
|
||||
```
|
||||
|
||||
@@ -279,19 +267,6 @@ PostgreSQL via Drizzle ORM. Lightweight — most queries are "get everything for
|
||||
- `room_id` (FK → rooms)
|
||||
- `squares` (jsonb, positionally ordered array of { tropeId, tapped } — index 0-15 maps left-to-right, top-to-bottom on the 4×4 grid)
|
||||
|
||||
**dishes**
|
||||
- `id` (uuid, PK)
|
||||
- `room_id` (FK → rooms)
|
||||
- `name` (varchar)
|
||||
- `correct_country` (varchar)
|
||||
- `revealed` (boolean)
|
||||
|
||||
**dish_guesses**
|
||||
- `id` (uuid, PK)
|
||||
- `player_id` (FK → players)
|
||||
- `dish_id` (FK → dishes)
|
||||
- `guessed_country` (varchar)
|
||||
|
||||
**quiz_rounds**
|
||||
- `id` (uuid, PK)
|
||||
- `room_id` (FK → rooms)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Prediction Scoring — Design Spec
|
||||
|
||||
**Date:** 2026-03-12
|
||||
**Status:** Approved
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Allow the host to enter actual ESC results, calculate prediction scores for all players, and display them on the leaderboard.
|
||||
|
||||
## What Exists
|
||||
|
||||
- Players submit predictions (1st, 2nd, 3rd, last place) during lobby/pre-show via `PredictionsForm`
|
||||
- Predictions lock when host advances to live-event act
|
||||
- `GameManager` stores predictions in a `Map<string, Prediction>`
|
||||
- Leaderboard already shows jury (J:) and bingo (B:) points
|
||||
- DB schema has `actual_winner`, `actual_second`, `actual_third`, `actual_last` columns on `rooms` table (nullable, set when host enters results)
|
||||
|
||||
## New Functionality
|
||||
|
||||
### 1. Host Enters Actual Results
|
||||
|
||||
- New component: `ActualResultsForm` — shown in the Host tab during `scoring` and `ended` acts
|
||||
- Same country-picker UX as the predictions form (select from lineup, all 4 must be different)
|
||||
- New WS message: `submit_actual_results` (client → server) with `{ winner, second, third, last }`
|
||||
- Server stores on the `GameManager` (in-memory) and broadcasts updated game state
|
||||
- Host can re-submit to correct mistakes (overwrites previous entry)
|
||||
|
||||
### 2. Server Scores Predictions
|
||||
|
||||
- `GameManager.setActualResults(winner, second, third, last)` — stores the actual results
|
||||
- `GameManager.getPredictionScore(playerId)` — compares player's prediction to actuals:
|
||||
- `first` matches `winner`: 25 pts (`scoring.prediction_winner`)
|
||||
- `second` matches `second`: 10 pts (`scoring.prediction_top3`)
|
||||
- `third` matches `third`: 10 pts (`scoring.prediction_top3`)
|
||||
- `last` matches `last`: 15 pts (`scoring.prediction_nul_points`)
|
||||
- Total possible: 60 pts
|
||||
- Prediction scores feed into `buildLeaderboard` as a new `predictionPoints` field
|
||||
|
||||
### 3. Leaderboard Update
|
||||
|
||||
- `LeaderboardEntry` gains `predictionPoints: number`
|
||||
- Before results are entered: shows `P:?` on the leaderboard
|
||||
- After results are entered: shows `P:<score>` with actual points
|
||||
- `totalPoints` includes prediction points (0 if no results entered yet)
|
||||
|
||||
### 4. Player View — Results Reveal
|
||||
|
||||
- After actual results are entered, each player's `gameState` includes `actualResults: { winner, second, third, last } | null`
|
||||
- `PredictionsForm` (locked state) gains visual indicators: green checkmark for correct predictions, red X for incorrect
|
||||
- Players who didn't submit predictions get 0 prediction points
|
||||
|
||||
### 5. Display View
|
||||
|
||||
- Shows actual results summary when entered
|
||||
- Shows leaderboard with prediction scores revealed
|
||||
|
||||
## WS Messages
|
||||
|
||||
**Client → Server:**
|
||||
|
||||
| Type | Payload | Guard |
|
||||
|---|---|---|
|
||||
| `submit_actual_results` | `winner`, `second`, `third`, `last` (country codes) | Host only, scoring or ended act |
|
||||
|
||||
**No new server → client messages** — the existing `game_state` broadcast carries all the data.
|
||||
|
||||
## GameState Changes
|
||||
|
||||
```ts
|
||||
// Added to GameState
|
||||
actualResults: { winner: string; second: string; third: string; last: string } | null
|
||||
|
||||
// LeaderboardEntry gains
|
||||
predictionPoints: number
|
||||
```
|
||||
|
||||
## Scoring Config Values (existing)
|
||||
|
||||
```json
|
||||
{
|
||||
"prediction_winner": 25,
|
||||
"prediction_top3": 10,
|
||||
"prediction_nul_points": 15
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,308 @@
|
||||
# Menu Rework — App-Style Navigation
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the current ad-hoc tab navigation with a native-app-style bottom navigation bar, driven by TanStack Router nested routes. Clean up the host/player split so the host is simply a player with an extra "Host" tab. Add bingo card completion/redraw flow.
|
||||
|
||||
## Scope
|
||||
|
||||
- In-room views only (`/play/$roomCode`, `/host/$roomCode`)
|
||||
- Display view (`/display/$roomCode`) unchanged — stays full-screen passive
|
||||
- Landing page (`/`) unchanged
|
||||
|
||||
---
|
||||
|
||||
## 1. Route Structure
|
||||
|
||||
### Current
|
||||
|
||||
```
|
||||
/play/$roomCode → flat component, all content inline
|
||||
/host/$roomCode → flat component, all content inline, top tabs (Play | Host)
|
||||
/display/$roomCode → flat component, passive view
|
||||
```
|
||||
|
||||
### New
|
||||
|
||||
```
|
||||
/play/$roomCode/ → layout route: WebSocket, store, header, bottom nav
|
||||
/play/$roomCode/game → game content (predictions, jury, quiz)
|
||||
/play/$roomCode/bingo → bingo card
|
||||
/play/$roomCode/board → leaderboard + player list
|
||||
|
||||
/host/$roomCode/ → layout route: WebSocket, store, header, bottom nav
|
||||
/host/$roomCode/game → same game content as player
|
||||
/host/$roomCode/bingo → same bingo card as player
|
||||
/host/$roomCode/board → same leaderboard as player
|
||||
/host/$roomCode/host → host-only controls
|
||||
|
||||
/display/$roomCode → unchanged
|
||||
```
|
||||
|
||||
- Layout routes handle WebSocket connection, Zustand store hydration, sticky header, and bottom nav rendering.
|
||||
- Child routes consume room/game state from the Zustand store.
|
||||
- Default redirect: `/play/$roomCode` → `/play/$roomCode/game` (same for host).
|
||||
- Game, Bingo, and Leaderboard tab components are shared between player and host — identical behavior. The host is just a player with an extra tab.
|
||||
|
||||
---
|
||||
|
||||
## 2. Sticky Header
|
||||
|
||||
Replaces the current `RoomHeader` component.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ I❤️ESC ABCD 🟢 │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Left:** "I❤️ESC" as the app title (text, not emoji — the heart is the Unicode character ❤️)
|
||||
- **Right:** Room code (monospace, bold) + connection status dot
|
||||
- Green = `connected`
|
||||
- Yellow = `connecting`
|
||||
- Red = `disconnected`
|
||||
- `position: sticky; top: 0; z-index: 50`
|
||||
- `padding-top: env(safe-area-inset-top)` for iPhone notch/Dynamic Island
|
||||
- Act badge removed — the game content makes the current phase obvious
|
||||
|
||||
---
|
||||
|
||||
## 3. Bottom Navigation Bar
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
Player:
|
||||
┌──────────┬──────────┬──────────────┐
|
||||
│ Game │ Bingo │ Leaderboard │
|
||||
└──────────┴──────────┴──────────────┘
|
||||
|
||||
Host:
|
||||
┌────────┬────────┬─────────┬────────┐
|
||||
│ Game │ Bingo │ Board │ Host │
|
||||
└────────┴────────┴─────────┴────────┘
|
||||
```
|
||||
|
||||
### Behavior
|
||||
|
||||
- `position: fixed; bottom: 0; left: 0; right: 0; z-index: 50`
|
||||
- `padding-bottom: env(safe-area-inset-bottom)` for iPhone home indicator
|
||||
- Each tab is a TanStack Router `<Link>` to the nested route
|
||||
- Active tab: highlighted with primary color
|
||||
- Inactive tab: muted foreground color
|
||||
- Icons: iOS-style (SF Symbols aesthetic), rendered as SVG. No emoji.
|
||||
- Game → gamepad/play icon
|
||||
- Bingo → grid/squares icon
|
||||
- Leaderboard/Board → trophy icon
|
||||
- Host → wrench/settings icon
|
||||
- Tab label text below each icon
|
||||
- Main content area needs `padding-bottom` matching the nav bar height to avoid content hiding behind it
|
||||
|
||||
### Bingo Tab Availability
|
||||
|
||||
- Tab is always visible and always tappable in all acts
|
||||
- Players can view and familiarize themselves with their bingo card before `live-event`
|
||||
- Tapping squares to mark them is only enabled during `live-event` act
|
||||
- Before `live-event`: card is visible but squares are non-interactive (visual only)
|
||||
|
||||
---
|
||||
|
||||
## 4. Game Tab Content by Act
|
||||
|
||||
Identical for player and host. No nested tabs.
|
||||
|
||||
| Act | Content |
|
||||
|-----|---------|
|
||||
| **Lobby** | Predictions form (editable) |
|
||||
| **Pre-Show** | Predictions form (editable until locked) |
|
||||
| **Live Event** | Jury voting (when round is open) or "Waiting for host to open voting..." |
|
||||
| **Scoring** | Locked predictions (with actual results comparison) + quiz buzzer (when question active) |
|
||||
| **Ended** | Locked predictions + "Thanks for playing!" |
|
||||
|
||||
The nested Jury/Bingo tabs that currently exist inside the Game content during `live-event` are removed — bingo has its own tab, and jury voting is the sole content of the Game tab during live-event.
|
||||
|
||||
---
|
||||
|
||||
## 5. Bingo Tab Content
|
||||
|
||||
### Active Card View
|
||||
|
||||
Shows the player's current bingo card. Same `BingoCard` component as today, with one change:
|
||||
|
||||
- Before `live-event`: squares are rendered but non-interactive (no `onTap` handler)
|
||||
- During `live-event`: squares are interactive (tap to mark)
|
||||
- After `live-event`: card is frozen (same as current behavior)
|
||||
|
||||
### Completion Flow (New)
|
||||
|
||||
When a player completes a bingo line:
|
||||
|
||||
1. **Server detects completion** — on every tap (not untap), the server checks for completed bingo lines. Tapping is tap-only (no toggle/untap) — once a square is marked, it stays marked. When a line is detected, the card is marked as `completed`.
|
||||
2. **Card stored** — the completed card moves to a `completedBingoCards` array in game state (per player). Contains: `playerId`, `displayName`, `card` (the full card data with marked squares), `completedAt` (ISO 8601 timestamp).
|
||||
3. **Player sees** — a celebration message ("Bingo!") + "Draw New Card" button.
|
||||
4. **Redraw** — new WS message `request_new_bingo_card`. Server generates a fresh card with tropes not present on the just-completed card (best effort — if the trope pool is exhausted, duplicates are allowed). The old card is already in `completedBingoCards`.
|
||||
5. **New card** — `myBingoCard` in game state updates to the fresh card. Player can continue playing.
|
||||
6. **Redraw is only available during `live-event`** — same act gate as tapping squares.
|
||||
|
||||
### Scoring Across Multiple Cards
|
||||
|
||||
Bingo points accumulate across all cards (completed + active). `getBingoScore()` sums:
|
||||
- 2 points per tapped square across all completed cards and the active card
|
||||
- 10 bonus points per completed bingo line (each completed card contributed at least one line; the active card may also have lines)
|
||||
|
||||
The `announcedBingo` set is changed to track `playerId:cardIndex` instead of just `playerId`, so multiple bingo announcements per player are possible. The existing `bingoAnnouncedMessage` schema stays unchanged (it carries `playerId` and `displayName`) — the display view shows "Player X got Bingo!" regardless of which card number it is. The `completedBingoCards` array length implicitly tracks the count.
|
||||
|
||||
### Data Changes
|
||||
|
||||
New Zod schema in `game-types.ts`:
|
||||
```typescript
|
||||
export const completedBingoCardSchema = z.object({
|
||||
playerId: z.string(),
|
||||
displayName: z.string(),
|
||||
card: bingoCardSchema,
|
||||
completedAt: z.string(),
|
||||
})
|
||||
```
|
||||
|
||||
Added to `gameStateSchema`:
|
||||
```typescript
|
||||
completedBingoCards: z.array(completedBingoCardSchema)
|
||||
```
|
||||
|
||||
New WS client message in `ws-messages.ts` (added to `clientMessage` discriminated union):
|
||||
```typescript
|
||||
export const requestNewBingoCardMessage = z.object({ type: z.literal("request_new_bingo_card") })
|
||||
```
|
||||
|
||||
Game state changes:
|
||||
- `myBingoCard` continues to represent the current active card
|
||||
- `completedBingoCards` is a new array on `GameState` (visible to all — host needs it for verification)
|
||||
- Display view receives `completedBingoCards` for bingo announcement purposes only (no card detail needed on projector)
|
||||
|
||||
---
|
||||
|
||||
## 6. Leaderboard Tab Content
|
||||
|
||||
Replaces both the current `Leaderboard` and `PlayerList` components as separate UI elements.
|
||||
|
||||
- **Lobby** (no scores yet): shows player list with names only — a "who's here" view. The `Leaderboard` component renders a simplified layout: just rank numbers and names, no score columns.
|
||||
- **All other acts**: full leaderboard table (rank, name, P/J/B/Q breakdown, total points)
|
||||
- **Scoring explanation**: "How scoring works" box at the bottom (same as current)
|
||||
|
||||
The `PlayerList` component is no longer rendered anywhere else. Every player appears in the leaderboard. The leaderboard tab is the single place to see who's in the room.
|
||||
|
||||
---
|
||||
|
||||
## 7. Host Tab Content
|
||||
|
||||
Vertical stack of cards, only visible to the host. This tab contains all host-exclusive controls.
|
||||
|
||||
### Always Present
|
||||
|
||||
1. **Act Controls** — advance/revert buttons with act-specific labels:
|
||||
- Lobby → "Start Pre-Show"
|
||||
- Pre-Show → "Start Live Event"
|
||||
- Live Event → "Start Scoring"
|
||||
- Scoring → "End Party"
|
||||
- Ended → "Back to Scoring" + re-open option
|
||||
- Revert button available for all acts except lobby
|
||||
|
||||
2. **Display View** — explanation of what the display view is ("Project this on a TV for everyone to see") + the display URL (`/display/$roomCode`) + "Copy Link" button
|
||||
|
||||
### Conditional (by act)
|
||||
|
||||
3. **Jury Host** (live-event) — open/close voting per country (existing `JuryHost` component)
|
||||
4. **Quiz Host** (scoring) — start question, judge answer, skip (existing `QuizHost` component)
|
||||
5. **Actual Results Form** (scoring/ended) — enter final placings (existing `ActualResultsForm` component)
|
||||
6. **Bingo Claims** (new, all acts after live-event starts) — list of completed bingo cards with player name and card preview, for host verification
|
||||
|
||||
### Bottom
|
||||
|
||||
7. **End Party** button — destructive (red), always available except when already ended. This is the same action as advancing from scoring to ended, but available from any act as a shortcut. The act-specific "End Party" label in Act Controls (scoring → ended) is removed to avoid duplication.
|
||||
|
||||
---
|
||||
|
||||
## 8. Display View
|
||||
|
||||
Unchanged. Full-screen passive view, no bottom nav. Continues to show:
|
||||
|
||||
- Lobby: large room code with join instructions
|
||||
- Pre-Show: prediction submission count
|
||||
- Live Event: jury display + bingo announcements + leaderboard
|
||||
- Scoring: quiz display + actual results + leaderboard
|
||||
- Ended: final results + leaderboard
|
||||
|
||||
---
|
||||
|
||||
## 9. Components Affected
|
||||
|
||||
### New Components
|
||||
|
||||
- `BottomNav` — the bottom navigation bar (renders tabs as `<Link>`s, highlights active)
|
||||
- `RoomLayout` — sticky header + bottom nav + content outlet (used by both layout routes)
|
||||
- `BingoClaims` — host-only component showing completed bingo cards for verification
|
||||
|
||||
### Modified Components
|
||||
|
||||
- `RoomHeader` → replaced by new sticky header in `RoomLayout`
|
||||
- `BingoCard` → add read-only mode (disable tapping before live-event)
|
||||
- `Leaderboard` → absorb player list display for lobby state
|
||||
- `PlayerList` → removed from all views (absorbed into leaderboard tab)
|
||||
|
||||
### Shared Between Player and Host
|
||||
|
||||
- Game tab content (predictions, jury voting, quiz buzzer)
|
||||
- Bingo tab content (bingo card)
|
||||
- Leaderboard tab content
|
||||
|
||||
### Host-Only
|
||||
|
||||
- Host tab content (act controls, display link, jury host, quiz host, actual results, bingo claims)
|
||||
|
||||
---
|
||||
|
||||
## 10. Server Changes
|
||||
|
||||
### Bingo Completion
|
||||
|
||||
- `GameManager.tapBingoSquare()` — after marking a square, check for completed lines. If bingo detected:
|
||||
- Move current card to `completedBingoCards` array
|
||||
- Award bonus points (already happens)
|
||||
- Flag card as completed in response
|
||||
|
||||
- New method: `GameManager.requestNewBingoCard(playerId)` — generates fresh card, assigns to player
|
||||
|
||||
- New WS handler: `request_new_bingo_card` message → calls `requestNewBingoCard`, broadcasts updated state
|
||||
|
||||
### Game State Changes
|
||||
|
||||
- Add `completedBingoCards` to game state schema (Zod schema + TypeScript type)
|
||||
- Include in `getGameStateForPlayer` (full card data for host verification) and `getGameStateForDisplay` (for announcements only)
|
||||
- Update `getBingoScore()` to sum points across all completed cards + active card
|
||||
- Change `announcedBingo` from `Set<string>` (playerId) to track `playerId:cardIndex` pairs, allowing multiple bingo announcements per player
|
||||
|
||||
### WS Handler Changes
|
||||
|
||||
- Add `request_new_bingo_card` case to handler, gated to `live-event` act
|
||||
- Add the message to the `clientMessage` discriminated union in `ws-messages.ts`
|
||||
- Change `tapBingoSquare` from toggle to tap-only (remove untap behavior)
|
||||
- Both `tap_bingo_square` and `request_new_bingo_card` responses use the existing pattern: full `game_state` broadcast to all players in the room (consistent with all other state mutations)
|
||||
|
||||
### Route Transition Safety
|
||||
|
||||
- WebSocket connection and Zustand store are managed in the layout route, not child routes. Navigating between tabs (child routes) does not trigger WS reconnection or store reset.
|
||||
|
||||
---
|
||||
|
||||
## 11. Migration / URL Compatibility
|
||||
|
||||
- Old URLs (`/play/ABCD`, `/host/ABCD`) should redirect to `/play/ABCD/game` and `/host/ABCD/game` respectively
|
||||
- Implemented via TanStack Router index routes that redirect (e.g., a `/play/$roomCode/` index route with `beforeLoad` that throws `redirect({ to: '/play/$roomCode/game' })`)
|
||||
|
||||
---
|
||||
|
||||
## 12. Label Naming
|
||||
|
||||
- Player bottom nav labels: "Game", "Bingo", "Leaderboard" (3 tabs, enough space for full labels)
|
||||
- Host bottom nav labels: "Game", "Bingo", "Board", "Host" (4 tabs, "Board" is shortened from "Leaderboard" for space)
|
||||
- Route paths use short names: `/game`, `/bingo`, `/board`, `/host`
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export function PlayerList({ players, mySessionId, predictionSubmitted }: Player
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${player.connected ? "bg-green-500" : "bg-muted"}`}
|
||||
/>
|
||||
<span className={player.sessionId === mySessionId ? "font-bold" : ""}>
|
||||
<span className={player.sessionId === mySessionId ? "font-bold underline" : ""}>
|
||||
{player.displayName}
|
||||
</span>
|
||||
{player.isHost && (
|
||||
@@ -25,9 +25,6 @@ export function PlayerList({ players, mySessionId, predictionSubmitted }: Player
|
||||
Host
|
||||
</Badge>
|
||||
)}
|
||||
{player.sessionId === mySessionId && (
|
||||
<span className="text-xs text-muted-foreground">(you)</span>
|
||||
)}
|
||||
{predictionSubmitted?.[player.id] && (
|
||||
<span className="text-green-600" title="Prediction submitted">✓</span>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react"
|
||||
import type { Entry, Prediction } from "@celebrate-esc/shared"
|
||||
import type { Entry, Prediction, ActualResults } from "@celebrate-esc/shared"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
@@ -20,10 +20,11 @@ interface PredictionsFormProps {
|
||||
entries: Entry[]
|
||||
existingPrediction: Prediction | null
|
||||
locked: boolean
|
||||
actualResults?: ActualResults | null
|
||||
onSubmit: (prediction: { first: string; second: string; third: string; last: string }) => void
|
||||
}
|
||||
|
||||
export function PredictionsForm({ entries, existingPrediction, locked, onSubmit }: PredictionsFormProps) {
|
||||
export function PredictionsForm({ entries, existingPrediction, locked, actualResults, onSubmit }: PredictionsFormProps) {
|
||||
const [slots, setSlots] = useState<Record<SlotKey, string | null>>(() => {
|
||||
if (existingPrediction) {
|
||||
return {
|
||||
@@ -67,15 +68,26 @@ export function PredictionsForm({ entries, existingPrediction, locked, onSubmit
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your Predictions (locked)</CardTitle>
|
||||
<CardTitle>Your Predictions {actualResults ? "(scored)" : "(locked)"}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{SLOTS.map((slot) => {
|
||||
const entry = findEntry(existingPrediction[slot.key])
|
||||
const isCorrect = actualResults
|
||||
? slot.key === "first" ? existingPrediction.first === actualResults.winner
|
||||
: slot.key === "second" ? existingPrediction.second === actualResults.second
|
||||
: slot.key === "third" ? existingPrediction.third === actualResults.third
|
||||
: existingPrediction.last === actualResults.last
|
||||
: null
|
||||
return (
|
||||
<div key={slot.key} className="flex items-center gap-2 rounded-md border p-2">
|
||||
<span className="w-20 text-xs font-medium text-muted-foreground">{slot.label}</span>
|
||||
<span className="text-sm">{entry ? formatEntry(entry) : "—"}</span>
|
||||
{isCorrect !== null && (
|
||||
<span className={isCorrect ? "ml-auto text-green-600" : "ml-auto text-red-500"}>
|
||||
{isCorrect ? "✓" : "✗"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { QuizQuestion } from "@celebrate-esc/shared"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
interface QuizBuzzerProps {
|
||||
question: QuizQuestion
|
||||
buzzStatus: "can_buzz" | "already_buzzed" | "excluded" | "waiting" | null
|
||||
onBuzz: () => void
|
||||
}
|
||||
|
||||
const difficultyColors: Record<string, string> = {
|
||||
easy: "text-green-600",
|
||||
medium: "text-yellow-600",
|
||||
hard: "text-red-600",
|
||||
}
|
||||
|
||||
export function QuizBuzzer({ question, buzzStatus, onBuzz }: QuizBuzzerProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Quiz — Question {question.index + 1}/{question.total}</span>
|
||||
<span className={`text-sm font-normal ${difficultyColors[question.difficulty] ?? ""}`}>
|
||||
{question.difficulty}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center gap-4">
|
||||
{question.status === "buzzing" && buzzStatus === "can_buzz" && (
|
||||
<Button
|
||||
size="lg"
|
||||
className="h-24 w-full text-2xl"
|
||||
onClick={onBuzz}
|
||||
>
|
||||
BUZZ!
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{question.status === "buzzing" && buzzStatus === "excluded" && (
|
||||
<p className="text-muted-foreground">You are excluded from this question.</p>
|
||||
)}
|
||||
|
||||
{question.status === "judging" && buzzStatus === "already_buzzed" && (
|
||||
<p className="text-lg font-semibold">You buzzed! Waiting for the host to judge...</p>
|
||||
)}
|
||||
|
||||
{buzzStatus === "waiting" && (
|
||||
<p className="text-muted-foreground">
|
||||
{question.buzzerName} buzzed in! Waiting for judgment...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{question.status === "resolved" && question.wasCorrect && (
|
||||
<p className="text-lg font-semibold text-green-600">
|
||||
{question.buzzerName} answered correctly!
|
||||
</p>
|
||||
)}
|
||||
|
||||
{question.status === "resolved" && question.wasCorrect === null && (
|
||||
<p className="text-muted-foreground">
|
||||
Question skipped.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{question.status === "resolved" && question.wasCorrect === false && (
|
||||
<p className="text-muted-foreground">
|
||||
Question resolved — no one answered correctly.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { QuizQuestion } from "@celebrate-esc/shared"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
interface QuizDisplayProps {
|
||||
question: QuizQuestion
|
||||
}
|
||||
|
||||
const difficultyColors: Record<string, string> = {
|
||||
easy: "text-green-600",
|
||||
medium: "text-yellow-600",
|
||||
hard: "text-red-600",
|
||||
}
|
||||
|
||||
export function QuizDisplay({ question }: QuizDisplayProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Quiz — Question {question.index + 1}/{question.total}</span>
|
||||
<span className={`${difficultyColors[question.difficulty] ?? ""}`}>
|
||||
{question.difficulty}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-6">
|
||||
<p className="text-center text-2xl font-semibold">{question.text}</p>
|
||||
|
||||
{question.status === "buzzing" && (
|
||||
<p className="text-center text-xl text-muted-foreground">
|
||||
Buzz in to answer!
|
||||
</p>
|
||||
)}
|
||||
|
||||
{question.status === "judging" && question.buzzerName && (
|
||||
<p className="text-center text-xl font-semibold">
|
||||
{question.buzzerName} buzzed in!
|
||||
</p>
|
||||
)}
|
||||
|
||||
{question.status === "resolved" && (
|
||||
<p className="text-center text-xl">
|
||||
{question.wasCorrect
|
||||
? `✓ ${question.buzzerName} got it right!`
|
||||
: "✗ No one got it right."}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { QuizQuestion } from "@celebrate-esc/shared"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
interface QuizHostProps {
|
||||
question: QuizQuestion | null
|
||||
onStartQuestion: () => void
|
||||
onJudge: (correct: boolean) => void
|
||||
onSkip: () => void
|
||||
}
|
||||
|
||||
const difficultyColors: Record<string, string> = {
|
||||
easy: "text-green-600",
|
||||
medium: "text-yellow-600",
|
||||
hard: "text-red-600",
|
||||
}
|
||||
|
||||
export function QuizHost({ question, onStartQuestion, onJudge, onSkip }: QuizHostProps) {
|
||||
if (!question) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quiz</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={onStartQuestion} className="w-full">
|
||||
Start Next Question
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Question {question.index + 1}/{question.total}</span>
|
||||
<span className={`text-sm font-normal ${difficultyColors[question.difficulty] ?? ""}`}>
|
||||
{question.difficulty}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="rounded-md bg-muted p-3">
|
||||
<p className="font-medium">{question.text}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-dashed p-3">
|
||||
<p className="text-sm text-muted-foreground">Answer:</p>
|
||||
<p className="font-medium">{question.answer}</p>
|
||||
</div>
|
||||
|
||||
{question.status === "buzzing" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-center text-muted-foreground">Waiting for someone to buzz...</p>
|
||||
<Button variant="outline" onClick={onSkip} className="w-full">
|
||||
Skip Question
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{question.status === "judging" && question.buzzerName && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-center font-semibold">
|
||||
{question.buzzerName} buzzed in!
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 border-red-300 text-red-600 hover:bg-red-50"
|
||||
onClick={() => onJudge(false)}
|
||||
>
|
||||
Incorrect
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => onJudge(true)}
|
||||
>
|
||||
Correct
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{question.status === "resolved" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-center text-muted-foreground">
|
||||
{question.wasCorrect
|
||||
? `${question.buzzerName} answered correctly!`
|
||||
: "No one answered correctly."}
|
||||
</p>
|
||||
<Button onClick={onStartQuestion} className="w-full">
|
||||
Next Question
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Outlet } from "@tanstack/react-router"
|
||||
|
||||
interface RoomLayoutProps {
|
||||
roomCode: string
|
||||
connectionStatus: "disconnected" | "connecting" | "connected"
|
||||
}
|
||||
|
||||
export function RoomLayout({ roomCode, connectionStatus }: RoomLayoutProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="sticky top-0 z-50 flex items-center justify-between border-b bg-background px-4 py-3" style={{ paddingTop: "max(0.75rem, env(safe-area-inset-top))" }}>
|
||||
<span className="text-lg font-bold" style={{ backgroundImage: "linear-gradient(90deg, #e40303, #ff8c00, #ffed00, #008026, #004dff, #750787)", WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent", backgroundClip: "text" }}>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>
|
||||
)
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export function useWebSocket(roomCode: string) {
|
||||
reset,
|
||||
setGameState,
|
||||
lockPredictions,
|
||||
setSend,
|
||||
} = useRoomStore()
|
||||
|
||||
const send = useCallback((message: ClientMessage) => {
|
||||
@@ -38,6 +39,10 @@ export function useWebSocket(roomCode: string) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setSend(send)
|
||||
}, [send, setSend])
|
||||
|
||||
useEffect(() => {
|
||||
const stored = getStoredSession()
|
||||
const sessionId = stored?.roomCode === roomCode ? stored.sessionId : null
|
||||
@@ -91,6 +96,11 @@ export function useWebSocket(roomCode: string) {
|
||||
case "predictions_locked":
|
||||
lockPredictions()
|
||||
break
|
||||
case "jury_vote_opened":
|
||||
case "jury_vote_closed":
|
||||
case "bingo_announced":
|
||||
// State updates arrive via game_state; these are supplementary signals
|
||||
break
|
||||
case "error":
|
||||
console.error("Server error:", msg.message)
|
||||
break
|
||||
@@ -105,7 +115,7 @@ export function useWebSocket(roomCode: string) {
|
||||
ws.close()
|
||||
reset()
|
||||
}
|
||||
}, [roomCode, setRoom, setMySessionId, setConnectionStatus, updatePlayerConnected, addPlayer, setAct, reset, setGameState, lockPredictions])
|
||||
}, [roomCode, setRoom, setMySessionId, setConnectionStatus, updatePlayerConnected, addPlayer, setAct, reset, setGameState, lockPredictions, setSend])
|
||||
|
||||
return { send }
|
||||
}
|
||||
|
||||
@@ -13,6 +13,15 @@ import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as PlayRoomCodeRouteImport } from './routes/play.$roomCode'
|
||||
import { Route as HostRoomCodeRouteImport } from './routes/host.$roomCode'
|
||||
import { Route as DisplayRoomCodeRouteImport } from './routes/display.$roomCode'
|
||||
import { Route as PlayRoomCodeIndexRouteImport } from './routes/play.$roomCode.index'
|
||||
import { Route as HostRoomCodeIndexRouteImport } from './routes/host.$roomCode.index'
|
||||
import { Route as PlayRoomCodeGameRouteImport } from './routes/play.$roomCode.game'
|
||||
import { Route as PlayRoomCodeBoardRouteImport } from './routes/play.$roomCode.board'
|
||||
import { Route as PlayRoomCodeBingoRouteImport } from './routes/play.$roomCode.bingo'
|
||||
import { Route as HostRoomCodeHostRouteImport } from './routes/host.$roomCode.host'
|
||||
import { Route as HostRoomCodeGameRouteImport } from './routes/host.$roomCode.game'
|
||||
import { Route as HostRoomCodeBoardRouteImport } from './routes/host.$roomCode.board'
|
||||
import { Route as HostRoomCodeBingoRouteImport } from './routes/host.$roomCode.bingo'
|
||||
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
@@ -34,44 +43,147 @@ const DisplayRoomCodeRoute = DisplayRoomCodeRouteImport.update({
|
||||
path: '/display/$roomCode',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PlayRoomCodeIndexRoute = PlayRoomCodeIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => PlayRoomCodeRoute,
|
||||
} as any)
|
||||
const HostRoomCodeIndexRoute = HostRoomCodeIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => HostRoomCodeRoute,
|
||||
} as any)
|
||||
const PlayRoomCodeGameRoute = PlayRoomCodeGameRouteImport.update({
|
||||
id: '/game',
|
||||
path: '/game',
|
||||
getParentRoute: () => PlayRoomCodeRoute,
|
||||
} as any)
|
||||
const PlayRoomCodeBoardRoute = PlayRoomCodeBoardRouteImport.update({
|
||||
id: '/board',
|
||||
path: '/board',
|
||||
getParentRoute: () => PlayRoomCodeRoute,
|
||||
} as any)
|
||||
const PlayRoomCodeBingoRoute = PlayRoomCodeBingoRouteImport.update({
|
||||
id: '/bingo',
|
||||
path: '/bingo',
|
||||
getParentRoute: () => PlayRoomCodeRoute,
|
||||
} as any)
|
||||
const HostRoomCodeHostRoute = HostRoomCodeHostRouteImport.update({
|
||||
id: '/host',
|
||||
path: '/host',
|
||||
getParentRoute: () => HostRoomCodeRoute,
|
||||
} as any)
|
||||
const HostRoomCodeGameRoute = HostRoomCodeGameRouteImport.update({
|
||||
id: '/game',
|
||||
path: '/game',
|
||||
getParentRoute: () => HostRoomCodeRoute,
|
||||
} as any)
|
||||
const HostRoomCodeBoardRoute = HostRoomCodeBoardRouteImport.update({
|
||||
id: '/board',
|
||||
path: '/board',
|
||||
getParentRoute: () => HostRoomCodeRoute,
|
||||
} as any)
|
||||
const HostRoomCodeBingoRoute = HostRoomCodeBingoRouteImport.update({
|
||||
id: '/bingo',
|
||||
path: '/bingo',
|
||||
getParentRoute: () => HostRoomCodeRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/display/$roomCode': typeof DisplayRoomCodeRoute
|
||||
'/host/$roomCode': typeof HostRoomCodeRoute
|
||||
'/play/$roomCode': typeof PlayRoomCodeRoute
|
||||
'/host/$roomCode': typeof HostRoomCodeRouteWithChildren
|
||||
'/play/$roomCode': typeof PlayRoomCodeRouteWithChildren
|
||||
'/host/$roomCode/bingo': typeof HostRoomCodeBingoRoute
|
||||
'/host/$roomCode/board': typeof HostRoomCodeBoardRoute
|
||||
'/host/$roomCode/game': typeof HostRoomCodeGameRoute
|
||||
'/host/$roomCode/host': typeof HostRoomCodeHostRoute
|
||||
'/play/$roomCode/bingo': typeof PlayRoomCodeBingoRoute
|
||||
'/play/$roomCode/board': typeof PlayRoomCodeBoardRoute
|
||||
'/play/$roomCode/game': typeof PlayRoomCodeGameRoute
|
||||
'/host/$roomCode/': typeof HostRoomCodeIndexRoute
|
||||
'/play/$roomCode/': typeof PlayRoomCodeIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/display/$roomCode': typeof DisplayRoomCodeRoute
|
||||
'/host/$roomCode': typeof HostRoomCodeRoute
|
||||
'/play/$roomCode': typeof PlayRoomCodeRoute
|
||||
'/host/$roomCode/bingo': typeof HostRoomCodeBingoRoute
|
||||
'/host/$roomCode/board': typeof HostRoomCodeBoardRoute
|
||||
'/host/$roomCode/game': typeof HostRoomCodeGameRoute
|
||||
'/host/$roomCode/host': typeof HostRoomCodeHostRoute
|
||||
'/play/$roomCode/bingo': typeof PlayRoomCodeBingoRoute
|
||||
'/play/$roomCode/board': typeof PlayRoomCodeBoardRoute
|
||||
'/play/$roomCode/game': typeof PlayRoomCodeGameRoute
|
||||
'/host/$roomCode': typeof HostRoomCodeIndexRoute
|
||||
'/play/$roomCode': typeof PlayRoomCodeIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/display/$roomCode': typeof DisplayRoomCodeRoute
|
||||
'/host/$roomCode': typeof HostRoomCodeRoute
|
||||
'/play/$roomCode': typeof PlayRoomCodeRoute
|
||||
'/host/$roomCode': typeof HostRoomCodeRouteWithChildren
|
||||
'/play/$roomCode': typeof PlayRoomCodeRouteWithChildren
|
||||
'/host/$roomCode/bingo': typeof HostRoomCodeBingoRoute
|
||||
'/host/$roomCode/board': typeof HostRoomCodeBoardRoute
|
||||
'/host/$roomCode/game': typeof HostRoomCodeGameRoute
|
||||
'/host/$roomCode/host': typeof HostRoomCodeHostRoute
|
||||
'/play/$roomCode/bingo': typeof PlayRoomCodeBingoRoute
|
||||
'/play/$roomCode/board': typeof PlayRoomCodeBoardRoute
|
||||
'/play/$roomCode/game': typeof PlayRoomCodeGameRoute
|
||||
'/host/$roomCode/': typeof HostRoomCodeIndexRoute
|
||||
'/play/$roomCode/': typeof PlayRoomCodeIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/display/$roomCode' | '/host/$roomCode' | '/play/$roomCode'
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/display/$roomCode'
|
||||
| '/host/$roomCode'
|
||||
| '/play/$roomCode'
|
||||
| '/host/$roomCode/bingo'
|
||||
| '/host/$roomCode/board'
|
||||
| '/host/$roomCode/game'
|
||||
| '/host/$roomCode/host'
|
||||
| '/play/$roomCode/bingo'
|
||||
| '/play/$roomCode/board'
|
||||
| '/play/$roomCode/game'
|
||||
| '/host/$roomCode/'
|
||||
| '/play/$roomCode/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/display/$roomCode' | '/host/$roomCode' | '/play/$roomCode'
|
||||
to:
|
||||
| '/'
|
||||
| '/display/$roomCode'
|
||||
| '/host/$roomCode/bingo'
|
||||
| '/host/$roomCode/board'
|
||||
| '/host/$roomCode/game'
|
||||
| '/host/$roomCode/host'
|
||||
| '/play/$roomCode/bingo'
|
||||
| '/play/$roomCode/board'
|
||||
| '/play/$roomCode/game'
|
||||
| '/host/$roomCode'
|
||||
| '/play/$roomCode'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/display/$roomCode'
|
||||
| '/host/$roomCode'
|
||||
| '/play/$roomCode'
|
||||
| '/host/$roomCode/bingo'
|
||||
| '/host/$roomCode/board'
|
||||
| '/host/$roomCode/game'
|
||||
| '/host/$roomCode/host'
|
||||
| '/play/$roomCode/bingo'
|
||||
| '/play/$roomCode/board'
|
||||
| '/play/$roomCode/game'
|
||||
| '/host/$roomCode/'
|
||||
| '/play/$roomCode/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
DisplayRoomCodeRoute: typeof DisplayRoomCodeRoute
|
||||
HostRoomCodeRoute: typeof HostRoomCodeRoute
|
||||
PlayRoomCodeRoute: typeof PlayRoomCodeRoute
|
||||
HostRoomCodeRoute: typeof HostRoomCodeRouteWithChildren
|
||||
PlayRoomCodeRoute: typeof PlayRoomCodeRouteWithChildren
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -104,14 +216,115 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof DisplayRoomCodeRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/play/$roomCode/': {
|
||||
id: '/play/$roomCode/'
|
||||
path: '/'
|
||||
fullPath: '/play/$roomCode/'
|
||||
preLoaderRoute: typeof PlayRoomCodeIndexRouteImport
|
||||
parentRoute: typeof PlayRoomCodeRoute
|
||||
}
|
||||
'/host/$roomCode/': {
|
||||
id: '/host/$roomCode/'
|
||||
path: '/'
|
||||
fullPath: '/host/$roomCode/'
|
||||
preLoaderRoute: typeof HostRoomCodeIndexRouteImport
|
||||
parentRoute: typeof HostRoomCodeRoute
|
||||
}
|
||||
'/play/$roomCode/game': {
|
||||
id: '/play/$roomCode/game'
|
||||
path: '/game'
|
||||
fullPath: '/play/$roomCode/game'
|
||||
preLoaderRoute: typeof PlayRoomCodeGameRouteImport
|
||||
parentRoute: typeof PlayRoomCodeRoute
|
||||
}
|
||||
'/play/$roomCode/board': {
|
||||
id: '/play/$roomCode/board'
|
||||
path: '/board'
|
||||
fullPath: '/play/$roomCode/board'
|
||||
preLoaderRoute: typeof PlayRoomCodeBoardRouteImport
|
||||
parentRoute: typeof PlayRoomCodeRoute
|
||||
}
|
||||
'/play/$roomCode/bingo': {
|
||||
id: '/play/$roomCode/bingo'
|
||||
path: '/bingo'
|
||||
fullPath: '/play/$roomCode/bingo'
|
||||
preLoaderRoute: typeof PlayRoomCodeBingoRouteImport
|
||||
parentRoute: typeof PlayRoomCodeRoute
|
||||
}
|
||||
'/host/$roomCode/host': {
|
||||
id: '/host/$roomCode/host'
|
||||
path: '/host'
|
||||
fullPath: '/host/$roomCode/host'
|
||||
preLoaderRoute: typeof HostRoomCodeHostRouteImport
|
||||
parentRoute: typeof HostRoomCodeRoute
|
||||
}
|
||||
'/host/$roomCode/game': {
|
||||
id: '/host/$roomCode/game'
|
||||
path: '/game'
|
||||
fullPath: '/host/$roomCode/game'
|
||||
preLoaderRoute: typeof HostRoomCodeGameRouteImport
|
||||
parentRoute: typeof HostRoomCodeRoute
|
||||
}
|
||||
'/host/$roomCode/board': {
|
||||
id: '/host/$roomCode/board'
|
||||
path: '/board'
|
||||
fullPath: '/host/$roomCode/board'
|
||||
preLoaderRoute: typeof HostRoomCodeBoardRouteImport
|
||||
parentRoute: typeof HostRoomCodeRoute
|
||||
}
|
||||
'/host/$roomCode/bingo': {
|
||||
id: '/host/$roomCode/bingo'
|
||||
path: '/bingo'
|
||||
fullPath: '/host/$roomCode/bingo'
|
||||
preLoaderRoute: typeof HostRoomCodeBingoRouteImport
|
||||
parentRoute: typeof HostRoomCodeRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface HostRoomCodeRouteChildren {
|
||||
HostRoomCodeBingoRoute: typeof HostRoomCodeBingoRoute
|
||||
HostRoomCodeBoardRoute: typeof HostRoomCodeBoardRoute
|
||||
HostRoomCodeGameRoute: typeof HostRoomCodeGameRoute
|
||||
HostRoomCodeHostRoute: typeof HostRoomCodeHostRoute
|
||||
HostRoomCodeIndexRoute: typeof HostRoomCodeIndexRoute
|
||||
}
|
||||
|
||||
const HostRoomCodeRouteChildren: HostRoomCodeRouteChildren = {
|
||||
HostRoomCodeBingoRoute: HostRoomCodeBingoRoute,
|
||||
HostRoomCodeBoardRoute: HostRoomCodeBoardRoute,
|
||||
HostRoomCodeGameRoute: HostRoomCodeGameRoute,
|
||||
HostRoomCodeHostRoute: HostRoomCodeHostRoute,
|
||||
HostRoomCodeIndexRoute: HostRoomCodeIndexRoute,
|
||||
}
|
||||
|
||||
const HostRoomCodeRouteWithChildren = HostRoomCodeRoute._addFileChildren(
|
||||
HostRoomCodeRouteChildren,
|
||||
)
|
||||
|
||||
interface PlayRoomCodeRouteChildren {
|
||||
PlayRoomCodeBingoRoute: typeof PlayRoomCodeBingoRoute
|
||||
PlayRoomCodeBoardRoute: typeof PlayRoomCodeBoardRoute
|
||||
PlayRoomCodeGameRoute: typeof PlayRoomCodeGameRoute
|
||||
PlayRoomCodeIndexRoute: typeof PlayRoomCodeIndexRoute
|
||||
}
|
||||
|
||||
const PlayRoomCodeRouteChildren: PlayRoomCodeRouteChildren = {
|
||||
PlayRoomCodeBingoRoute: PlayRoomCodeBingoRoute,
|
||||
PlayRoomCodeBoardRoute: PlayRoomCodeBoardRoute,
|
||||
PlayRoomCodeGameRoute: PlayRoomCodeGameRoute,
|
||||
PlayRoomCodeIndexRoute: PlayRoomCodeIndexRoute,
|
||||
}
|
||||
|
||||
const PlayRoomCodeRouteWithChildren = PlayRoomCodeRoute._addFileChildren(
|
||||
PlayRoomCodeRouteChildren,
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
DisplayRoomCodeRoute: DisplayRoomCodeRoute,
|
||||
HostRoomCodeRoute: HostRoomCodeRoute,
|
||||
PlayRoomCodeRoute: PlayRoomCodeRoute,
|
||||
HostRoomCodeRoute: HostRoomCodeRouteWithChildren,
|
||||
PlayRoomCodeRoute: PlayRoomCodeRouteWithChildren,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useState } from "react"
|
||||
import { QRCodeSVG } from "qrcode.react"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useWebSocket } from "@/hooks/use-websocket"
|
||||
import { useRoomStore } from "@/stores/room-store"
|
||||
import { PlayerList } from "@/components/player-list"
|
||||
import { JuryDisplay } from "@/components/jury-display"
|
||||
import { BingoDisplay } from "@/components/bingo-display"
|
||||
import { Leaderboard } from "@/components/leaderboard"
|
||||
import { QuizDisplay } from "@/components/quiz-display"
|
||||
import { RoomHeader } from "@/components/room-header"
|
||||
import { ACT_LABELS } from "@celebrate-esc/shared"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
export const Route = createFileRoute("/display/$roomCode")({
|
||||
component: DisplayView,
|
||||
@@ -40,15 +45,77 @@ function DisplayView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{room.currentAct !== "lobby" && room.currentAct !== "ended" && room.currentAct !== "pre-show" && (
|
||||
<div className="flex flex-col items-center gap-4 py-12">
|
||||
<p className="text-2xl text-muted-foreground">{ACT_LABELS[room.currentAct]}</p>
|
||||
{room.currentAct === "live-event" && gameState && (
|
||||
<div className="flex flex-col items-center gap-8">
|
||||
<JuryDisplay
|
||||
currentRound={gameState.currentJuryRound}
|
||||
results={gameState.juryResults}
|
||||
/>
|
||||
<BingoDisplay announcements={gameState.bingoAnnouncements} />
|
||||
<Leaderboard entries={gameState.leaderboard} resultsEntered={!!gameState?.actualResults} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{room.currentAct === "scoring" && gameState && (
|
||||
<div className="flex flex-col items-center gap-8">
|
||||
<p className="text-2xl text-muted-foreground">Scoring</p>
|
||||
{gameState.currentQuizQuestion && (
|
||||
<QuizDisplay question={gameState.currentQuizQuestion} />
|
||||
)}
|
||||
{gameState?.actualResults && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Actual Results</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-1 text-sm">
|
||||
{[
|
||||
{ label: "Winner", code: gameState.actualResults.winner },
|
||||
{ label: "2nd", code: gameState.actualResults.second },
|
||||
{ label: "3rd", code: gameState.actualResults.third },
|
||||
{ label: "Last", code: gameState.actualResults.last },
|
||||
].map(({ label, code }) => {
|
||||
const entry = gameState.lineup.entries.find((e) => e.country.code === code)
|
||||
return (
|
||||
<div key={label} className="flex items-center gap-2">
|
||||
<span className="w-16 text-xs font-medium text-muted-foreground">{label}</span>
|
||||
<span>{entry ? `${entry.country.flag} ${entry.country.name}` : code}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<Leaderboard entries={gameState.leaderboard} resultsEntered={!!gameState?.actualResults} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{room.currentAct === "ended" && (
|
||||
<div className="flex flex-col items-center gap-4 py-12">
|
||||
<p className="text-2xl text-muted-foreground">The party has ended. Thanks for playing!</p>
|
||||
{gameState?.actualResults && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Actual Results</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-1 text-sm">
|
||||
{[
|
||||
{ label: "Winner", code: gameState.actualResults.winner },
|
||||
{ label: "2nd", code: gameState.actualResults.second },
|
||||
{ label: "3rd", code: gameState.actualResults.third },
|
||||
{ label: "Last", code: gameState.actualResults.last },
|
||||
].map(({ label, code }) => {
|
||||
const entry = gameState.lineup.entries.find((e) => e.country.code === code)
|
||||
return (
|
||||
<div key={label} className="flex items-center gap-2">
|
||||
<span className="w-16 text-xs font-medium text-muted-foreground">{label}</span>
|
||||
<span>{entry ? `${entry.country.flag} ${entry.country.name}` : code}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{gameState && <Leaderboard entries={gameState.leaderboard} resultsEntered={!!gameState?.actualResults} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -92,6 +159,7 @@ function LobbyDisplay({ roomCode }: { roomCode: string }) {
|
||||
<>Tap the code to copy</>
|
||||
)}
|
||||
</p>
|
||||
<QRCodeSVG value={joinUrl} size={192} level="M" />
|
||||
<p className="text-muted-foreground">
|
||||
Go to <span className="font-mono font-medium">{joinUrl}</span>
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useRoomStore } from "@/stores/room-store"
|
||||
import { BingoTab } from "@/components/bingo-tab"
|
||||
|
||||
export const Route = createFileRoute("/host/$roomCode/bingo")({
|
||||
component: HostBingo,
|
||||
})
|
||||
|
||||
function HostBingo() {
|
||||
const { room, gameState, send } = useRoomStore()
|
||||
|
||||
if (!room || !gameState) return null
|
||||
|
||||
return <BingoTab currentAct={room.currentAct} gameState={gameState} send={send} />
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useRoomStore } from "@/stores/room-store"
|
||||
import { BoardTab } from "@/components/board-tab"
|
||||
|
||||
export const Route = createFileRoute("/host/$roomCode/board")({
|
||||
component: HostBoard,
|
||||
})
|
||||
|
||||
function HostBoard() {
|
||||
const { room, gameState } = useRoomStore()
|
||||
|
||||
if (!room || !gameState) return null
|
||||
|
||||
return <BoardTab currentAct={room.currentAct} gameState={gameState} />
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useRoomStore } from "@/stores/room-store"
|
||||
import { GameTab } from "@/components/game-tab"
|
||||
|
||||
export const Route = createFileRoute("/host/$roomCode/game")({
|
||||
component: HostGame,
|
||||
})
|
||||
|
||||
function HostGame() {
|
||||
const { room, gameState, send } = useRoomStore()
|
||||
|
||||
if (!room || !gameState) return null
|
||||
|
||||
return <GameTab currentAct={room.currentAct} gameState={gameState} send={send} />
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useRoomStore } from "@/stores/room-store"
|
||||
import { HostTab } from "@/components/host-tab"
|
||||
|
||||
export const Route = createFileRoute("/host/$roomCode/host")({
|
||||
component: HostControls,
|
||||
})
|
||||
|
||||
function HostControls() {
|
||||
const { roomCode } = Route.useParams()
|
||||
const { room, gameState, send } = useRoomStore()
|
||||
|
||||
if (!room || !gameState) return null
|
||||
|
||||
return <HostTab roomCode={roomCode} currentAct={room.currentAct} gameState={gameState} send={send} />
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/host/$roomCode/")({
|
||||
beforeLoad: ({ params }) => {
|
||||
throw redirect({ to: "/host/$roomCode/game", params })
|
||||
},
|
||||
})
|
||||
@@ -1,29 +1,17 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useWebSocket } from "@/hooks/use-websocket"
|
||||
import { useRoomStore } from "@/stores/room-store"
|
||||
import { PlayerList } from "@/components/player-list"
|
||||
import { PredictionsForm } from "@/components/predictions-form"
|
||||
import { RoomHeader } from "@/components/room-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import type { Act } from "@celebrate-esc/shared"
|
||||
import { RoomLayout } from "@/components/room-layout"
|
||||
import { BottomNav } from "@/components/bottom-nav"
|
||||
|
||||
export const Route = createFileRoute("/host/$roomCode")({
|
||||
component: HostView,
|
||||
component: HostLayout,
|
||||
})
|
||||
|
||||
const nextActLabels: Partial<Record<Act, string>> = {
|
||||
lobby: "Start Pre-Show",
|
||||
"pre-show": "Start Live Event",
|
||||
"live-event": "Start Scoring",
|
||||
scoring: "End Party",
|
||||
}
|
||||
|
||||
function HostView() {
|
||||
function HostLayout() {
|
||||
const { roomCode } = Route.useParams()
|
||||
const { send } = useWebSocket(roomCode)
|
||||
const { room, mySessionId, connectionStatus, gameState } = useRoomStore()
|
||||
useWebSocket(roomCode)
|
||||
const { room, connectionStatus } = useRoomStore()
|
||||
|
||||
if (!room) {
|
||||
return (
|
||||
@@ -36,72 +24,9 @@ function HostView() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<RoomHeader roomCode={roomCode} currentAct={room.currentAct} connectionStatus={connectionStatus} />
|
||||
<Tabs defaultValue="host" className="flex-1">
|
||||
<TabsList className="w-full rounded-none">
|
||||
<TabsTrigger value="play" className="flex-1">
|
||||
Play
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="host" className="flex-1">
|
||||
Host
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="play" className="p-4">
|
||||
{gameState && room.currentAct !== "ended" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PredictionsForm
|
||||
entries={gameState.lineup.entries}
|
||||
existingPrediction={gameState.myPrediction}
|
||||
locked={gameState.predictionsLocked}
|
||||
onSubmit={(prediction) =>
|
||||
send({ type: "submit_prediction", ...prediction })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<PlayerList
|
||||
players={room.players}
|
||||
mySessionId={mySessionId}
|
||||
predictionSubmitted={gameState?.predictionSubmitted}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="host" className="p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Room Controls</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{room.currentAct !== "ended" && (
|
||||
<Button onClick={() => send({ type: "advance_act" })} className="w-full">
|
||||
{nextActLabels[room.currentAct] ?? "Next"}
|
||||
</Button>
|
||||
)}
|
||||
{room.currentAct !== "ended" && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => send({ type: "end_room" })}
|
||||
className="w-full"
|
||||
>
|
||||
End Party
|
||||
</Button>
|
||||
)}
|
||||
{room.currentAct === "ended" && (
|
||||
<p className="text-center text-muted-foreground">
|
||||
The party has ended. Thanks for playing!
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PlayerList
|
||||
players={room.players}
|
||||
mySessionId={mySessionId}
|
||||
predictionSubmitted={gameState?.predictionSubmitted}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<>
|
||||
<RoomLayout roomCode={roomCode} connectionStatus={connectionStatus} />
|
||||
<BottomNav basePath="/host/$roomCode" roomCode={roomCode} isHost={true} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useRoomStore } from "@/stores/room-store"
|
||||
import { BingoTab } from "@/components/bingo-tab"
|
||||
|
||||
export const Route = createFileRoute("/play/$roomCode/bingo")({
|
||||
component: PlayBingo,
|
||||
})
|
||||
|
||||
function PlayBingo() {
|
||||
const { room, gameState, send } = useRoomStore()
|
||||
|
||||
if (!room || !gameState) return null
|
||||
|
||||
return <BingoTab currentAct={room.currentAct} gameState={gameState} send={send} />
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useRoomStore } from "@/stores/room-store"
|
||||
import { BoardTab } from "@/components/board-tab"
|
||||
|
||||
export const Route = createFileRoute("/play/$roomCode/board")({
|
||||
component: PlayBoard,
|
||||
})
|
||||
|
||||
function PlayBoard() {
|
||||
const { room, gameState } = useRoomStore()
|
||||
|
||||
if (!room || !gameState) return null
|
||||
|
||||
return <BoardTab currentAct={room.currentAct} gameState={gameState} />
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useRoomStore } from "@/stores/room-store"
|
||||
import { GameTab } from "@/components/game-tab"
|
||||
|
||||
export const Route = createFileRoute("/play/$roomCode/game")({
|
||||
component: PlayGame,
|
||||
})
|
||||
|
||||
function PlayGame() {
|
||||
const { room, gameState, send } = useRoomStore()
|
||||
|
||||
if (!room || !gameState) return null
|
||||
|
||||
return <GameTab currentAct={room.currentAct} gameState={gameState} send={send} />
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/play/$roomCode/")({
|
||||
beforeLoad: ({ params }) => {
|
||||
throw redirect({ to: "/play/$roomCode/game", params })
|
||||
},
|
||||
})
|
||||
@@ -2,20 +2,19 @@ import { useEffect, useRef, useState } from "react"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useWebSocket } from "@/hooks/use-websocket"
|
||||
import { useRoomStore } from "@/stores/room-store"
|
||||
import { PlayerList } from "@/components/player-list"
|
||||
import { PredictionsForm } from "@/components/predictions-form"
|
||||
import { RoomHeader } from "@/components/room-header"
|
||||
import { RoomLayout } from "@/components/room-layout"
|
||||
import { BottomNav } from "@/components/bottom-nav"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
export const Route = createFileRoute("/play/$roomCode")({
|
||||
component: PlayerView,
|
||||
component: PlayLayout,
|
||||
})
|
||||
|
||||
function PlayerView() {
|
||||
function PlayLayout() {
|
||||
const { roomCode } = Route.useParams()
|
||||
const { send } = useWebSocket(roomCode)
|
||||
const { room, mySessionId, connectionStatus, gameState } = useRoomStore()
|
||||
const { room, mySessionId, connectionStatus } = useRoomStore()
|
||||
const joinSentRef = useRef(false)
|
||||
const [manualName, setManualName] = useState("")
|
||||
|
||||
@@ -80,40 +79,9 @@ function PlayerView() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<RoomHeader roomCode={roomCode} currentAct={room.currentAct} connectionStatus={connectionStatus} />
|
||||
<div className="flex-1 p-4">
|
||||
{room.currentAct === "lobby" && !gameState && (
|
||||
<div className="flex flex-col items-center gap-4 py-8">
|
||||
<p className="text-lg text-muted-foreground">Waiting for the host to start...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gameState && room.currentAct !== "ended" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PredictionsForm
|
||||
entries={gameState.lineup.entries}
|
||||
existingPrediction={gameState.myPrediction}
|
||||
locked={gameState.predictionsLocked}
|
||||
onSubmit={(prediction) =>
|
||||
send({ type: "submit_prediction", ...prediction })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{room.currentAct === "ended" && (
|
||||
<div className="flex flex-col items-center gap-4 py-8">
|
||||
<p className="text-lg text-muted-foreground">The party has ended. Thanks for playing!</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PlayerList
|
||||
players={room.players}
|
||||
mySessionId={mySessionId}
|
||||
predictionSubmitted={gameState?.predictionSubmitted}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<RoomLayout roomCode={roomCode} connectionStatus={connectionStatus} />
|
||||
<BottomNav basePath="/play/$roomCode" roomCode={roomCode} isHost={false} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { create } from "zustand"
|
||||
import type { RoomState, Player, GameState } from "@celebrate-esc/shared"
|
||||
import type { RoomState, Player, GameState, ClientMessage } from "@celebrate-esc/shared"
|
||||
|
||||
interface RoomStore {
|
||||
room: RoomState | null
|
||||
mySessionId: string | null
|
||||
connectionStatus: "disconnected" | "connecting" | "connected"
|
||||
gameState: GameState | null
|
||||
send: (message: ClientMessage) => void
|
||||
|
||||
setRoom: (room: RoomState) => void
|
||||
setMySessionId: (sessionId: string) => void
|
||||
@@ -15,14 +16,18 @@ interface RoomStore {
|
||||
setAct: (act: RoomState["currentAct"]) => void
|
||||
setGameState: (gameState: GameState) => void
|
||||
lockPredictions: () => void
|
||||
setSend: (send: (message: ClientMessage) => void) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
export const useRoomStore = create<RoomStore>((set) => ({
|
||||
room: null,
|
||||
mySessionId: null,
|
||||
connectionStatus: "disconnected",
|
||||
gameState: null,
|
||||
send: noop,
|
||||
|
||||
setRoom: (room) => set({ room }),
|
||||
setMySessionId: (sessionId) => set({ mySessionId: sessionId }),
|
||||
@@ -67,5 +72,7 @@ export const useRoomStore = create<RoomStore>((set) => ({
|
||||
}
|
||||
}),
|
||||
|
||||
reset: () => set({ room: null, mySessionId: null, connectionStatus: "disconnected", gameState: null }),
|
||||
setSend: (send) => set({ send }),
|
||||
|
||||
reset: () => set({ room: null, mySessionId: null, connectionStatus: "disconnected", gameState: null, send: noop }),
|
||||
}))
|
||||
|
||||
@@ -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,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,4 +1,4 @@
|
||||
import { boolean, pgEnum, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core"
|
||||
import { boolean, integer, jsonb, pgEnum, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core"
|
||||
|
||||
export const actEnum = pgEnum("act", ["lobby", "pre-show", "live-event", "scoring", "ended"])
|
||||
|
||||
@@ -44,3 +44,41 @@ export const predictions = pgTable("predictions", {
|
||||
third: varchar("third").notNull(),
|
||||
last: varchar("last").notNull(),
|
||||
})
|
||||
|
||||
// ─── Jury Voting ────────────────────────────────────────────────────
|
||||
|
||||
export const juryRoundStatusEnum = pgEnum("jury_round_status", ["open", "closed"])
|
||||
|
||||
export const juryRounds = pgTable("jury_rounds", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
roomId: uuid("room_id")
|
||||
.notNull()
|
||||
.references(() => rooms.id),
|
||||
countryCode: varchar("country_code").notNull(),
|
||||
status: juryRoundStatusEnum("status").notNull().default("open"),
|
||||
openedAt: timestamp("opened_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const juryVotes = pgTable("jury_votes", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
playerId: uuid("player_id")
|
||||
.notNull()
|
||||
.references(() => players.id),
|
||||
juryRoundId: uuid("jury_round_id")
|
||||
.notNull()
|
||||
.references(() => juryRounds.id),
|
||||
rating: integer("rating").notNull(),
|
||||
})
|
||||
|
||||
// ─── Bingo ──────────────────────────────────────────────────────────
|
||||
|
||||
export const bingoCards = pgTable("bingo_cards", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
playerId: uuid("player_id")
|
||||
.notNull()
|
||||
.references(() => players.id),
|
||||
roomId: uuid("room_id")
|
||||
.notNull()
|
||||
.references(() => rooms.id),
|
||||
squares: jsonb("squares").notNull(),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { describe, it, expect, beforeEach } from "bun:test"
|
||||
import { GameManager } from "../game-manager"
|
||||
|
||||
describe("Bingo", () => {
|
||||
let gm: GameManager
|
||||
|
||||
beforeEach(() => {
|
||||
gm = new GameManager()
|
||||
})
|
||||
|
||||
describe("generateBingoCards", () => {
|
||||
it("should create a 16-square card for each player", () => {
|
||||
gm.generateBingoCards(["p1", "p2"])
|
||||
const card1 = gm.getBingoCard("p1")
|
||||
const card2 = gm.getBingoCard("p2")
|
||||
expect(card1).not.toBeNull()
|
||||
expect(card1!.squares).toHaveLength(16)
|
||||
expect(card1!.hasBingo).toBe(false)
|
||||
expect(card2).not.toBeNull()
|
||||
expect(card2!.squares).toHaveLength(16)
|
||||
})
|
||||
|
||||
it("should return null for unknown player", () => {
|
||||
expect(gm.getBingoCard("unknown")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("tapBingoSquare", () => {
|
||||
beforeEach(() => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
})
|
||||
|
||||
it("should mark a square as tapped", () => {
|
||||
const card = gm.getBingoCard("p1")!
|
||||
const tropeId = card.squares[0]!.tropeId
|
||||
const result = gm.tapBingoSquare("p1", tropeId)
|
||||
expect(result).toHaveProperty("success", true)
|
||||
expect(card.squares[0]!.tapped).toBe(true)
|
||||
})
|
||||
|
||||
it("should be tap-only (not toggle)", () => {
|
||||
const card = gm.getBingoCard("p1")!
|
||||
const tropeId = card.squares[0]!.tropeId
|
||||
gm.tapBingoSquare("p1", tropeId)
|
||||
expect(card.squares[0]!.tapped).toBe(true)
|
||||
// Tap again — should stay tapped
|
||||
const result = gm.tapBingoSquare("p1", tropeId)
|
||||
expect(result).toHaveProperty("success", true)
|
||||
expect(card.squares[0]!.tapped).toBe(true)
|
||||
})
|
||||
|
||||
it("should error for unknown player", () => {
|
||||
const result = gm.tapBingoSquare("unknown", "trope1")
|
||||
expect(result).toHaveProperty("error")
|
||||
})
|
||||
|
||||
it("should error for trope not on card", () => {
|
||||
const result = gm.tapBingoSquare("p1", "nonexistent-trope")
|
||||
expect(result).toHaveProperty("error")
|
||||
})
|
||||
})
|
||||
|
||||
describe("bingo detection", () => {
|
||||
beforeEach(() => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
})
|
||||
|
||||
it("should detect a completed row", () => {
|
||||
const card = gm.getBingoCard("p1")!
|
||||
// Tap first row (indices 0-3)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const result = gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||||
if (i < 3) {
|
||||
expect((result as any).hasBingo).toBe(false)
|
||||
} else {
|
||||
expect((result as any).hasBingo).toBe(true)
|
||||
expect((result as any).isNewBingo).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("should detect a completed column", () => {
|
||||
const card = gm.getBingoCard("p1")!
|
||||
// Tap first column (indices 0, 4, 8, 12)
|
||||
for (const i of [0, 4, 8, 12]) {
|
||||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||||
}
|
||||
expect(card.hasBingo).toBe(true)
|
||||
})
|
||||
|
||||
it("should detect a completed diagonal", () => {
|
||||
const card = gm.getBingoCard("p1")!
|
||||
// Tap main diagonal (indices 0, 5, 10, 15)
|
||||
for (const i of [0, 5, 10, 15]) {
|
||||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||||
}
|
||||
expect(card.hasBingo).toBe(true)
|
||||
})
|
||||
|
||||
it("should detect anti-diagonal", () => {
|
||||
const card = gm.getBingoCard("p1")!
|
||||
// Tap anti-diagonal (indices 3, 6, 9, 12)
|
||||
for (const i of [3, 6, 9, 12]) {
|
||||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||||
}
|
||||
expect(card.hasBingo).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("bingo completion flow", () => {
|
||||
beforeEach(() => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
})
|
||||
|
||||
it("should NOT move card to completedBingoCards on bingo detection (only on redraw)", () => {
|
||||
const card = gm.getBingoCard("p1")!
|
||||
// Complete first row — sets hasBingo but does NOT move card
|
||||
for (let i = 0; i < 4; i++) {
|
||||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||||
}
|
||||
expect(card.hasBingo).toBe(true)
|
||||
expect(gm.getCompletedBingoCards()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should move card to completedBingoCards on requestNewBingoCard", () => {
|
||||
const card = gm.getBingoCard("p1")!
|
||||
// Complete first row
|
||||
for (let i = 0; i < 4; i++) {
|
||||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||||
}
|
||||
// Redraw moves the completed card
|
||||
gm.requestNewBingoCard("p1", "Player 1")
|
||||
const completed = gm.getCompletedBingoCards()
|
||||
expect(completed).toHaveLength(1)
|
||||
expect(completed[0]!.playerId).toBe("p1")
|
||||
expect(completed[0]!.displayName).toBe("Player 1")
|
||||
expect(completed[0]!.card.hasBingo).toBe(true)
|
||||
expect(completed[0]!.completedAt).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe("requestNewBingoCard", () => {
|
||||
beforeEach(() => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
})
|
||||
|
||||
it("should error if card has no bingo", () => {
|
||||
const result = gm.requestNewBingoCard("p1", "Player 1")
|
||||
expect(result).toHaveProperty("error")
|
||||
})
|
||||
|
||||
it("should generate a fresh card after bingo", () => {
|
||||
const card = gm.getBingoCard("p1")!
|
||||
// Complete first row
|
||||
for (let i = 0; i < 4; i++) {
|
||||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||||
}
|
||||
const result = gm.requestNewBingoCard("p1", "Player 1")
|
||||
expect(result).toHaveProperty("success", true)
|
||||
const newCard = gm.getBingoCard("p1")!
|
||||
expect(newCard.hasBingo).toBe(false)
|
||||
expect(newCard.squares.every((s) => !s.tapped)).toBe(true)
|
||||
expect(newCard.squares).toHaveLength(16)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getBingoScore — accumulation across cards", () => {
|
||||
beforeEach(() => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
})
|
||||
|
||||
it("should score tapped squares on active card", () => {
|
||||
const card = gm.getBingoCard("p1")!
|
||||
gm.tapBingoSquare("p1", card.squares[0]!.tropeId)
|
||||
gm.tapBingoSquare("p1", card.squares[1]!.tropeId)
|
||||
// 2 tapped squares * 2 points = 4
|
||||
expect(gm.getBingoScore("p1")).toBe(4)
|
||||
})
|
||||
|
||||
it("should include bingo bonus on completed card", () => {
|
||||
const card = gm.getBingoCard("p1")!
|
||||
// Complete first row (4 squares)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||||
}
|
||||
// 4 tapped * 2 = 8, plus 10 bonus = 18
|
||||
expect(gm.getBingoScore("p1")).toBe(18)
|
||||
})
|
||||
|
||||
it("should accumulate scores across completed + new card", () => {
|
||||
const card = gm.getBingoCard("p1")!
|
||||
// Complete first row (4 squares) — triggers completion
|
||||
for (let i = 0; i < 4; i++) {
|
||||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||||
}
|
||||
// Request new card
|
||||
gm.requestNewBingoCard("p1", "Player 1")
|
||||
const newCard = gm.getBingoCard("p1")!
|
||||
// Tap 2 squares on new card
|
||||
gm.tapBingoSquare("p1", newCard.squares[0]!.tropeId)
|
||||
gm.tapBingoSquare("p1", newCard.squares[1]!.tropeId)
|
||||
// Old card: 4 tapped * 2 = 8 + 10 bonus = 18
|
||||
// New card: 2 tapped * 2 = 4
|
||||
// Total: 22
|
||||
expect(gm.getBingoScore("p1")).toBe(22)
|
||||
})
|
||||
|
||||
it("should return 0 for unknown player", () => {
|
||||
expect(gm.getBingoScore("unknown")).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("addBingoAnnouncement — multiple per player", () => {
|
||||
beforeEach(() => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
})
|
||||
|
||||
it("should announce first bingo", () => {
|
||||
const card = gm.getBingoCard("p1")!
|
||||
for (let i = 0; i < 4; i++) {
|
||||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||||
}
|
||||
const isNew = gm.addBingoAnnouncement("p1", "Player 1")
|
||||
expect(isNew).toBe(true)
|
||||
})
|
||||
|
||||
it("should not re-announce same bingo", () => {
|
||||
const card = gm.getBingoCard("p1")!
|
||||
for (let i = 0; i < 4; i++) {
|
||||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||||
}
|
||||
gm.addBingoAnnouncement("p1", "Player 1")
|
||||
const isNew = gm.addBingoAnnouncement("p1", "Player 1")
|
||||
expect(isNew).toBe(false)
|
||||
})
|
||||
|
||||
it("should announce second bingo after redraw", () => {
|
||||
const card = gm.getBingoCard("p1")!
|
||||
for (let i = 0; i < 4; i++) {
|
||||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||||
}
|
||||
gm.addBingoAnnouncement("p1", "Player 1")
|
||||
// Redraw
|
||||
gm.requestNewBingoCard("p1", "Player 1")
|
||||
const newCard = gm.getBingoCard("p1")!
|
||||
// Complete first row of new card
|
||||
for (let i = 0; i < 4; i++) {
|
||||
gm.tapBingoSquare("p1", newCard.squares[i]!.tropeId)
|
||||
}
|
||||
const isNew = gm.addBingoAnnouncement("p1", "Player 1")
|
||||
expect(isNew).toBe(true)
|
||||
expect(gm.getBingoAnnouncements()).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("game state includes completedBingoCards", () => {
|
||||
it("should include completedBingoCards in player game state", () => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Player 1" })
|
||||
expect(state.completedBingoCards).toEqual([])
|
||||
})
|
||||
|
||||
it("should include completedBingoCards in display game state", () => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
const state = gm.getGameStateForDisplay(["p1"], { p1: "Player 1" })
|
||||
expect(state.completedBingoCards).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, beforeEach } from "bun:test"
|
||||
import { GameManager } from "../game-manager"
|
||||
|
||||
describe("GameManager Quiz", () => {
|
||||
let gm: GameManager
|
||||
|
||||
beforeEach(() => {
|
||||
gm = new GameManager()
|
||||
})
|
||||
|
||||
describe("startQuizQuestion", () => {
|
||||
it("starts the first question", () => {
|
||||
const result = gm.startQuizQuestion()
|
||||
expect("error" in result).toBe(false)
|
||||
if (!("error" in result)) {
|
||||
expect(result.questionIndex).toBe(0)
|
||||
}
|
||||
const round = gm.getCurrentQuizRound()
|
||||
expect(round).not.toBeNull()
|
||||
expect(round!.status).toBe("buzzing")
|
||||
})
|
||||
|
||||
it("returns error if a question is already active", () => {
|
||||
gm.startQuizQuestion()
|
||||
const result = gm.startQuizQuestion()
|
||||
expect(result).toEqual({ error: "A quiz question is already active — skip it first" })
|
||||
})
|
||||
|
||||
it("advances to next question after resolving", () => {
|
||||
gm.startQuizQuestion()
|
||||
gm.buzz("p1")
|
||||
gm.judgeQuizAnswer(true)
|
||||
const result = gm.startQuizQuestion()
|
||||
expect("error" in result).toBe(false)
|
||||
if (!("error" in result)) {
|
||||
expect(result.questionIndex).toBe(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("buzz", () => {
|
||||
it("sets the buzzer player", () => {
|
||||
gm.startQuizQuestion()
|
||||
const result = gm.buzz("p1")
|
||||
expect("error" in result).toBe(false)
|
||||
const round = gm.getCurrentQuizRound()
|
||||
expect(round!.status).toBe("judging")
|
||||
expect(round!.buzzerPlayerId).toBe("p1")
|
||||
})
|
||||
|
||||
it("returns error if no question active", () => {
|
||||
const result = gm.buzz("p1")
|
||||
expect(result).toEqual({ error: "No quiz question active" })
|
||||
})
|
||||
|
||||
it("returns error if not in buzzing status", () => {
|
||||
gm.startQuizQuestion()
|
||||
gm.buzz("p1")
|
||||
const result = gm.buzz("p2")
|
||||
expect(result).toEqual({ error: "Buzzing is not open" })
|
||||
})
|
||||
|
||||
it("returns error if player is excluded", () => {
|
||||
gm.startQuizQuestion()
|
||||
gm.buzz("p1")
|
||||
gm.judgeQuizAnswer(false)
|
||||
const result = gm.buzz("p1")
|
||||
expect(result).toEqual({ error: "You are excluded from this question" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("judgeQuizAnswer", () => {
|
||||
it("awards points on correct answer", () => {
|
||||
gm.startQuizQuestion()
|
||||
gm.buzz("p1")
|
||||
const result = gm.judgeQuizAnswer(true)
|
||||
expect("error" in result).toBe(false)
|
||||
const round = gm.getCurrentQuizRound()
|
||||
expect(round!.status).toBe("resolved")
|
||||
expect(round!.wasCorrect).toBe(true)
|
||||
expect(gm.getQuizScore("p1")).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("excludes player and reopens buzzing on incorrect", () => {
|
||||
gm.startQuizQuestion()
|
||||
gm.buzz("p1")
|
||||
gm.judgeQuizAnswer(false)
|
||||
const round = gm.getCurrentQuizRound()
|
||||
expect(round!.status).toBe("buzzing")
|
||||
expect(round!.buzzerPlayerId).toBeNull()
|
||||
expect(round!.wasCorrect).toBeNull()
|
||||
expect(gm.getQuizBuzzStatus("p1")).toBe("excluded")
|
||||
})
|
||||
|
||||
it("returns error if no one has buzzed", () => {
|
||||
gm.startQuizQuestion()
|
||||
const result = gm.judgeQuizAnswer(true)
|
||||
expect(result).toEqual({ error: "No one has buzzed yet" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("skipQuizQuestion", () => {
|
||||
it("resolves the current question without awarding points", () => {
|
||||
gm.startQuizQuestion()
|
||||
const result = gm.skipQuizQuestion()
|
||||
expect("error" in result).toBe(false)
|
||||
const round = gm.getCurrentQuizRound()
|
||||
expect(round!.status).toBe("resolved")
|
||||
expect(round!.wasCorrect).toBeNull()
|
||||
})
|
||||
|
||||
it("returns error when no question active", () => {
|
||||
const result = gm.skipQuizQuestion()
|
||||
expect(result).toEqual({ error: "No quiz question active" })
|
||||
})
|
||||
|
||||
it("allows starting next question after skip", () => {
|
||||
gm.startQuizQuestion()
|
||||
gm.skipQuizQuestion()
|
||||
const result = gm.startQuizQuestion()
|
||||
expect("error" in result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getQuizBuzzStatus", () => {
|
||||
it("returns null when no question active", () => {
|
||||
expect(gm.getQuizBuzzStatus("p1")).toBeNull()
|
||||
})
|
||||
|
||||
it("returns can_buzz for non-excluded player during buzzing", () => {
|
||||
gm.startQuizQuestion()
|
||||
expect(gm.getQuizBuzzStatus("p1")).toBe("can_buzz")
|
||||
})
|
||||
|
||||
it("returns already_buzzed for the buzzer during judging", () => {
|
||||
gm.startQuizQuestion()
|
||||
gm.buzz("p1")
|
||||
expect(gm.getQuizBuzzStatus("p1")).toBe("already_buzzed")
|
||||
})
|
||||
|
||||
it("returns excluded for incorrectly judged player", () => {
|
||||
gm.startQuizQuestion()
|
||||
gm.buzz("p1")
|
||||
gm.judgeQuizAnswer(false)
|
||||
expect(gm.getQuizBuzzStatus("p1")).toBe("excluded")
|
||||
})
|
||||
|
||||
it("returns waiting for non-buzzer players during judging", () => {
|
||||
gm.startQuizQuestion()
|
||||
gm.buzz("p1")
|
||||
expect(gm.getQuizBuzzStatus("p2")).toBe("waiting")
|
||||
})
|
||||
})
|
||||
|
||||
describe("scoring", () => {
|
||||
it("awards points based on difficulty", () => {
|
||||
gm.startQuizQuestion()
|
||||
gm.buzz("p1")
|
||||
gm.judgeQuizAnswer(true)
|
||||
const score = gm.getQuizScore("p1")
|
||||
expect(score).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { Prediction, GameState, Lineup } from "@celebrate-esc/shared"
|
||||
import type { Prediction, GameState, Lineup, JuryRound, JuryResult, QuizQuestion, CompletedBingoCard } from "@celebrate-esc/shared"
|
||||
import lineupData from "../../data/esc-2025.json"
|
||||
import scoringConfig from "../../data/scoring.json"
|
||||
import tropesData from "../../data/bingo-tropes.json"
|
||||
import quizQuestionsData from "../../data/quiz-questions.json"
|
||||
|
||||
const quizQuestions = quizQuestionsData as { id: string; text: string; answer: string; difficulty: "easy" | "medium" | "hard" }[]
|
||||
|
||||
const tropes: { id: string; label: string }[] = tropesData
|
||||
|
||||
const lineup: Lineup = lineupData as Lineup
|
||||
const countryCodes = new Set(lineup.entries.map((e) => e.country.code))
|
||||
@@ -7,6 +14,7 @@ const countryCodes = new Set(lineup.entries.map((e) => e.country.code))
|
||||
export class GameManager {
|
||||
private predictions = new Map<string, Prediction>() // playerId → prediction
|
||||
private locked = false
|
||||
private actualResults: { winner: string; second: string; third: string; last: string } | null = null
|
||||
|
||||
getLineup(): Lineup {
|
||||
return lineup
|
||||
@@ -60,6 +68,415 @@ export class GameManager {
|
||||
return this.predictions.has(playerId)
|
||||
}
|
||||
|
||||
// ─── Jury Voting ────────────────────────────────────────────────
|
||||
|
||||
private currentJuryRound: {
|
||||
id: string
|
||||
countryCode: string
|
||||
countryName: string
|
||||
countryFlag: string
|
||||
votes: Map<string, number>
|
||||
} | null = null
|
||||
|
||||
private juryResults: JuryResult[] = []
|
||||
private juryScores = new Map<string, number>()
|
||||
|
||||
openJuryRound(
|
||||
countryCode: string,
|
||||
countryName: string,
|
||||
countryFlag: string,
|
||||
): { success: true } | { error: string } {
|
||||
if (this.currentJuryRound) return { error: "A jury round is already open" }
|
||||
this.currentJuryRound = {
|
||||
id: crypto.randomUUID(),
|
||||
countryCode,
|
||||
countryName,
|
||||
countryFlag,
|
||||
votes: new Map(),
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
submitJuryVote(playerId: string, rating: number): { success: true } | { error: string } {
|
||||
if (!this.currentJuryRound) return { error: "No jury round is open" }
|
||||
if (rating < 1 || rating > 12) return { error: "Rating must be between 1 and 12" }
|
||||
this.currentJuryRound.votes.set(playerId, rating)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
getPlayerJuryVote(playerId: string): number | null {
|
||||
if (!this.currentJuryRound) return null
|
||||
return this.currentJuryRound.votes.get(playerId) ?? null
|
||||
}
|
||||
|
||||
closeJuryRound(): JuryResult | { error: string } {
|
||||
if (!this.currentJuryRound) return { error: "No jury round is open" }
|
||||
const round = this.currentJuryRound
|
||||
const votes = Array.from(round.votes.values())
|
||||
|
||||
const averageRating = votes.length > 0
|
||||
? Math.round((votes.reduce((a, b) => a + b, 0) / votes.length) * 10) / 10
|
||||
: 0
|
||||
|
||||
const maxPts = scoringConfig.jury_max_per_round
|
||||
for (const [playerId, rating] of round.votes) {
|
||||
const diff = Math.abs(rating - averageRating)
|
||||
const pts = Math.max(0, maxPts - Math.round(diff))
|
||||
this.juryScores.set(playerId, (this.juryScores.get(playerId) ?? 0) + pts)
|
||||
}
|
||||
|
||||
const result: JuryResult = {
|
||||
countryCode: round.countryCode,
|
||||
countryName: round.countryName,
|
||||
countryFlag: round.countryFlag,
|
||||
averageRating,
|
||||
totalVotes: votes.length,
|
||||
}
|
||||
this.juryResults.push(result)
|
||||
this.currentJuryRound = null
|
||||
return result
|
||||
}
|
||||
|
||||
getCurrentJuryRound(): JuryRound | null {
|
||||
if (!this.currentJuryRound) return null
|
||||
return {
|
||||
id: this.currentJuryRound.id,
|
||||
countryCode: this.currentJuryRound.countryCode,
|
||||
countryName: this.currentJuryRound.countryName,
|
||||
countryFlag: this.currentJuryRound.countryFlag,
|
||||
status: "open",
|
||||
}
|
||||
}
|
||||
|
||||
getJuryResults(): JuryResult[] {
|
||||
return this.juryResults
|
||||
}
|
||||
|
||||
getJuryScore(playerId: string): number {
|
||||
return this.juryScores.get(playerId) ?? 0
|
||||
}
|
||||
|
||||
// ─── Bingo ──────────────────────────────────────────────────────
|
||||
|
||||
private bingoCards = new Map<string, {
|
||||
squares: { tropeId: string; label: string; tapped: boolean }[]
|
||||
hasBingo: boolean
|
||||
}>()
|
||||
private bingoAnnouncements: { playerId: string; displayName: string }[] = []
|
||||
private completedBingoCards: CompletedBingoCard[] = []
|
||||
|
||||
generateBingoCards(playerIds: string[]): void {
|
||||
for (const playerId of playerIds) {
|
||||
if (this.bingoCards.has(playerId)) continue
|
||||
const shuffled = [...tropes].sort(() => Math.random() - 0.5)
|
||||
const selected = shuffled.slice(0, 16)
|
||||
this.bingoCards.set(playerId, {
|
||||
squares: selected.map((t) => ({
|
||||
tropeId: t.id,
|
||||
label: t.label,
|
||||
tapped: false,
|
||||
})),
|
||||
hasBingo: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
generateBingoCardForPlayer(playerId: string): void {
|
||||
if (this.bingoCards.has(playerId)) return
|
||||
const shuffled = [...tropes].sort(() => Math.random() - 0.5)
|
||||
const selected = shuffled.slice(0, 16)
|
||||
this.bingoCards.set(playerId, {
|
||||
squares: selected.map((t) => ({ tropeId: t.id, label: t.label, tapped: false })),
|
||||
hasBingo: false,
|
||||
})
|
||||
}
|
||||
|
||||
getBingoCard(playerId: string): { squares: { tropeId: string; label: string; tapped: boolean }[]; hasBingo: boolean } | null {
|
||||
return this.bingoCards.get(playerId) ?? null
|
||||
}
|
||||
|
||||
tapBingoSquare(playerId: string, tropeId: string): { success: true; hasBingo: boolean; isNewBingo: boolean } | { error: string } {
|
||||
const card = this.bingoCards.get(playerId)
|
||||
if (!card) return { error: "No bingo card found" }
|
||||
const square = card.squares.find((s) => s.tropeId === tropeId)
|
||||
if (!square) return { error: "Trope not on your card" }
|
||||
if (square.tapped) return { success: true, hasBingo: card.hasBingo, isNewBingo: false }
|
||||
square.tapped = true
|
||||
const hadBingo = card.hasBingo
|
||||
card.hasBingo = this.checkBingo(card.squares)
|
||||
const isNewBingo = card.hasBingo && !hadBingo
|
||||
return { success: true, hasBingo: card.hasBingo, isNewBingo }
|
||||
}
|
||||
|
||||
private checkBingo(squares: { tapped: boolean }[]): boolean {
|
||||
for (let row = 0; row < 4; row++) {
|
||||
if (squares[row * 4]!.tapped && squares[row * 4 + 1]!.tapped && squares[row * 4 + 2]!.tapped && squares[row * 4 + 3]!.tapped) return true
|
||||
}
|
||||
for (let col = 0; col < 4; col++) {
|
||||
if (squares[col]!.tapped && squares[col + 4]!.tapped && squares[col + 8]!.tapped && squares[col + 12]!.tapped) return true
|
||||
}
|
||||
if (squares[0]!.tapped && squares[5]!.tapped && squares[10]!.tapped && squares[15]!.tapped) return true
|
||||
if (squares[3]!.tapped && squares[6]!.tapped && squares[9]!.tapped && squares[12]!.tapped) return true
|
||||
return false
|
||||
}
|
||||
|
||||
addBingoAnnouncement(playerId: string, displayName: string): boolean {
|
||||
// Count how many bingos this player already announced
|
||||
const count = this.bingoAnnouncements.filter((a) => a.playerId === playerId).length
|
||||
// Count how many bingo-detected cards this player has (completed + current if hasBingo)
|
||||
const completedCount = this.completedBingoCards.filter((c) => c.playerId === playerId).length
|
||||
const activeCard = this.bingoCards.get(playerId)
|
||||
const totalBingos = completedCount + (activeCard?.hasBingo ? 1 : 0)
|
||||
// Only announce if there are more bingos than announcements
|
||||
if (count >= totalBingos) return false
|
||||
this.bingoAnnouncements.push({ playerId, displayName })
|
||||
return true
|
||||
}
|
||||
|
||||
getBingoAnnouncements(): { playerId: string; displayName: string }[] {
|
||||
return this.bingoAnnouncements
|
||||
}
|
||||
|
||||
getBingoScore(playerId: string): number {
|
||||
let totalTapped = 0
|
||||
let totalBonuses = 0
|
||||
// Count completed cards (moved here on redraw)
|
||||
const completed = this.completedBingoCards.filter((c) => c.playerId === playerId)
|
||||
for (const c of completed) {
|
||||
totalTapped += c.card.squares.filter((s) => s.tapped).length
|
||||
totalBonuses += scoringConfig.bingo_full_bonus
|
||||
}
|
||||
// Count active card (never overlaps with completed — card moves on redraw)
|
||||
const activeCard = this.bingoCards.get(playerId)
|
||||
if (activeCard) {
|
||||
totalTapped += activeCard.squares.filter((s) => s.tapped).length
|
||||
if (activeCard.hasBingo) totalBonuses += scoringConfig.bingo_full_bonus
|
||||
}
|
||||
return totalTapped * scoringConfig.bingo_per_square + totalBonuses
|
||||
}
|
||||
|
||||
requestNewBingoCard(playerId: string, displayName: string): { success: true } | { error: string } {
|
||||
const currentCard = this.bingoCards.get(playerId)
|
||||
if (!currentCard || !currentCard.hasBingo) {
|
||||
return { error: "No completed bingo card to replace" }
|
||||
}
|
||||
// Move current card to completedBingoCards
|
||||
this.completedBingoCards.push({
|
||||
playerId,
|
||||
displayName,
|
||||
card: { squares: currentCard.squares.map((s) => ({ ...s })), hasBingo: true },
|
||||
completedAt: new Date().toISOString(),
|
||||
})
|
||||
// Generate new card excluding tropes from the just-completed card
|
||||
const excludeIds = new Set(currentCard.squares.map((s) => s.tropeId))
|
||||
const available = tropes.filter((t) => !excludeIds.has(t.id))
|
||||
const pool = available.length >= 16 ? available : tropes
|
||||
const shuffled = [...pool].sort(() => Math.random() - 0.5)
|
||||
const selected = shuffled.slice(0, 16)
|
||||
this.bingoCards.set(playerId, {
|
||||
squares: selected.map((t) => ({ tropeId: t.id, label: t.label, tapped: false })),
|
||||
hasBingo: false,
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
getCompletedBingoCards(): CompletedBingoCard[] {
|
||||
return this.completedBingoCards
|
||||
}
|
||||
|
||||
// ─── Quiz ────────────────────────────────────────────────────────
|
||||
|
||||
private currentQuizRound: {
|
||||
questionIndex: number
|
||||
question: { id: string; text: string; answer: string; difficulty: "easy" | "medium" | "hard" }
|
||||
status: "buzzing" | "judging" | "resolved"
|
||||
buzzerPlayerId: string | null
|
||||
excludedPlayers: Set<string>
|
||||
wasCorrect: boolean | null
|
||||
} | null = null
|
||||
private quizQuestionIndex = 0
|
||||
private quizScores = new Map<string, number>()
|
||||
|
||||
skipQuizQuestion(): { success: true } | { error: string } {
|
||||
if (!this.currentQuizRound) return { error: "No quiz question active" }
|
||||
if (this.currentQuizRound.status === "resolved") return { error: "Question already resolved" }
|
||||
this.currentQuizRound.status = "resolved"
|
||||
this.currentQuizRound.wasCorrect = null
|
||||
this.currentQuizRound.buzzerPlayerId = null
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
startQuizQuestion(): { questionIndex: number } | { error: string } {
|
||||
if (this.currentQuizRound && this.currentQuizRound.status !== "resolved") {
|
||||
return { error: "A quiz question is already active — skip it first" }
|
||||
}
|
||||
if (this.quizQuestionIndex >= quizQuestions.length) {
|
||||
return { error: "No more questions available" }
|
||||
}
|
||||
const question = quizQuestions[this.quizQuestionIndex]!
|
||||
this.currentQuizRound = {
|
||||
questionIndex: this.quizQuestionIndex,
|
||||
question,
|
||||
status: "buzzing",
|
||||
buzzerPlayerId: null,
|
||||
excludedPlayers: new Set(),
|
||||
wasCorrect: null,
|
||||
}
|
||||
const index = this.quizQuestionIndex
|
||||
this.quizQuestionIndex++
|
||||
return { questionIndex: index }
|
||||
}
|
||||
|
||||
buzz(playerId: string): { success: true } | { error: string } {
|
||||
if (!this.currentQuizRound) return { error: "No quiz question active" }
|
||||
if (this.currentQuizRound.status !== "buzzing") return { error: "Buzzing is not open" }
|
||||
if (this.currentQuizRound.excludedPlayers.has(playerId)) return { error: "You are excluded from this question" }
|
||||
this.currentQuizRound.buzzerPlayerId = playerId
|
||||
this.currentQuizRound.status = "judging"
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
judgeQuizAnswer(correct: boolean): { success: true } | { error: string } {
|
||||
if (!this.currentQuizRound) return { error: "No quiz question active" }
|
||||
if (this.currentQuizRound.status !== "judging" || !this.currentQuizRound.buzzerPlayerId) {
|
||||
return { error: "No one has buzzed yet" }
|
||||
}
|
||||
const playerId = this.currentQuizRound.buzzerPlayerId
|
||||
if (correct) {
|
||||
const difficulty = this.currentQuizRound.question.difficulty
|
||||
const points = difficulty === "easy"
|
||||
? scoringConfig.quiz_easy
|
||||
: difficulty === "medium"
|
||||
? scoringConfig.quiz_medium
|
||||
: scoringConfig.quiz_hard
|
||||
this.quizScores.set(playerId, (this.quizScores.get(playerId) ?? 0) + points)
|
||||
this.currentQuizRound.status = "resolved"
|
||||
this.currentQuizRound.wasCorrect = true
|
||||
} else {
|
||||
this.currentQuizRound.excludedPlayers.add(playerId)
|
||||
this.currentQuizRound.buzzerPlayerId = null
|
||||
this.currentQuizRound.wasCorrect = null
|
||||
this.currentQuizRound.status = "buzzing"
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
getCurrentQuizRound(): {
|
||||
questionIndex: number
|
||||
status: "buzzing" | "judging" | "resolved"
|
||||
difficulty: "easy" | "medium" | "hard"
|
||||
text: string
|
||||
answer: string
|
||||
buzzerPlayerId: string | null
|
||||
wasCorrect: boolean | null
|
||||
} | null {
|
||||
if (!this.currentQuizRound) return null
|
||||
return {
|
||||
questionIndex: this.currentQuizRound.questionIndex,
|
||||
status: this.currentQuizRound.status,
|
||||
difficulty: this.currentQuizRound.question.difficulty,
|
||||
text: this.currentQuizRound.question.text,
|
||||
answer: this.currentQuizRound.question.answer,
|
||||
buzzerPlayerId: this.currentQuizRound.buzzerPlayerId,
|
||||
wasCorrect: this.currentQuizRound.wasCorrect,
|
||||
}
|
||||
}
|
||||
|
||||
getQuizBuzzStatus(playerId: string): "can_buzz" | "already_buzzed" | "excluded" | "waiting" | null {
|
||||
if (!this.currentQuizRound) return null
|
||||
if (this.currentQuizRound.status === "resolved") return null
|
||||
if (this.currentQuizRound.excludedPlayers.has(playerId)) return "excluded"
|
||||
if (this.currentQuizRound.buzzerPlayerId === playerId) return "already_buzzed"
|
||||
if (this.currentQuizRound.status === "judging") return "waiting"
|
||||
return "can_buzz"
|
||||
}
|
||||
|
||||
getQuizScore(playerId: string): number {
|
||||
return this.quizScores.get(playerId) ?? 0
|
||||
}
|
||||
|
||||
getTotalQuizQuestions(): number {
|
||||
return quizQuestions.length
|
||||
}
|
||||
|
||||
private buildQuizQuestionForPlayer(
|
||||
_playerId: string,
|
||||
displayNames: Record<string, string>,
|
||||
): QuizQuestion | null {
|
||||
const round = this.currentQuizRound
|
||||
if (!round) return null
|
||||
return {
|
||||
index: round.questionIndex,
|
||||
total: quizQuestions.length,
|
||||
difficulty: round.question.difficulty,
|
||||
text: "",
|
||||
answer: "",
|
||||
status: round.status,
|
||||
buzzerPlayerId: round.buzzerPlayerId,
|
||||
buzzerName: round.buzzerPlayerId ? (displayNames[round.buzzerPlayerId] ?? "Unknown") : null,
|
||||
wasCorrect: round.wasCorrect,
|
||||
}
|
||||
}
|
||||
|
||||
private buildQuizQuestionForDisplay(
|
||||
displayNames: Record<string, string>,
|
||||
): QuizQuestion | null {
|
||||
const round = this.currentQuizRound
|
||||
if (!round) return null
|
||||
return {
|
||||
index: round.questionIndex,
|
||||
total: quizQuestions.length,
|
||||
difficulty: round.question.difficulty,
|
||||
text: round.question.text,
|
||||
answer: "",
|
||||
status: round.status,
|
||||
buzzerPlayerId: round.buzzerPlayerId,
|
||||
buzzerName: round.buzzerPlayerId ? (displayNames[round.buzzerPlayerId] ?? "Unknown") : null,
|
||||
wasCorrect: round.wasCorrect,
|
||||
}
|
||||
}
|
||||
|
||||
buildQuizQuestionForHost(
|
||||
displayNames: Record<string, string>,
|
||||
): QuizQuestion | null {
|
||||
const round = this.currentQuizRound
|
||||
if (!round) return null
|
||||
return {
|
||||
index: round.questionIndex,
|
||||
total: quizQuestions.length,
|
||||
difficulty: round.question.difficulty,
|
||||
text: round.question.text,
|
||||
answer: round.question.answer,
|
||||
status: round.status,
|
||||
buzzerPlayerId: round.buzzerPlayerId,
|
||||
buzzerName: round.buzzerPlayerId ? (displayNames[round.buzzerPlayerId] ?? "Unknown") : null,
|
||||
wasCorrect: round.wasCorrect,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Prediction Scoring ─────────────────────────────────────────
|
||||
|
||||
setActualResults(winner: string, second: string, third: string, last: string): void {
|
||||
this.actualResults = { winner, second, third, last }
|
||||
}
|
||||
|
||||
getActualResults(): { winner: string; second: string; third: string; last: string } | null {
|
||||
return this.actualResults
|
||||
}
|
||||
|
||||
getPredictionScore(playerId: string): number {
|
||||
if (!this.actualResults) return 0
|
||||
const prediction = this.predictions.get(playerId)
|
||||
if (!prediction) return 0
|
||||
|
||||
let score = 0
|
||||
if (prediction.first === this.actualResults.winner) score += scoringConfig.prediction_winner
|
||||
if (prediction.second === this.actualResults.second) score += scoringConfig.prediction_top3
|
||||
if (prediction.third === this.actualResults.third) score += scoringConfig.prediction_top3
|
||||
if (prediction.last === this.actualResults.last) score += scoringConfig.prediction_nul_points
|
||||
return score
|
||||
}
|
||||
|
||||
// ─── State for client ───────────────────────────────────────────
|
||||
|
||||
private buildPredictionSubmitted(playerIds: string[]): Record<string, boolean> {
|
||||
@@ -70,21 +487,71 @@ export class GameManager {
|
||||
return result
|
||||
}
|
||||
|
||||
getGameStateForPlayer(playerId: string, allPlayerIds: string[]): GameState {
|
||||
getGameStateForPlayer(
|
||||
playerId: string,
|
||||
allPlayerIds: string[],
|
||||
displayNames?: Record<string, string>,
|
||||
): GameState {
|
||||
return {
|
||||
lineup,
|
||||
myPrediction: this.getPrediction(playerId),
|
||||
predictionsLocked: this.locked,
|
||||
predictionSubmitted: this.buildPredictionSubmitted(allPlayerIds),
|
||||
currentJuryRound: this.getCurrentJuryRound(),
|
||||
juryResults: this.juryResults,
|
||||
myJuryVote: this.getPlayerJuryVote(playerId),
|
||||
myBingoCard: this.getBingoCard(playerId),
|
||||
bingoAnnouncements: this.bingoAnnouncements,
|
||||
completedBingoCards: this.completedBingoCards,
|
||||
currentQuizQuestion: this.buildQuizQuestionForPlayer(playerId, displayNames ?? {}),
|
||||
myQuizBuzzStatus: this.getQuizBuzzStatus(playerId),
|
||||
actualResults: this.actualResults,
|
||||
leaderboard: this.buildLeaderboard(allPlayerIds, displayNames ?? {}),
|
||||
}
|
||||
}
|
||||
|
||||
getGameStateForDisplay(allPlayerIds: string[]): GameState {
|
||||
getGameStateForDisplay(
|
||||
allPlayerIds: string[],
|
||||
displayNames?: Record<string, string>,
|
||||
): GameState {
|
||||
return {
|
||||
lineup,
|
||||
myPrediction: null,
|
||||
predictionsLocked: this.locked,
|
||||
predictionSubmitted: this.buildPredictionSubmitted(allPlayerIds),
|
||||
currentJuryRound: this.getCurrentJuryRound(),
|
||||
juryResults: this.juryResults,
|
||||
myJuryVote: null,
|
||||
myBingoCard: null,
|
||||
bingoAnnouncements: this.bingoAnnouncements,
|
||||
completedBingoCards: this.completedBingoCards,
|
||||
currentQuizQuestion: this.buildQuizQuestionForDisplay(displayNames ?? {}),
|
||||
myQuizBuzzStatus: null,
|
||||
actualResults: this.actualResults,
|
||||
leaderboard: this.buildLeaderboard(allPlayerIds, displayNames ?? {}),
|
||||
}
|
||||
}
|
||||
|
||||
private buildLeaderboard(
|
||||
playerIds: string[],
|
||||
displayNames: Record<string, string>,
|
||||
): { playerId: string; displayName: string; juryPoints: number; bingoPoints: number; predictionPoints: number; quizPoints: number; totalPoints: number }[] {
|
||||
return playerIds
|
||||
.map((id) => {
|
||||
const juryPoints = this.getJuryScore(id)
|
||||
const bingoPoints = this.getBingoScore(id)
|
||||
const predictionPoints = this.getPredictionScore(id)
|
||||
const quizPoints = this.getQuizScore(id)
|
||||
return {
|
||||
playerId: id,
|
||||
displayName: displayNames[id] ?? "Unknown",
|
||||
juryPoints,
|
||||
bingoPoints,
|
||||
predictionPoints,
|
||||
quizPoints,
|
||||
totalPoints: juryPoints + bingoPoints + predictionPoints + quizPoints,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.totalPoints - a.totalPoints)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,18 @@ export class RoomManager {
|
||||
return { newAct: nextAct }
|
||||
}
|
||||
|
||||
revertAct(code: string, sessionId: string): { newAct: Act } | { error: string } {
|
||||
const room = this.rooms.get(code)
|
||||
if (!room) return { error: "Room not found" }
|
||||
if (room.hostSessionId !== sessionId) return { error: "Only the host can revert acts" }
|
||||
if (room.currentAct === "lobby") return { error: "Already at the first act" }
|
||||
|
||||
const currentIndex = ACTS.indexOf(room.currentAct)
|
||||
const prevAct = ACTS[currentIndex - 1]!
|
||||
room.currentAct = prevAct
|
||||
return { newAct: prevAct }
|
||||
}
|
||||
|
||||
endRoom(code: string, sessionId: string): { success: true } | { error: string } {
|
||||
const room = this.rooms.get(code)
|
||||
if (!room) return { error: "Room not found" }
|
||||
@@ -154,6 +166,16 @@ export class RoomManager {
|
||||
return Array.from(room.players.values()).map((p) => p.id)
|
||||
}
|
||||
|
||||
getPlayerDisplayNames(code: string): Record<string, string> {
|
||||
const room = this.rooms.get(code)
|
||||
if (!room) return {}
|
||||
const result: Record<string, string> = {}
|
||||
for (const player of room.players.values()) {
|
||||
result[player.id] = player.displayName
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
getPlayerIdBySession(code: string, sessionId: string): string | null {
|
||||
const room = this.rooms.get(code)
|
||||
if (!room) return null
|
||||
|
||||
@@ -47,7 +47,15 @@ function sendGameState(ws: WSContext, roomCode: string, sessionId: string) {
|
||||
if (!gm || !playerId) return
|
||||
|
||||
const allPlayerIds = roomManager.getAllPlayerIds(roomCode)
|
||||
const gameState = gm.getGameStateForPlayer(playerId, allPlayerIds)
|
||||
const displayNames = roomManager.getPlayerDisplayNames(roomCode)
|
||||
const gameState = gm.getGameStateForPlayer(playerId, allPlayerIds, displayNames)
|
||||
|
||||
// If this player is host, override quiz question with full host view
|
||||
if (roomManager.isHost(roomCode, sessionId)) {
|
||||
const hostQuiz = gm.buildQuizQuestionForHost(displayNames)
|
||||
gameState.currentQuizQuestion = hostQuiz
|
||||
}
|
||||
|
||||
sendTo(ws, { type: "game_state", gameState })
|
||||
}
|
||||
|
||||
@@ -56,7 +64,8 @@ function sendDisplayGameState(ws: WSContext, roomCode: string) {
|
||||
if (!gm) return
|
||||
|
||||
const allPlayerIds = roomManager.getAllPlayerIds(roomCode)
|
||||
const gameState = gm.getGameStateForDisplay(allPlayerIds)
|
||||
const displayNames = roomManager.getPlayerDisplayNames(roomCode)
|
||||
const gameState = gm.getGameStateForDisplay(allPlayerIds, displayNames)
|
||||
sendTo(ws, { type: "game_state", gameState })
|
||||
}
|
||||
|
||||
@@ -167,6 +176,15 @@ export function registerWebSocketRoutes() {
|
||||
})
|
||||
sendGameState(ws, roomCode, result.sessionId)
|
||||
|
||||
// Generate bingo card for new player immediately
|
||||
{
|
||||
const gmForCard = roomManager.getGameManager(roomCode)
|
||||
if (gmForCard) {
|
||||
const playerIdForCard = roomManager.getPlayerIdBySession(roomCode, result.sessionId)
|
||||
if (playerIdForCard) gmForCard.generateBingoCardForPlayer(playerIdForCard)
|
||||
}
|
||||
}
|
||||
|
||||
const room = roomManager.getRoom(roomCode)!
|
||||
const newPlayer = room.players.find((p) => p.sessionId === sessionId)!
|
||||
broadcast(roomCode, {
|
||||
@@ -211,18 +229,39 @@ export function registerWebSocketRoutes() {
|
||||
type: "act_changed",
|
||||
newAct: result.newAct,
|
||||
})
|
||||
// Lock predictions when moving from pre-show to live-event
|
||||
// Lock predictions and generate bingo cards when entering live-event
|
||||
if (result.newAct === "live-event") {
|
||||
const gm = roomManager.getGameManager(roomCode)
|
||||
if (gm) {
|
||||
gm.lockPredictions()
|
||||
broadcast(roomCode, { type: "predictions_locked" })
|
||||
const allPlayerIds = roomManager.getAllPlayerIds(roomCode)
|
||||
gm.generateBingoCards(allPlayerIds)
|
||||
broadcastGameStateToAll(roomCode)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "end_room": {
|
||||
case "revert_act": {
|
||||
if (!sessionId) {
|
||||
sendError(ws, "Not joined")
|
||||
return
|
||||
}
|
||||
const result = roomManager.revertAct(roomCode, sessionId)
|
||||
if ("error" in result) {
|
||||
sendError(ws, result.error)
|
||||
return
|
||||
}
|
||||
broadcast(roomCode, {
|
||||
type: "act_changed",
|
||||
newAct: result.newAct,
|
||||
})
|
||||
broadcastGameStateToAll(roomCode)
|
||||
break
|
||||
}
|
||||
|
||||
case "end_room": {
|
||||
if (!sessionId) {
|
||||
sendError(ws, "Not joined")
|
||||
return
|
||||
@@ -258,6 +297,300 @@ export function registerWebSocketRoutes() {
|
||||
broadcastGameStateToAll(roomCode)
|
||||
break
|
||||
}
|
||||
|
||||
case "open_jury_vote": {
|
||||
if (!sessionId) {
|
||||
sendError(ws, "Not joined")
|
||||
return
|
||||
}
|
||||
const room = roomManager.getRoom(roomCode)
|
||||
if (room?.currentAct !== "live-event") {
|
||||
sendError(ws, "Jury voting is only available during Live Event")
|
||||
return
|
||||
}
|
||||
if (!roomManager.isHost(roomCode, sessionId)) {
|
||||
sendError(ws, "Only the host can open jury voting")
|
||||
return
|
||||
}
|
||||
const gm = roomManager.getGameManager(roomCode)
|
||||
if (!gm) {
|
||||
sendError(ws, "Room not found")
|
||||
return
|
||||
}
|
||||
const entry = gm.getLineup().entries.find((e) => e.country.code === msg.countryCode)
|
||||
if (!entry) {
|
||||
sendError(ws, "Invalid country code")
|
||||
return
|
||||
}
|
||||
const result = gm.openJuryRound(entry.country.code, entry.country.name, entry.country.flag)
|
||||
if ("error" in result) {
|
||||
sendError(ws, result.error)
|
||||
return
|
||||
}
|
||||
const round = gm.getCurrentJuryRound()!
|
||||
broadcast(roomCode, {
|
||||
type: "jury_vote_opened",
|
||||
roundId: round.id,
|
||||
countryCode: round.countryCode,
|
||||
countryName: round.countryName,
|
||||
countryFlag: round.countryFlag,
|
||||
})
|
||||
broadcastGameStateToAll(roomCode)
|
||||
break
|
||||
}
|
||||
|
||||
case "close_jury_vote": {
|
||||
if (!sessionId) {
|
||||
sendError(ws, "Not joined")
|
||||
return
|
||||
}
|
||||
if (!roomManager.isHost(roomCode, sessionId)) {
|
||||
sendError(ws, "Only the host can close jury voting")
|
||||
return
|
||||
}
|
||||
const gm = roomManager.getGameManager(roomCode)
|
||||
if (!gm) {
|
||||
sendError(ws, "Room not found")
|
||||
return
|
||||
}
|
||||
const result = gm.closeJuryRound()
|
||||
if ("error" in result) {
|
||||
sendError(ws, result.error)
|
||||
return
|
||||
}
|
||||
broadcast(roomCode, {
|
||||
type: "jury_vote_closed",
|
||||
countryCode: result.countryCode,
|
||||
countryName: result.countryName,
|
||||
countryFlag: result.countryFlag,
|
||||
averageRating: result.averageRating,
|
||||
totalVotes: result.totalVotes,
|
||||
})
|
||||
broadcastGameStateToAll(roomCode)
|
||||
break
|
||||
}
|
||||
|
||||
case "submit_jury_vote": {
|
||||
if (!sessionId) {
|
||||
sendError(ws, "Not joined")
|
||||
return
|
||||
}
|
||||
if (roomManager.getRoom(roomCode)?.currentAct !== "live-event") {
|
||||
sendError(ws, "Jury voting is only available during Live Event")
|
||||
return
|
||||
}
|
||||
const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId)
|
||||
const gm = roomManager.getGameManager(roomCode)
|
||||
if (!playerId || !gm) {
|
||||
sendError(ws, "Room not found")
|
||||
return
|
||||
}
|
||||
const result = gm.submitJuryVote(playerId, msg.rating)
|
||||
if ("error" in result) {
|
||||
sendError(ws, result.error)
|
||||
return
|
||||
}
|
||||
sendGameState(ws, roomCode, sessionId)
|
||||
break
|
||||
}
|
||||
|
||||
case "submit_actual_results": {
|
||||
if (!sessionId) {
|
||||
sendError(ws, "Not joined")
|
||||
return
|
||||
}
|
||||
const room = roomManager.getRoom(roomCode)
|
||||
if (room?.currentAct !== "scoring" && room?.currentAct !== "ended") {
|
||||
sendError(ws, "Results can only be entered during Scoring or Ended")
|
||||
return
|
||||
}
|
||||
if (!roomManager.isHost(roomCode, sessionId)) {
|
||||
sendError(ws, "Only the host can enter actual results")
|
||||
return
|
||||
}
|
||||
const gm = roomManager.getGameManager(roomCode)
|
||||
if (!gm) {
|
||||
sendError(ws, "Room not found")
|
||||
return
|
||||
}
|
||||
const allPicks = [msg.winner, msg.second, msg.third, msg.last]
|
||||
for (const code of allPicks) {
|
||||
if (!gm.isValidCountry(code)) {
|
||||
sendError(ws, `Invalid country: ${code}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (new Set(allPicks).size !== 4) {
|
||||
sendError(ws, "All 4 picks must be different countries")
|
||||
return
|
||||
}
|
||||
gm.setActualResults(msg.winner, msg.second, msg.third, msg.last)
|
||||
broadcastGameStateToAll(roomCode)
|
||||
break
|
||||
}
|
||||
|
||||
case "tap_bingo_square": {
|
||||
if (!sessionId) {
|
||||
sendError(ws, "Not joined")
|
||||
return
|
||||
}
|
||||
if (roomManager.getRoom(roomCode)?.currentAct !== "live-event") {
|
||||
sendError(ws, "Bingo is only available during Live Event")
|
||||
return
|
||||
}
|
||||
const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId)
|
||||
const gm = roomManager.getGameManager(roomCode)
|
||||
if (!playerId || !gm) {
|
||||
sendError(ws, "Room not found")
|
||||
return
|
||||
}
|
||||
const result = gm.tapBingoSquare(playerId, msg.tropeId)
|
||||
if ("error" in result) {
|
||||
sendError(ws, result.error)
|
||||
return
|
||||
}
|
||||
sendGameState(ws, roomCode, sessionId)
|
||||
if (result.isNewBingo) {
|
||||
const room = roomManager.getRoom(roomCode)
|
||||
const player = room?.players.find((p) => p.sessionId === sessionId)
|
||||
const displayName = player?.displayName ?? "Unknown"
|
||||
const isNew = gm.addBingoAnnouncement(playerId, displayName)
|
||||
if (isNew) {
|
||||
broadcast(roomCode, {
|
||||
type: "bingo_announced",
|
||||
playerId,
|
||||
displayName,
|
||||
})
|
||||
broadcastGameStateToAll(roomCode)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "request_new_bingo_card": {
|
||||
if (!sessionId) {
|
||||
sendError(ws, "Not joined")
|
||||
return
|
||||
}
|
||||
if (roomManager.getRoom(roomCode)?.currentAct !== "live-event") {
|
||||
sendError(ws, "New bingo cards are only available during Live Event")
|
||||
return
|
||||
}
|
||||
const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId)
|
||||
const gm = roomManager.getGameManager(roomCode)
|
||||
if (!playerId || !gm) {
|
||||
sendError(ws, "Room not found")
|
||||
return
|
||||
}
|
||||
const room = roomManager.getRoom(roomCode)
|
||||
const player = room?.players.find((p) => p.sessionId === sessionId)
|
||||
const displayName = player?.displayName ?? "Unknown"
|
||||
const result = gm.requestNewBingoCard(playerId, displayName)
|
||||
if ("error" in result) {
|
||||
sendError(ws, result.error)
|
||||
return
|
||||
}
|
||||
broadcastGameStateToAll(roomCode)
|
||||
break
|
||||
}
|
||||
|
||||
case "start_quiz_question": {
|
||||
if (!sessionId) {
|
||||
sendError(ws, "Not joined")
|
||||
return
|
||||
}
|
||||
const room = roomManager.getRoom(roomCode)
|
||||
if (room?.currentAct !== "scoring") {
|
||||
sendError(ws, "Quiz is only available during Scoring")
|
||||
return
|
||||
}
|
||||
if (!roomManager.isHost(roomCode, sessionId)) {
|
||||
sendError(ws, "Only the host can start quiz questions")
|
||||
return
|
||||
}
|
||||
const gm = roomManager.getGameManager(roomCode)
|
||||
if (!gm) {
|
||||
sendError(ws, "Room not found")
|
||||
return
|
||||
}
|
||||
const result = gm.startQuizQuestion()
|
||||
if ("error" in result) {
|
||||
sendError(ws, result.error)
|
||||
return
|
||||
}
|
||||
broadcastGameStateToAll(roomCode)
|
||||
break
|
||||
}
|
||||
|
||||
case "skip_quiz_question": {
|
||||
if (!sessionId) {
|
||||
sendError(ws, "Not joined")
|
||||
return
|
||||
}
|
||||
if (!roomManager.isHost(roomCode, sessionId)) {
|
||||
sendError(ws, "Only the host can skip quiz questions")
|
||||
return
|
||||
}
|
||||
const gm = roomManager.getGameManager(roomCode)
|
||||
if (!gm) {
|
||||
sendError(ws, "Room not found")
|
||||
return
|
||||
}
|
||||
const result = gm.skipQuizQuestion()
|
||||
if ("error" in result) {
|
||||
sendError(ws, result.error)
|
||||
return
|
||||
}
|
||||
broadcastGameStateToAll(roomCode)
|
||||
break
|
||||
}
|
||||
|
||||
case "buzz": {
|
||||
if (!sessionId) {
|
||||
sendError(ws, "Not joined")
|
||||
return
|
||||
}
|
||||
if (roomManager.getRoom(roomCode)?.currentAct !== "scoring") {
|
||||
sendError(ws, "Quiz is only available during Scoring")
|
||||
return
|
||||
}
|
||||
const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId)
|
||||
const gm = roomManager.getGameManager(roomCode)
|
||||
if (!playerId || !gm) {
|
||||
sendError(ws, "Room not found")
|
||||
return
|
||||
}
|
||||
const result = gm.buzz(playerId)
|
||||
if ("error" in result) {
|
||||
sendError(ws, result.error)
|
||||
return
|
||||
}
|
||||
broadcastGameStateToAll(roomCode)
|
||||
break
|
||||
}
|
||||
|
||||
case "judge_quiz_answer": {
|
||||
if (!sessionId) {
|
||||
sendError(ws, "Not joined")
|
||||
return
|
||||
}
|
||||
if (!roomManager.isHost(roomCode, sessionId)) {
|
||||
sendError(ws, "Only the host can judge quiz answers")
|
||||
return
|
||||
}
|
||||
const gm = roomManager.getGameManager(roomCode)
|
||||
if (!gm) {
|
||||
sendError(ws, "Room not found")
|
||||
return
|
||||
}
|
||||
const result = gm.judgeQuizAnswer(msg.correct)
|
||||
if ("error" in result) {
|
||||
sendError(ws, result.error)
|
||||
return
|
||||
}
|
||||
broadcastGameStateToAll(roomCode)
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -72,6 +72,124 @@ describe("GameManager", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("bingo", () => {
|
||||
it("generates a bingo card with 16 unique squares", () => {
|
||||
gm.generateBingoCards(["p1", "p2"])
|
||||
const card = gm.getBingoCard("p1")
|
||||
expect(card).not.toBeNull()
|
||||
expect(card!.squares).toHaveLength(16)
|
||||
expect(card!.hasBingo).toBe(false)
|
||||
const ids = card!.squares.map((s) => s.tropeId)
|
||||
expect(new Set(ids).size).toBe(16)
|
||||
})
|
||||
|
||||
it("generates different cards for different players", () => {
|
||||
gm.generateBingoCards(["p1", "p2"])
|
||||
const card1 = gm.getBingoCard("p1")!
|
||||
const card2 = gm.getBingoCard("p2")!
|
||||
const ids1 = card1.squares.map((s) => s.tropeId).sort()
|
||||
const ids2 = card2.squares.map((s) => s.tropeId).sort()
|
||||
expect(ids1).not.toEqual(ids2)
|
||||
})
|
||||
|
||||
it("taps a bingo square", () => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
const card = gm.getBingoCard("p1")!
|
||||
const tropeId = card.squares[0]!.tropeId
|
||||
const result = gm.tapBingoSquare("p1", tropeId)
|
||||
expect(result).toMatchObject({ success: true, hasBingo: false })
|
||||
expect(gm.getBingoCard("p1")!.squares[0]!.tapped).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects tap on unknown trope", () => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
const result = gm.tapBingoSquare("p1", "nonexistent")
|
||||
expect(result).toEqual({ error: "Trope not on your card" })
|
||||
})
|
||||
|
||||
it("rejects tap when no card exists", () => {
|
||||
const result = gm.tapBingoSquare("p1", "key-change")
|
||||
expect(result).toEqual({ error: "No bingo card found" })
|
||||
})
|
||||
|
||||
it("does not untap a square (tap is one-way)", () => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
const card = gm.getBingoCard("p1")!
|
||||
const tropeId = card.squares[0]!.tropeId
|
||||
gm.tapBingoSquare("p1", tropeId)
|
||||
gm.tapBingoSquare("p1", tropeId)
|
||||
// Second tap is idempotent — square stays tapped
|
||||
expect(gm.getBingoCard("p1")!.squares[0]!.tapped).toBe(true)
|
||||
})
|
||||
|
||||
it("detects bingo on a completed row", () => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
const card = gm.getBingoCard("p1")!
|
||||
let result: any
|
||||
for (let i = 0; i < 4; i++) {
|
||||
result = gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||||
}
|
||||
expect(result).toMatchObject({ success: true, hasBingo: true })
|
||||
expect(gm.getBingoCard("p1")!.hasBingo).toBe(true)
|
||||
})
|
||||
|
||||
it("detects bingo on a completed column", () => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
const card = gm.getBingoCard("p1")!
|
||||
let result: any
|
||||
for (const i of [0, 4, 8, 12]) {
|
||||
result = gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||||
}
|
||||
expect(result).toMatchObject({ success: true, hasBingo: true })
|
||||
})
|
||||
|
||||
it("detects bingo on a diagonal", () => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
const card = gm.getBingoCard("p1")!
|
||||
let result: any
|
||||
for (const i of [0, 5, 10, 15]) {
|
||||
result = gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||||
}
|
||||
expect(result).toMatchObject({ success: true, hasBingo: true })
|
||||
})
|
||||
|
||||
it("bingo persists after re-tapping a completing square (tap is one-way)", () => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
const card = gm.getBingoCard("p1")!
|
||||
for (let i = 0; i < 4; i++) {
|
||||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||||
}
|
||||
expect(gm.getBingoCard("p1")!.hasBingo).toBe(true)
|
||||
// Re-tapping is a no-op — bingo stays
|
||||
gm.tapBingoSquare("p1", card.squares[0]!.tropeId)
|
||||
expect(gm.getBingoCard("p1")!.hasBingo).toBe(true)
|
||||
})
|
||||
|
||||
it("does not duplicate bingo announcements on re-bingo", () => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
const card = gm.getBingoCard("p1")!
|
||||
for (let i = 0; i < 4; i++) {
|
||||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||||
}
|
||||
expect(gm.addBingoAnnouncement("p1", "Player 1")).toBe(true)
|
||||
gm.tapBingoSquare("p1", card.squares[0]!.tropeId) // untap
|
||||
gm.tapBingoSquare("p1", card.squares[0]!.tropeId) // re-tap → re-bingo
|
||||
expect(gm.addBingoAnnouncement("p1", "Player 1")).toBe(false)
|
||||
expect(gm.getBingoAnnouncements()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("computes bingo score", () => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
const card = gm.getBingoCard("p1")!
|
||||
for (let i = 0; i < 4; i++) {
|
||||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||||
}
|
||||
gm.tapBingoSquare("p1", card.squares[4]!.tropeId)
|
||||
// 5 tapped * 2 + 10 bingo bonus = 20
|
||||
expect(gm.getBingoScore("p1")).toBe(20)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getGameStateForPlayer", () => {
|
||||
it("includes only the requesting player's prediction", () => {
|
||||
gm.submitPrediction("p1", "SE", "DE", "IT", "GB")
|
||||
@@ -87,6 +205,240 @@ describe("GameManager", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("jury voting", () => {
|
||||
it("opens a jury round", () => {
|
||||
const result = gm.openJuryRound("SE", "Sweden", "🇸🇪")
|
||||
expect(result).toEqual({ success: true })
|
||||
const round = gm.getCurrentJuryRound()
|
||||
expect(round).not.toBeNull()
|
||||
expect(round!.countryCode).toBe("SE")
|
||||
expect(round!.status).toBe("open")
|
||||
})
|
||||
|
||||
it("rejects opening when a round is already open", () => {
|
||||
gm.openJuryRound("SE", "Sweden", "🇸🇪")
|
||||
const result = gm.openJuryRound("DE", "Germany", "🇩🇪")
|
||||
expect(result).toEqual({ error: "A jury round is already open" })
|
||||
})
|
||||
|
||||
it("accepts a valid jury vote", () => {
|
||||
gm.openJuryRound("SE", "Sweden", "🇸🇪")
|
||||
const result = gm.submitJuryVote("p1", 8)
|
||||
expect(result).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it("rejects jury vote when no round is open", () => {
|
||||
const result = gm.submitJuryVote("p1", 8)
|
||||
expect(result).toEqual({ error: "No jury round is open" })
|
||||
})
|
||||
|
||||
it("rejects jury vote outside 1-12 range", () => {
|
||||
gm.openJuryRound("SE", "Sweden", "🇸🇪")
|
||||
const result = gm.submitJuryVote("p1", 0)
|
||||
expect(result).toEqual({ error: "Rating must be between 1 and 12" })
|
||||
})
|
||||
|
||||
it("allows overwriting a jury vote in the same round", () => {
|
||||
gm.openJuryRound("SE", "Sweden", "🇸🇪")
|
||||
gm.submitJuryVote("p1", 8)
|
||||
gm.submitJuryVote("p1", 10)
|
||||
const result = gm.closeJuryRound()
|
||||
expect("averageRating" in result && result.averageRating).toBe(10)
|
||||
})
|
||||
|
||||
it("closes a jury round and computes average", () => {
|
||||
gm.openJuryRound("SE", "Sweden", "🇸🇪")
|
||||
gm.submitJuryVote("p1", 8)
|
||||
gm.submitJuryVote("p2", 10)
|
||||
const result = gm.closeJuryRound()
|
||||
expect(result).toMatchObject({
|
||||
countryCode: "SE",
|
||||
averageRating: 9,
|
||||
totalVotes: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it("rejects close when no round is open", () => {
|
||||
const result = gm.closeJuryRound()
|
||||
expect(result).toEqual({ error: "No jury round is open" })
|
||||
})
|
||||
|
||||
it("handles closing a round with zero votes", () => {
|
||||
gm.openJuryRound("SE", "Sweden", "🇸🇪")
|
||||
const result = gm.closeJuryRound()
|
||||
expect(result).toMatchObject({
|
||||
countryCode: "SE",
|
||||
averageRating: 0,
|
||||
totalVotes: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it("accumulates results across rounds", () => {
|
||||
gm.openJuryRound("SE", "Sweden", "🇸🇪")
|
||||
gm.submitJuryVote("p1", 10)
|
||||
gm.closeJuryRound()
|
||||
gm.openJuryRound("DE", "Germany", "🇩🇪")
|
||||
gm.submitJuryVote("p1", 6)
|
||||
gm.closeJuryRound()
|
||||
expect(gm.getJuryResults()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("computes jury scores based on closeness to average", () => {
|
||||
gm.openJuryRound("SE", "Sweden", "🇸🇪")
|
||||
gm.submitJuryVote("p1", 10) // avg will be 10, diff=0, score=5
|
||||
gm.submitJuryVote("p2", 10) // diff=0, score=5
|
||||
gm.closeJuryRound()
|
||||
expect(gm.getJuryScore("p1")).toBe(5)
|
||||
expect(gm.getJuryScore("p2")).toBe(5)
|
||||
|
||||
gm.openJuryRound("DE", "Germany", "🇩🇪")
|
||||
gm.submitJuryVote("p1", 4) // avg=(4+10)/2=7, diff=3, score=2
|
||||
gm.submitJuryVote("p2", 10) // diff=3, score=2
|
||||
gm.closeJuryRound()
|
||||
expect(gm.getJuryScore("p1")).toBe(7) // 5+2
|
||||
expect(gm.getJuryScore("p2")).toBe(7) // 5+2
|
||||
})
|
||||
|
||||
it("returns the player's current vote for a round", () => {
|
||||
gm.openJuryRound("SE", "Sweden", "🇸🇪")
|
||||
expect(gm.getPlayerJuryVote("p1")).toBeNull()
|
||||
gm.submitJuryVote("p1", 7)
|
||||
expect(gm.getPlayerJuryVote("p1")).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getGameStateForPlayer (with jury + bingo)", () => {
|
||||
it("includes jury round state", () => {
|
||||
gm.openJuryRound("SE", "Sweden", "🇸🇪")
|
||||
gm.submitJuryVote("p1", 8)
|
||||
const state = gm.getGameStateForPlayer("p1", ["p1"])
|
||||
expect(state.currentJuryRound).not.toBeNull()
|
||||
expect(state.currentJuryRound!.countryCode).toBe("SE")
|
||||
expect(state.myJuryVote).toBe(8)
|
||||
})
|
||||
|
||||
it("includes bingo card", () => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
const state = gm.getGameStateForPlayer("p1", ["p1"])
|
||||
expect(state.myBingoCard).not.toBeNull()
|
||||
expect(state.myBingoCard!.squares).toHaveLength(16)
|
||||
})
|
||||
|
||||
it("includes leaderboard with jury + bingo scores", () => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
gm.openJuryRound("SE", "Sweden", "🇸🇪")
|
||||
gm.submitJuryVote("p1", 8)
|
||||
gm.closeJuryRound()
|
||||
|
||||
const card = gm.getBingoCard("p1")!
|
||||
gm.tapBingoSquare("p1", card.squares[0]!.tropeId)
|
||||
|
||||
const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Player 1" })
|
||||
expect(state.leaderboard).toHaveLength(1)
|
||||
expect(state.leaderboard[0]!.juryPoints).toBe(5) // solo voter = exact match
|
||||
expect(state.leaderboard[0]!.bingoPoints).toBe(2) // 1 tapped * 2
|
||||
expect(state.leaderboard[0]!.totalPoints).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
describe("prediction scoring", () => {
|
||||
it("returns 0 for all when no actual results set", () => {
|
||||
const gm = new GameManager()
|
||||
gm.submitPrediction("p1", "SE", "IT", "FR", "GB")
|
||||
expect(gm.getPredictionScore("p1")).toBe(0)
|
||||
})
|
||||
|
||||
it("scores correct winner", () => {
|
||||
const gm = new GameManager()
|
||||
gm.submitPrediction("p1", "SE", "IT", "FR", "GB")
|
||||
gm.setActualResults("SE", "CH", "DE", "AL")
|
||||
expect(gm.getPredictionScore("p1")).toBe(25)
|
||||
})
|
||||
|
||||
it("scores correct second place", () => {
|
||||
const gm = new GameManager()
|
||||
gm.submitPrediction("p1", "NO", "IT", "FR", "GB")
|
||||
gm.setActualResults("SE", "IT", "DE", "AL")
|
||||
expect(gm.getPredictionScore("p1")).toBe(10)
|
||||
})
|
||||
|
||||
it("scores correct third place", () => {
|
||||
const gm = new GameManager()
|
||||
gm.submitPrediction("p1", "NO", "DK", "FR", "GB")
|
||||
gm.setActualResults("SE", "IT", "FR", "AL")
|
||||
expect(gm.getPredictionScore("p1")).toBe(10)
|
||||
})
|
||||
|
||||
it("scores correct last place", () => {
|
||||
const gm = new GameManager()
|
||||
gm.submitPrediction("p1", "NO", "DK", "FI", "GB")
|
||||
gm.setActualResults("SE", "IT", "FR", "GB")
|
||||
expect(gm.getPredictionScore("p1")).toBe(15)
|
||||
})
|
||||
|
||||
it("scores perfect prediction (all correct)", () => {
|
||||
const gm = new GameManager()
|
||||
gm.submitPrediction("p1", "SE", "IT", "FR", "GB")
|
||||
gm.setActualResults("SE", "IT", "FR", "GB")
|
||||
expect(gm.getPredictionScore("p1")).toBe(60)
|
||||
})
|
||||
|
||||
it("scores 0 for all wrong", () => {
|
||||
const gm = new GameManager()
|
||||
gm.submitPrediction("p1", "NO", "DK", "FI", "EE")
|
||||
gm.setActualResults("SE", "IT", "FR", "GB")
|
||||
expect(gm.getPredictionScore("p1")).toBe(0)
|
||||
})
|
||||
|
||||
it("returns 0 for player with no prediction", () => {
|
||||
const gm = new GameManager()
|
||||
gm.setActualResults("SE", "IT", "FR", "GB")
|
||||
expect(gm.getPredictionScore("p1")).toBe(0)
|
||||
})
|
||||
|
||||
it("getActualResults returns null before setting", () => {
|
||||
const gm = new GameManager()
|
||||
expect(gm.getActualResults()).toBeNull()
|
||||
})
|
||||
|
||||
it("getActualResults returns results after setting", () => {
|
||||
const gm = new GameManager()
|
||||
gm.setActualResults("SE", "IT", "FR", "GB")
|
||||
expect(gm.getActualResults()).toEqual({ winner: "SE", second: "IT", third: "FR", last: "GB" })
|
||||
})
|
||||
|
||||
it("setActualResults overwrites previous results", () => {
|
||||
const gm = new GameManager()
|
||||
gm.submitPrediction("p1", "SE", "IT", "FR", "GB")
|
||||
gm.setActualResults("NO", "DK", "FI", "EE")
|
||||
expect(gm.getPredictionScore("p1")).toBe(0)
|
||||
gm.setActualResults("SE", "IT", "FR", "GB")
|
||||
expect(gm.getPredictionScore("p1")).toBe(60)
|
||||
})
|
||||
|
||||
it("prediction points appear in leaderboard", () => {
|
||||
const gm = new GameManager()
|
||||
gm.submitPrediction("p1", "SE", "IT", "FR", "GB")
|
||||
gm.setActualResults("SE", "IT", "FR", "GB")
|
||||
const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Alice" })
|
||||
expect(state.leaderboard[0]!.predictionPoints).toBe(60)
|
||||
expect(state.leaderboard[0]!.totalPoints).toBe(60)
|
||||
})
|
||||
|
||||
it("actualResults included in game state", () => {
|
||||
const gm = new GameManager()
|
||||
gm.setActualResults("SE", "IT", "FR", "GB")
|
||||
const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Alice" })
|
||||
expect(state.actualResults).toEqual({ winner: "SE", second: "IT", third: "FR", last: "GB" })
|
||||
})
|
||||
|
||||
it("actualResults null in game state when not set", () => {
|
||||
const gm = new GameManager()
|
||||
const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Alice" })
|
||||
expect(state.actualResults).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getGameStateForDisplay", () => {
|
||||
it("returns null myPrediction", () => {
|
||||
gm.submitPrediction("p1", "SE", "DE", "IT", "GB")
|
||||
|
||||
@@ -37,6 +37,95 @@ export const predictionSchema = z.object({
|
||||
|
||||
export type Prediction = z.infer<typeof predictionSchema>
|
||||
|
||||
// ─── Actual Results ─────────────────────────────────────────────────
|
||||
|
||||
export const actualResultsSchema = z.object({
|
||||
winner: z.string(),
|
||||
second: z.string(),
|
||||
third: z.string(),
|
||||
last: z.string(),
|
||||
})
|
||||
|
||||
export type ActualResults = z.infer<typeof actualResultsSchema>
|
||||
|
||||
// ─── Jury Voting ────────────────────────────────────────────────────
|
||||
|
||||
export const juryRoundSchema = z.object({
|
||||
id: z.string(),
|
||||
countryCode: z.string(),
|
||||
countryName: z.string(),
|
||||
countryFlag: z.string(),
|
||||
status: z.enum(["open", "closed"]),
|
||||
})
|
||||
|
||||
export type JuryRound = z.infer<typeof juryRoundSchema>
|
||||
|
||||
export const juryResultSchema = z.object({
|
||||
countryCode: z.string(),
|
||||
countryName: z.string(),
|
||||
countryFlag: z.string(),
|
||||
averageRating: z.number(),
|
||||
totalVotes: z.number(),
|
||||
})
|
||||
|
||||
export type JuryResult = z.infer<typeof juryResultSchema>
|
||||
|
||||
// ─── Bingo ──────────────────────────────────────────────────────────
|
||||
|
||||
export const bingoSquareSchema = z.object({
|
||||
tropeId: z.string(),
|
||||
label: z.string(),
|
||||
tapped: z.boolean(),
|
||||
})
|
||||
|
||||
export type BingoSquare = z.infer<typeof bingoSquareSchema>
|
||||
|
||||
export const bingoCardSchema = z.object({
|
||||
squares: z.array(bingoSquareSchema).length(16),
|
||||
hasBingo: z.boolean(),
|
||||
})
|
||||
|
||||
export type BingoCard = z.infer<typeof bingoCardSchema>
|
||||
|
||||
export const completedBingoCardSchema = z.object({
|
||||
playerId: z.string(),
|
||||
displayName: z.string(),
|
||||
card: bingoCardSchema,
|
||||
completedAt: z.string(),
|
||||
})
|
||||
|
||||
export type CompletedBingoCard = z.infer<typeof completedBingoCardSchema>
|
||||
|
||||
// ─── Quiz ────────────────────────────────────────────────────────
|
||||
|
||||
export const quizQuestionSchema = z.object({
|
||||
index: z.number(),
|
||||
total: z.number(),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]),
|
||||
text: z.string(),
|
||||
answer: z.string(),
|
||||
status: z.enum(["buzzing", "judging", "resolved"]),
|
||||
buzzerPlayerId: z.string().nullable(),
|
||||
buzzerName: z.string().nullable(),
|
||||
wasCorrect: z.boolean().nullable(),
|
||||
})
|
||||
|
||||
export type QuizQuestion = z.infer<typeof quizQuestionSchema>
|
||||
|
||||
// ─── Scoring ────────────────────────────────────────────────────────
|
||||
|
||||
export const leaderboardEntrySchema = z.object({
|
||||
playerId: z.string(),
|
||||
displayName: z.string(),
|
||||
juryPoints: z.number(),
|
||||
bingoPoints: z.number(),
|
||||
predictionPoints: z.number(),
|
||||
quizPoints: z.number(),
|
||||
totalPoints: z.number(),
|
||||
})
|
||||
|
||||
export type LeaderboardEntry = z.infer<typeof leaderboardEntrySchema>
|
||||
|
||||
// ─── Game State (sent to clients) ───────────────────────────────────
|
||||
|
||||
export const gameStateSchema = z.object({
|
||||
@@ -44,6 +133,23 @@ export const gameStateSchema = z.object({
|
||||
myPrediction: predictionSchema.nullable(),
|
||||
predictionsLocked: z.boolean(),
|
||||
predictionSubmitted: z.record(z.string(), z.boolean()),
|
||||
// Jury
|
||||
currentJuryRound: juryRoundSchema.nullable(),
|
||||
juryResults: z.array(juryResultSchema),
|
||||
myJuryVote: z.number().nullable(),
|
||||
// Bingo
|
||||
myBingoCard: bingoCardSchema.nullable(),
|
||||
bingoAnnouncements: z.array(z.object({
|
||||
playerId: z.string(),
|
||||
displayName: z.string(),
|
||||
})),
|
||||
completedBingoCards: z.array(completedBingoCardSchema),
|
||||
// Quiz
|
||||
currentQuizQuestion: quizQuestionSchema.nullable(),
|
||||
myQuizBuzzStatus: z.enum(["can_buzz", "already_buzzed", "excluded", "waiting"]).nullable(),
|
||||
actualResults: actualResultsSchema.nullable(),
|
||||
// Leaderboard
|
||||
leaderboard: z.array(leaderboardEntrySchema),
|
||||
})
|
||||
|
||||
export type GameState = z.infer<typeof gameStateSchema>
|
||||
|
||||
@@ -19,6 +19,10 @@ export const advanceActMessage = z.object({
|
||||
type: z.literal("advance_act"),
|
||||
})
|
||||
|
||||
export const revertActMessage = z.object({
|
||||
type: z.literal("revert_act"),
|
||||
})
|
||||
|
||||
export const endRoomMessage = z.object({
|
||||
type: z.literal("end_room"),
|
||||
})
|
||||
@@ -31,12 +35,71 @@ export const submitPredictionMessage = z.object({
|
||||
last: z.string(),
|
||||
})
|
||||
|
||||
export const openJuryVoteMessage = z.object({
|
||||
type: z.literal("open_jury_vote"),
|
||||
countryCode: z.string(),
|
||||
})
|
||||
|
||||
export const closeJuryVoteMessage = z.object({
|
||||
type: z.literal("close_jury_vote"),
|
||||
})
|
||||
|
||||
export const submitJuryVoteMessage = z.object({
|
||||
type: z.literal("submit_jury_vote"),
|
||||
rating: z.number().int().min(1).max(12),
|
||||
})
|
||||
|
||||
export const tapBingoSquareMessage = z.object({
|
||||
type: z.literal("tap_bingo_square"),
|
||||
tropeId: z.string(),
|
||||
})
|
||||
|
||||
export const requestNewBingoCardMessage = z.object({
|
||||
type: z.literal("request_new_bingo_card"),
|
||||
})
|
||||
|
||||
export const submitActualResultsMessage = z.object({
|
||||
type: z.literal("submit_actual_results"),
|
||||
winner: z.string(),
|
||||
second: z.string(),
|
||||
third: z.string(),
|
||||
last: z.string(),
|
||||
})
|
||||
|
||||
export const startQuizQuestionMessage = z.object({
|
||||
type: z.literal("start_quiz_question"),
|
||||
})
|
||||
|
||||
export const buzzMessage = z.object({
|
||||
type: z.literal("buzz"),
|
||||
})
|
||||
|
||||
export const judgeQuizAnswerMessage = z.object({
|
||||
type: z.literal("judge_quiz_answer"),
|
||||
correct: z.boolean(),
|
||||
})
|
||||
|
||||
export const skipQuizQuestionMessage = z.object({
|
||||
type: z.literal("skip_quiz_question"),
|
||||
})
|
||||
|
||||
export const clientMessage = z.discriminatedUnion("type", [
|
||||
joinRoomMessage,
|
||||
reconnectMessage,
|
||||
advanceActMessage,
|
||||
revertActMessage,
|
||||
endRoomMessage,
|
||||
submitPredictionMessage,
|
||||
openJuryVoteMessage,
|
||||
closeJuryVoteMessage,
|
||||
submitJuryVoteMessage,
|
||||
tapBingoSquareMessage,
|
||||
requestNewBingoCardMessage,
|
||||
submitActualResultsMessage,
|
||||
startQuizQuestionMessage,
|
||||
buzzMessage,
|
||||
judgeQuizAnswerMessage,
|
||||
skipQuizQuestionMessage,
|
||||
])
|
||||
|
||||
export type ClientMessage = z.infer<typeof clientMessage>
|
||||
@@ -87,6 +150,29 @@ export const predictionsLockedMessage = z.object({
|
||||
type: z.literal("predictions_locked"),
|
||||
})
|
||||
|
||||
export const juryVoteOpenedMessage = z.object({
|
||||
type: z.literal("jury_vote_opened"),
|
||||
roundId: z.string(),
|
||||
countryCode: z.string(),
|
||||
countryName: z.string(),
|
||||
countryFlag: z.string(),
|
||||
})
|
||||
|
||||
export const juryVoteClosedMessage = z.object({
|
||||
type: z.literal("jury_vote_closed"),
|
||||
countryCode: z.string(),
|
||||
countryName: z.string(),
|
||||
countryFlag: z.string(),
|
||||
averageRating: z.number(),
|
||||
totalVotes: z.number(),
|
||||
})
|
||||
|
||||
export const bingoAnnouncedMessage = z.object({
|
||||
type: z.literal("bingo_announced"),
|
||||
playerId: z.string(),
|
||||
displayName: z.string(),
|
||||
})
|
||||
|
||||
export const serverMessage = z.discriminatedUnion("type", [
|
||||
roomStateMessage,
|
||||
playerJoinedMessage,
|
||||
@@ -97,6 +183,9 @@ export const serverMessage = z.discriminatedUnion("type", [
|
||||
errorMessage,
|
||||
gameStateMessage,
|
||||
predictionsLockedMessage,
|
||||
juryVoteOpenedMessage,
|
||||
juryVoteClosedMessage,
|
||||
bingoAnnouncedMessage,
|
||||
])
|
||||
|
||||
export type ServerMessage = z.infer<typeof serverMessage>
|
||||
|
||||
Reference in New Issue
Block a user