guandan.dev
guandan.dev
https://git.tonybtw.com/guandan.dev.git
git://git.tonybtw.com/guandan.dev.git
Initial commit.
Diff
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..20416ea
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+.direnv/
+.envrc
+notes/
+server/tmp/
+client/node_modules/
+client/dist/
diff --git a/assets/guandan.png b/assets/guandan.png
new file mode 100644
index 0000000..307f569
Binary files /dev/null and b/assets/guandan.png differ
diff --git a/client/index.html b/client/index.html
new file mode 100644
index 0000000..824f098
--- /dev/null
+++ b/client/index.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Guan Dan</title>
+ </head>
+ <body>
+ <div id="root"></div>
+ <script type="module" src="/src/main.tsx"></script>
+ </body>
+</html>
diff --git a/client/package-lock.json b/client/package-lock.json
new file mode 100644
index 0000000..97a1118
--- /dev/null
+++ b/client/package-lock.json
@@ -0,0 +1,1780 @@
+{
+ "name": "guandanbtw-client",
+ "version": "0.0.1",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "guandanbtw-client",
+ "version": "0.0.1",
+ "dependencies": {
+ "framer-motion": "^10.16.0",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.2.0",
+ "@types/react-dom": "^18.2.0",
+ "@vitejs/plugin-react": "^4.2.0",
+ "typescript": "^5.3.0",
+ "vite": "^5.0.0"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
+ "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz",
+ "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz",
+ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/generator": "^7.28.6",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz",
+ "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
+ "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz",
+ "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.6"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz",
+ "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/generator": "^7.28.6",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.28.6",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.28.6",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",
+ "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@emotion/is-prop-valid": {
+ "version": "0.8.8",
+ "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz",
+ "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emotion/memoize": "0.7.4"
+ }
+ },
+ "node_modules/@emotion/memoize": {
+ "version": "0.7.4",
+ "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz",
+ "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz",
+ "integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz",
+ "integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz",
+ "integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz",
+ "integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz",
+ "integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz",
+ "integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz",
+ "integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz",
+ "integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz",
+ "integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz",
+ "integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz",
+ "integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz",
+ "integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz",
+ "integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz",
+ "integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz",
+ "integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz",
+ "integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz",
+ "integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz",
+ "integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz",
+ "integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz",
+ "integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz",
+ "integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz",
+ "integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz",
+ "integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz",
+ "integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz",
+ "integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.27",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
+ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.9.17",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.17.tgz",
+ "integrity": "sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.js"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.1",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
+ "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "baseline-browser-mapping": "^2.9.0",
+ "caniuse-lite": "^1.0.30001759",
+ "electron-to-chromium": "^1.5.263",
+ "node-releases": "^2.0.27",
+ "update-browserslist-db": "^1.2.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001766",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
+ "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.278",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.278.tgz",
+ "integrity": "sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/framer-motion": {
+ "version": "10.18.0",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-10.18.0.tgz",
+ "integrity": "sha512-oGlDh1Q1XqYPksuTD/usb0I70hq95OUzmL9+6Zd+Hs4XV0oaISBa/UUMSjYiq6m8EUF32132mOJ8xVZS+I0S6w==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.4.0"
+ },
+ "optionalDependencies": {
+ "@emotion/is-prop-valid": "^0.8.2"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.27",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
+ "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz",
+ "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.56.0",
+ "@rollup/rollup-android-arm64": "4.56.0",
+ "@rollup/rollup-darwin-arm64": "4.56.0",
+ "@rollup/rollup-darwin-x64": "4.56.0",
+ "@rollup/rollup-freebsd-arm64": "4.56.0",
+ "@rollup/rollup-freebsd-x64": "4.56.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.56.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.56.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.56.0",
+ "@rollup/rollup-linux-arm64-musl": "4.56.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.56.0",
+ "@rollup/rollup-linux-loong64-musl": "4.56.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.56.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.56.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.56.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.56.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.56.0",
+ "@rollup/rollup-linux-x64-gnu": "4.56.0",
+ "@rollup/rollup-linux-x64-musl": "4.56.0",
+ "@rollup/rollup-openbsd-x64": "4.56.0",
+ "@rollup/rollup-openharmony-arm64": "4.56.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.56.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.56.0",
+ "@rollup/rollup-win32-x64-gnu": "4.56.0",
+ "@rollup/rollup-win32-x64-msvc": "4.56.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ }
+ }
+}
diff --git a/client/package.json b/client/package.json
new file mode 100644
index 0000000..14c1cd1
--- /dev/null
+++ b/client/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "guandanbtw-client",
+ "private": true,
+ "version": "0.0.1",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "framer-motion": "^10.16.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.2.0",
+ "@types/react-dom": "^18.2.0",
+ "@vitejs/plugin-react": "^4.2.0",
+ "typescript": "^5.3.0",
+ "vite": "^5.0.0"
+ }
+}
diff --git a/client/src/App.tsx b/client/src/App.tsx
new file mode 100644
index 0000000..fa831e6
--- /dev/null
+++ b/client/src/App.tsx
@@ -0,0 +1,259 @@
+import { useCallback, useEffect, useState } from 'react'
+import { use_websocket } from './hooks/use_websocket'
+import { Lobby } from './components/Lobby'
+import { Game } from './components/Game'
+import {
+ Card,
+ Player_Info,
+ Rank,
+ Rank_Two,
+ Message,
+} from './game/types'
+
+interface Deal_Cards_Payload {
+ cards: Card[]
+ level: Rank
+}
+
+interface Room_State_Payload {
+ room_id: string
+ players: Player_Info[]
+ game_active: boolean
+ your_id: string
+}
+
+interface Turn_Payload {
+ player_id: string
+ seat: number
+ can_pass: boolean
+}
+
+interface Play_Made_Payload {
+ player_id: string
+ seat: number
+ cards: Card[]
+ combo_type: string
+ is_pass: boolean
+}
+
+interface Error_Payload {
+ message: string
+}
+
+export default function App() {
+ const ws_url = `ws://${window.location.hostname}:8080/ws`
+ const { connected, send, on } = use_websocket(ws_url)
+
+ const [room_id, set_room_id] = useState<string | null>(null)
+ const [players, set_players] = useState<Player_Info[]>([])
+ const [game_active, set_game_active] = useState(false)
+
+ const [hand, set_hand] = useState<Card[]>([])
+ const [level, set_level] = useState<Rank>(Rank_Two)
+ const [selected_ids, set_selected_ids] = useState<Set<number>>(new Set())
+ const [current_turn, set_current_turn] = useState(0)
+ const [my_seat, set_my_seat] = useState(0)
+ const [can_pass, set_can_pass] = useState(false)
+ const [table_cards, set_table_cards] = useState<Card[]>([])
+ const [combo_type, set_combo_type] = useState('')
+ const [player_card_counts, set_player_card_counts] = useState([27, 27, 27, 27])
+ const [team_levels, set_team_levels] = useState<[number, number]>([0, 0])
+ const [error, set_error] = useState<string | null>(null)
+
+ useEffect(() => {
+ const unsub_room_state = on('room_state', (msg: Message) => {
+ const payload = msg.payload as Room_State_Payload
+ set_room_id(payload.room_id)
+ set_players(payload.players)
+ set_game_active(payload.game_active)
+
+ const me = payload.players.find((p) => p.id === payload.your_id)
+ if (me) {
+ set_my_seat(me.seat)
+ }
+ })
+
+ const unsub_deal = on('deal_cards', (msg: Message) => {
+ const payload = msg.payload as Deal_Cards_Payload
+ set_hand(sort_cards(payload.cards, payload.level))
+ set_level(payload.level)
+ set_game_active(true)
+ set_table_cards([])
+ set_combo_type('')
+ set_selected_ids(new Set())
+ set_player_card_counts([27, 27, 27, 27])
+ })
+
+ const unsub_turn = on('turn', (msg: Message) => {
+ const payload = msg.payload as Turn_Payload
+ set_current_turn(payload.seat)
+ set_can_pass(payload.can_pass)
+ })
+
+ const unsub_play_made = on('play_made', (msg: Message) => {
+ const payload = msg.payload as Play_Made_Payload
+ if (!payload.is_pass) {
+ set_table_cards(payload.cards)
+ set_combo_type(payload.combo_type)
+ set_player_card_counts((prev) => {
+ const next = [...prev]
+ next[payload.seat] -= payload.cards.length
+ return next as [number, number, number, number]
+ })
+ const played_ids = new Set(payload.cards.map((c) => c.Id))
+ set_hand((prev) => prev.filter((c) => !played_ids.has(c.Id)))
+ }
+ })
+
+ const unsub_hand_end = on('hand_end', (msg: Message) => {
+ const payload = msg.payload as { new_levels: [number, number] }
+ set_team_levels(payload.new_levels)
+ })
+
+ const unsub_error = on('error', (msg: Message) => {
+ const payload = msg.payload as Error_Payload
+ set_error(payload.message)
+ setTimeout(() => set_error(null), 3000)
+ })
+
+ return () => {
+ unsub_room_state()
+ unsub_deal()
+ unsub_turn()
+ unsub_play_made()
+ unsub_hand_end()
+ unsub_error()
+ }
+ }, [on])
+
+ const handle_create_room = useCallback(
+ (name: string) => {
+ send({
+ type: 'create_room',
+ payload: { player_name: name },
+ })
+ },
+ [send]
+ )
+
+ const handle_join_room = useCallback(
+ (room_code: string, name: string) => {
+ send({
+ type: 'join_room',
+ payload: { room_id: room_code, player_name: name },
+ })
+ },
+ [send]
+ )
+
+ const handle_card_click = useCallback((id: number) => {
+ set_selected_ids((prev) => {
+ const next = new Set(prev)
+ if (next.has(id)) {
+ next.delete(id)
+ } else {
+ next.add(id)
+ }
+ return next
+ })
+ }, [])
+
+ const handle_play = useCallback(() => {
+ if (selected_ids.size === 0) return
+
+ send({
+ type: 'play_cards',
+ payload: { card_ids: Array.from(selected_ids) },
+ })
+
+ set_selected_ids(new Set())
+ }, [send, selected_ids])
+
+ const handle_pass = useCallback(() => {
+ send({ type: 'pass', payload: {} })
+ }, [send])
+
+ if (!connected) {
+ return (
+ <div style={styles.connecting}>
+ <div>Connecting...</div>
+ </div>
+ )
+ }
+
+ if (!game_active) {
+ return (
+ <>
+ <Lobby
+ room_id={room_id}
+ players={players}
+ on_create_room={handle_create_room}
+ on_join_room={handle_join_room}
+ />
+ {error && <div style={styles.error}>{error}</div>}
+ </>
+ )
+ }
+
+ return (
+ <>
+ <Game
+ hand={hand}
+ level={level}
+ selected_ids={selected_ids}
+ on_card_click={handle_card_click}
+ on_play={handle_play}
+ on_pass={handle_pass}
+ table_cards={table_cards}
+ combo_type={combo_type}
+ current_turn={current_turn}
+ my_seat={my_seat}
+ can_pass={can_pass}
+ player_card_counts={player_card_counts}
+ team_levels={team_levels}
+ />
+ {error && <div style={styles.error}>{error}</div>}
+ </>
+ )
+}
+
+function sort_cards(cards: Card[], level: Rank): Card[] {
+ return [...cards].sort((a, b) => {
+ const va = card_sort_value(a, level)
+ const vb = card_sort_value(b, level)
+ if (va !== vb) return va - vb
+ return a.Suit - b.Suit
+ })
+}
+
+function card_sort_value(card: Card, level: Rank): number {
+ if (card.Rank === 14) return 100
+ if (card.Rank === 13) return 99
+ if (card.Rank === level) return 98
+
+ const base_order = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
+ return base_order[card.Rank] ?? 0
+}
+
+const styles: Record<string, React.CSSProperties> = {
+ connecting: {
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ height: '100vh',
+ backgroundColor: '#1a1a2e',
+ color: '#fff',
+ fontSize: 24,
+ },
+ error: {
+ position: 'fixed',
+ bottom: 20,
+ left: '50%',
+ transform: 'translateX(-50%)',
+ padding: '12px 24px',
+ backgroundColor: '#dc3545',
+ color: '#fff',
+ borderRadius: 8,
+ zIndex: 1000,
+ },
+}
diff --git a/client/src/components/Card.tsx b/client/src/components/Card.tsx
new file mode 100644
index 0000000..790719d
--- /dev/null
+++ b/client/src/components/Card.tsx
@@ -0,0 +1,95 @@
+import { motion } from 'framer-motion'
+import { Card as Card_Type, get_suit_symbol, get_rank_symbol, is_red_suit, is_wild, Rank, Rank_Black_Joker, Rank_Red_Joker, Suit_Joker } from '../game/types'
+
+interface Card_Props {
+ card: Card_Type
+ level: Rank
+ selected: boolean
+ on_click: () => void
+}
+
+export function Card({ card, level, selected, on_click }: Card_Props) {
+ const is_joker = card.Suit === Suit_Joker
+ const is_red = is_joker ? card.Rank === Rank_Red_Joker : is_red_suit(card.Suit)
+ const is_wild_card = is_wild(card, level)
+
+ return (
+ <motion.div
+ onClick={on_click}
+ animate={{
+ y: selected ? -20 : 0,
+ scale: selected ? 1.05 : 1,
+ }}
+ whileHover={{ scale: 1.08 }}
+ transition={{ type: 'spring', stiffness: 400, damping: 25 }}
+ style={{
+ width: 70,
+ height: 100,
+ backgroundColor: is_wild_card ? '#fff3cd' : '#fff',
+ border: is_wild_card ? '3px solid #ffc107' : '2px solid #333',
+ borderRadius: 8,
+ position: 'relative',
+ cursor: 'pointer',
+ userSelect: 'none',
+ boxShadow: selected ? '0 8px 16px rgba(0,0,0,0.3)' : '0 2px 4px rgba(0,0,0,0.1)',
+ color: is_red ? '#dc3545' : '#000',
+ fontWeight: 'bold',
+ }}
+ >
+ <div style={{
+ position: 'absolute',
+ top: 4,
+ left: 6,
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ lineHeight: 1,
+ }}>
+ <span style={{ fontSize: 16, fontWeight: 'bold' }}>{is_joker ? (card.Rank === Rank_Red_Joker ? 'R' : 'B') : get_rank_symbol(card.Rank)}</span>
+ <span style={{ fontSize: 18 }}>{is_joker ? '🃏' : get_suit_symbol(card.Suit)}</span>
+ </div>
+ <div style={{
+ position: 'absolute',
+ bottom: 4,
+ right: 6,
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ lineHeight: 1,
+ transform: 'rotate(180deg)',
+ }}>
+ <span style={{ fontSize: 14 }}>{is_joker ? (card.Rank === Rank_Red_Joker ? 'R' : 'B') : get_rank_symbol(card.Rank)}</span>
+ <span style={{ fontSize: 12 }}>{is_joker ? '🃏' : get_suit_symbol(card.Suit)}</span>
+ </div>
+ <div style={{
+ position: 'absolute',
+ top: '50%',
+ left: '50%',
+ transform: 'translate(-50%, -50%)',
+ fontSize: is_joker ? 28 : 24,
+ }}>
+ {is_joker ? '🃏' : get_suit_symbol(card.Suit)}
+ </div>
+ </motion.div>
+ )
+}
+
+export function Card_Back() {
+ return (
+ <div
+ style={{
+ width: 70,
+ height: 100,
+ backgroundColor: '#1e3a5f',
+ border: '2px solid #0d1b2a',
+ borderRadius: 8,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 5px, rgba(255,255,255,0.1) 5px, rgba(255,255,255,0.1) 10px)',
+ }}
+ >
+ <div style={{ color: '#fff', fontSize: 24 }}>🀄</div>
+ </div>
+ )
+}
diff --git a/client/src/components/Game.tsx b/client/src/components/Game.tsx
new file mode 100644
index 0000000..2d32fec
--- /dev/null
+++ b/client/src/components/Game.tsx
@@ -0,0 +1,282 @@
+import { motion } from 'framer-motion'
+import { Card as Card_Type, Rank, get_rank_symbol } from '../game/types'
+import { Hand } from './Hand'
+import { Table } from './Table'
+import { Card_Back } from './Card'
+
+interface Game_Props {
+ hand: Card_Type[]
+ level: Rank
+ selected_ids: Set<number>
+ on_card_click: (id: number) => void
+ on_play: () => void
+ on_pass: () => void
+ table_cards: Card_Type[]
+ combo_type: string
+ current_turn: number
+ my_seat: number
+ can_pass: boolean
+ player_card_counts: number[]
+ team_levels: [number, number]
+}
+
+export function Game({
+ hand,
+ level,
+ selected_ids,
+ on_card_click,
+ on_play,
+ on_pass,
+ table_cards,
+ combo_type,
+ current_turn,
+ my_seat,
+ can_pass,
+ player_card_counts,
+ team_levels,
+}: Game_Props) {
+ const is_my_turn = current_turn === my_seat
+ const relative_positions = get_relative_positions(my_seat)
+
+ return (
+ <div style={styles.container}>
+ <div style={styles.info_bar}>
+ <div style={styles.level_badge}>
+ Level: {get_rank_symbol(level)}
+ </div>
+ <div style={styles.team_scores}>
+ <span style={{ color: '#2196f3' }}>Team 1: {get_rank_symbol(team_levels[0] as Rank)}</span>
+ <span style={{ marginLeft: 16, color: '#e91e63' }}>Team 2: {get_rank_symbol(team_levels[1] as Rank)}</span>
+ </div>
+ </div>
+
+ <div style={styles.game_area}>
+ <div style={styles.opponent_top}>
+ <Opponent_Hand
+ count={player_card_counts[relative_positions.top]}
+ is_turn={current_turn === relative_positions.top}
+ seat={relative_positions.top}
+ />
+ </div>
+
+ <div style={styles.middle_row}>
+ <div style={styles.opponent_side}>
+ <Opponent_Hand
+ count={player_card_counts[relative_positions.left]}
+ is_turn={current_turn === relative_positions.left}
+ seat={relative_positions.left}
+ vertical
+ />
+ </div>
+
+ <div style={styles.table_area}>
+ <Table cards={table_cards} level={level} combo_type={combo_type} />
+ </div>
+
+ <div style={styles.opponent_side}>
+ <Opponent_Hand
+ count={player_card_counts[relative_positions.right]}
+ is_turn={current_turn === relative_positions.right}
+ seat={relative_positions.right}
+ vertical
+ />
+ </div>
+ </div>
+
+ <div style={styles.my_area}>
+ <Hand
+ cards={hand}
+ level={level}
+ selected_ids={selected_ids}
+ on_card_click={on_card_click}
+ />
+
+ <div style={styles.actions}>
+ <motion.button
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ onClick={on_play}
+ disabled={!is_my_turn || selected_ids.size === 0}
+ style={{
+ ...styles.action_button,
+ backgroundColor: is_my_turn && selected_ids.size > 0 ? '#28a745' : '#444',
+ }}
+ >
+ Play
+ </motion.button>
+ <motion.button
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ onClick={on_pass}
+ disabled={!is_my_turn || !can_pass}
+ style={{
+ ...styles.action_button,
+ backgroundColor: is_my_turn && can_pass ? '#dc3545' : '#444',
+ }}
+ >
+ Pass
+ </motion.button>
+ </div>
+
+ {is_my_turn && (
+ <motion.div
+ initial={{ opacity: 0 }}
+ animate={{ opacity: 1 }}
+ style={styles.turn_indicator}
+ >
+ Your turn!
+ </motion.div>
+ )}
+ </div>
+ </div>
+ </div>
+ )
+}
+
+interface Opponent_Hand_Props {
+ count: number
+ is_turn: boolean
+ seat: number
+ vertical?: boolean
+}
+
+function Opponent_Hand({ count, is_turn, seat, vertical }: Opponent_Hand_Props) {
+ const display_count = Math.min(count, 10)
+ const overlap = vertical ? 15 : 20
+
+ return (
+ <div
+ style={{
+ display: 'flex',
+ flexDirection: vertical ? 'column' : 'row',
+ alignItems: 'center',
+ gap: 8,
+ padding: 8,
+ backgroundColor: is_turn ? 'rgba(255,193,7,0.2)' : 'transparent',
+ borderRadius: 8,
+ border: is_turn ? '2px solid #ffc107' : '2px solid transparent',
+ }}
+ >
+ <div
+ style={{
+ display: 'flex',
+ flexDirection: vertical ? 'column' : 'row',
+ position: 'relative',
+ width: vertical ? 50 : 50 + (display_count - 1) * overlap,
+ height: vertical ? 70 + (display_count - 1) * overlap : 70,
+ }}
+ >
+ {Array.from({ length: display_count }).map((_, i) => (
+ <div
+ key={i}
+ style={{
+ position: 'absolute',
+ left: vertical ? 0 : i * overlap,
+ top: vertical ? i * overlap : 0,
+ transform: 'scale(0.7)',
+ transformOrigin: 'top left',
+ }}
+ >
+ <Card_Back />
+ </div>
+ ))}
+ </div>
+ <div style={{ color: '#fff', fontSize: 12 }}>
+ Seat {seat + 1}: {count}
+ </div>
+ </div>
+ )
+}
+
+function get_relative_positions(my_seat: number) {
+ return {
+ top: (my_seat + 2) % 4,
+ left: (my_seat + 1) % 4,
+ right: (my_seat + 3) % 4,
+ }
+}
+
+const styles: Record<string, React.CSSProperties> = {
+ container: {
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100vh',
+ backgroundColor: '#0f3460',
+ },
+ info_bar: {
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ padding: '12px 24px',
+ backgroundColor: '#16213e',
+ },
+ level_badge: {
+ padding: '8px 16px',
+ backgroundColor: '#ffc107',
+ color: '#000',
+ borderRadius: 8,
+ fontWeight: 'bold',
+ },
+ team_scores: {
+ color: '#fff',
+ fontSize: 14,
+ },
+ game_area: {
+ flex: 1,
+ display: 'flex',
+ flexDirection: 'column',
+ padding: 20,
+ },
+ opponent_top: {
+ display: 'flex',
+ justifyContent: 'center',
+ marginBottom: 20,
+ },
+ middle_row: {
+ flex: 1,
+ display: 'flex',
+ alignItems: 'center',
+ },
+ opponent_side: {
+ width: 120,
+ display: 'flex',
+ justifyContent: 'center',
+ },
+ table_area: {
+ flex: 1,
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ backgroundColor: 'rgba(0,0,0,0.2)',
+ borderRadius: 16,
+ margin: '0 20px',
+ },
+ my_area: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ paddingTop: 20,
+ borderTop: '2px solid #333',
+ },
+ actions: {
+ display: 'flex',
+ gap: 16,
+ marginTop: 16,
+ },
+ action_button: {
+ padding: '12px 32px',
+ fontSize: 16,
+ border: 'none',
+ borderRadius: 8,
+ color: '#fff',
+ cursor: 'pointer',
+ },
+ turn_indicator: {
+ marginTop: 12,
+ padding: '8px 16px',
+ backgroundColor: '#ffc107',
+ color: '#000',
+ borderRadius: 8,
+ fontWeight: 'bold',
+ },
+}
diff --git a/client/src/components/Hand.tsx b/client/src/components/Hand.tsx
new file mode 100644
index 0000000..1a49a86
--- /dev/null
+++ b/client/src/components/Hand.tsx
@@ -0,0 +1,59 @@
+import { motion, AnimatePresence } from 'framer-motion'
+import { Card as Card_Type, Rank } from '../game/types'
+import { Card } from './Card'
+
+interface Hand_Props {
+ cards: Card_Type[]
+ level: Rank
+ selected_ids: Set<number>
+ on_card_click: (id: number) => void
+}
+
+export function Hand({ cards, level, selected_ids, on_card_click }: Hand_Props) {
+ const card_width = 70
+ const overlap = 35
+
+ return (
+ <div
+ style={{
+ display: 'flex',
+ justifyContent: 'center',
+ padding: 20,
+ minHeight: 140,
+ }}
+ >
+ <div
+ style={{
+ display: 'flex',
+ position: 'relative',
+ width: cards.length > 0 ? card_width + (cards.length - 1) * overlap : 0,
+ height: 100,
+ }}
+ >
+ <AnimatePresence>
+ {cards.map((card, index) => (
+ <motion.div
+ key={card.Id}
+ initial={{ opacity: 0, y: 50, scale: 0.8 }}
+ animate={{ opacity: 1, y: 0, scale: 1 }}
+ exit={{ opacity: 0, y: -50, scale: 0.8 }}
+ transition={{ delay: index * 0.02 }}
+ style={{
+ position: 'absolute',
+ left: index * overlap,
+ zIndex: index,
+ }}
+ >
+ <Card
+ card={card}
+ level={level}
+ selected={selected_ids.has(card.Id)}
+ on_click={() => on_card_click(card.Id)}
+ />
+ </motion.div>
+ ))}
+ </AnimatePresence>
+ </div>
+ </div>
+ )
+}
diff --git a/client/src/components/Lobby.tsx b/client/src/components/Lobby.tsx
new file mode 100644
index 0000000..6cbf30d
--- /dev/null
+++ b/client/src/components/Lobby.tsx
@@ -0,0 +1,261 @@
+import { useState } from 'react'
+import { motion } from 'framer-motion'
+import { Player_Info } from '../game/types'
+
+interface Lobby_Props {
+ room_id: string | null
+ players: Player_Info[]
+ on_create_room: (name: string) => void
+ on_join_room: (room_id: string, name: string) => void
+}
+
+export function Lobby({ room_id, players, on_create_room, on_join_room }: Lobby_Props) {
+ const [name, set_name] = useState('')
+ const [join_code, set_join_code] = useState('')
+ const [mode, set_mode] = useState<'select' | 'create' | 'join'>('select')
+
+ const handle_create = () => {
+ if (name.trim()) {
+ on_create_room(name.trim())
+ }
+ }
+
+ const handle_join = () => {
+ if (name.trim() && join_code.trim()) {
+ on_join_room(join_code.trim(), name.trim())
+ }
+ }
+
+ if (room_id) {
+ return (
+ <div style={styles.container}>
+ <motion.div
+ initial={{ opacity: 0, y: 20 }}
+ animate={{ opacity: 1, y: 0 }}
+ style={styles.card}
+ >
+ <h2 style={styles.title}>Room: {room_id}</h2>
+ <p style={styles.subtitle}>Waiting for players... ({players.length}/4)</p>
+
+ <div style={styles.players_grid}>
+ {[0, 1, 2, 3].map((seat) => {
+ const player = players.find((p) => p.seat === seat)
+ const team = seat % 2
+ return (
+ <motion.div
+ key={seat}
+ initial={{ opacity: 0, scale: 0.8 }}
+ animate={{ opacity: 1, scale: 1 }}
+ transition={{ delay: seat * 0.1 }}
+ style={{
+ ...styles.player_slot,
+ backgroundColor: team === 0 ? '#e3f2fd' : '#fce4ec',
+ borderColor: team === 0 ? '#2196f3' : '#e91e63',
+ }}
+ >
+ {player ? (
+ <>
+ <div style={styles.player_name}>{player.name}</div>
+ <div style={styles.player_team}>Team {team + 1}</div>
+ </>
+ ) : (
+ <div style={styles.empty_slot}>Empty</div>
+ )}
+ </motion.div>
+ )
+ })}
+ </div>
+
+ <p style={styles.hint}>Share room code with friends to join</p>
+ </motion.div>
+ </div>
+ )
+ }
+
+ return (
+ <div style={styles.container}>
+ <motion.div
+ initial={{ opacity: 0, y: 20 }}
+ animate={{ opacity: 1, y: 0 }}
+ style={styles.card}
+ >
+ <h1 style={styles.logo}>掼蛋</h1>
+ <h2 style={styles.title}>Guan Dan</h2>
+
+ {mode === 'select' && (
+ <div style={styles.buttons}>
+ <motion.button
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ onClick={() => set_mode('create')}
+ style={styles.button}
+ >
+ Create Room
+ </motion.button>
+ <motion.button
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ onClick={() => set_mode('join')}
+ style={{ ...styles.button, backgroundColor: '#28a745' }}
+ >
+ Join Room
+ </motion.button>
+ </div>
+ )}
+
+ {mode === 'create' && (
+ <div style={styles.form}>
+ <input
+ type="text"
+ placeholder="Your name"
+ value={name}
+ onChange={(e) => set_name(e.target.value)}
+ style={styles.input}
+ />
+ <div style={styles.buttons}>
+ <motion.button
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ onClick={handle_create}
+ style={styles.button}
+ >
+ Create
+ </motion.button>
+ <motion.button
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ onClick={() => set_mode('select')}
+ style={{ ...styles.button, backgroundColor: '#6c757d' }}
+ >
+ Back
+ </motion.button>
+ </div>
+ </div>
+ )}
+
+ {mode === 'join' && (
+ <div style={styles.form}>
+ <input
+ type="text"
+ placeholder="Your name"
+ value={name}
+ onChange={(e) => set_name(e.target.value)}
+ style={styles.input}
+ />
+ <input
+ type="text"
+ placeholder="Room code"
+ value={join_code}
+ onChange={(e) => set_join_code(e.target.value)}
+ style={styles.input}
+ />
+ <div style={styles.buttons}>
+ <motion.button
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ onClick={handle_join}
+ style={{ ...styles.button, backgroundColor: '#28a745' }}
+ >
+ Join
+ </motion.button>
+ <motion.button
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ onClick={() => set_mode('select')}
+ style={{ ...styles.button, backgroundColor: '#6c757d' }}
+ >
+ Back
+ </motion.button>
+ </div>
+ </div>
+ )}
+ </motion.div>
+ </div>
+ )
+}
+
+const styles: Record<string, React.CSSProperties> = {
+ container: {
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ minHeight: '100vh',
+ backgroundColor: '#1a1a2e',
+ },
+ card: {
+ backgroundColor: '#16213e',
+ padding: 40,
+ borderRadius: 16,
+ textAlign: 'center',
+ boxShadow: '0 8px 32px rgba(0,0,0,0.3)',
+ minWidth: 360,
+ },
+ logo: {
+ fontSize: 64,
+ margin: 0,
+ color: '#fff',
+ },
+ title: {
+ color: '#fff',
+ marginTop: 8,
+ marginBottom: 24,
+ },
+ subtitle: {
+ color: '#aaa',
+ marginBottom: 24,
+ },
+ buttons: {
+ display: 'flex',
+ gap: 12,
+ justifyContent: 'center',
+ },
+ button: {
+ padding: '12px 24px',
+ fontSize: 16,
+ border: 'none',
+ borderRadius: 8,
+ backgroundColor: '#007bff',
+ color: '#fff',
+ cursor: 'pointer',
+ },
+ form: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 12,
+ },
+ input: {
+ padding: '12px 16px',
+ fontSize: 16,
+ border: '2px solid #333',
+ borderRadius: 8,
+ backgroundColor: '#0f3460',
+ color: '#fff',
+ outline: 'none',
+ },
+ players_grid: {
+ display: 'grid',
+ gridTemplateColumns: '1fr 1fr',
+ gap: 12,
+ marginBottom: 24,
+ },
+ player_slot: {
+ padding: 16,
+ borderRadius: 8,
+ border: '2px solid',
+ },
+ player_name: {
+ fontWeight: 'bold',
+ color: '#333',
+ },
+ player_team: {
+ fontSize: 12,
+ color: '#666',
+ },
+ empty_slot: {
+ color: '#999',
+ },
+ hint: {
+ color: '#666',
+ fontSize: 12,
+ },
+}
diff --git a/client/src/components/Table.tsx b/client/src/components/Table.tsx
new file mode 100644
index 0000000..90684d4
--- /dev/null
+++ b/client/src/components/Table.tsx
@@ -0,0 +1,94 @@
+import { motion, AnimatePresence } from 'framer-motion'
+import { Card as Card_Type, Rank } from '../game/types'
+import { Card } from './Card'
+
+interface Table_Props {
+ cards: Card_Type[]
+ level: Rank
+ combo_type: string
+}
+
+export function Table({ cards, level, combo_type }: Table_Props) {
+ const card_width = 70
+ const overlap = 40
+
+ return (
+ <div
+ style={{
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ minHeight: 180,
+ padding: 20,
+ }}
+ >
+ <div
+ style={{
+ display: 'flex',
+ position: 'relative',
+ width: cards.length > 0 ? card_width + (cards.length - 1) * overlap : 100,
+ height: 100,
+ justifyContent: 'center',
+ }}
+ >
+ <AnimatePresence mode="wait">
+ {cards.length > 0 ? (
+ cards.map((card, index) => (
+ <motion.div
+ key={`table-${card.Id}`}
+ initial={{ opacity: 0, scale: 0.5, y: 100 }}
+ animate={{ opacity: 1, scale: 1, y: 0 }}
+ exit={{ opacity: 0, scale: 0.5, y: -100 }}
+ transition={{ delay: index * 0.05, type: 'spring', stiffness: 300 }}
+ style={{
+ position: 'absolute',
+ left: index * overlap,
+ zIndex: index,
+ }}
+ >
+ <Card
+ card={card}
+ level={level}
+ selected={false}
+ on_click={() => {}}
+ />
+ </motion.div>
+ ))
+ ) : (
+ <motion.div
+ initial={{ opacity: 0 }}
+ animate={{ opacity: 0.5 }}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ color: '#666',
+ fontSize: 14,
+ }}
+ >
+ No cards played
+ </motion.div>
+ )}
+ </AnimatePresence>
+ </div>
+ {combo_type && (
+ <motion.div
+ initial={{ opacity: 0, y: 10 }}
+ animate={{ opacity: 1, y: 0 }}
+ style={{
+ marginTop: 12,
+ padding: '4px 12px',
+ backgroundColor: '#333',
+ color: '#fff',
+ borderRadius: 4,
+ fontSize: 12,
+ textTransform: 'uppercase',
+ }}
+ >
+ {combo_type}
+ </motion.div>
+ )}
+ </div>
+ )
+}
diff --git a/client/src/game/types.ts b/client/src/game/types.ts
new file mode 100644
index 0000000..5265310
--- /dev/null
+++ b/client/src/game/types.ts
@@ -0,0 +1,99 @@
+export type Suit = 0 | 1 | 2 | 3 | 4
+
+export const Suit_Hearts: Suit = 0
+export const Suit_Diamonds: Suit = 1
+export const Suit_Clubs: Suit = 2
+export const Suit_Spades: Suit = 3
+export const Suit_Joker: Suit = 4
+
+export type Rank = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14
+
+export const Rank_Two: Rank = 0
+export const Rank_Three: Rank = 1
+export const Rank_Four: Rank = 2
+export const Rank_Five: Rank = 3
+export const Rank_Six: Rank = 4
+export const Rank_Seven: Rank = 5
+export const Rank_Eight: Rank = 6
+export const Rank_Nine: Rank = 7
+export const Rank_Ten: Rank = 8
+export const Rank_Jack: Rank = 9
+export const Rank_Queen: Rank = 10
+export const Rank_King: Rank = 11
+export const Rank_Ace: Rank = 12
+export const Rank_Black_Joker: Rank = 13
+export const Rank_Red_Joker: Rank = 14
+
+export interface Card {
+ Suit: Suit
+ Rank: Rank
+ Id: number
+}
+
+export interface Player_Info {
+ id: string
+ name: string
+ seat: number
+ team: number
+ is_ready: boolean
+}
+
+export interface Room_State {
+ room_id: string
+ players: Player_Info[]
+ game_active: boolean
+}
+
+export interface Game_State {
+ hand: Card[]
+ level: Rank
+ current_turn: number
+ my_seat: number
+ can_pass: boolean
+ table_cards: Card[]
+ player_card_counts: number[]
+ finish_order: string[]
+ team_levels: [number, number]
+}
+
+export type Msg_Type =
+ | 'join_room'
+ | 'create_room'
+ | 'room_state'
+ | 'game_start'
+ | 'deal_cards'
+ | 'play_cards'
+ | 'pass'
+ | 'turn'
+ | 'play_made'
+ | 'hand_end'
+ | 'tribute'
+ | 'tribute_give'
+ | 'tribute_recv'
+ | 'game_end'
+ | 'error'
+ | 'player_joined'
+ | 'player_left'
+
+export interface Message<T = unknown> {
+ type: Msg_Type
+ payload: T
+}
+
+export function get_suit_symbol(suit: Suit): string {
+ const symbols = ['♥', '♦', '♣', '♠', '']
+ return symbols[suit]
+}
+
+export function get_rank_symbol(rank: Rank): string {
+ const symbols = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A', '🃏', '🃏']
+ return symbols[rank]
+}
+
+export function is_red_suit(suit: Suit): boolean {
+ return suit === Suit_Hearts || suit === Suit_Diamonds
+}
+
+export function is_wild(card: Card, level: Rank): boolean {
+ return card.Suit === Suit_Hearts && card.Rank === level
+}
diff --git a/client/src/hooks/use_websocket.ts b/client/src/hooks/use_websocket.ts
new file mode 100644
index 0000000..d25006d
--- /dev/null
+++ b/client/src/hooks/use_websocket.ts
@@ -0,0 +1,50 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { Message } from '../game/types'
+
+type Message_Handler = (msg: Message) => void
+
+export function use_websocket(url: string) {
+ const [connected, set_connected] = useState(false)
+ const ws_ref = useRef<WebSocket | null>(null)
+ const handlers_ref = useRef<Map<string, Message_Handler>>(new Map())
+
+ useEffect(() => {
+ const ws = new WebSocket(url)
+ ws_ref.current = ws
+
+ ws.onopen = () => {
+ set_connected(true)
+ }
+
+ ws.onclose = () => {
+ set_connected(false)
+ }
+
+ ws.onmessage = (event) => {
+ const msg: Message = JSON.parse(event.data)
+ const handler = handlers_ref.current.get(msg.type)
+ if (handler) {
+ handler(msg)
+ }
+ }
+
+ return () => {
+ ws.close()
+ }
+ }, [url])
+
+ const send = useCallback((msg: Message) => {
+ if (ws_ref.current && ws_ref.current.readyState === WebSocket.OPEN) {
+ ws_ref.current.send(JSON.stringify(msg))
+ }
+ }, [])
+
+ const on = useCallback((type: string, handler: Message_Handler) => {
+ handlers_ref.current.set(type, handler)
+ return () => {
+ handlers_ref.current.delete(type)
+ }
+ }, [])
+
+ return { connected, send, on }
+}
diff --git a/client/src/main.tsx b/client/src/main.tsx
new file mode 100644
index 0000000..d18d7ec
--- /dev/null
+++ b/client/src/main.tsx
@@ -0,0 +1,23 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App'
+
+const style = document.createElement('style')
+style.textContent = `
+ * {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+ }
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ background-color: #1a1a2e;
+ }
+`
+document.head.appendChild(style)
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+ <React.StrictMode>
+ <App />
+ </React.StrictMode>
+)
diff --git a/client/tsconfig.json b/client/tsconfig.json
new file mode 100644
index 0000000..3934b8f
--- /dev/null
+++ b/client/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/client/tsconfig.node.json b/client/tsconfig.node.json
new file mode 100644
index 0000000..42872c5
--- /dev/null
+++ b/client/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/client/vite.config.ts b/client/vite.config.ts
new file mode 100644
index 0000000..d47b1b5
--- /dev/null
+++ b/client/vite.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ proxy: {
+ '/ws': {
+ target: 'ws://localhost:8080',
+ ws: true,
+ },
+ },
+ },
+})
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..22a4992
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,27 @@
+{
+ "nodes": {
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1769018530,
+ "narHash": "sha256-MJ27Cy2NtBEV5tsK+YraYr2g851f3Fl1LpNHDzDX15c=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "88d3861acdd3d2f0e361767018218e51810df8a1",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "nixpkgs": "nixpkgs"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..7b0cdf2
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,34 @@
+{
+ description = "guandanbtw - Online Guan Dan card game";
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+ };
+ outputs = {
+ self,
+ nixpkgs,
+ }: let
+ systems = ["x86_64-linux" "aarch64-linux"];
+
+ forAllSystems = fn: nixpkgs.lib.genAttrs systems (system: fn nixpkgs.legacyPackages.${system});
+ in {
+ devShells = forAllSystems (pkgs: {
+ default = pkgs.mkShell {
+ packages = [
+ pkgs.go
+ pkgs.gopls
+ pkgs.gotools
+ pkgs.air
+ pkgs.nodejs
+ pkgs.nodePackages.typescript
+ pkgs.nodePackages.typescript-language-server
+ pkgs.just
+ ];
+ shellHook = ''
+ export PS1="(guandan-dev) $PS1"
+ '';
+ };
+ });
+
+ formatter = forAllSystems (pkgs: pkgs.alejandra);
+ };
+}
diff --git a/readme.org b/readme.org
new file mode 100644
index 0000000..cdef113
--- /dev/null
+++ b/readme.org
@@ -0,0 +1,115 @@
+#+AUTHOR: Tony
+#+STARTUP: overview
+
+* Guan Dan BTW
+A real-time multiplayer web implementation of Guan Dan (掼蛋), a popular Chinese climbing card game for 4 players in 2v2 teams.
+
+[[./assets/guandan.png]]
+
+* Table of Contents :toc:
+- [[#guan-dan-btw][Guan Dan BTW]]
+- [[#features][Features]]
+- [[#installation][Installation]]
+- [[#usage][Usage]]
+- [[#development][Development]]
+- [[#project-structure][Project Structure]]
+- [[#game-rules][Game Rules]]
+- [[#future-plans][Future Plans]]
+- [[#license][License]]
+
+* Features
+- Real-time multiplayer via WebSockets
+- Room-based lobbies (create/join with room code)
+- Full Guan Dan ruleset:
+ - Level system (2 through A)
+ - Wild cards (heart of current level)
+ - Bomb hierarchy (4-10 of a kind, straight flush, 4-joker)
+ - Tribute system between hands
+- Animated card UI with Framer Motion
+
+* Installation
+Requires Go 1.23+ and Node.js 18+.
+
+#+begin_src sh
+git clone https://github.com/tonybanters/guandanbtw
+cd guandanbtw
+nix develop # or install go, node manually
+#+end_src
+
+* Usage
+Start the server:
+#+begin_src sh
+cd server
+air # or: go run .
+#+end_src
+
+Start the client:
+#+begin_src sh
+cd client
+npm install
+npm run dev
+#+end_src
+
+Open =http://localhost:5173=, create a room, and share the room code with 3 friends.
+
+* Development
+Using Nix (recommended):
+#+begin_src sh
+nix develop
+#+end_src
+
+Dev shell includes: go, gopls, air, nodejs, typescript.
+
+* Project Structure
+#+begin_src
+server/
+├── main.go [Entry point, HTTP/WS server]
+├── game/
+│ ├── card.go [Card types, deck, ranking]
+│ ├── combination.go [Valid play detection]
+│ ├── bomb.go [Bomb hierarchy]
+│ └── state.go [Game state machine]
+├── room/
+│ ├── hub.go [WebSocket hub, room management]
+│ ├── room.go [Room logic, game flow]
+│ └── client.go [Client connection handling]
+└── protocol/
+ └── messages.go [JSON message types]
+
+client/
+├── src/
+│ ├── App.tsx [Main app, state management]
+│ ├── components/
+│ │ ├── Card.tsx [Card component]
+│ │ ├── Hand.tsx [Player hand]
+│ │ ├── Table.tsx [Center play area]
+│ │ ├── Lobby.tsx [Create/join room UI]
+│ │ └── Game.tsx [Main game layout]
+│ ├── hooks/
+│ │ └── use_websocket.ts
+│ └── game/
+│ └── types.ts [Shared types]
+└── package.json
+#+end_src
+
+* Game Rules
+- 4 players, 2v2 teams (partners sit opposite)
+- 2 decks + 4 jokers = 108 cards, 27 each
+- Teams race to empty their hands
+- Current level card ranks highest; heart of level is wild
+- Valid plays: singles, pairs, triples, full houses, straights, tubes, plates
+- Bombs beat everything (ranked by size, then straight flush, then 4-joker)
+- Winners advance levels based on finish order
+
+Full rules: [[https://www.pagat.com/climbing/guan_dan.html][pagat.com]]
+
+* Future Plans
+- Tribute UI on frontend
+- Sound effects and better animations
+- Card sorting options
+- Spectator mode
+- Mobile responsive layout
+- Deployment (fly.io/railway)
+
+* License
+[[https://www.gnu.org/licenses/gpl-3.0.en.html][GPL v3]]
diff --git a/server/.air.toml b/server/.air.toml
new file mode 100644
index 0000000..eeb5593
--- /dev/null
+++ b/server/.air.toml
@@ -0,0 +1,22 @@
+root = "."
+tmp_dir = "tmp"
+
+[build]
+cmd = "go build -o ./tmp/main ."
+bin = "./tmp/main"
+full_bin = "./tmp/main"
+include_ext = ["go"]
+exclude_dir = ["tmp", "vendor"]
+delay = 1000
+
+[log]
+time = false
+
+[color]
+main = "magenta"
+watcher = "cyan"
+build = "yellow"
+runner = "green"
+
+[misc]
+clean_on_exit = true
diff --git a/server/game/bomb.go b/server/game/bomb.go
new file mode 100644
index 0000000..ef81d42
--- /dev/null
+++ b/server/game/bomb.go
@@ -0,0 +1,147 @@
+package game
+
+func detect_bomb(cards []Card, level Rank) Combination {
+ n := len(cards)
+ if n == 4 && is_four_joker_bomb(cards) {
+ return Combination{
+ Type: Comb_Bomb,
+ Cards: cards,
+ Bomb_Power: 1000,
+ }
+ }
+ if is_straight_flush(cards, level) {
+ return Combination{
+ Type: Comb_Bomb,
+ Cards: cards,
+ Bomb_Power: 900 + straight_flush_value(cards, level),
+ }
+ }
+ if n >= 4 && n <= 10 {
+ if power, ok := is_n_of_kind_bomb(cards, level); ok {
+ return Combination{
+ Type: Comb_Bomb,
+ Cards: cards,
+ Bomb_Power: power,
+ }
+ }
+ }
+
+ return Combination{Type: Comb_Invalid}
+}
+
+func is_four_joker_bomb(cards []Card) bool {
+ if len(cards) != 4 {
+ return false
+ }
+
+ red_count := 0
+ black_count := 0
+
+ for _, c := range cards {
+ if c.Rank == Rank_Red_Joker {
+ red_count++
+ } else if c.Rank == Rank_Black_Joker {
+ black_count++
+ } else {
+ return false
+ }
+ }
+
+ return red_count == 2 && black_count == 2
+}
+
+func is_straight_flush(cards []Card, level Rank) bool {
+ if len(cards) < 5 {
+ return false
+ }
+
+ non_wild, wild := separate_wilds(cards, level)
+
+ if len(non_wild) == 0 {
+ return false
+ }
+
+ var suit Suit = -1
+ for _, c := range non_wild {
+ if c.Rank == Rank_Black_Joker || c.Rank == Rank_Red_Joker {
+ return false
+ }
+ if suit == -1 {
+ suit = c.Suit
+ } else if c.Suit != suit {
+ return false
+ }
+ }
+
+ rank_present := make(map[Rank]bool)
+ for _, c := range non_wild {
+ rank_present[c.Rank] = true
+ }
+
+ natural_order := []Rank{
+ Rank_Ace, Rank_Two, Rank_Three, Rank_Four, Rank_Five,
+ Rank_Six, Rank_Seven, Rank_Eight, Rank_Nine, Rank_Ten,
+ Rank_Jack, Rank_Queen, Rank_King, Rank_Ace,
+ }
+
+ needed_len := len(cards)
+ wilds_available := len(wild)
+
+ for start := 0; start <= len(natural_order)-needed_len; start++ {
+ gaps := 0
+ for i := 0; i < needed_len; i++ {
+ rank := natural_order[start+i]
+ if !rank_present[rank] {
+ gaps++
+ }
+ }
+ if gaps <= wilds_available {
+ return true
+ }
+ }
+
+ return false
+}
+
+func straight_flush_value(cards []Card, level Rank) int {
+ non_wild, _ := separate_wilds(cards, level)
+
+ max_val := 0
+ for _, c := range non_wild {
+ v := rank_value(c.Rank, level)
+ if v > max_val {
+ max_val = v
+ }
+ }
+
+ return len(cards)*10 + max_val
+}
+
+func is_n_of_kind_bomb(cards []Card, level Rank) (int, bool) {
+ n := len(cards)
+ if n < 4 || n > 10 {
+ return 0, false
+ }
+
+ non_wild, wild := separate_wilds(cards, level)
+
+ rank_counts := count_ranks(non_wild)
+
+ for rank, count := range rank_counts {
+ if rank == Rank_Black_Joker || rank == Rank_Red_Joker {
+ continue
+ }
+ total := count + len(wild)
+ if total >= n {
+ power := n*100 + rank_value(rank, level)
+ return power, true
+ }
+ }
+
+ if len(wild) >= n {
+ power := n*100 + rank_value(level, level)
+ return power, true
+ }
+
+ return 0, false
+}
diff --git a/server/game/card.go b/server/game/card.go
new file mode 100644
index 0000000..1b5ef27
--- /dev/null
+++ b/server/game/card.go
@@ -0,0 +1,126 @@
+package game
+
+type Suit int
+
+const (
+ Suit_Hearts Suit = iota
+ Suit_Diamonds
+ Suit_Clubs
+ Suit_Spades
+ Suit_Joker
+)
+
+type Rank int
+
+const (
+ Rank_Two Rank = iota
+ Rank_Three
+ Rank_Four
+ Rank_Five
+ Rank_Six
+ Rank_Seven
+ Rank_Eight
+ Rank_Nine
+ Rank_Ten
+ Rank_Jack
+ Rank_Queen
+ Rank_King
+ Rank_Ace
+ Rank_Black_Joker
+ Rank_Red_Joker
+)
+
+type Card struct {
+ Suit Suit
+ Rank Rank
+ Id int
+}
+
+type Deck struct {
+ Cards []Card
+}
+
+func New_Deck() *Deck {
+ deck := &Deck{Cards: make([]Card, 0, 108)}
+ id := 0
+
+ for copy := 0; copy < 2; copy++ {
+ for suit := Suit_Hearts; suit <= Suit_Spades; suit++ {
+ for rank := Rank_Two; rank <= Rank_Ace; rank++ {
+ deck.Cards = append(deck.Cards, Card{Suit: suit, Rank: rank, Id: id})
+ id++
+ }
+ }
+ deck.Cards = append(deck.Cards, Card{Suit: Suit_Joker, Rank: Rank_Black_Joker, Id: id})
+ id++
+ deck.Cards = append(deck.Cards, Card{Suit: Suit_Joker, Rank: Rank_Red_Joker, Id: id})
+ id++
+ }
+
+ return deck
+}
+
+func (d *Deck) Shuffle() {
+ for i := len(d.Cards) - 1; i > 0; i-- {
+ j := rand_int(i + 1)
+ d.Cards[i], d.Cards[j] = d.Cards[j], d.Cards[i]
+ }
+}
+
+func (d *Deck) Deal() [4][]Card {
+ var hands [4][]Card
+ for i := 0; i < 4; i++ {
+ hands[i] = make([]Card, 0, 27)
+ }
+
+ for i, card := range d.Cards {
+ hands[i%4] = append(hands[i%4], card)
+ }
+
+ return hands
+}
+
+func rank_value(rank Rank, level Rank) int {
+ if rank == Rank_Red_Joker {
+ return 100
+ }
+ if rank == Rank_Black_Joker {
+ return 99
+ }
+ if rank == level {
+ return 98
+ }
+
+ base_order := []Rank{
+ Rank_Two, Rank_Three, Rank_Four, Rank_Five, Rank_Six,
+ Rank_Seven, Rank_Eight, Rank_Nine, Rank_Ten,
+ Rank_Jack, Rank_Queen, Rank_King, Rank_Ace,
+ }
+
+ for i, r := range base_order {
+ if r == rank {
+ return i
+ }
+ }
+ return -1
+}
+
+func card_value(card Card, level Rank) int {
+ return rank_value(card.Rank, level)
+}
+
+func Is_Wild(card Card, level Rank) bool {
+ return card.Suit == Suit_Hearts && card.Rank == level
+}
+
+func compare_cards(a, b Card, level Rank) int {
+ va := card_value(a, level)
+ vb := card_value(b, level)
+ if va > vb {
+ return 1
+ }
+ if va < vb {
+ return -1
+ }
+ return 0
+}
diff --git a/server/game/combination.go b/server/game/combination.go
new file mode 100644
index 0000000..233dae3
--- /dev/null
+++ b/server/game/combination.go
@@ -0,0 +1,417 @@
+package game
+
+type Combination_Type int
+
+const (
+ Comb_Invalid Combination_Type = iota
+ Comb_Single
+ Comb_Pair
+ Comb_Triple
+ Comb_Full_House
+ Comb_Straight
+ Comb_Tube
+ Comb_Plate
+ Comb_Bomb
+)
+
+type Combination struct {
+ Type Combination_Type
+ Cards []Card
+ Rank_Value int
+ Bomb_Power int
+}
+
+func Detect_Combination(cards []Card, level Rank) Combination {
+ n := len(cards)
+ if n == 0 {
+ return Combination{Type: Comb_Invalid}
+ }
+
+ non_wild, wild := separate_wilds(cards, level)
+
+ if bomb := detect_bomb(cards, level); bomb.Type == Comb_Bomb {
+ return bomb
+ }
+
+ switch n {
+ case 1:
+ return detect_single(non_wild, wild, level)
+ case 2:
+ return detect_pair(non_wild, wild, level)
+ case 3:
+ return detect_triple(non_wild, wild, level)
+ case 5:
+ if comb := detect_full_house(non_wild, wild, level); comb.Type != Comb_Invalid {
+ return comb
+ }
+ return detect_straight(non_wild, wild, level)
+ case 6:
+ if comb := detect_tube(non_wild, wild, level); comb.Type != Comb_Invalid {
+ return comb
+ }
+ return detect_plate(non_wild, wild, level)
+ }
+
+ return Combination{Type: Comb_Invalid}
+}
+
+func separate_wilds(cards []Card, level Rank) ([]Card, []Card) {
+ var non_wild, wild []Card
+ for _, c := range cards {
+ if Is_Wild(c, level) {
+ wild = append(wild, c)
+ } else {
+ non_wild = append(non_wild, c)
+ }
+ }
+ return non_wild, wild
+}
+
+func detect_single(non_wild, wild []Card, level Rank) Combination {
+ if len(non_wild) == 1 && len(wild) == 0 {
+ return Combination{
+ Type: Comb_Single,
+ Cards: non_wild,
+ Rank_Value: card_value(non_wild[0], level),
+ }
+ }
+ if len(non_wild) == 0 && len(wild) == 1 {
+ return Combination{
+ Type: Comb_Single,
+ Cards: wild,
+ Rank_Value: card_value(wild[0], level),
+ }
+ }
+ return Combination{Type: Comb_Invalid}
+}
+
+func detect_pair(non_wild, wild []Card, level Rank) Combination {
+ total := len(non_wild) + len(wild)
+ if total != 2 {
+ return Combination{Type: Comb_Invalid}
+ }
+
+ if len(non_wild) == 2 {
+ if non_wild[0].Rank == non_wild[1].Rank {
+ return Combination{
+ Type: Comb_Pair,
+ Cards: append(non_wild, wild...),
+ Rank_Value: card_value(non_wild[0], level),
+ }
+ }
+ return Combination{Type: Comb_Invalid}
+ }
+
+ if len(non_wild) == 1 && len(wild) == 1 {
+ return Combination{
+ Type: Comb_Pair,
+ Cards: append(non_wild, wild...),
+ Rank_Value: card_value(non_wild[0], level),
+ }
+ }
+
+ if len(wild) == 2 {
+ return Combination{
+ Type: Comb_Pair,
+ Cards: wild,
+ Rank_Value: card_value(wild[0], level),
+ }
+ }
+
+ return Combination{Type: Comb_Invalid}
+}
+
+func detect_triple(non_wild, wild []Card, level Rank) Combination {
+ total := len(non_wild) + len(wild)
+ if total != 3 {
+ return Combination{Type: Comb_Invalid}
+ }
+
+ rank_counts := count_ranks(non_wild)
+
+ for rank, count := range rank_counts {
+ needed := 3 - count
+ if needed <= len(wild) {
+ return Combination{
+ Type: Comb_Triple,
+ Cards: append(non_wild, wild...),
+ Rank_Value: rank_value(rank, level),
+ }
+ }
+ }
+
+ if len(wild) == 3 {
+ return Combination{
+ Type: Comb_Triple,
+ Cards: wild,
+ Rank_Value: card_value(wild[0], level),
+ }
+ }
+
+ return Combination{Type: Comb_Invalid}
+}
+
+func detect_full_house(non_wild, wild []Card, level Rank) Combination {
+ total := len(non_wild) + len(wild)
+ if total != 5 {
+ return Combination{Type: Comb_Invalid}
+ }
+
+ rank_counts := count_ranks(non_wild)
+ wilds_available := len(wild)
+
+ var triple_rank Rank = -1
+ var pair_rank Rank = -1
+
+ ranks := sorted_ranks(rank_counts, level)
+
+ for _, rank := range ranks {
+ count := rank_counts[rank]
+ if count >= 3 && triple_rank == -1 {
+ triple_rank = rank
+ } else if count >= 2 && pair_rank == -1 && triple_rank != rank {
+ pair_rank = rank
+ }
+ }
+
+ if triple_rank != -1 && pair_rank != -1 {
+ return Combination{
+ Type: Comb_Full_House,
+ Cards: append(non_wild, wild...),
+ Rank_Value: rank_value(triple_rank, level),
+ }
+ }
+
+ for _, rank := range ranks {
+ count := rank_counts[rank]
+ needed_for_triple := 3 - count
+ if needed_for_triple <= wilds_available {
+ remaining_wilds := wilds_available - needed_for_triple
+ for _, other_rank := range ranks {
+ if other_rank == rank {
+ continue
+ }
+ other_count := rank_counts[other_rank]
+ needed_for_pair := 2 - other_count
+ if needed_for_pair <= remaining_wilds {
+ return Combination{
+ Type: Comb_Full_House,
+ Cards: append(non_wild, wild...),
+ Rank_Value: rank_value(rank, level),
+ }
+ }
+ }
+ if remaining_wilds >= 2 {
+ return Combination{
+ Type: Comb_Full_House,
+ Cards: append(non_wild, wild...),
+ Rank_Value: rank_value(rank, level),
+ }
+ }
+ }
+ }
+
+ return Combination{Type: Comb_Invalid}
+}
+
+func detect_straight(non_wild, wild []Card, level Rank) Combination {
+ total := len(non_wild) + len(wild)
+ if total != 5 {
+ return Combination{Type: Comb_Invalid}
+ }
+
+ for _, card := range non_wild {
+ if card.Rank == Rank_Black_Joker || card.Rank == Rank_Red_Joker {
+ return Combination{Type: Comb_Invalid}
+ }
+ }
+
+ rank_counts := count_ranks(non_wild)
+ wilds_available := len(wild)
+
+ natural_order := []Rank{
+ Rank_Ace, Rank_Two, Rank_Three, Rank_Four, Rank_Five,
+ Rank_Six, Rank_Seven, Rank_Eight, Rank_Nine, Rank_Ten,
+ Rank_Jack, Rank_Queen, Rank_King, Rank_Ace,
+ }
+
+ for start := 0; start <= len(natural_order)-5; start++ {
+ needed := 0
+ valid := true
+ highest := natural_order[start+4]
+
+ for i := 0; i < 5; i++ {
+ rank := natural_order[start+i]
+ if rank_counts[rank] == 0 {
+ needed++
+ } else if rank_counts[rank] > 1 {
+ valid = false
+ break
+ }
+ }
+
+ if valid && needed <= wilds_available {
+ return Combination{
+ Type: Comb_Straight,
+ Cards: append(non_wild, wild...),
+ Rank_Value: straight_value(highest, level),
+ }
+ }
+ }
+
+ return Combination{Type: Comb_Invalid}
+}
+
+func straight_value(highest Rank, level Rank) int {
+ natural_order := []Rank{
+ Rank_Two, Rank_Three, Rank_Four, Rank_Five,
+ Rank_Six, Rank_Seven, Rank_Eight, Rank_Nine, Rank_Ten,
+ Rank_Jack, Rank_Queen, Rank_King, Rank_Ace,
+ }
+ for i, r := range natural_order {
+ if r == highest {
+ return i
+ }
+ }
+ return 0
+}
+
+func detect_tube(non_wild, wild []Card, level Rank) Combination {
+ total := len(non_wild) + len(wild)
+ if total != 6 {
+ return Combination{Type: Comb_Invalid}
+ }
+
+ for _, card := range non_wild {
+ if card.Rank == Rank_Black_Joker || card.Rank == Rank_Red_Joker {
+ return Combination{Type: Comb_Invalid}
+ }
+ }
+
+ rank_counts := count_ranks(non_wild)
+ wilds_available := len(wild)
+
+ natural_order := []Rank{
+ Rank_Ace, Rank_Two, Rank_Three, Rank_Four, Rank_Five,
+ Rank_Six, Rank_Seven, Rank_Eight, Rank_Nine, Rank_Ten,
+ Rank_Jack, Rank_Queen, Rank_King, Rank_Ace,
+ }
+
+ for start := 0; start <= len(natural_order)-3; start++ {
+ needed := 0
+ valid := true
+ highest := natural_order[start+2]
+
+ for i := 0; i < 3; i++ {
+ rank := natural_order[start+i]
+ count := rank_counts[rank]
+ if count < 2 {
+ needed += 2 - count
+ }
+ }
+
+ if valid && needed <= wilds_available {
+ return Combination{
+ Type: Comb_Tube,
+ Cards: append(non_wild, wild...),
+ Rank_Value: straight_value(highest, level),
+ }
+ }
+ }
+
+ return Combination{Type: Comb_Invalid}
+}
+
+func detect_plate(non_wild, wild []Card, level Rank) Combination {
+ total := len(non_wild) + len(wild)
+ if total != 6 {
+ return Combination{Type: Comb_Invalid}
+ }
+
+ for _, card := range non_wild {
+ if card.Rank == Rank_Black_Joker || card.Rank == Rank_Red_Joker {
+ return Combination{Type: Comb_Invalid}
+ }
+ }
+
+ rank_counts := count_ranks(non_wild)
+ wilds_available := len(wild)
+
+ natural_order := []Rank{
+ Rank_Ace, Rank_Two, Rank_Three, Rank_Four, Rank_Five,
+ Rank_Six, Rank_Seven, Rank_Eight, Rank_Nine, Rank_Ten,
+ Rank_Jack, Rank_Queen, Rank_King, Rank_Ace,
+ }
+
+ for start := 0; start <= len(natural_order)-2; start++ {
+ needed := 0
+ highest := natural_order[start+1]
+
+ for i := 0; i < 2; i++ {
+ rank := natural_order[start+i]
+ count := rank_counts[rank]
+ if count < 3 {
+ needed += 3 - count
+ }
+ }
+
+ if needed <= wilds_available {
+ return Combination{
+ Type: Comb_Plate,
+ Cards: append(non_wild, wild...),
+ Rank_Value: straight_value(highest, level),
+ }
+ }
+ }
+
+ return Combination{Type: Comb_Invalid}
+}
+
+func count_ranks(cards []Card) map[Rank]int {
+ counts := make(map[Rank]int)
+ for _, c := range cards {
+ counts[c.Rank]++
+ }
+ return counts
+}
+
+func sorted_ranks(counts map[Rank]int, level Rank) []Rank {
+ var ranks []Rank
+ for r := range counts {
+ ranks = append(ranks, r)
+ }
+
+ for i := 0; i < len(ranks)-1; i++ {
+ for j := i + 1; j < len(ranks); j++ {
+ if rank_value(ranks[i], level) < rank_value(ranks[j], level) {
+ ranks[i], ranks[j] = ranks[j], ranks[i]
+ }
+ }
+ }
+
+ return ranks
+}
+
+func Can_Beat(play, lead Combination) bool {
+ if play.Type == Comb_Invalid {
+ return false
+ }
+
+ if play.Type == Comb_Bomb && lead.Type != Comb_Bomb {
+ return true
+ }
+
+ if play.Type == Comb_Bomb && lead.Type == Comb_Bomb {
+ return play.Bomb_Power > lead.Bomb_Power
+ }
+
+ if play.Type != lead.Type {
+ return false
+ }
+
+ if len(play.Cards) != len(lead.Cards) {
+ return false
+ }
+
+ return play.Rank_Value > lead.Rank_Value
+}
diff --git a/server/game/rand.go b/server/game/rand.go
new file mode 100644
index 0000000..3a401db
--- /dev/null
+++ b/server/game/rand.go
@@ -0,0 +1,12 @@
+package game
+
+import (
+ "crypto/rand"
+ "encoding/binary"
+)
+
+func rand_int(max int) int {
+ var b [8]byte
+ rand.Read(b[:])
+ return int(binary.LittleEndian.Uint64(b[:]) % uint64(max))
+}
diff --git a/server/game/state.go b/server/game/state.go
new file mode 100644
index 0000000..6bfb067
--- /dev/null
+++ b/server/game/state.go
@@ -0,0 +1,199 @@
+package game
+
+type Game_Phase int
+
+const (
+ Phase_Waiting Game_Phase = iota
+ Phase_Deal
+ Phase_Play
+ Phase_Tribute
+ Phase_End
+)
+
+type Tribute_Info struct {
+ From_Seat int
+ To_Seat int
+ Done bool
+}
+
+type Game_State struct {
+ Phase Game_Phase
+ Level Rank
+ Team_Levels [2]int
+ Hands [4][]Card
+ Current_Turn int
+ Current_Lead Combination
+ Lead_Player int
+ Pass_Count int
+ Finish_Order []int
+ Tributes []Tribute_Info
+ Tribute_Leader int
+}
+
+func New_Game_State() *Game_State {
+ return &Game_State{
+ Phase: Phase_Waiting,
+ Level: Rank_Two,
+ Team_Levels: [2]int{0, 0},
+ Finish_Order: make([]int, 0, 4),
+ }
+}
+
+func (g *Game_State) Get_Cards_By_Id(seat int, ids []int) []Card {
+ id_set := make(map[int]bool)
+ for _, id := range ids {
+ id_set[id] = true
+ }
+
+ var cards []Card
+ for _, card := range g.Hands[seat] {
+ if id_set[card.Id] {
+ cards = append(cards, card)
+ delete(id_set, card.Id)
+ }
+ }
+
+ if len(id_set) > 0 {
+ return nil
+ }
+
+ return cards
+}
+
+func (g *Game_State) Get_Card_By_Id(seat int, id int) *Card {
+ for i := range g.Hands[seat] {
+ if g.Hands[seat][i].Id == id {
+ return &g.Hands[seat][i]
+ }
+ }
+ return nil
+}
+
+func (g *Game_State) Remove_Cards(seat int, ids []int) {
+ id_set := make(map[int]bool)
+ for _, id := range ids {
+ id_set[id] = true
+ }
+
+ var remaining []Card
+ for _, card := range g.Hands[seat] {
+ if !id_set[card.Id] {
+ remaining = append(remaining, card)
+ }
+ }
+ g.Hands[seat] = remaining
+}
+
+func (g *Game_State) Setup_Tributes() {
+ g.Tributes = nil
+
+ if len(g.Finish_Order) < 2 {
+ return
+ }
+
+ first := g.Finish_Order[0]
+ winning_team := first % 2
+
+ var last_loser int = -1
+ var second_last_loser int = -1
+
+ for i := 3; i >= 0; i-- {
+ seat := -1
+ if i < len(g.Finish_Order) {
+ seat = g.Finish_Order[i]
+ } else {
+ for s := 0; s < 4; s++ {
+ found := false
+ for _, f := range g.Finish_Order {
+ if f == s {
+ found = true
+ break
+ }
+ }
+ if !found {
+ seat = s
+ break
+ }
+ }
+ }
+
+ if seat%2 != winning_team {
+ if last_loser == -1 {
+ last_loser = seat
+ } else if second_last_loser == -1 {
+ second_last_loser = seat
+ break
+ }
+ }
+ }
+
+ first_winner := g.Finish_Order[0]
+ var second_winner int = -1
+ for _, seat := range g.Finish_Order {
+ if seat%2 == winning_team && seat != first_winner {
+ second_winner = seat
+ break
+ }
+ }
+
+ if last_loser != -1 {
+ g.Tributes = append(g.Tributes, Tribute_Info{
+ From_Seat: last_loser,
+ To_Seat: first_winner,
+ })
+ }
+
+ if g.is_double_win() && second_last_loser != -1 && second_winner != -1 {
+ g.Tributes = append(g.Tributes, Tribute_Info{
+ From_Seat: second_last_loser,
+ To_Seat: second_winner,
+ })
+ }
+
+ g.Tribute_Leader = first_winner
+}
+
+func (g *Game_State) is_double_win() bool {
+ if len(g.Finish_Order) < 2 {
+ return false
+ }
+ return g.Finish_Order[0]%2 == g.Finish_Order[1]%2
+}
+
+func (g *Game_State) Get_Tribute_Info(seat int) *Tribute_Info {
+ for i := range g.Tributes {
+ if g.Tributes[i].From_Seat == seat && !g.Tributes[i].Done {
+ return &g.Tributes[i]
+ }
+ }
+ return nil
+}
+
+func (g *Game_State) Mark_Tribute_Done(seat int) {
+ for i := range g.Tributes {
+ if g.Tributes[i].From_Seat == seat {
+ g.Tributes[i].Done = true
+ break
+ }
+ }
+}
+
+func (g *Game_State) All_Tributes_Done() bool {
+ for _, t := range g.Tributes {
+ if !t.Done {
+ return false
+ }
+ }
+ return true
+}
+
+func (g *Game_State) Reset_Hand() {
+ g.Current_Lead = Combination{Type: Comb_Invalid}
+ g.Lead_Player = 0
+ g.Pass_Count = 0
+ g.Finish_Order = g.Finish_Order[:0]
+ g.Tributes = nil
+
+ winning_team := g.Tribute_Leader % 2
+ g.Level = Rank(g.Team_Levels[winning_team])
+}
diff --git a/server/go.mod b/server/go.mod
new file mode 100644
index 0000000..193363d
--- /dev/null
+++ b/server/go.mod
@@ -0,0 +1,5 @@
+module guandanbtw
+
+go 1.23
+
+require github.com/gorilla/websocket v1.5.3
diff --git a/server/go.sum b/server/go.sum
new file mode 100644
index 0000000..25a9fc4
--- /dev/null
+++ b/server/go.sum
@@ -0,0 +1,2 @@
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
diff --git a/server/main.go b/server/main.go
new file mode 100644
index 0000000..e9a559c
--- /dev/null
+++ b/server/main.go
@@ -0,0 +1,19 @@
+package main
+
+import (
+ "guandanbtw/room"
+ "log"
+ "net/http"
+)
+
+func main() {
+ hub := room.New_Hub()
+ go hub.Run()
+
+ http.HandleFunc("/ws", hub.Handle_Websocket)
+
+ http.Handle("/", http.FileServer(http.Dir("../client/dist")))
+
+ log.Println("server starting on :8080")
+ log.Fatal(http.ListenAndServe(":8080", nil))
+}
diff --git a/server/protocol/messages.go b/server/protocol/messages.go
new file mode 100644
index 0000000..128d338
--- /dev/null
+++ b/server/protocol/messages.go
@@ -0,0 +1,107 @@
+package protocol
+
+import "guandanbtw/game"
+
+type Msg_Type string
+
+const (
+ Msg_Join_Room Msg_Type = "join_room"
+ Msg_Create_Room Msg_Type = "create_room"
+ Msg_Room_State Msg_Type = "room_state"
+ Msg_Game_Start Msg_Type = "game_start"
+ Msg_Deal_Cards Msg_Type = "deal_cards"
+ Msg_Play_Cards Msg_Type = "play_cards"
+ Msg_Pass Msg_Type = "pass"
+ Msg_Turn Msg_Type = "turn"
+ Msg_Play_Made Msg_Type = "play_made"
+ Msg_Hand_End Msg_Type = "hand_end"
+ Msg_Tribute Msg_Type = "tribute"
+ Msg_Tribute_Give Msg_Type = "tribute_give"
+ Msg_Tribute_Recv Msg_Type = "tribute_recv"
+ Msg_Game_End Msg_Type = "game_end"
+ Msg_Error Msg_Type = "error"
+ Msg_Player_Joined Msg_Type = "player_joined"
+ Msg_Player_Left Msg_Type = "player_left"
+)
+
+type Message struct {
+ Type Msg_Type `json:"type"`
+ Payload interface{} `json:"payload"`
+}
+
+type Join_Room_Payload struct {
+ Room_Id string `json:"room_id"`
+ Player_Name string `json:"player_name"`
+}
+
+type Create_Room_Payload struct {
+ Player_Name string `json:"player_name"`
+}
+
+type Room_State_Payload struct {
+ Room_Id string `json:"room_id"`
+ Players []Player_Info `json:"players"`
+ Game_Active bool `json:"game_active"`
+ Your_Id string `json:"your_id"`
+}
+
+type Player_Info struct {
+ Id string `json:"id"`
+ Name string `json:"name"`
+ Seat int `json:"seat"`
+ Team int `json:"team"`
+ Is_Ready bool `json:"is_ready"`
+}
+
+type Deal_Cards_Payload struct {
+ Cards []game.Card `json:"cards"`
+ Level game.Rank `json:"level"`
+}
+
+type Play_Cards_Payload struct {
+ Card_Ids []int `json:"card_ids"`
+}
+
+type Turn_Payload struct {
+ Player_Id string `json:"player_id"`
+ Seat int `json:"seat"`
+ Lead_Combo_Type game.Combination `json:"lead_combo_type,omitempty"`
+ Can_Pass bool `json:"can_pass"`
+}
+
+type Play_Made_Payload struct {
+ Player_Id string `json:"player_id"`
+ Seat int `json:"seat"`
+ Cards []game.Card `json:"cards"`
+ Combo_Type string `json:"combo_type"`
+ Is_Pass bool `json:"is_pass"`
+}
+
+type Hand_End_Payload struct {
+ Finish_Order []string `json:"finish_order"`
+ Winning_Team int `json:"winning_team"`
+ Level_Advance int `json:"level_advance"`
+ New_Levels [2]int `json:"new_levels"`
+}
+
+type Tribute_Payload struct {
+ From_Seat int `json:"from_seat"`
+ To_Seat int `json:"to_seat"`
+}
+
+type Tribute_Give_Payload struct {
+ Card_Id int `json:"card_id"`
+}
+
+type Tribute_Recv_Payload struct {
+ Card game.Card `json:"card"`
+}
+
+type Game_End_Payload struct {
+ Winning_Team int `json:"winning_team"`
+ Final_Levels [2]int `json:"final_levels"`
+}
+
+type Error_Payload struct {
+ Message string `json:"message"`
+}
diff --git a/server/room/client.go b/server/room/client.go
new file mode 100644
index 0000000..d596fe9
--- /dev/null
+++ b/server/room/client.go
@@ -0,0 +1,198 @@
+package room
+
+import (
+ "encoding/json"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "guandanbtw/protocol"
+)
+
+const (
+ write_wait = 10 * time.Second
+ pong_wait = 60 * time.Second
+ ping_period = (pong_wait * 9) / 10
+ max_message_size = 4096
+)
+
+type Client struct {
+ id string
+ name string
+ room *Room
+ conn *websocket.Conn
+ send chan []byte
+ mu sync.Mutex
+}
+
+func new_client(id string, conn *websocket.Conn) *Client {
+ return &Client{
+ id: id,
+ conn: conn,
+ send: make(chan []byte, 256),
+ }
+}
+
+func (c *Client) read_pump(hub *Hub) {
+ defer func() {
+ if c.room != nil {
+ c.room.leave <- c
+ }
+ hub.unregister <- c
+ c.conn.Close()
+ }()
+
+ c.conn.SetReadLimit(max_message_size)
+ c.conn.SetReadDeadline(time.Now().Add(pong_wait))
+ c.conn.SetPongHandler(func(string) error {
+ c.conn.SetReadDeadline(time.Now().Add(pong_wait))
+ return nil
+ })
+
+ for {
+ _, data, err := c.conn.ReadMessage()
+ if err != nil {
+ break
+ }
+
+ var msg protocol.Message
+ if err := json.Unmarshal(data, &msg); err != nil {
+ c.send_error("invalid message format")
+ continue
+ }
+
+ c.handle_message(hub, &msg)
+ }
+}
+
+func (c *Client) write_pump() {
+ ticker := time.NewTicker(ping_period)
+ defer func() {
+ ticker.Stop()
+ c.conn.Close()
+ }()
+
+ for {
+ select {
+ case message, ok := <-c.send:
+ c.conn.SetWriteDeadline(time.Now().Add(write_wait))
+ if !ok {
+ c.conn.WriteMessage(websocket.CloseMessage, []byte{})
+ return
+ }
+
+ w, err := c.conn.NextWriter(websocket.TextMessage)
+ if err != nil {
+ return
+ }
+ w.Write(message)
+
+ if err := w.Close(); err != nil {
+ return
+ }
+ case <-ticker.C:
+ c.conn.SetWriteDeadline(time.Now().Add(write_wait))
+ if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
+ return
+ }
+ }
+ }
+}
+
+func (c *Client) handle_message(hub *Hub, msg *protocol.Message) {
+ switch msg.Type {
+ case protocol.Msg_Create_Room:
+ c.handle_create_room(hub, msg)
+ case protocol.Msg_Join_Room:
+ c.handle_join_room(hub, msg)
+ case protocol.Msg_Play_Cards:
+ c.handle_play_cards(msg)
+ case protocol.Msg_Pass:
+ c.handle_pass()
+ case protocol.Msg_Tribute_Give:
+ c.handle_tribute_give(msg)
+ }
+}
+
+func (c *Client) handle_create_room(hub *Hub, msg *protocol.Message) {
+ payload_bytes, _ := json.Marshal(msg.Payload)
+ var payload protocol.Create_Room_Payload
+ json.Unmarshal(payload_bytes, &payload)
+
+ c.name = payload.Player_Name
+ room := hub.create_room()
+ room.join <- c
+}
+
+func (c *Client) handle_join_room(hub *Hub, msg *protocol.Message) {
+ payload_bytes, _ := json.Marshal(msg.Payload)
+ var payload protocol.Join_Room_Payload
+ json.Unmarshal(payload_bytes, &payload)
+
+ c.name = payload.Player_Name
+ room := hub.get_room(payload.Room_Id)
+ if room == nil {
+ c.send_error("room not found")
+ return
+ }
+ room.join <- c
+}
+
+func (c *Client) handle_play_cards(msg *protocol.Message) {
+ if c.room == nil {
+ return
+ }
+
+ payload_bytes, _ := json.Marshal(msg.Payload)
+ var payload protocol.Play_Cards_Payload
+ json.Unmarshal(payload_bytes, &payload)
+
+ c.room.play <- Play_Action{
+ client: c,
+ card_ids: payload.Card_Ids,
+ }
+}
+
+func (c *Client) handle_pass() {
+ if c.room == nil {
+ return
+ }
+
+ c.room.pass <- c
+}
+
+func (c *Client) handle_tribute_give(msg *protocol.Message) {
+ if c.room == nil {
+ return
+ }
+
+ payload_bytes, _ := json.Marshal(msg.Payload)
+ var payload protocol.Tribute_Give_Payload
+ json.Unmarshal(payload_bytes, &payload)
+
+ c.room.tribute <- Tribute_Action{
+ client: c,
+ card_id: payload.Card_Id,
+ }
+}
+
+func (c *Client) send_message(msg *protocol.Message) {
+ data, err := json.Marshal(msg)
+ if err != nil {
+ return
+ }
+
+ select {
+ case c.send <- data:
+ default:
+ }
+}
+
+func (c *Client) send_error(message string) {
+ c.send_message(&protocol.Message{
+ Type: protocol.Msg_Error,
+ Payload: protocol.Error_Payload{
+ Message: message,
+ },
+ })
+}
diff --git a/server/room/hub.go b/server/room/hub.go
new file mode 100644
index 0000000..6e3c803
--- /dev/null
+++ b/server/room/hub.go
@@ -0,0 +1,96 @@
+package room
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "github.com/gorilla/websocket"
+ "net/http"
+ "sync"
+)
+
+type Hub struct {
+ rooms map[string]*Room
+ register chan *Client
+ unregister chan *Client
+ mu sync.RWMutex
+}
+
+var upgrader = websocket.Upgrader{
+ ReadBufferSize: 1024,
+ WriteBufferSize: 1024,
+ CheckOrigin: func(r *http.Request) bool {
+ return true
+ },
+}
+
+func New_Hub() *Hub {
+ return &Hub{
+ rooms: make(map[string]*Room),
+ register: make(chan *Client),
+ unregister: make(chan *Client),
+ }
+}
+
+func (h *Hub) Run() {
+ for {
+ select {
+ case client := <-h.register:
+ _ = client
+ case client := <-h.unregister:
+ if client.room != nil {
+ client.room.leave <- client
+ }
+ }
+ }
+}
+
+func (h *Hub) Handle_Websocket(w http.ResponseWriter, r *http.Request) {
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ return
+ }
+
+ client := new_client(generate_id(), conn)
+
+ h.register <- client
+
+ go client.write_pump()
+ go client.read_pump(h)
+}
+
+func (h *Hub) create_room() *Room {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
+ room := new_room(generate_room_code())
+ h.rooms[room.id] = room
+ go room.run()
+
+ return room
+}
+
+func (h *Hub) get_room(id string) *Room {
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+
+ return h.rooms[id]
+}
+
+func (h *Hub) delete_room(id string) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
+ delete(h.rooms, id)
+}
+
+func generate_id() string {
+ b := make([]byte, 16)
+ rand.Read(b)
+ return hex.EncodeToString(b)
+}
+
+func generate_room_code() string {
+ b := make([]byte, 3)
+ rand.Read(b)
+ return hex.EncodeToString(b)
+}
diff --git a/server/room/room.go b/server/room/room.go
new file mode 100644
index 0000000..69e370a
--- /dev/null
+++ b/server/room/room.go
@@ -0,0 +1,526 @@
+package room
+
+import (
+ "guandanbtw/game"
+ "guandanbtw/protocol"
+)
+
+type Play_Action struct {
+ client *Client
+ card_ids []int
+}
+
+type Tribute_Action struct {
+ client *Client
+ card_id int
+}
+
+type Room struct {
+ id string
+ clients [4]*Client
+ game *game.Game_State
+ join chan *Client
+ leave chan *Client
+ play chan Play_Action
+ pass chan *Client
+ tribute chan Tribute_Action
+}
+
+func new_room(id string) *Room {
+ return &Room{
+ id: id,
+ join: make(chan *Client),
+ leave: make(chan *Client),
+ play: make(chan Play_Action),
+ pass: make(chan *Client),
+ tribute: make(chan Tribute_Action),
+ }
+}
+
+func (r *Room) run() {
+ for {
+ select {
+ case client := <-r.join:
+ r.handle_join(client)
+ case client := <-r.leave:
+ r.handle_leave(client)
+ case action := <-r.play:
+ r.handle_play(action)
+ case client := <-r.pass:
+ r.handle_pass(client)
+ case action := <-r.tribute:
+ r.handle_tribute(action)
+ }
+ }
+}
+
+func (r *Room) handle_join(client *Client) {
+ seat := r.find_empty_seat()
+ if seat == -1 {
+ client.send_error("room is full")
+ return
+ }
+
+ r.clients[seat] = client
+ client.room = r
+
+ r.broadcast_room_state()
+
+ if r.is_full() {
+ r.start_game()
+ }
+}
+
+func (r *Room) handle_leave(client *Client) {
+ for i := range 4 {
+ if r.clients[i] == client {
+ r.clients[i] = nil
+ break
+ }
+ }
+ client.room = nil
+
+ r.broadcast(&protocol.Message{
+ Type: protocol.Msg_Player_Left,
+ Payload: protocol.Player_Info{
+ Id: client.id,
+ Name: client.name,
+ },
+ })
+
+ r.broadcast_room_state()
+}
+
+func (r *Room) handle_play(action Play_Action) {
+ if r.game == nil {
+ return
+ }
+
+ seat := r.get_seat(action.client)
+ if seat == -1 || seat != r.game.Current_Turn {
+ action.client.send_error("not your turn")
+ return
+ }
+
+ cards := r.game.Get_Cards_By_Id(seat, action.card_ids)
+ if cards == nil {
+ action.client.send_error("invalid cards")
+ return
+ }
+
+ combo := game.Detect_Combination(cards, r.game.Level)
+ if combo.Type == game.Comb_Invalid {
+ action.client.send_error("invalid combination")
+ return
+ }
+
+ if r.game.Current_Lead.Type != game.Comb_Invalid {
+ if !game.Can_Beat(combo, r.game.Current_Lead) {
+ action.client.send_error("cannot beat current play")
+ return
+ }
+ }
+
+ r.game.Remove_Cards(seat, action.card_ids)
+ r.game.Current_Lead = combo
+ r.game.Lead_Player = seat
+ r.game.Pass_Count = 0
+
+ r.broadcast(&protocol.Message{
+ Type: protocol.Msg_Play_Made,
+ Payload: protocol.Play_Made_Payload{
+ Player_Id: action.client.id,
+ Seat: seat,
+ Cards: cards,
+ Combo_Type: combo_type_name(combo.Type),
+ Is_Pass: false,
+ },
+ })
+
+ if len(r.game.Hands[seat]) == 0 {
+ r.game.Finish_Order = append(r.game.Finish_Order, seat)
+ if r.check_hand_end() {
+ return
+ }
+ }
+
+ r.advance_turn()
+}
+
+func (r *Room) handle_pass(client *Client) {
+ if r.game == nil {
+ return
+ }
+
+ seat := r.get_seat(client)
+ if seat == -1 || seat != r.game.Current_Turn {
+ client.send_error("not your turn")
+ return
+ }
+
+ if r.game.Current_Lead.Type == game.Comb_Invalid {
+ client.send_error("cannot pass when leading")
+ return
+ }
+
+ r.game.Pass_Count++
+
+ r.broadcast(&protocol.Message{
+ Type: protocol.Msg_Play_Made,
+ Payload: protocol.Play_Made_Payload{
+ Player_Id: client.id,
+ Seat: seat,
+ Is_Pass: true,
+ },
+ })
+
+ if r.game.Pass_Count >= 3 {
+ r.game.Current_Lead = game.Combination{Type: game.Comb_Invalid}
+ r.game.Current_Turn = r.game.Lead_Player
+ r.game.Pass_Count = 0
+ r.send_turn_notification()
+ return
+ }
+
+ r.advance_turn()
+}
+
+func (r *Room) handle_tribute(action Tribute_Action) {
+ if r.game == nil || r.game.Phase != game.Phase_Tribute {
+ return
+ }
+
+ seat := r.get_seat(action.client)
+ tribute_info := r.game.Get_Tribute_Info(seat)
+ if tribute_info == nil {
+ action.client.send_error("you don't need to give tribute")
+ return
+ }
+
+ card := r.game.Get_Card_By_Id(seat, action.card_id)
+ if card == nil {
+ action.client.send_error("invalid card")
+ return
+ }
+
+ if game.Is_Wild(*card, r.game.Level) {
+ action.client.send_error("cannot tribute wild cards")
+ return
+ }
+
+ r.game.Remove_Cards(seat, []int{action.card_id})
+ r.game.Hands[tribute_info.To_Seat] = append(r.game.Hands[tribute_info.To_Seat], *card)
+
+ if r.clients[tribute_info.To_Seat] != nil {
+ r.clients[tribute_info.To_Seat].send_message(&protocol.Message{
+ Type: protocol.Msg_Tribute_Recv,
+ Payload: protocol.Tribute_Recv_Payload{
+ Card: *card,
+ },
+ })
+ }
+
+ r.game.Mark_Tribute_Done(seat)
+
+ if r.game.All_Tributes_Done() {
+ r.game.Phase = game.Phase_Play
+ r.game.Current_Turn = r.game.Tribute_Leader
+ r.send_turn_notification()
+ }
+}
+
+func (r *Room) start_game() {
+ r.game = game.New_Game_State()
+
+ deck := game.New_Deck()
+ deck.Shuffle()
+ hands := deck.Deal()
+
+ for i := 0; i < 4; i++ {
+ r.game.Hands[i] = hands[i]
+ }
+
+ for i := 0; i < 4; i++ {
+ if r.clients[i] != nil {
+ r.clients[i].send_message(&protocol.Message{
+ Type: protocol.Msg_Deal_Cards,
+ Payload: protocol.Deal_Cards_Payload{
+ Cards: r.game.Hands[i],
+ Level: r.game.Level,
+ },
+ })
+ }
+ }
+
+ r.game.Phase = game.Phase_Play
+ r.game.Current_Turn = 0
+ r.send_turn_notification()
+}
+
+func (r *Room) check_hand_end() bool {
+ if len(r.game.Finish_Order) < 2 {
+ return false
+ }
+
+ first := r.game.Finish_Order[0]
+ second := r.game.Finish_Order[1]
+
+ first_team := first % 2
+ second_team := second % 2
+
+ if first_team == second_team {
+ r.end_hand(first_team, r.calculate_level_advance())
+ return true
+ }
+
+ if len(r.game.Finish_Order) >= 3 {
+ third := r.game.Finish_Order[2]
+ third_team := third % 2
+
+ winning_team := first_team
+ r.end_hand(winning_team, r.calculate_level_advance())
+ return true
+ _ = third_team
+ }
+
+ return false
+}
+
+func (r *Room) calculate_level_advance() int {
+ if len(r.game.Finish_Order) < 2 {
+ return 0
+ }
+
+ first := r.game.Finish_Order[0]
+ first_team := first % 2
+
+ for i, seat := range r.game.Finish_Order {
+ if seat%2 != first_team {
+ partner_pos := -1
+ for j, s := range r.game.Finish_Order {
+ if s%2 == first_team && j != 0 {
+ partner_pos = j
+ break
+ }
+ }
+
+ if partner_pos == -1 {
+ partner_pos = 3
+ }
+
+ switch {
+ case i == 3 && partner_pos == 1:
+ return 4
+ case i == 2 && partner_pos == 1:
+ return 2
+ default:
+ return 1
+ }
+ }
+ }
+
+ return 4
+}
+
+func (r *Room) end_hand(winning_team int, level_advance int) {
+ old_level := r.game.Team_Levels[winning_team]
+ new_level := old_level + level_advance
+ if new_level > 12 {
+ new_level = 12
+ }
+ r.game.Team_Levels[winning_team] = new_level
+
+ r.broadcast(&protocol.Message{
+ Type: protocol.Msg_Hand_End,
+ Payload: protocol.Hand_End_Payload{
+ Finish_Order: seats_to_ids(r.game.Finish_Order, r.clients),
+ Winning_Team: winning_team,
+ Level_Advance: level_advance,
+ New_Levels: r.game.Team_Levels,
+ },
+ })
+
+ if new_level >= 12 && game.Rank(old_level) == game.Rank_Ace {
+ r.broadcast(&protocol.Message{
+ Type: protocol.Msg_Game_End,
+ Payload: protocol.Game_End_Payload{
+ Winning_Team: winning_team,
+ Final_Levels: r.game.Team_Levels,
+ },
+ })
+ return
+ }
+
+ r.setup_tribute()
+}
+
+func (r *Room) setup_tribute() {
+ r.game.Setup_Tributes()
+
+ if len(r.game.Tributes) == 0 {
+ r.start_new_hand()
+ return
+ }
+
+ for _, t := range r.game.Tributes {
+ if r.clients[t.From_Seat] != nil {
+ r.clients[t.From_Seat].send_message(&protocol.Message{
+ Type: protocol.Msg_Tribute,
+ Payload: protocol.Tribute_Payload{
+ From_Seat: t.From_Seat,
+ To_Seat: t.To_Seat,
+ },
+ })
+ }
+ }
+
+ r.game.Phase = game.Phase_Tribute
+}
+
+func (r *Room) start_new_hand() {
+ r.game.Reset_Hand()
+
+ deck := game.New_Deck()
+ deck.Shuffle()
+ hands := deck.Deal()
+
+ for i := 0; i < 4; i++ {
+ r.game.Hands[i] = hands[i]
+ }
+
+ for i := 0; i < 4; i++ {
+ if r.clients[i] != nil {
+ r.clients[i].send_message(&protocol.Message{
+ Type: protocol.Msg_Deal_Cards,
+ Payload: protocol.Deal_Cards_Payload{
+ Cards: r.game.Hands[i],
+ Level: r.game.Level,
+ },
+ })
+ }
+ }
+
+ r.game.Phase = game.Phase_Play
+ r.game.Current_Turn = r.game.Tribute_Leader
+ r.send_turn_notification()
+}
+
+func (r *Room) advance_turn() {
+ for i := 1; i <= 4; i++ {
+ next := (r.game.Current_Turn + i) % 4
+ if !r.is_finished(next) {
+ r.game.Current_Turn = next
+ r.send_turn_notification()
+ return
+ }
+ }
+}
+
+func (r *Room) is_finished(seat int) bool {
+ for _, s := range r.game.Finish_Order {
+ if s == seat {
+ return true
+ }
+ }
+ return false
+}
+
+func (r *Room) send_turn_notification() {
+ can_pass := r.game.Current_Lead.Type != game.Comb_Invalid
+
+ r.broadcast(&protocol.Message{
+ Type: protocol.Msg_Turn,
+ Payload: protocol.Turn_Payload{
+ Player_Id: r.clients[r.game.Current_Turn].id,
+ Seat: r.game.Current_Turn,
+ Can_Pass: can_pass,
+ },
+ })
+}
+
+func (r *Room) broadcast(msg *protocol.Message) {
+ for _, client := range r.clients {
+ if client != nil {
+ client.send_message(msg)
+ }
+ }
+}
+
+func (r *Room) broadcast_room_state() {
+ players := make([]protocol.Player_Info, 0)
+ for i, c := range r.clients {
+ if c != nil {
+ players = append(players, protocol.Player_Info{
+ Id: c.id,
+ Name: c.name,
+ Seat: i,
+ Team: i % 2,
+ })
+ }
+ }
+
+ for _, client := range r.clients {
+ if client != nil {
+ client.send_message(&protocol.Message{
+ Type: protocol.Msg_Room_State,
+ Payload: protocol.Room_State_Payload{
+ Room_Id: r.id,
+ Players: players,
+ Game_Active: r.game != nil,
+ Your_Id: client.id,
+ },
+ })
+ }
+ }
+}
+
+func (r *Room) find_empty_seat() int {
+ for i := 0; i < 4; i++ {
+ if r.clients[i] == nil {
+ return i
+ }
+ }
+ return -1
+}
+
+func (r *Room) is_full() bool {
+ for _, c := range r.clients {
+ if c == nil {
+ return false
+ }
+ }
+ return true
+}
+
+func (r *Room) get_seat(client *Client) int {
+ for i, c := range r.clients {
+ if c == client {
+ return i
+ }
+ }
+ return -1
+}
+
+func combo_type_name(t game.Combination_Type) string {
+ names := map[game.Combination_Type]string{
+ game.Comb_Single: "single",
+ game.Comb_Pair: "pair",
+ game.Comb_Triple: "triple",
+ game.Comb_Full_House: "full_house",
+ game.Comb_Straight: "straight",
+ game.Comb_Tube: "tube",
+ game.Comb_Plate: "plate",
+ game.Comb_Bomb: "bomb",
+ }
+ return names[t]
+}
+
+func seats_to_ids(seats []int, clients [4]*Client) []string {
+ ids := make([]string, len(seats))
+ for i, seat := range seats {
+ if clients[seat] != nil {
+ ids[i] = clients[seat].id
+ }
+ }
+ return ids
+}