diff --git a/package-lock.json b/package-lock.json
index 709ee805..1dae521c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -35,6 +35,7 @@
},
"devDependencies": {
"@types/express": "^5.0.0",
+ "@types/livereload": "^0.9.5",
"@types/node": "^20.4.4",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
@@ -46,6 +47,7 @@
"eslint-import-resolver-typescript": "^3.6.3",
"eslint-plugin-import": "^2.31.0",
"husky": "^8.0.3",
+ "livereload": "^0.9.3",
"prettier": "^3.3.3"
}
},
@@ -533,25 +535,6 @@
"@parcel/watcher-win32-x64": "2.5.1"
}
},
- "node_modules/@parcel/watcher-android-arm64": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
- "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
- "cpu": [
- "arm64"
- ],
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
@@ -571,215 +554,6 @@
"url": "https://opencollective.com/parcel"
}
},
- "node_modules/@parcel/watcher-darwin-x64": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
- "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-freebsd-x64": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
- "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-linux-arm-glibc": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
- "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
- "cpu": [
- "arm"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-linux-arm-musl": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
- "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
- "cpu": [
- "arm"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-linux-arm64-glibc": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
- "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
- "cpu": [
- "arm64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-linux-arm64-musl": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
- "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
- "cpu": [
- "arm64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-linux-x64-glibc": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
- "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-linux-x64-musl": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
- "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-win32-arm64": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
- "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
- "cpu": [
- "arm64"
- ],
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-win32-ia32": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
- "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
- "cpu": [
- "ia32"
- ],
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-win32-x64": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
- "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -853,151 +627,16 @@
}
}
},
- "node_modules/@swc/core-darwin-arm64": {
- "version": "1.9.2",
- "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.9.2.tgz",
- "integrity": "sha512-nETmsCoY29krTF2PtspEgicb3tqw7Ci5sInTI03EU5zpqYbPjoPH99BVTjj0OsF53jP5MxwnLI5Hm21lUn1d6A==",
- "cpu": [
- "arm64"
- ],
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@swc/core-darwin-x64": {
- "version": "1.9.2",
- "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.9.2.tgz",
- "integrity": "sha512-9gD+bwBz8ZByjP6nZTXe/hzd0tySIAjpDHgkFiUrc+5zGF+rdTwhcNrzxNHJmy6mw+PW38jqII4uspFHUqqxuQ==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@swc/core-linux-arm-gnueabihf": {
- "version": "1.9.2",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.9.2.tgz",
- "integrity": "sha512-kYq8ief1Qrn+WmsTWAYo4r+Coul4dXN6cLFjiPZ29Cv5pyU+GFvSPAB4bEdMzwy99rCR0u2P10UExaeCjurjvg==",
- "cpu": [
- "arm"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@swc/core-linux-arm64-gnu": {
- "version": "1.9.2",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.9.2.tgz",
- "integrity": "sha512-n0W4XiXlmEIVqxt+rD3ZpkogsEWUk1jJ+i5bQNgB+1JuWh0fBE8c/blDgTQXa0GB5lTPVDZQussgdNOCnAZwiA==",
- "cpu": [
- "arm64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@swc/core-linux-arm64-musl": {
- "version": "1.9.2",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.9.2.tgz",
- "integrity": "sha512-8xzrOmsyCC1zrx2Wzx/h8dVsdewO1oMCwBTLc1gSJ/YllZYTb04pNm6NsVbzUX2tKddJVRgSJXV10j/NECLwpA==",
- "cpu": [
- "arm64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@swc/core-linux-x64-gnu": {
- "version": "1.9.2",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.9.2.tgz",
- "integrity": "sha512-kZrNz/PjRQKcchWF6W292jk3K44EoVu1ad5w+zbS4jekIAxsM8WwQ1kd+yjUlN9jFcF8XBat5NKIs9WphJCVXg==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@swc/core-linux-x64-musl": {
- "version": "1.9.2",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.9.2.tgz",
- "integrity": "sha512-TTIpR4rjMkhX1lnFR+PSXpaL83TrQzp9znRdp2TzYrODlUd/R20zOwSo9vFLCyH6ZoD47bccY7QeGZDYT3nlRg==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@swc/core-win32-arm64-msvc": {
- "version": "1.9.2",
- "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.9.2.tgz",
- "integrity": "sha512-+Eg2d4icItKC0PMjZxH7cSYFLWk0aIp94LNmOw6tPq0e69ax6oh10upeq0D1fjWsKLmOJAWEvnXlayZcijEXDw==",
- "cpu": [
- "arm64"
- ],
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@swc/core-win32-ia32-msvc": {
- "version": "1.9.2",
- "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.9.2.tgz",
- "integrity": "sha512-nLWBi4vZDdM/LkiQmPCakof8Dh1/t5EM7eudue04V1lIcqx9YHVRS3KMwEaCoHLGg0c312Wm4YgrWQd9vwZ5zQ==",
- "cpu": [
- "ia32"
- ],
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@swc/core-win32-x64-msvc": {
+ "node_modules/@swc/core-darwin-arm64": {
"version": "1.9.2",
- "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.9.2.tgz",
- "integrity": "sha512-ik/k+JjRJBFkXARukdU82tSVx0CbExFQoQ78qTO682esbYXzjdB5eLVkoUbwen299pnfr88Kn4kyIqFPTje8Xw==",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.9.2.tgz",
+ "integrity": "sha512-nETmsCoY29krTF2PtspEgicb3tqw7Ci5sInTI03EU5zpqYbPjoPH99BVTjj0OsF53jP5MxwnLI5Hm21lUn1d6A==",
"cpu": [
- "x64"
+ "arm64"
],
"optional": true,
"os": [
- "win32"
+ "darwin"
],
"engines": {
"node": ">=10"
@@ -1065,21 +704,6 @@
"@tailwindcss/oxide-win32-x64-msvc": "4.0.8"
}
},
- "node_modules/@tailwindcss/oxide-android-arm64": {
- "version": "4.0.8",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.8.tgz",
- "integrity": "sha512-We7K79+Sm4mwJHk26Yzu/GAj7C7myemm7PeXvpgMxyxO70SSFSL3uCcqFbz9JA5M5UPkrl7N9fkBe/Y0iazqpA==",
- "cpu": [
- "arm64"
- ],
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.8.tgz",
@@ -1095,141 +719,6 @@
"node": ">= 10"
}
},
- "node_modules/@tailwindcss/oxide-darwin-x64": {
- "version": "4.0.8",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.8.tgz",
- "integrity": "sha512-fWfywfYIlSWtKoqWTjukTHLWV3ARaBRjXCC2Eo0l6KVpaqGY4c2y8snUjp1xpxUtpqwMvCvFWFaleMoz1Vhzlw==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@tailwindcss/oxide-freebsd-x64": {
- "version": "4.0.8",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.8.tgz",
- "integrity": "sha512-SO+dyvjJV9G94bnmq2288Ke0BIdvrbSbvtPLaQdqjqHR83v5L2fWADyFO+1oecHo9Owsk8MxcXh1agGVPIKIqw==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
- "version": "4.0.8",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.8.tgz",
- "integrity": "sha512-ZSHggWiEblQNV69V0qUK5vuAtHP+I+S2eGrKGJ5lPgwgJeAd6GjLsVBN+Mqn2SPVfYM3BOpS9jX/zVg9RWQVDQ==",
- "cpu": [
- "arm"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
- "version": "4.0.8",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.8.tgz",
- "integrity": "sha512-xWpr6M0OZLDNsr7+bQz+3X7zcnDJZJ1N9gtBWCtfhkEtDjjxYEp+Lr5L5nc/yXlL4MyCHnn0uonGVXy3fhxaVA==",
- "cpu": [
- "arm64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
- "version": "4.0.8",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.8.tgz",
- "integrity": "sha512-5tz2IL7LN58ssGEq7h/staD7pu/izF/KeMWdlJ86WDe2Ah46LF3ET6ZGKTr5eZMrnEA0M9cVFuSPprKRHNgjeg==",
- "cpu": [
- "arm64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
- "version": "4.0.8",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.8.tgz",
- "integrity": "sha512-KSzMkhyrxAQyY2o194NKVKU9j/c+NFSoMvnHWFaNHKi3P1lb+Vq1UC19tLHrmxSkKapcMMu69D7+G1+FVGNDXQ==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@tailwindcss/oxide-linux-x64-musl": {
- "version": "4.0.8",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.8.tgz",
- "integrity": "sha512-yFYKG5UtHTRimjtqxUWXBgI4Tc6NJe3USjRIVdlTczpLRxq/SFwgzGl5JbatCxgSRDPBFwRrNPxq+ukfQFGdrw==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
- "version": "4.0.8",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.8.tgz",
- "integrity": "sha512-tndGujmCSba85cRCnQzXgpA2jx5gXimyspsUYae5jlPyLRG0RjXbDshFKOheVXU4TLflo7FSG8EHCBJ0EHTKdQ==",
- "cpu": [
- "arm64"
- ],
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
- "version": "4.0.8",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.8.tgz",
- "integrity": "sha512-T77jroAc0p4EHVVgTUiNeFn6Nj3jtD3IeNId2X+0k+N1XxfNipy81BEkYErpKLiOkNhpNFjPee8/ZVas29b2OQ==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
"node_modules/@tsconfig/node10": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
@@ -1333,6 +822,16 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true
},
+ "node_modules/@types/livereload": {
+ "version": "0.9.5",
+ "resolved": "https://registry.npmjs.org/@types/livereload/-/livereload-0.9.5.tgz",
+ "integrity": "sha512-2RXcRKdivPmn67pwjytvHoRv46AeXaLYVUWA0zkel1XSAOH5i71G0KfUdE5u3g80T155gR3Fo3ilVaqparLsVA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/ws": "*"
+ }
+ },
"node_modules/@types/lodash": {
"version": "4.17.20",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz",
@@ -4944,175 +4443,52 @@
"url": "https://opencollective.com/parcel"
}
},
- "node_modules/lightningcss-darwin-x64": {
- "version": "1.29.1",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.1.tgz",
- "integrity": "sha512-k33G9IzKUpHy/J/3+9MCO4e+PzaFblsgBjSGlpAaFikeBFm8B/CkO3cKU9oI4g+fjS2KlkLM/Bza9K/aw8wsNA==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-freebsd-x64": {
- "version": "1.29.1",
- "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.1.tgz",
- "integrity": "sha512-0SUW22fv/8kln2LnIdOCmSuXnxgxVC276W5KLTwoehiO0hxkacBxjHOL5EtHD8BAXg2BvuhsJPmVMasvby3LiQ==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm-gnueabihf": {
- "version": "1.29.1",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.1.tgz",
- "integrity": "sha512-sD32pFvlR0kDlqsOZmYqH/68SqUMPNj+0pucGxToXZi4XZgZmqeX/NkxNKCPsswAXU3UeYgDSpGhu05eAufjDg==",
- "cpu": [
- "arm"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm64-gnu": {
- "version": "1.29.1",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.1.tgz",
- "integrity": "sha512-0+vClRIZ6mmJl/dxGuRsE197o1HDEeeRk6nzycSy2GofC2JsY4ifCRnvUWf/CUBQmlrvMzt6SMQNMSEu22csWQ==",
- "cpu": [
- "arm64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
+ "node_modules/livereload": {
+ "version": "0.9.3",
+ "resolved": "https://registry.npmjs.org/livereload/-/livereload-0.9.3.tgz",
+ "integrity": "sha512-q7Z71n3i4X0R9xthAryBdNGVGAO2R5X+/xXpmKeuPMrteg+W2U8VusTKV3YiJbXZwKsOlFlHe+go6uSNjfxrZw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chokidar": "^3.5.0",
+ "livereload-js": "^3.3.1",
+ "opts": ">= 1.2.0",
+ "ws": "^7.4.3"
},
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm64-musl": {
- "version": "1.29.1",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.1.tgz",
- "integrity": "sha512-UKMFrG4rL/uHNgelBsDwJcBqVpzNJbzsKkbI3Ja5fg00sgQnHw/VrzUTEc4jhZ+AN2BvQYz/tkHu4vt1kLuJyw==",
- "cpu": [
- "arm64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
+ "bin": {
+ "livereload": "bin/livereload.js"
},
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-x64-gnu": {
- "version": "1.29.1",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.1.tgz",
- "integrity": "sha512-u1S+xdODy/eEtjADqirA774y3jLcm8RPtYztwReEXoZKdzgsHYPl0s5V52Tst+GKzqjebkULT86XMSxejzfISw==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
"engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
+ "node": ">=8.0.0"
}
},
- "node_modules/lightningcss-linux-x64-musl": {
- "version": "1.29.1",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.1.tgz",
- "integrity": "sha512-L0Tx0DtaNUTzXv0lbGCLB/c/qEADanHbu4QdcNOXLIe1i8i22rZRpbT3gpWYsCh9aSL9zFujY/WmEXIatWvXbw==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
+ "node_modules/livereload-js": {
+ "version": "3.4.1",
+ "resolved": "https://registry.npmjs.org/livereload-js/-/livereload-js-3.4.1.tgz",
+ "integrity": "sha512-5MP0uUeVCec89ZbNOT/i97Mc+q3SxXmiUGhRFOTmhrGPn//uWVQdCvcLJDy64MSBR5MidFdOR7B9viumoavy6g==",
+ "dev": true,
+ "license": "MIT"
},
- "node_modules/lightningcss-win32-arm64-msvc": {
- "version": "1.29.1",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.1.tgz",
- "integrity": "sha512-QoOVnkIEFfbW4xPi+dpdft/zAKmgLgsRHfJalEPYuJDOWf7cLQzYg0DEh8/sn737FaeMJxHZRc1oBreiwZCjog==",
- "cpu": [
- "arm64"
- ],
- "optional": true,
- "os": [
- "win32"
- ],
+ "node_modules/livereload/node_modules/ws": {
+ "version": "7.5.10",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
+ "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
+ "dev": true,
+ "license": "MIT",
"engines": {
- "node": ">= 12.0.0"
+ "node": ">=8.3.0"
},
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-win32-x64-msvc": {
- "version": "1.29.1",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.1.tgz",
- "integrity": "sha512-NygcbThNBe4JElP+olyTI/doBNGJvLs3bFCRPdvuCcxZCcCZ71B858IHpdm7L1btZex0FvCmM17FK98Y9MRy1Q==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 12.0.0"
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": "^5.0.2"
},
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
}
},
"node_modules/loader-runner": {
@@ -5577,6 +4953,13 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/opts": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/opts/-/opts-2.0.2.tgz",
+ "integrity": "sha512-k41FwbcLnlgnFh69f4qdUfvDQ+5vaSDnVPFI/y5XuhKRq97EnVVneO9F1ESVCdiVu4fCS2L8usX3mU331hB7pg==",
+ "dev": true,
+ "license": "BSD-2-Clause"
+ },
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
diff --git a/package.json b/package.json
index 9388dc69..945b6e2c 100644
--- a/package.json
+++ b/package.json
@@ -4,6 +4,7 @@
"description": "A chatbot for Pokémon Showdown",
"main": "src/index.js",
"scripts": {
+ "debug": "./scripts/debug-games/start.sh",
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix",
"notify-unpushed": "sh scripts/notify_unpushed.sh",
@@ -47,6 +48,7 @@
},
"devDependencies": {
"@types/express": "^5.0.0",
+ "@types/livereload": "^0.9.5",
"@types/node": "^20.4.4",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
@@ -58,6 +60,7 @@
"eslint-import-resolver-typescript": "^3.6.3",
"eslint-plugin-import": "^2.31.0",
"husky": "^8.0.3",
+ "livereload": "^0.9.3",
"prettier": "^3.3.3"
}
}
diff --git a/scripts/debug-games/live/.gitignore b/scripts/debug-games/live/.gitignore
new file mode 100644
index 00000000..2d19fc76
--- /dev/null
+++ b/scripts/debug-games/live/.gitignore
@@ -0,0 +1 @@
+*.html
diff --git a/scripts/debug-games/start.sh b/scripts/debug-games/start.sh
new file mode 100755
index 00000000..db1e3352
--- /dev/null
+++ b/scripts/debug-games/start.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+
+npx http-server src/web -p 8080 &
+USE_WEB=false WEB_PORT=8080 WEB_URL=http://localhost:8080 npx ts-node --project tsconfig.debug.json src/ps/games/debug.ts &
+npx http-server scripts/debug-games/live -p 8081 -o page.html
diff --git a/scripts/debug-games/templates/page.html b/scripts/debug-games/templates/page.html
new file mode 100644
index 00000000..f1910c69
--- /dev/null
+++ b/scripts/debug-games/templates/page.html
@@ -0,0 +1,73 @@
+
+
+
+
+ Size: Desktop
+
+
+
+
+
+
+ Desktop
+ Mobile
+ Ruka
+
+ {HTML}
+
+
+
+
diff --git a/src/globals/index.ts b/src/globals/index.ts
index 9cbd9d2f..7ac5094f 100644
--- a/src/globals/index.ts
+++ b/src/globals/index.ts
@@ -1,2 +1,3 @@
+import '@/globals/augment';
import '@/globals/patches';
import '@/globals/prototypes';
diff --git a/src/globals/prototypes.ts b/src/globals/prototypes.ts
index 61660ecc..bfd96682 100644
--- a/src/globals/prototypes.ts
+++ b/src/globals/prototypes.ts
@@ -10,6 +10,10 @@ declare global {
count(): Record;
count(map: true): Map;
group(size: number): T[][];
+ groupBy(classification: (element: T) => Key): Partial>;
+ /**
+ * filterMap runs map. Only results that are NOT exactly 'undefined' are returned.
+ */
filterMap(cb: (element: T, index: number, thisArray: T[]) => X | undefined): X[];
list($T?: TranslationFn | string): string;
random(rng?: RNGSource): T | null;
@@ -19,26 +23,37 @@ declare global {
/** Default order is ascending */
sortBy(getSort: ((term: T, thisArray: T[]) => unknown) | null, dir?: 'asc' | 'desc'): T[];
space(spacer: S): (T | S)[];
- sum(): T;
+ sum(): T extends number ? number : never;
unique(): T[];
}
interface ReadonlyArray {
access>(pos: number[]): V;
count(): Record;
count(map: true): Map;
+ /**
+ * filterMap runs map. Only results that are NOT exactly 'undefined' are returned.
+ */
filterMap(cb: (element: T, index: number, thisArray: T[]) => X | undefined): X[];
group(size: number): T[][];
+ groupBy(classification: (element: T) => Key): Partial>;
list($T?: TranslationFn): string;
random(rng?: RNGSource): T | null;
sample(amount: number, rng?: RNGSource): T[];
space(spacer: S): (T | S)[];
- sum(): T;
+ sum(): T extends number ? number : never;
unique(): T[];
}
interface String {
gsub(match: RegExp, replace: string | ((arg: string, ...captures: string[]) => string)): string;
- lazySplit(match: string | RegExp, cases: number): string[];
+
+ /**
+ * Split the string exactly as many times as needed.
+ * @param matcher Pattern/string to split by.
+ * @param cases Number of cases to split.
+ * @example 'a b c'.lazySplit(' ', 1); // ['a', 'bc']
+ */
+ lazySplit(matcher: string | RegExp, cases: number): string[];
}
interface Number {
@@ -119,6 +134,19 @@ Object.defineProperties(Array.prototype, {
return [];
},
},
+ groupBy: {
+ enumerable: false,
+ writable: false,
+ configurable: false,
+ value: function (this: T[], classification: (element: T) => Key): Partial> {
+ return this.reduce>>((acc, current) => {
+ const key = classification(current);
+ if (acc[key]) acc[key].push(current);
+ else acc[key] = [current];
+ return acc;
+ }, {});
+ },
+ },
list: {
enumerable: false,
writable: false,
diff --git a/src/ps/games/battleship/index.ts b/src/ps/games/battleship/index.ts
index f9af6a17..5ee9fd82 100644
--- a/src/ps/games/battleship/index.ts
+++ b/src/ps/games/battleship/index.ts
@@ -79,7 +79,7 @@ export class Battleship extends BaseGame {
this.room.sendHTML(...renderMove(logEntry, this));
if (this.state.ready.A === true && this.state.ready.B === true) {
this.state.allReady = true;
- this.nextPlayer();
+ this.endTurn();
} else {
this.update(player.id);
}
@@ -122,7 +122,7 @@ export class Battleship extends BaseGame {
this.winCtx = { type: 'win', winner: player, loser: this.players[opponent] };
this.end();
}
- this.nextPlayer();
+ this.endTurn();
this.update();
break;
}
diff --git a/src/ps/games/chess/index.ts b/src/ps/games/chess/index.ts
index 5fd9deeb..3a1dfcf1 100644
--- a/src/ps/games/chess/index.ts
+++ b/src/ps/games/chess/index.ts
@@ -112,7 +112,7 @@ export class Chess extends BaseGame {
this.cleanup();
this.state.pgn = this.lib.pgn();
- this.nextPlayer();
+ this.endTurn();
}
// Cleans up stuff like selections and draw offers
diff --git a/src/ps/games/connectfour/index.ts b/src/ps/games/connectfour/index.ts
index 7366ae5e..3deab1bf 100644
--- a/src/ps/games/connectfour/index.ts
+++ b/src/ps/games/connectfour/index.ts
@@ -57,7 +57,7 @@ export class ConnectFour extends BaseGame {
this.end();
return true;
}
- this.nextPlayer();
+ this.endTurn();
return board;
}
diff --git a/src/ps/games/debug.ts b/src/ps/games/debug.ts
new file mode 100644
index 00000000..7c2094c4
--- /dev/null
+++ b/src/ps/games/debug.ts
@@ -0,0 +1,40 @@
+/* eslint-disable no-console -- This isn't part of the bot */
+import 'dotenv/config';
+
+import { watch } from 'chokidar';
+import { promises as fs } from 'fs';
+import { createServer } from 'livereload';
+import path from 'path';
+
+import { debounce } from '@/utils/debounce';
+import { fsPath } from '@/utils/fsPath';
+
+const debugDir = fsPath('..', 'scripts', 'debug-games');
+
+async function baseRenderTemplates(): Promise {
+ const { test } = await import('@/ps/games/test');
+ const html = await test();
+ console.log('Rendering templates!');
+ fs.readdir(path.join(debugDir, 'templates'))
+ .then(files => files.filter(file => file.endsWith('.html')))
+ .then(templates =>
+ Promise.all(
+ templates.map(async templateName => {
+ const template = await fs.readFile(path.join(debugDir, 'templates', templateName), 'utf8');
+ await fs.writeFile(path.join(debugDir, 'live', templateName), template.replace('{HTML}', html));
+ })
+ )
+ )
+ .then(() => console.log('Rendered templates.'));
+}
+
+const renderTemplates = debounce(baseRenderTemplates, 100);
+
+if (require.main === module) {
+ const watcher = watch([path.join(debugDir, 'templates'), fsPath('ps', 'games')]);
+ watcher.on('all', async () => {
+ renderTemplates();
+ });
+ const server = createServer({ port: 8082 });
+ server.watch(path.join(debugDir, 'live'));
+}
diff --git a/src/ps/games/game.ts b/src/ps/games/game.ts
index 61299444..b14c167d 100644
--- a/src/ps/games/game.ts
+++ b/src/ps/games/game.ts
@@ -97,7 +97,7 @@ export class BaseGame {
onAddPlayer?(user: User, ctx: string): ActionResponse;
onAfterAddPlayer?(player: Player): void;
- onLeavePlayer?(player: Player, ctx: string | User): ActionResponse;
+ onLeavePlayer?(player: Player, ctx: string | User): ActionResponse<'end' | null>;
onForfeitPlayer?(player: Player, ctx: string | User): ActionResponse;
onReplacePlayer?(turn: BaseState['turn'], withPlayer: User): ActionResponse;
onAfterReplacePlayer?(player: Player): void;
@@ -247,6 +247,7 @@ export class BaseGame {
}
backup(): void {
if (this.meta.players === 'single') return; // Don't back up single-player games
+ if (this.endedAt) return;
const backup = this.serialize();
gameCache.set({ id: this.id, room: this.roomid, game: this.meta.id, backup, at: Date.now() });
}
@@ -345,7 +346,7 @@ export class BaseGame {
cb: () => {
const playersLeft = Object.values(this.players).filter((player: Player) => !player.out);
if (playersLeft.length <= 1) this.end('dq');
- else if (this.turn === player.turn) this.nextPlayer(); // Needs to be run AFTER consumer has finished DQing
+ else if (this.turn === player.turn) this.endTurn(); // Needs to be run AFTER consumer has finished DQing
this.backup();
},
},
@@ -356,7 +357,12 @@ export class BaseGame {
delete this.players[player.turn];
return {
success: true,
- data: { message: this.$T(staffAction ? 'GAME.REMOVED' : 'GAME.LEFT', { player: player.name }) },
+ data: {
+ message: this.$T(staffAction ? 'GAME.REMOVED' : 'GAME.LEFT', { player: player.name }),
+ cb: () => {
+ if (removePlayer?.data === 'end') this.end('dq');
+ },
+ },
};
}
@@ -377,7 +383,7 @@ export class BaseGame {
this.players[newTurn] = { ...oldPlayer, ...assign, turn: newTurn };
if (!this.meta.turns) this.turns.splice(this.turns.indexOf(turn), 1, newTurn);
if (this.turn === turn) this.turn = newTurn;
- this.spectators.remove(oldPlayer.id);
+ this.spectators.remove(oldPlayer.id, withPlayer.id);
this.onAfterReplacePlayer?.(this.players[newTurn]);
this.backup();
return { success: true, data: this.$T('GAME.SUB', { in: withPlayer.name, out: oldPlayer.name }) };
@@ -410,7 +416,7 @@ export class BaseGame {
if (tryStart?.success === false) return tryStart;
this.started = true;
if (!this.turns.length) this.turns = Object.keys(this.players).shuffle(this.prng);
- this.nextPlayer();
+ this.endTurn();
this.startedAt = new Date();
this.setTimer('Game started');
this.onAfterStart?.();
@@ -425,7 +431,7 @@ export class BaseGame {
}
// Increments turn as needed and backs up state.
- nextPlayer(): State['turn'] | null {
+ endTurn(): State['turn'] | null {
let current = this.turn;
do {
current = this.getNext(current);
diff --git a/src/ps/games/index.ts b/src/ps/games/index.ts
index d03c387e..88794dde 100644
--- a/src/ps/games/index.ts
+++ b/src/ps/games/index.ts
@@ -6,6 +6,7 @@ import { Mastermind, meta as MastermindMeta } from '@/ps/games/mastermind';
import { Othello, meta as OthelloMeta } from '@/ps/games/othello';
import { Scrabble, meta as ScrabbleMeta } from '@/ps/games/scrabble';
import { SnakesLadders, meta as SnakesLaddersMeta } from '@/ps/games/snakesladders';
+import { Splendor, meta as SplendorMeta } from '@/ps/games/splendor';
import { GamesList, type Meta } from '@/ps/games/types';
export const Games = {
@@ -41,5 +42,9 @@ export const Games = {
meta: SnakesLaddersMeta,
instance: SnakesLadders,
},
+ [GamesList.Splendor]: {
+ meta: SplendorMeta,
+ instance: Splendor,
+ },
} satisfies Readonly>>;
export type Games = typeof Games;
diff --git a/src/ps/games/lightsout/index.ts b/src/ps/games/lightsout/index.ts
index f5dbf881..7c742d55 100644
--- a/src/ps/games/lightsout/index.ts
+++ b/src/ps/games/lightsout/index.ts
@@ -84,7 +84,7 @@ export class LightsOut extends BaseGame {
if (this.state.board.every(row => row.every(cell => cell === false))) {
return this.end();
}
- this.nextPlayer();
+ this.endTurn();
}
onEnd(type: Exclude): TranslatedText {
diff --git a/src/ps/games/mastermind/index.ts b/src/ps/games/mastermind/index.ts
index 1c80f80c..8b0af198 100644
--- a/src/ps/games/mastermind/index.ts
+++ b/src/ps/games/mastermind/index.ts
@@ -53,7 +53,7 @@ export class Mastermind extends BaseGame {
if (this.state.board.length >= this.state.cap) {
return this.end('loss');
}
- this.nextPlayer();
+ this.endTurn();
}
guess(guess: Guess): GuessResult {
if (this.state.board.length === 0 && !this.setBy) this.closeSignups();
diff --git a/src/ps/games/othello/index.ts b/src/ps/games/othello/index.ts
index 0ca51e25..57499751 100644
--- a/src/ps/games/othello/index.ts
+++ b/src/ps/games/othello/index.ts
@@ -85,7 +85,7 @@ export class Othello extends BaseGame {
board[i][j] = turn;
this.log.push({ action: 'play', time: new Date(), turn, ctx: [i, j] });
- const next = this.nextPlayer();
+ const next = this.endTurn();
if (!next) this.end();
return board;
}
diff --git a/src/ps/games/scrabble/index.ts b/src/ps/games/scrabble/index.ts
index cba29089..50e97ddf 100644
--- a/src/ps/games/scrabble/index.ts
+++ b/src/ps/games/scrabble/index.ts
@@ -253,7 +253,7 @@ export class Scrabble extends BaseGame {
this.passCount = 0;
if (rack.length === 0) return this.end();
- const next = this.nextPlayer();
+ const next = this.endTurn();
if (!next) return this.end();
}
@@ -292,7 +292,7 @@ export class Scrabble extends BaseGame {
this.log.push(logEntry);
this.room.sendHTML(...renderMove(logEntry, this));
- this.nextPlayer();
+ this.endTurn();
}
pass(): void {
@@ -305,7 +305,7 @@ export class Scrabble extends BaseGame {
if (this.passCount > Object.keys(this.players).length) {
return this.end('regular');
}
- this.nextPlayer();
+ this.endTurn();
}
onEnd(type?: EndType): TranslatedText {
diff --git a/src/ps/games/scrabble/meta.ts b/src/ps/games/scrabble/meta.ts
index e06fee83..0a5e67eb 100644
--- a/src/ps/games/scrabble/meta.ts
+++ b/src/ps/games/scrabble/meta.ts
@@ -21,5 +21,5 @@ export const meta: Meta = {
autostart: false,
pokeTimer: fromHumanTime('1 min'),
- timer: fromHumanTime('2 min'),
+ timer: fromHumanTime('5 min'),
};
diff --git a/src/ps/games/snakesladders/index.ts b/src/ps/games/snakesladders/index.ts
index 07d578cb..2ffa13fe 100644
--- a/src/ps/games/snakesladders/index.ts
+++ b/src/ps/games/snakesladders/index.ts
@@ -79,7 +79,7 @@ export class SnakesLadders extends BaseGame {
player,
`You rolled a ${dice}, but needed a ${100 - current}${100 - current === 1 ? '' : ' or lower'}...` as ToTranslate
);
- this.nextPlayer();
+ this.endTurn();
return;
}
@@ -107,7 +107,7 @@ export class SnakesLadders extends BaseGame {
this.frames = frameNums.map(pos => this.render(null, pos));
- this.nextPlayer();
+ this.endTurn();
}
update(user?: string): void {
diff --git a/src/ps/games/splendor/README.md b/src/ps/games/splendor/README.md
new file mode 100644
index 00000000..030ac607
--- /dev/null
+++ b/src/ps/games/splendor/README.md
@@ -0,0 +1,22 @@
+## Splendor Art
+
+### Trainers
+
+- Aspect ratio 1:1
+- Min 256x256px
+
+### Pokémon
+
+- Aspect ratio 2:2.8
+- Min 200x280px
+- Image credits: [Pokéos](https://www.pokeos.com/tcg/textless)
+
+### Types
+
+- Aspect ratio 1:1
+- Min 1000x1000px
+- Image credits: [u/BlueSatoshi](https://www.reddit.com/r/pokemon/comments/19vpii/i_made_some_hires_versions_of_the_energy_symbols/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button)
+
+## Cards Reference
+
+https://github.com/bouk/splendimax/blob/master/Splendor%20Cards.csv
diff --git a/src/ps/games/splendor/constants.ts b/src/ps/games/splendor/constants.ts
new file mode 100644
index 00000000..0dcec49d
--- /dev/null
+++ b/src/ps/games/splendor/constants.ts
@@ -0,0 +1,32 @@
+export enum TOKEN_TYPE {
+ COLORLESS = 'colorless',
+ DARK = 'dark',
+ FIRE = 'fire',
+ GRASS = 'grass',
+ WATER = 'water',
+ DRAGON = 'dragon',
+}
+
+export const TokenTypes = [TOKEN_TYPE.COLORLESS, TOKEN_TYPE.DARK, TOKEN_TYPE.FIRE, TOKEN_TYPE.GRASS, TOKEN_TYPE.WATER];
+export const AllTokenTypes = [...TokenTypes, TOKEN_TYPE.DRAGON];
+
+export enum ACTIONS {
+ BUY = 'buy',
+ BUY_RESERVE = 'buy-reserve',
+ RESERVE = 'reserve',
+ DRAW = 'draw',
+ PASS = 'pass',
+}
+
+export enum VIEW_ACTION_TYPE {
+ NONE = 'none',
+ CLICK_WILD = 'wild',
+ CLICK_RESERVE = 'payback',
+ CLICK_TOKENS = 'tokens',
+ TOO_MANY_TOKENS = 'discard',
+ GAME_END = 'end',
+}
+
+export const POINTS_TO_WIN = 15;
+export const MAX_TOKEN_COUNT = 10;
+export const MAX_RESERVE_COUNT = 3;
diff --git a/src/ps/games/splendor/index.ts b/src/ps/games/splendor/index.ts
new file mode 100644
index 00000000..3de0c769
--- /dev/null
+++ b/src/ps/games/splendor/index.ts
@@ -0,0 +1,478 @@
+import { BaseGame } from '@/ps/games/game';
+import {
+ ACTIONS,
+ AllTokenTypes,
+ MAX_RESERVE_COUNT,
+ MAX_TOKEN_COUNT,
+ POINTS_TO_WIN,
+ TOKEN_TYPE,
+ TokenTypes,
+ VIEW_ACTION_TYPE,
+} from '@/ps/games/splendor/constants';
+import metadata from '@/ps/games/splendor/metadata.json';
+import { render, renderLog } from '@/ps/games/splendor/render';
+import { toId } from '@/tools';
+import { ChatError } from '@/utils/chatError';
+
+import type { ToTranslate, TranslatedText } from '@/i18n/types';
+import type { BaseContext } from '@/ps/games/game';
+import type { Log } from '@/ps/games/splendor/logs';
+import type { Card, PlayerData, RenderCtx, State, TokenCount, Turn, ViewType, WinCtx } from '@/ps/games/splendor/types';
+import type { ActionResponse, BaseState, EndType, Player } from '@/ps/games/types';
+import type { User } from 'ps-client';
+
+export { meta } from '@/ps/games/splendor/meta';
+
+export class Splendor extends BaseGame {
+ log: Log[] = [];
+ winCtx?: WinCtx | { type: EndType };
+
+ constructor(ctx: BaseContext) {
+ super(ctx);
+ super.persist(ctx);
+
+ if (ctx.backup) return;
+
+ const allCards = Object.values(metadata.pokemon);
+ this.state.board = {
+ tokens: Object.fromEntries(AllTokenTypes.map(tokenType => [tokenType, 0])) as TokenCount,
+ cards: {
+ '1': { wild: [], deck: allCards.filter(({ tier }) => tier === 1) },
+ '2': { wild: [], deck: allCards.filter(({ tier }) => tier === 2) },
+ '3': { wild: [], deck: allCards.filter(({ tier }) => tier === 3) },
+ },
+ trainers: [],
+ };
+ this.state.actionState = { action: VIEW_ACTION_TYPE.NONE };
+ this.state.playerData = {};
+ }
+
+ createPlayerData(name: string, input: Partial = {}): PlayerData {
+ return {
+ points: 0,
+ tokens: Object.fromEntries(AllTokenTypes.map(type => [type, 0])) as TokenCount,
+ cards: [],
+ reserved: [],
+ trainers: [],
+ ...input,
+ id: toId(name),
+ name,
+ };
+ }
+
+ gameCanEnd(): boolean {
+ const lastPlayerInRound = this.turns.findLast(turn => !this.players[turn].out);
+
+ return (
+ this.turn === lastPlayerInRound &&
+ Object.values(this.players)
+ .filter(player => !player.out)
+ .some(player => this.state.playerData[player.turn].points >= POINTS_TO_WIN)
+ );
+ }
+
+ onStart(): ActionResponse {
+ const playerCount = Object.keys(this.players).length;
+
+ // Set tokens. Tokens at the beginning are given by startCount. Base player count is 2.
+ AllTokenTypes.forEach(tokenType => (this.state.board.tokens[tokenType] = metadata.types[tokenType].startCount[playerCount - 2]));
+ // Set wild cards
+ ([1, 2, 3] as const).forEach(tier => {
+ const TierCards = this.state.board.cards[tier];
+ TierCards.deck.shuffle(this.prng);
+ TierCards.wild = TierCards.deck.splice(0, 4);
+ });
+ // Set trainers
+ this.state.board.trainers = Object.values(metadata.trainers).sample(playerCount + 1, this.prng);
+
+ this.state.playerData = Object.fromEntries(
+ Object.values(this.players).map(player => [player.id, this.createPlayerData(player.name)])
+ );
+
+ return { success: true, data: null };
+ }
+
+ onReplacePlayer(turn: BaseState['turn'], withPlayer: User): ActionResponse {
+ const newData = this.createPlayerData(withPlayer.name, this.state.playerData[turn]);
+ delete this.state.playerData[turn];
+ this.state.playerData[withPlayer.id] = newData;
+ return { success: true, data: null };
+ }
+
+ onLeavePlayer(player: Player): ActionResponse<'end' | null> {
+ if (this.started) {
+ if (this.gameCanEnd()) return { success: true, data: 'end' };
+ else {
+ const playerData = this.state.playerData[player.turn];
+ playerData.out = true;
+
+ const playerCount = Object.values(this.players).filter(player => !player.out).length as 2 | 3 | 4;
+
+ AllTokenTypes.forEach(tokenType => {
+ const meta = metadata.types[tokenType].startCount;
+ const reduceTokens = meta[playerCount - 2] - meta[playerCount - 2 - 1];
+ this.state.board.tokens[tokenType] += playerData.tokens[tokenType] - reduceTokens;
+ playerData.tokens[tokenType] = 0;
+ if (this.state.board.tokens[tokenType] < 0) this.state.board.tokens[tokenType] = 0;
+ });
+ }
+ }
+ return { success: true, data: null };
+ }
+
+ lookupCard(ctx: string): Card | null {
+ const id = toId(ctx);
+ if (id === 'constructor') return null;
+ return metadata.pokemon[id] ?? null;
+ }
+
+ findWildCard(ctx: string): ActionResponse {
+ const card = this.lookupCard(ctx);
+ if (!card) return { success: false, error: `${ctx} is not a valid card.` as ToTranslate };
+ const foundCard = Object.values(this.state.board.cards)
+ .flatMap(cards => cards.wild)
+ .find(wildCard => wildCard.id === card.id);
+
+ if (!foundCard) return { success: false, error: `Cannot access ${card.name} for the desired action.` as ToTranslate };
+ return { success: true, data: foundCard };
+ }
+
+ getTokens(tokens: Partial, playerData: PlayerData): void {
+ const bank = this.state.board.tokens;
+ (Object.entries(tokens) as [TOKEN_TYPE, number][]).forEach(([tokenType, count]) => {
+ if (count > bank[tokenType]) {
+ throw new Error(`Tried to get ${count} ${metadata.types[tokenType].name} tokens (bank had ${playerData.tokens[tokenType]})!`);
+ }
+ bank[tokenType] -= count;
+ playerData.tokens[tokenType] += count;
+ });
+ }
+ spendTokens(tokens: Partial, playerData: PlayerData): void {
+ const bank = this.state.board.tokens;
+ (Object.entries(tokens) as [TOKEN_TYPE, number][]).forEach(([tokenType, count]) => {
+ if (count > playerData.tokens[tokenType]) {
+ throw new Error(`Tried to use ${count} ${metadata.types[tokenType].name} tokens (only had ${playerData.tokens[tokenType]})!`);
+ }
+ bank[tokenType] += count;
+ playerData.tokens[tokenType] -= count;
+ });
+ }
+
+ action(user: User, ctx: string): void {
+ if (!this.started) this.throw('GAME.NOT_STARTED');
+ if (user.id !== this.players[this.turn!].id) this.throw('GAME.IMPOSTOR_ALERT');
+ const player = this.getPlayer(user)!;
+ const playerData = this.state.playerData[player.turn];
+ const [action, actionCtx] = ctx.lazySplit(' ', 1);
+
+ let logEntry: Log;
+ // VIEW_ACTION_TYPES update the user's state while staying on the same turn. Use 'return'.
+ // The exception to this is TOO_MANY_TOKENS, which is deferred from ACTIONS and uses 'break'.
+ // ACTIONS are actual actions, and will end the turn and stuff if valid. Use 'break'.
+ switch (action) {
+ case VIEW_ACTION_TYPE.CLICK_TOKENS: {
+ this.state.actionState = { action: VIEW_ACTION_TYPE.CLICK_TOKENS };
+ this.update(user.id);
+ return;
+ }
+ case VIEW_ACTION_TYPE.CLICK_RESERVE: {
+ const card = this.lookupCard(actionCtx);
+ if (!card) throw new ChatError(`${actionCtx} is not available to reserve.` as ToTranslate);
+
+ const canAfford = this.canAfford(card.cost, playerData.tokens, playerData.cards);
+
+ this.state.actionState = {
+ action: VIEW_ACTION_TYPE.CLICK_RESERVE,
+ id: card.id,
+ preset: canAfford ? canAfford.recommendation : null,
+ };
+ this.update(user.id);
+ return;
+ }
+ case VIEW_ACTION_TYPE.CLICK_WILD: {
+ const lookupCard = this.findWildCard(actionCtx);
+ if (!lookupCard.success) throw new ChatError(`${actionCtx} is not available to buy.` as ToTranslate);
+
+ const card = lookupCard.data;
+
+ const canBuy = this.canAfford(card.cost, playerData.tokens, playerData.cards);
+ const canReserve = this.canReserve(player);
+
+ if (!canBuy && !canReserve) throw new ChatError(`You can neither buy nor reserve ${card.name}.` as ToTranslate);
+ this.state.actionState = {
+ action: VIEW_ACTION_TYPE.CLICK_WILD,
+ id: card.id,
+ ...(canBuy ? { canBuy: true, preset: canBuy.recommendation } : { canBuy: false, preset: null }),
+ canReserve,
+ };
+ this.update(user.id);
+ return;
+ }
+
+ case VIEW_ACTION_TYPE.TOO_MANY_TOKENS: {
+ if (this.state.actionState.action !== VIEW_ACTION_TYPE.TOO_MANY_TOKENS)
+ throw new ChatError("You don't need to discard any tokens yet." as ToTranslate);
+ const toDiscard = this.state.actionState.discard;
+ const tokens = this.parseTokens(actionCtx, true);
+ const discarding = Object.values(tokens).sum();
+
+ if (discarding < toDiscard)
+ throw new ChatError(`You must discard at least ${toDiscard} tokens! ${discarding} isn't enough.` as ToTranslate);
+ if (!this.canAfford(tokens, playerData.tokens, null))
+ throw new ChatError("Unfortunately it doesn't look like you don't have those to discard." as ToTranslate);
+
+ this.spendTokens(tokens, playerData);
+ logEntry = { turn: player.turn, time: new Date(), action: VIEW_ACTION_TYPE.TOO_MANY_TOKENS, ctx: { discard: tokens } };
+ break;
+ }
+
+ case ACTIONS.BUY: {
+ const [mon, tokenInfo = ''] = actionCtx.lazySplit(' ', 1);
+ const getCard = this.findWildCard(mon);
+ if (!getCard.success) throw new ChatError(getCard.error);
+ const card = getCard.data;
+
+ const paying = this.parseTokens(tokenInfo, true);
+ const canAfford = this.canAfford(card.cost, paying, playerData.cards);
+ if (!canAfford) throw new ChatError(`The given tokens are insufficient to purchase ${card.name}!` as ToTranslate);
+
+ if (Object.values(paying).sum() !== Object.values(canAfford.recommendation).sum())
+ throw new ChatError(`You're overpaying!` as ToTranslate);
+
+ playerData.cards.push(card);
+
+ const stage = this.state.board.cards[card.tier];
+ stage.wild.remove(card);
+ stage.wild.push(...stage.deck.splice(0, 1));
+
+ this.spendTokens(paying, playerData);
+
+ logEntry = { turn: player.turn, time: new Date(), action: ACTIONS.BUY, ctx: { id: card.id, cost: paying } };
+ break;
+ }
+
+ case ACTIONS.RESERVE: {
+ const getCard = this.findWildCard(actionCtx);
+ if (!getCard.success) throw new ChatError(getCard.error);
+
+ if (!this.canReserve(player)) {
+ throw new ChatError(
+ ('You cannot reserve a card.' +
+ 'You may only reserve a card if a Dragon token is available AND you have less than three cards currently reserved.') as ToTranslate
+ );
+ }
+ const card = getCard.data;
+
+ playerData.reserved.push(card);
+
+ const stage = this.state.board.cards[card.tier];
+ stage.wild.remove(card);
+ stage.wild.push(...stage.deck.splice(0, 1));
+
+ this.getTokens({ [TOKEN_TYPE.DRAGON]: 1 }, playerData);
+
+ logEntry = { turn: player.turn, time: new Date(), action: ACTIONS.RESERVE, ctx: { id: card.id } };
+ break;
+ }
+
+ case ACTIONS.BUY_RESERVE: {
+ const [mon, tokenInfo = ''] = actionCtx.lazySplit(' ', 1);
+ const baseCard = this.lookupCard(mon);
+ if (!baseCard) throw new ChatError(`${mon} is not a valid card!` as ToTranslate);
+ const reservedCard = playerData.reserved.find(card => card.id === baseCard.id);
+ if (!reservedCard) throw new ChatError(`You have not reserved ${baseCard.name}!` as ToTranslate);
+
+ const paying = this.parseTokens(tokenInfo, true);
+ if (!this.canAfford(reservedCard.cost, paying, playerData.cards))
+ throw new ChatError(`The given tokens are insufficient to purchase ${reservedCard.name}!` as ToTranslate);
+
+ this.spendTokens(paying, playerData);
+ playerData.reserved.remove(reservedCard);
+ playerData.cards.push(reservedCard);
+
+ logEntry = { turn: player.turn, time: new Date(), action: ACTIONS.BUY_RESERVE, ctx: { id: reservedCard.id, cost: paying } };
+ break;
+ }
+
+ case ACTIONS.DRAW: {
+ const tokens = this.parseTokens(actionCtx);
+ const validateTokens = this.getTokenIssues(tokens);
+ if (!validateTokens.success) throw new ChatError(validateTokens.error);
+ this.getTokens(tokens, playerData);
+
+ logEntry = { turn: player.turn, time: new Date(), action: ACTIONS.DRAW, ctx: { tokens } };
+ break;
+ }
+
+ case ACTIONS.PASS: {
+ logEntry = { turn: player.turn, time: new Date(), action: ACTIONS.PASS, ctx: null };
+ break;
+ }
+
+ default: {
+ throw new ChatError(`Unrecognized action ${action} (${actionCtx})` as ToTranslate);
+ }
+ }
+
+ // TODO: Add a UI for one-at-a-time
+ const newTrainers = this.state.board.trainers.filter(trainer => this.canAfford(trainer.types, {}, playerData.cards));
+ this.state.board.trainers.remove(...newTrainers);
+ playerData.trainers.push(...newTrainers);
+ if (logEntry.ctx) logEntry.ctx.trainers = newTrainers.map(trainer => trainer.id);
+ this.chatLog(logEntry);
+
+ playerData.points = playerData.cards.map(card => card.points).sum() + playerData.trainers.map(trainer => trainer.points).sum();
+
+ this.state.actionState = { action: VIEW_ACTION_TYPE.NONE };
+
+ if (this.gameCanEnd()) this.end();
+ else if (Object.values(playerData.tokens).sum() > MAX_TOKEN_COUNT) {
+ const count = Object.values(playerData.tokens).sum();
+ this.state.actionState = { action: VIEW_ACTION_TYPE.TOO_MANY_TOKENS, discard: count - MAX_TOKEN_COUNT };
+ this.update(user.id);
+ this.backup();
+ } else this.endTurn();
+ }
+
+ canAfford(cost: Partial, funds: Partial, cards: Card[] | null): { recommendation: TokenCount } | false {
+ const cardCounts = cards?.groupBy(card => card.type) ?? {};
+
+ const spendingPower = Object.fromEntries(
+ AllTokenTypes.map(type => [type, (funds[type] ?? 0) + (cardCounts[type]?.length ?? 0)])
+ ) as TokenCount;
+
+ const availableDragons = spendingPower[TOKEN_TYPE.DRAGON] - (cost[TOKEN_TYPE.DRAGON] ?? 0);
+ if (availableDragons < 0) return false;
+
+ const neededDragons = TokenTypes.filterMap(type => {
+ const needed = cost[type];
+ if (needed && needed > spendingPower[type]) return needed - spendingPower[type];
+ }).sum();
+
+ if (neededDragons > availableDragons) return false;
+ return {
+ recommendation: {
+ ...Object.fromEntries(
+ TokenTypes.map(type => [
+ type,
+ Math.min(cost[type] ? Math.max(cost[type] - (cardCounts[type]?.length ?? 0), 0) : 0, funds[type] ?? 0),
+ ])
+ ),
+ [TOKEN_TYPE.DRAGON]: neededDragons,
+ } as TokenCount,
+ };
+ }
+
+ canReserve(player: Player): boolean {
+ return this.state.playerData[player.turn].reserved.length < MAX_RESERVE_COUNT && this.state.board.tokens[TOKEN_TYPE.DRAGON] > 0;
+ }
+
+ /**
+ * @example this.parseTokens('colorless 1'); // { colorless: 1... }
+ */
+ parseTokens(input: string, allowDragon?: boolean): TokenCount {
+ const tokens = Object.fromEntries(AllTokenTypes.map(type => [type, 0])) as TokenCount;
+ input.split(/ /i).forEach(entry => {
+ const type = entry.replace(/[^a-z]/gi, '').toLowerCase() as TOKEN_TYPE;
+ const amt = +(entry.match(/\d/) ?? '0');
+ if (!(amt >= 0 && amt < 10)) throw new ChatError(`${entry.substring(1)} is not a valid count.` as ToTranslate);
+ if (!AllTokenTypes.includes(type)) throw new ChatError(`${type} is not a recognized type.` as ToTranslate);
+ if (type === TOKEN_TYPE.DRAGON && !allowDragon)
+ throw new ChatError("Dragon isn't allowed as a valid token here." as ToTranslate);
+ tokens[type] += amt;
+ });
+ return tokens;
+ }
+
+ getTokenIssues(tokens: TokenCount): ActionResponse {
+ const input = (Object.entries(tokens) as [TOKEN_TYPE, number][]).filterMap(([type, count]) => {
+ if (count > 0) return { type, count, available: this.state.board.tokens[type], name: metadata.types[type].name };
+ });
+
+ if (tokens[TOKEN_TYPE.DRAGON])
+ return { success: false, error: 'You may only obtain Dragon tokens by reserving cards!' as ToTranslate };
+
+ const tooMany = input.filter(({ count, available }) => count > available);
+ if (tooMany.length > 0) {
+ const extraInfo = ` (${tooMany.map(({ count, available, name }) => `${count} from ${name} (${available})`).list(this.$T)})`;
+ return {
+ success: false,
+ error: `Tried to take more tokens than available!${extraInfo}` as ToTranslate,
+ };
+ }
+
+ if (input.length > 3) return { success: false, error: "You can't take that many tokens!" as ToTranslate };
+ if (input.length === 0) return { success: false, error: 'You must take at least 2 tokens!' as ToTranslate };
+ if (input.length === 2) {
+ return { success: false, error: 'You can only take 2 from 1 type or 1 each from 3 types!' as ToTranslate };
+ }
+
+ if (input.length === 1) {
+ const { count, name, available } = input[0];
+ if (count !== 2) return { success: false, error: 'When taking from one stack you can only take exactly 2.' as ToTranslate };
+ if (available < 4)
+ return {
+ success: false,
+ error: `You can only take 2 tokens if the stack has 4 or more. ${name} only had ${available}.` as ToTranslate,
+ };
+ }
+
+ if (input.length === 3) {
+ const moreThanOne = input.filter(({ count }) => count !== 1);
+ if (moreThanOne.length > 0) {
+ const extraInfo = ` Tried to take ${moreThanOne.map(({ count, name }) => `${count} from ${name}`).list(this.$T)}`;
+ return {
+ success: false,
+ error: `You can only take 1 token from each of the 3 types!${extraInfo}` as ToTranslate,
+ };
+ }
+ }
+
+ return { success: true, data: null };
+ }
+
+ onEnd(type?: EndType): TranslatedText {
+ if (type) {
+ this.winCtx = { type };
+ if (type === 'dq') return this.$T('GAME.ENDED_AUTOMATICALLY', { game: this.meta.name, id: this.id });
+ return this.$T('GAME.ENDED', { game: this.meta.name, id: this.id });
+ }
+ const sorted = Object.values(this.state.playerData).sort((p1, p2) => {
+ if (p1.points !== p2.points) return p2.points - p1.points;
+ if (p1.cards.length !== p2.cards.length) return p1.cards.length - p2.cards.length;
+ return [-1, 1].random(this.prng)!;
+ });
+
+ const winner = sorted[0];
+ this.winCtx = { type: 'win', winner };
+ return this.$T('GAME.WON', { winner: winner.name });
+ }
+
+ chatLog(log: Log): void {
+ this.log.push(log);
+ this.room.sendHTML(...renderLog(log, this));
+ }
+
+ render(side: Turn | null) {
+ let view: ViewType;
+ if (side) {
+ if (side === this.turn) view = { type: 'player', active: true, self: side, ...this.state.actionState };
+ else view = { type: 'player', active: false, self: side };
+ } else view = { type: 'spectator', active: false, action: this.winCtx ? VIEW_ACTION_TYPE.GAME_END : null };
+
+ const ctx: RenderCtx = { id: this.id, board: this.state.board, players: this.state.playerData, turns: this.turns, view };
+
+ if (this.winCtx) {
+ ctx.header = this.$T('GAME.GAME_ENDED');
+ } else if (side === this.turn) {
+ ctx.header = this.$T('GAME.YOUR_TURN');
+ } else if (side) {
+ ctx.header = this.$T('GAME.WAITING_FOR_PLAYER', { player: this.players[this.turn!]?.name });
+ ctx.dimHeader = true;
+ } else if (this.turn) {
+ const current = this.players[this.turn];
+ ctx.header = this.$T('GAME.WAITING_FOR_PLAYER', { player: `${current.name}${this.sides ? ` (${this.turn})` : ''}` });
+ }
+ return render.bind(this.renderCtx)(ctx);
+ }
+}
diff --git a/src/ps/games/splendor/logs.ts b/src/ps/games/splendor/logs.ts
new file mode 100644
index 00000000..2921d275
--- /dev/null
+++ b/src/ps/games/splendor/logs.ts
@@ -0,0 +1,36 @@
+import type { ACTIONS, VIEW_ACTION_TYPE } from '@/ps/games/splendor/constants';
+import type { TokenCount, Turn } from '@/ps/games/splendor/types';
+import type { BaseLog } from '@/ps/games/types';
+import type { Satisfies, SerializedInstance } from '@/types/common';
+
+export type Log = Satisfies<
+ BaseLog,
+ {
+ time: Date;
+ turn: Turn;
+ } & (
+ | {
+ action: ACTIONS.BUY;
+ ctx: { id: string; cost: Partial; trainers?: string[] };
+ }
+ | {
+ action: ACTIONS.BUY_RESERVE;
+ ctx: { id: string; cost: Partial; trainers?: string[] };
+ }
+ | {
+ action: ACTIONS.RESERVE;
+ ctx: { id: string; trainers?: string[] };
+ }
+ | {
+ action: ACTIONS.DRAW;
+ ctx: { tokens: Partial; trainers?: string[] };
+ }
+ | {
+ action: VIEW_ACTION_TYPE.TOO_MANY_TOKENS;
+ ctx: { discard: Partial; trainers?: string[] };
+ }
+ | { action: 'pass'; ctx: null }
+ )
+>;
+
+export type APILog = SerializedInstance;
diff --git a/src/ps/games/splendor/meta.ts b/src/ps/games/splendor/meta.ts
new file mode 100644
index 00000000..f01f52e2
--- /dev/null
+++ b/src/ps/games/splendor/meta.ts
@@ -0,0 +1,17 @@
+import { GamesList } from '@/ps/games/types';
+import { fromHumanTime } from '@/tools';
+
+import type { Meta } from '@/ps/games/types';
+
+export const meta: Meta = {
+ name: 'Splendor',
+ id: GamesList.Splendor,
+ aliases: [],
+ players: 'many',
+ minSize: 2,
+ maxSize: 4,
+
+ autostart: false,
+ pokeTimer: fromHumanTime('1 min'),
+ timer: fromHumanTime('2 min'),
+};
diff --git a/src/ps/games/splendor/metadata-schema.json b/src/ps/games/splendor/metadata-schema.json
new file mode 100644
index 00000000..09421747
--- /dev/null
+++ b/src/ps/games/splendor/metadata-schema.json
@@ -0,0 +1,116 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "Splendor Schema",
+ "description": "Schema for Splendor card metadata",
+ "type": "object",
+ "properties": {
+ "pokemon": {
+ "patternProperties": {
+ ".*": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "tier": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 3
+ },
+ "points": {
+ "type": "number"
+ },
+ "cost": {
+ "type": "object",
+ "properties": {
+ "fire": { "type": "number" },
+ "water": { "type": "number" },
+ "grass": { "type": "number" },
+ "colorless": { "type": "number" },
+ "dark": { "type": "number" }
+ },
+ "minProperties": 1,
+ "additionalProperties": false
+ },
+ "art": {
+ "type": "string"
+ }
+ },
+ "patternProperties": {
+ "^type$": {
+ "enum": ["fire", "water", "grass", "colorless", "dark"]
+ }
+ },
+ "required": ["id", "name", "tier", "type", "points", "cost", "art"]
+ }
+ }
+ },
+ "trainers": {
+ "patternProperties": {
+ ".*": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "types": {
+ "type": "object",
+ "properties": {
+ "fire": { "type": "number" },
+ "water": { "type": "number" },
+ "grass": { "type": "number" },
+ "colorless": { "type": "number" },
+ "dark": { "type": "number" }
+ },
+ "minProperties": 2,
+ "maxProperties": 3,
+ "additionalProperties": false
+ },
+ "points": {
+ "type": "number",
+ "minimum": 3,
+ "maximum": 3
+ },
+ "art": {
+ "type": "string"
+ }
+ },
+ "required": ["id", "name", "types", "points", "art"]
+ }
+ }
+ },
+ "types": {
+ "type": "object",
+ "patternProperties": {
+ "^(colorless|dark|fire|grass|water|dragon)$": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "art": {
+ "type": "string"
+ },
+ "startCount": {
+ "type": "array",
+ "items": {
+ "type": "number"
+ },
+ "minLength": 3,
+ "maxLength": 3
+ }
+ },
+ "required": ["name", "art", "startCount"]
+ }
+ },
+ "required": ["colorless", "dark", "fire", "grass", "water", "dragon"]
+ }
+ },
+ "required": ["pokemon", "trainers", "types"]
+}
diff --git a/src/ps/games/splendor/metadata.json b/src/ps/games/splendor/metadata.json
new file mode 100644
index 00000000..b0b1cfe0
--- /dev/null
+++ b/src/ps/games/splendor/metadata.json
@@ -0,0 +1,1265 @@
+{
+ "$schema": "./metadata-schema.json",
+ "pokemon": {
+ "aipom": {
+ "id": "aipom",
+ "name": "Aipom",
+ "tier": 1,
+ "type": "colorless",
+ "points": 0,
+ "cost": {
+ "water": 3
+ },
+ "art": "aipom.png"
+ },
+ "alcremie": {
+ "id": "alcremie",
+ "name": "Alcremie",
+ "tier": 2,
+ "type": "fire",
+ "points": 2,
+ "cost": {
+ "colorless": 3,
+ "dark": 5
+ },
+ "art": "alcremie.png"
+ },
+ "articuno": {
+ "id": "articuno",
+ "name": "Articuno",
+ "tier": 3,
+ "type": "water",
+ "points": 4,
+ "cost": {
+ "colorless": 6,
+ "dark": 3,
+ "water": 3
+ },
+ "art": "articuno.png"
+ },
+ "audino": {
+ "id": "audino",
+ "name": "Audino",
+ "tier": 2,
+ "type": "colorless",
+ "points": 2,
+ "cost": {
+ "dark": 3,
+ "fire": 5
+ },
+ "art": "audino.png"
+ },
+ "bellsprout": {
+ "id": "bellsprout",
+ "name": "Bellsprout",
+ "tier": 1,
+ "type": "grass",
+ "points": 0,
+ "cost": {
+ "fire": 2,
+ "water": 2
+ },
+ "art": "bellsprout.png"
+ },
+ "blissey": {
+ "id": "blissey",
+ "name": "Blissey",
+ "tier": 3,
+ "type": "colorless",
+ "points": 5,
+ "cost": {
+ "colorless": 3,
+ "dark": 7
+ },
+ "art": "blissey.png"
+ },
+ "bulbasaur": {
+ "id": "bulbasaur",
+ "name": "Bulbasaur",
+ "tier": 1,
+ "type": "grass",
+ "points": 0,
+ "cost": {
+ "colorless": 1,
+ "dark": 1,
+ "fire": 1,
+ "water": 1
+ },
+ "art": "bulbasaur.png"
+ },
+ "carvanha": {
+ "id": "carvanha",
+ "name": "Carvanha",
+ "tier": 1,
+ "type": "dark",
+ "points": 0,
+ "cost": {
+ "colorless": 1,
+ "fire": 1,
+ "grass": 1,
+ "water": 1
+ },
+ "art": "carvanha.png"
+ },
+ "caterpie": {
+ "id": "caterpie",
+ "name": "Caterpie",
+ "tier": 1,
+ "type": "grass",
+ "points": 0,
+ "cost": {
+ "colorless": 1,
+ "dark": 2,
+ "fire": 1,
+ "water": 1
+ },
+ "art": "caterpie.png"
+ },
+ "celebi": {
+ "id": "celebi",
+ "name": "Celebi",
+ "tier": 3,
+ "type": "grass",
+ "points": 5,
+ "cost": {
+ "grass": 3,
+ "water": 7
+ },
+ "art": "celebi.png"
+ },
+ "chandelure": {
+ "id": "chandelure",
+ "name": "Chandelure",
+ "tier": 3,
+ "type": "dark",
+ "points": 4,
+ "cost": {
+ "dark": 3,
+ "fire": 6,
+ "grass": 3
+ },
+ "art": "chandelure.png"
+ },
+ "chansey": {
+ "id": "chansey",
+ "name": "Chansey",
+ "tier": 2,
+ "type": "colorless",
+ "points": 3,
+ "cost": {
+ "colorless": 6
+ },
+ "art": "chansey.png"
+ },
+ "cherubi": {
+ "id": "cherubi",
+ "name": "Cherubi",
+ "tier": 1,
+ "type": "grass",
+ "points": 0,
+ "cost": {
+ "fire": 3
+ },
+ "art": "cherubi.png"
+ },
+ "chimchar": {
+ "id": "chimchar",
+ "name": "Chimchar",
+ "tier": 1,
+ "type": "fire",
+ "points": 0,
+ "cost": {
+ "colorless": 2,
+ "dark": 1,
+ "grass": 1,
+ "water": 1
+ },
+ "art": "chimchar.png"
+ },
+ "cinccino": {
+ "id": "cinccino",
+ "name": "Cinccino",
+ "tier": 2,
+ "type": "colorless",
+ "points": 2,
+ "cost": {
+ "dark": 2,
+ "fire": 4,
+ "grass": 1
+ },
+ "art": "cinccino.png"
+ },
+ "cleffa": {
+ "id": "cleffa",
+ "name": "Cleffa",
+ "tier": 1,
+ "type": "fire",
+ "points": 0,
+ "cost": {
+ "colorless": 3
+ },
+ "art": "cleffa.png"
+ },
+ "comfey": {
+ "id": "comfey",
+ "name": "Comfey",
+ "tier": 2,
+ "type": "water",
+ "points": 1,
+ "cost": {
+ "fire": 3,
+ "grass": 2,
+ "water": 2
+ },
+ "art": "comfey.png"
+ },
+ "decidueye": {
+ "id": "decidueye",
+ "name": "Decidueye",
+ "tier": 3,
+ "type": "dark",
+ "points": 3,
+ "cost": {
+ "colorless": 3,
+ "fire": 3,
+ "grass": 5,
+ "water": 3
+ },
+ "art": "decidueye.png"
+ },
+ "dedenne": {
+ "id": "dedenne",
+ "name": "Dedenne",
+ "tier": 2,
+ "type": "fire",
+ "points": 2,
+ "cost": {
+ "colorless": 1,
+ "grass": 2,
+ "water": 4
+ },
+ "art": "dedenne.png"
+ },
+ "dewpider": {
+ "id": "dewpider",
+ "name": "Dewpider",
+ "tier": 1,
+ "type": "grass",
+ "points": 0,
+ "cost": {
+ "colorless": 2,
+ "water": 1
+ },
+ "art": "dewpider.png"
+ },
+ "dragonair": {
+ "id": "dragonair",
+ "name": "Dragonair",
+ "tier": 2,
+ "type": "water",
+ "points": 3,
+ "cost": {
+ "water": 6
+ },
+ "art": "dragonair.png"
+ },
+ "drampa": {
+ "id": "drampa",
+ "name": "Drampa",
+ "tier": 2,
+ "type": "colorless",
+ "points": 2,
+ "cost": {
+ "fire": 5
+ },
+ "art": "drampa.png"
+ },
+ "eevee": {
+ "id": "eevee",
+ "name": "Eevee",
+ "tier": 1,
+ "type": "colorless",
+ "points": 0,
+ "cost": {
+ "dark": 1,
+ "fire": 1,
+ "grass": 1,
+ "water": 1
+ },
+ "art": "eevee.png"
+ },
+ "eldegoss": {
+ "id": "eldegoss",
+ "name": "Eldegoss",
+ "tier": 2,
+ "type": "grass",
+ "points": 3,
+ "cost": {
+ "grass": 6
+ },
+ "art": "eldegoss.png"
+ },
+ "espeon": {
+ "id": "espeon",
+ "name": "Espeon",
+ "tier": 2,
+ "type": "fire",
+ "points": 2,
+ "cost": {
+ "dark": 5
+ },
+ "art": "espeon.png"
+ },
+ "flareon": {
+ "id": "flareon",
+ "name": "Flareon",
+ "tier": 2,
+ "type": "fire",
+ "points": 3,
+ "cost": {
+ "fire": 6
+ },
+ "art": "flareon.png"
+ },
+ "foongus": {
+ "id": "foongus",
+ "name": "Foongus",
+ "tier": 1,
+ "type": "grass",
+ "points": 0,
+ "cost": {
+ "dark": 2,
+ "fire": 2,
+ "water": 1
+ },
+ "art": "foongus.png"
+ },
+ "frillish": {
+ "id": "frillish",
+ "name": "Frillish",
+ "tier": 1,
+ "type": "dark",
+ "points": 1,
+ "cost": {
+ "water": 4
+ },
+ "art": "frillish.png"
+ },
+ "froakie": {
+ "id": "froakie",
+ "name": "Froakie",
+ "tier": 1,
+ "type": "water",
+ "points": 0,
+ "cost": {
+ "colorless": 1,
+ "dark": 1,
+ "fire": 1,
+ "grass": 1
+ },
+ "art": "froakie.png"
+ },
+ "glaceon": {
+ "id": "glaceon",
+ "name": "Glaceon",
+ "tier": 2,
+ "type": "water",
+ "points": 2,
+ "cost": {
+ "colorless": 5,
+ "water": 3
+ },
+ "art": "glaceon.png"
+ },
+ "grimeralola": {
+ "id": "grimeralola",
+ "name": "Grimer-Alola",
+ "tier": 1,
+ "type": "dark",
+ "points": 0,
+ "cost": {
+ "fire": 1,
+ "grass": 2
+ },
+ "art": "grimeralola.png"
+ },
+ "grovyle": {
+ "id": "grovyle",
+ "name": "Grovyle",
+ "tier": 2,
+ "type": "grass",
+ "points": 2,
+ "cost": {
+ "grass": 3,
+ "water": 5
+ },
+ "art": "grovyle.png"
+ },
+ "haunter": {
+ "id": "haunter",
+ "name": "Haunter",
+ "tier": 2,
+ "type": "dark",
+ "points": 3,
+ "cost": {
+ "dark": 6
+ },
+ "art": "haunter.png"
+ },
+ "heatran": {
+ "id": "heatran",
+ "name": "Heatran",
+ "tier": 3,
+ "type": "fire",
+ "points": 4,
+ "cost": {
+ "fire": 3,
+ "grass": 6,
+ "water": 3
+ },
+ "art": "heatran.png"
+ },
+ "heracross": {
+ "id": "heracross",
+ "name": "Heracross",
+ "tier": 2,
+ "type": "dark",
+ "points": 2,
+ "cost": {
+ "colorless": 5
+ },
+ "art": "heracross.png"
+ },
+ "hooh": {
+ "id": "hooh",
+ "name": "Ho-oh",
+ "tier": 3,
+ "type": "fire",
+ "points": 4,
+ "cost": {
+ "grass": 7
+ },
+ "art": "hooh.png"
+ },
+ "jolteon": {
+ "id": "jolteon",
+ "name": "Jolteon",
+ "tier": 2,
+ "type": "grass",
+ "points": 2,
+ "cost": {
+ "colorless": 4,
+ "dark": 1,
+ "water": 2
+ },
+ "art": "jolteon.png"
+ },
+ "kangaskhan": {
+ "id": "kangaskhan",
+ "name": "Kangaskhan",
+ "tier": 3,
+ "type": "colorless",
+ "points": 4,
+ "cost": {
+ "colorless": 3,
+ "dark": 6,
+ "fire": 3
+ },
+ "art": "kangaskhan.png"
+ },
+ "klang": {
+ "id": "klang",
+ "name": "Klang",
+ "tier": 2,
+ "type": "colorless",
+ "points": 1,
+ "cost": {
+ "dark": 2,
+ "fire": 2,
+ "grass": 3
+ },
+ "art": "klang.png"
+ },
+ "klink": {
+ "id": "klink",
+ "name": "Klink",
+ "tier": 1,
+ "type": "colorless",
+ "points": 0,
+ "cost": {
+ "dark": 1,
+ "grass": 2,
+ "water": 2
+ },
+ "art": "klink.png"
+ },
+ "klinklang": {
+ "id": "klinklang",
+ "name": "Klinklang",
+ "tier": 3,
+ "type": "colorless",
+ "points": 4,
+ "cost": {
+ "dark": 7
+ },
+ "art": "klinklang.png"
+ },
+ "krabby": {
+ "id": "krabby",
+ "name": "Krabby",
+ "tier": 1,
+ "type": "water",
+ "points": 1,
+ "cost": {
+ "fire": 4
+ },
+ "art": "krabby.png"
+ },
+ "lapras": {
+ "id": "lapras",
+ "name": "Lapras",
+ "tier": 2,
+ "type": "water",
+ "points": 1,
+ "cost": {
+ "dark": 3,
+ "grass": 3,
+ "water": 2
+ },
+ "art": "lapras.png"
+ },
+ "larvesta": {
+ "id": "larvesta",
+ "name": "Larvesta",
+ "tier": 1,
+ "type": "fire",
+ "points": 0,
+ "cost": {
+ "colorless": 2,
+ "dark": 2,
+ "grass": 1
+ },
+ "art": "larvesta.png"
+ },
+ "leafeon": {
+ "id": "leafeon",
+ "name": "Leafeon",
+ "tier": 2,
+ "type": "grass",
+ "points": 2,
+ "cost": {
+ "grass": 5
+ },
+ "art": "leafeon.png"
+ },
+ "litten": {
+ "id": "litten",
+ "name": "Litten",
+ "tier": 1,
+ "type": "fire",
+ "points": 0,
+ "cost": {
+ "colorless": 1,
+ "dark": 3,
+ "fire": 1
+ },
+ "art": "litten.png"
+ },
+ "litwick": {
+ "id": "litwick",
+ "name": "Litwick",
+ "tier": 1,
+ "type": "fire",
+ "points": 1,
+ "cost": {
+ "colorless": 4
+ },
+ "art": "litwick.png"
+ },
+ "magby": {
+ "id": "magby",
+ "name": "Magby",
+ "tier": 1,
+ "type": "fire",
+ "points": 0,
+ "cost": {
+ "colorless": 1,
+ "dark": 1,
+ "grass": 1,
+ "water": 1
+ },
+ "art": "magby.png"
+ },
+ "magikarp": {
+ "id": "magikarp",
+ "name": "Magikarp",
+ "tier": 1,
+ "type": "water",
+ "points": 0,
+ "cost": {
+ "colorless": 1,
+ "dark": 1,
+ "fire": 2,
+ "grass": 1
+ },
+ "art": "magikarp.png"
+ },
+ "mantine": {
+ "id": "mantine",
+ "name": "Mantine",
+ "tier": 2,
+ "type": "water",
+ "points": 2,
+ "cost": {
+ "colorless": 2,
+ "dark": 4,
+ "fire": 1
+ },
+ "art": "mantine.png"
+ },
+ "marill": {
+ "id": "marill",
+ "name": "Marill",
+ "tier": 1,
+ "type": "water",
+ "points": 0,
+ "cost": {
+ "fire": 1,
+ "grass": 3,
+ "water": 1
+ },
+ "art": "marill.png"
+ },
+ "marshadow": {
+ "id": "marshadow",
+ "name": "Marshadow",
+ "tier": 3,
+ "type": "dark",
+ "points": 4,
+ "cost": {
+ "fire": 7
+ },
+ "art": "marshadow.png"
+ },
+ "minccino": {
+ "id": "minccino",
+ "name": "Minccino",
+ "tier": 1,
+ "type": "colorless",
+ "points": 1,
+ "cost": {
+ "grass": 4
+ },
+ "art": "minccino.png"
+ },
+ "misdreavus": {
+ "id": "misdreavus",
+ "name": "Misdreavus",
+ "tier": 1,
+ "type": "dark",
+ "points": 0,
+ "cost": {
+ "colorless": 1,
+ "fire": 1,
+ "grass": 1,
+ "water": 2
+ },
+ "art": "misdreavus.png"
+ },
+ "mudkip": {
+ "id": "mudkip",
+ "name": "Mudkip",
+ "tier": 1,
+ "type": "water",
+ "points": 0,
+ "cost": {
+ "dark": 2,
+ "grass": 2
+ },
+ "art": "mudkip.png"
+ },
+ "murkrow": {
+ "id": "murkrow",
+ "name": "Murkrow",
+ "tier": 1,
+ "type": "dark",
+ "points": 0,
+ "cost": {
+ "colorless": 2,
+ "grass": 2
+ },
+ "art": "murkrow.png"
+ },
+ "nuzleaf": {
+ "id": "nuzleaf",
+ "name": "Nuzleaf",
+ "tier": 1,
+ "type": "dark",
+ "points": 0,
+ "cost": {
+ "grass": 3
+ },
+ "art": "nuzleaf.png"
+ },
+ "oddish": {
+ "id": "oddish",
+ "name": "Oddish",
+ "tier": 1,
+ "type": "grass",
+ "points": 0,
+ "cost": {
+ "colorless": 1,
+ "grass": 1,
+ "water": 3
+ },
+ "art": "oddish.png"
+ },
+ "onix": {
+ "id": "onix",
+ "name": "Onix",
+ "tier": 2,
+ "type": "dark",
+ "points": 1,
+ "cost": {
+ "colorless": 3,
+ "grass": 2,
+ "water": 2
+ },
+ "art": "onix.png"
+ },
+ "phantump": {
+ "id": "phantump",
+ "name": "Phantump",
+ "tier": 1,
+ "type": "grass",
+ "points": 1,
+ "cost": {
+ "dark": 4
+ },
+ "art": "phantump.png"
+ },
+ "pichu": {
+ "id": "pichu",
+ "name": "Pichu",
+ "tier": 1,
+ "type": "water",
+ "points": 0,
+ "cost": {
+ "colorless": 1,
+ "dark": 2
+ },
+ "art": "pichu.png"
+ },
+ "poochyena": {
+ "id": "poochyena",
+ "name": "Poochyena",
+ "tier": 1,
+ "type": "dark",
+ "points": 0,
+ "cost": {
+ "colorless": 2,
+ "fire": 1,
+ "water": 2
+ },
+ "art": "poochyena.png"
+ },
+ "porygon": {
+ "id": "porygon",
+ "name": "Porygon",
+ "tier": 1,
+ "type": "colorless",
+ "points": 0,
+ "cost": {
+ "colorless": 3,
+ "dark": 1,
+ "water": 1
+ },
+ "art": "porygon.png"
+ },
+ "porygon2": {
+ "id": "porygon2",
+ "name": "Porygon2",
+ "tier": 2,
+ "type": "colorless",
+ "points": 1,
+ "cost": {
+ "colorless": 2,
+ "fire": 3,
+ "water": 3
+ },
+ "art": "porygon2.png"
+ },
+ "porygonz": {
+ "id": "porygonz",
+ "name": "Porygon-Z",
+ "tier": 3,
+ "type": "colorless",
+ "points": 3,
+ "cost": {
+ "dark": 3,
+ "fire": 5,
+ "grass": 3,
+ "water": 3
+ },
+ "art": "porygonz.png"
+ },
+ "primarina": {
+ "id": "primarina",
+ "name": "Primarina",
+ "tier": 3,
+ "type": "water",
+ "points": 4,
+ "cost": {
+ "colorless": 7
+ },
+ "art": "primarina.png"
+ },
+ "raticatealola": {
+ "id": "raticatealola",
+ "name": "Raticate-Alola",
+ "tier": 2,
+ "type": "dark",
+ "points": 1,
+ "cost": {
+ "colorless": 3,
+ "dark": 2,
+ "grass": 3
+ },
+ "art": "raticatealola.png"
+ },
+ "rayquaza": {
+ "id": "rayquaza",
+ "name": "Rayquaza",
+ "tier": 3,
+ "type": "grass",
+ "points": 4,
+ "cost": {
+ "colorless": 3,
+ "grass": 3,
+ "water": 6
+ },
+ "art": "rayquaza.png"
+ },
+ "rotomheat": {
+ "id": "rotomheat",
+ "name": "Rotom-Heat",
+ "tier": 3,
+ "type": "fire",
+ "points": 3,
+ "cost": {
+ "colorless": 3,
+ "dark": 3,
+ "grass": 3,
+ "water": 5
+ },
+ "art": "rotomheat.png"
+ },
+ "scorbunny": {
+ "id": "scorbunny",
+ "name": "Scorbunny",
+ "tier": 1,
+ "type": "fire",
+ "points": 0,
+ "cost": {
+ "colorless": 2,
+ "fire": 2
+ },
+ "art": "scorbunny.png"
+ },
+ "scovillain": {
+ "id": "scovillain",
+ "name": "Scovillain",
+ "tier": 2,
+ "type": "grass",
+ "points": 1,
+ "cost": {
+ "colorless": 3,
+ "fire": 3,
+ "grass": 2
+ },
+ "art": "scovillain.png"
+ },
+ "sentret": {
+ "id": "sentret",
+ "name": "Sentret",
+ "tier": 1,
+ "type": "colorless",
+ "points": 0,
+ "cost": {
+ "dark": 2,
+ "water": 2
+ },
+ "art": "sentret.png"
+ },
+ "sharpedo": {
+ "id": "sharpedo",
+ "name": "Sharpedo",
+ "tier": 3,
+ "type": "water",
+ "points": 3,
+ "cost": {
+ "colorless": 3,
+ "dark": 5,
+ "fire": 3,
+ "grass": 3
+ },
+ "art": "sharpedo.png"
+ },
+ "shaymin": {
+ "id": "shaymin",
+ "name": "Shaymin",
+ "tier": 3,
+ "type": "grass",
+ "points": 3,
+ "cost": {
+ "colorless": 5,
+ "dark": 3,
+ "fire": 3,
+ "water": 3
+ },
+ "art": "shaymin.png"
+ },
+ "shuckle": {
+ "id": "shuckle",
+ "name": "Shuckle",
+ "tier": 2,
+ "type": "fire",
+ "points": 1,
+ "cost": {
+ "dark": 3,
+ "fire": 2,
+ "water": 3
+ },
+ "art": "shuckle.png"
+ },
+ "sneasel": {
+ "id": "sneasel",
+ "name": "Sneasel",
+ "tier": 1,
+ "type": "dark",
+ "points": 0,
+ "cost": {
+ "dark": 1,
+ "fire": 3,
+ "grass": 1
+ },
+ "art": "sneasel.png"
+ },
+ "snover": {
+ "id": "snover",
+ "name": "Snover",
+ "tier": 1,
+ "type": "water",
+ "points": 0,
+ "cost": {
+ "colorless": 1,
+ "fire": 2,
+ "grass": 2
+ },
+ "art": "snover.png"
+ },
+ "stufful": {
+ "id": "stufful",
+ "name": "Stufful",
+ "tier": 1,
+ "type": "colorless",
+ "points": 0,
+ "cost": {
+ "dark": 1,
+ "fire": 2
+ },
+ "art": "stufful.png"
+ },
+ "swadloon": {
+ "id": "swadloon",
+ "name": "Swadloon",
+ "tier": 2,
+ "type": "grass",
+ "points": 1,
+ "cost": {
+ "colorless": 2,
+ "dark": 2,
+ "water": 3
+ },
+ "art": "swadloon.png"
+ },
+ "sylveon": {
+ "id": "sylveon",
+ "name": "Sylveon",
+ "tier": 2,
+ "type": "fire",
+ "points": 1,
+ "cost": {
+ "colorless": 2,
+ "dark": 3,
+ "fire": 2
+ },
+ "art": "sylveon.png"
+ },
+ "tangrowth": {
+ "id": "tangrowth",
+ "name": "Tangrowth",
+ "tier": 3,
+ "type": "grass",
+ "points": 4,
+ "cost": {
+ "water": 7
+ },
+ "art": "tangrowth.png"
+ },
+ "tapufini": {
+ "id": "tapufini",
+ "name": "Tapu Fini",
+ "tier": 3,
+ "type": "water",
+ "points": 5,
+ "cost": {
+ "colorless": 7,
+ "water": 3
+ },
+ "art": "tapufini.png"
+ },
+ "tapulele": {
+ "id": "tapulele",
+ "name": "Tapu Lele",
+ "tier": 3,
+ "type": "fire",
+ "points": 5,
+ "cost": {
+ "fire": 3,
+ "grass": 7
+ },
+ "art": "tapulele.png"
+ },
+ "togepi": {
+ "id": "togepi",
+ "name": "Togepi",
+ "tier": 1,
+ "type": "colorless",
+ "points": 0,
+ "cost": {
+ "dark": 1,
+ "fire": 1,
+ "grass": 2,
+ "water": 1
+ },
+ "art": "togepi.png"
+ },
+ "typhlosionhisui": {
+ "id": "typhlosionhisui",
+ "name": "Typhlosion-Hisui",
+ "tier": 3,
+ "type": "dark",
+ "points": 5,
+ "cost": {
+ "dark": 3,
+ "fire": 7
+ },
+ "art": "typhlosionhisui.png"
+ },
+ "umbreon": {
+ "id": "umbreon",
+ "name": "Umbreon",
+ "tier": 2,
+ "type": "dark",
+ "points": 2,
+ "cost": {
+ "fire": 3,
+ "grass": 5
+ },
+ "art": "umbreon.png"
+ },
+ "vaporeon": {
+ "id": "vaporeon",
+ "name": "Vaporeon",
+ "tier": 2,
+ "type": "water",
+ "points": 2,
+ "cost": {
+ "water": 5
+ },
+ "art": "vaporeon.png"
+ },
+ "vulpix": {
+ "id": "vulpix",
+ "name": "Vulpix",
+ "tier": 1,
+ "type": "fire",
+ "points": 0,
+ "cost": {
+ "grass": 1,
+ "water": 2
+ },
+ "art": "vulpix.png"
+ },
+ "wimpod": {
+ "id": "wimpod",
+ "name": "Wimpod",
+ "tier": 1,
+ "type": "water",
+ "points": 0,
+ "cost": {
+ "dark": 3
+ },
+ "art": "wimpod.png"
+ },
+ "wochien": {
+ "id": "wochien",
+ "name": "Wo-Chien",
+ "tier": 2,
+ "type": "dark",
+ "points": 2,
+ "cost": {
+ "fire": 2,
+ "grass": 4,
+ "water": 1
+ },
+ "art": "wochien.png"
+ }
+ },
+ "trainers": {
+ "cheryl": {
+ "id": "cheryl",
+ "name": "Cheryl",
+ "points": 3,
+ "types": {
+ "grass": 4,
+ "dark": 4
+ },
+ "art": "cheryl.png"
+ },
+ "drasna": {
+ "id": "drasna",
+ "name": "Drasna",
+ "points": 3,
+ "types": {
+ "dark": 3,
+ "fire": 3,
+ "grass": 3
+ },
+ "art": "drasna.png"
+ },
+ "flint": {
+ "id": "flint",
+ "name": "Flint",
+ "points": 3,
+ "types": {
+ "fire": 4,
+ "dark": 4
+ },
+ "art": "flint.png"
+ },
+ "glacia": {
+ "id": "glacia",
+ "name": "Glacia",
+ "points": 3,
+ "types": {
+ "water": 3,
+ "colorless": 3,
+ "dark": 3
+ },
+ "art": "glacia.png"
+ },
+ "grimsley": {
+ "id": "grimsley",
+ "name": "Grimsley",
+ "points": 3,
+ "types": {
+ "dark": 3,
+ "colorless": 3,
+ "fire": 3
+ },
+ "art": "grimsley.png"
+ },
+ "gardenia": {
+ "id": "gardenia",
+ "name": "Gardenia",
+ "points": 3,
+ "types": {
+ "grass": 4,
+ "fire": 4
+ },
+ "art": "gardenia.png"
+ },
+ "larry": {
+ "id": "larry",
+ "name": "Larry",
+ "points": 3,
+ "types": {
+ "colorless": 4,
+ "dark": 4
+ },
+ "art": "larry.png"
+ },
+ "red": {
+ "id": "red",
+ "name": "Red",
+ "points": 3,
+ "types": {
+ "fire": 3,
+ "grass": 3,
+ "water": 3
+ },
+ "art": "red.png"
+ },
+ "siebold": {
+ "id": "siebold",
+ "name": "Siebold",
+ "points": 3,
+ "types": {
+ "water": 3,
+ "colorless": 3,
+ "grass": 3
+ },
+ "art": "siebold.png"
+ },
+ "wallace": {
+ "id": "wallace",
+ "name": "Wallace",
+ "points": 3,
+ "types": {
+ "water": 4,
+ "grass": 4
+ },
+ "art": "wallace.png"
+ }
+ },
+ "types": {
+ "colorless": {
+ "id": "colorless",
+ "name": "Colorless",
+ "art": "colorless.png",
+ "startCount": [4, 5, 7]
+ },
+ "dark": {
+ "id": "dark",
+ "name": "Dark",
+ "art": "dark.png",
+ "startCount": [4, 5, 7]
+ },
+ "fire": {
+ "id": "fire",
+ "name": "Fire",
+ "art": "fire.png",
+ "startCount": [4, 5, 7]
+ },
+ "grass": {
+ "id": "grass",
+ "name": "Grass",
+ "art": "grass.png",
+ "startCount": [4, 5, 7]
+ },
+ "water": {
+ "id": "water",
+ "name": "Water",
+ "art": "water.png",
+ "startCount": [4, 5, 7]
+ },
+ "dragon": {
+ "id": "dragon",
+ "name": "Dragon",
+ "art": "dragon.png",
+ "startCount": [5, 5, 5]
+ }
+ }
+}
diff --git a/src/ps/games/splendor/metadata.json.d.ts b/src/ps/games/splendor/metadata.json.d.ts
new file mode 100644
index 00000000..3ad5e4f1
--- /dev/null
+++ b/src/ps/games/splendor/metadata.json.d.ts
@@ -0,0 +1,5 @@
+import type { Metadata } from '@/ps/games/splendor/types';
+
+declare module '@/ps/games/splendor/metadata.json' {
+ export default metadata as Metadata;
+}
diff --git a/src/ps/games/splendor/render.tsx b/src/ps/games/splendor/render.tsx
new file mode 100644
index 00000000..683b0e58
--- /dev/null
+++ b/src/ps/games/splendor/render.tsx
@@ -0,0 +1,698 @@
+import { ACTIONS, AllTokenTypes, MAX_TOKEN_COUNT, TOKEN_TYPE, TokenTypes, VIEW_ACTION_TYPE } from '@/ps/games/splendor/constants';
+import metadata from '@/ps/games/splendor/metadata.json';
+import { Username } from '@/utils/components';
+import { Button, Form } from '@/utils/components/ps';
+import { Logger } from '@/utils/logger';
+
+import type { ToTranslate, TranslatedText } from '@/i18n/types';
+import type { Splendor } from '@/ps/games/splendor/index';
+import type { Log } from '@/ps/games/splendor/logs';
+import type { Board, Card, PlayerData, RenderCtx, TokenCount, Trainer, ViewType } from '@/ps/games/splendor/types';
+import type { CSSProperties, ReactElement, ReactNode } from 'react';
+
+type This = { msg: string };
+
+function getArtUrl(type: 'pokemon' | 'trainers' | 'type' | 'other', path: string, tag: 'img' | 'bg' = 'bg'): string {
+ const baseURL = `${process.env.WEB_URL}/static/splendor/${type}/${path}`;
+ return tag === 'bg' ? `url(${baseURL})` : baseURL;
+}
+
+const TOKEN_COLOURS: Record = {
+ [TOKEN_TYPE.COLORLESS]: '#dddddd',
+ [TOKEN_TYPE.DARK]: '#444444',
+ [TOKEN_TYPE.DRAGON]: '#eebe4e',
+ [TOKEN_TYPE.FIRE]: '#fe3e4e',
+ [TOKEN_TYPE.GRASS]: '#00a900',
+ [TOKEN_TYPE.WATER]: '#1996e2',
+};
+
+export function renderLog(logEntry: Log, { id, players, $T, renderCtx: { msg } }: Splendor): [ReactElement, { name: string }] {
+ const Wrapper = ({ children }: { children: ReactNode }): ReactElement => (
+ <>
+
+ {children}
+ {logEntry.ctx?.trainers?.length ? ` ${logEntry.ctx.trainers.map(id => metadata.trainers[id].name).list($T)} joined them!` : null}
+
+ {$T('GAME.LABELS.WATCH')}
+
+
+ >
+ );
+
+ const playerName = players[logEntry.turn]?.name;
+ const opts = { name: `${id}-chatlog` };
+
+ switch (logEntry.action) {
+ case ACTIONS.BUY:
+ case ACTIONS.BUY_RESERVE: {
+ const card = metadata.pokemon[logEntry.ctx.id];
+ return [
+
+ bought {card.name}!
+ ,
+ opts,
+ ];
+ }
+ case ACTIONS.RESERVE: {
+ const card = metadata.pokemon[logEntry.ctx.id];
+ return [
+
+ reserved {card.name}.
+ ,
+ opts,
+ ];
+ }
+ case ACTIONS.DRAW:
+ case VIEW_ACTION_TYPE.TOO_MANY_TOKENS: {
+ const tokens = logEntry.action === ACTIONS.DRAW ? logEntry.ctx.tokens : logEntry.ctx.discard;
+ return [
+
+ {logEntry.action === ACTIONS.DRAW ? 'drew' : 'discarded'} tokens{' '}
+
+ {(Object.entries(tokens) as [TOKEN_TYPE, number][])
+ .filter(([_type, count]) => count > 0)
+ .map(([type, count]) => (
+
+ ))}
+
+ .
+ ,
+ opts,
+ ];
+ }
+ case ACTIONS.PASS:
+ return [
+
+ passed.
+ ,
+ opts,
+ ];
+ default:
+ Logger.log('Splendor had some weird move', logEntry, players);
+ return [
+
+ Well something happened, I think! Someone go poke PartMan
+ ,
+ opts,
+ ];
+ }
+}
+
+function getCardStyles(imageSrc: string | null): CSSProperties {
+ return {
+ ...(imageSrc ? { backgroundImage: imageSrc, backgroundSize: 'cover' } : {}),
+ boxSizing: 'border-box',
+ height: 280,
+ width: 200,
+ overflow: 'hidden',
+ display: 'inline-block',
+ verticalAlign: 'top',
+ border: '1px solid black',
+ borderRadius: 12,
+ padding: 0,
+ margin: 12,
+ };
+}
+
+function TypeToken({ type, square }: { type: TOKEN_TYPE; square?: boolean | undefined }): ReactElement {
+ const data = metadata.types[type];
+
+ const imgUrl = square && type === TOKEN_TYPE.DRAGON ? getArtUrl('other', 'question-mark.png') : getArtUrl('type', data.art);
+ const size = square ? '94%' : '77%';
+
+ return (
+
+ );
+}
+
+function TypeTokenCount({ type, count, square }: { type: TOKEN_TYPE; count: number; square?: boolean | undefined }): ReactElement {
+ return (
+
+
+
+ ×{count}
+
+
+ );
+}
+
+function TrainerCard({ data }: { data: Trainer }): ReactElement {
+ const trainersToScooch = ['flint', 'gardenia', 'grimsley'];
+ return (
+
+
+
{data.points}
+
+ {(Object.entries(data.types) as [TOKEN_TYPE, number][]).map(([tokenType, cost]) => (
+
+
+
+ ))}
+
+
+
+ );
+}
+
+function CardWrapper({
+ onClick,
+ style,
+ children,
+}: {
+ onClick: string | undefined;
+ style?: CSSProperties;
+ children?: ReactNode;
+}): ReactElement {
+ if (!onClick) return {children}
;
+ return (
+
+ {children}
+
+ );
+}
+
+export function PokemonCard({
+ data,
+ reserved,
+ onClick,
+ stackIndex,
+}: {
+ data: Card;
+ reserved?: boolean | undefined;
+ onClick?: string | undefined;
+ stackIndex?: number | undefined;
+}): ReactElement {
+ return (
+
+
+
+ {data.points || '\u200b'}
+
+
+
+ {reserved ? (
+
+
+ RESERVED
+
+
+ ) : null}
+
+ {(Object.entries(data.cost) as [TOKEN_TYPE, number][]).map(([tokenType, cost]) => (
+
+
+
+ ))}
+
+
+ );
+}
+
+function FlippedCard({ data, count }: { data: Card; count: number }): ReactElement {
+ return (
+
+ );
+}
+
+function PlaceholderCard(): ReactElement {
+ return
;
+}
+
+export function Stack({
+ cards,
+ hidden,
+ reserved,
+ onClick,
+ style,
+}: {
+ cards: Card[];
+ hidden?: boolean | undefined;
+ reserved?: boolean | undefined;
+ onClick?: string | undefined;
+ style?: CSSProperties | undefined;
+}): ReactElement {
+ return (
+
+
+ {cards.length === 0 ? (
+
+ ) : (
+ cards.map((card, index) =>
+ hidden ? (
+ index === 0 ? (
+
+ ) : null
+ ) : (
+
+ )
+ )
+ )}
+ {!hidden && cards.length > 1 ?
: null}
+
+ {!hidden && cards.length > 1 ?
: null}
+
+ );
+}
+
+function TokenInput({
+ types: _types,
+ allowDragon,
+ preset,
+ label,
+ onClick,
+}: {
+ types?: TOKEN_TYPE[];
+ allowDragon?: boolean;
+ preset: TokenCount | null;
+ label: TranslatedText;
+ onClick: string;
+}): ReactElement {
+ const types = _types ?? (allowDragon ? AllTokenTypes : TokenTypes);
+ return (
+
+ );
+}
+
+function WildCardInput({ action, onClick }: { action: ViewType; onClick: string }): ReactElement {
+ if (!action.active || action.action !== VIEW_ACTION_TYPE.CLICK_WILD) return <>>;
+ const card = metadata.pokemon[action.id];
+ const typesToInclude = (Object.keys(card.cost) as TOKEN_TYPE[]).concat([TOKEN_TYPE.DRAGON]);
+ const typesToShow = AllTokenTypes.filter(type => typesToInclude.includes(type));
+ return (
+
+
+ {action.canBuy ? (
+
+ ) : null}
+ {action.canReserve ? (
+
+
+ {'Reserve!' as ToTranslate}
+
+
+ ) : null}
+
+ );
+}
+
+function ReservedCardInput({ card, preset, onClick }: { preset: TokenCount; card: Card; onClick: string }): ReactElement {
+ const typesToInclude = (Object.keys(card.cost) as TOKEN_TYPE[]).concat([TOKEN_TYPE.DRAGON]);
+ const typesToShow = AllTokenTypes.filter(type => typesToInclude.includes(type));
+ return (
+
+ );
+}
+
+export function BaseBoard({ board, view, onClick }: { board: Board; view: ViewType; onClick?: string | undefined }): ReactElement {
+ return (
+ <>
+
+ {board.trainers.map(trainer => (
+
+ ))}
+
+
+ {view.active && view.action === VIEW_ACTION_TYPE.CLICK_TOKENS ? (
+
+ ) : (
+ <>
+ {AllTokenTypes.map(tokenType => (
+
+ ))}
+ {view.active ? (
+
+ Draw Tokens
+
+ ) : null}
+ >
+ )}
+
+ {view.active && view.action === VIEW_ACTION_TYPE.CLICK_WILD ? : null}
+
+ {[board.cards[3], board.cards[2], board.cards[1]].map(({ wild, deck }) => (
+
+ {wild.map(card => (
+
+ ))}
+
+
+ ))}
+ >
+ );
+}
+
+const RESOURCE_STYLES: CSSProperties = {
+ zoom: '40%',
+ overflowX: 'auto',
+ fontSize: 24,
+ background: '#1119',
+ borderRadius: 8,
+ padding: '8px 12px',
+ margin: 12,
+};
+
+export function ActivePlayer({
+ data,
+ action,
+ onClick,
+}: {
+ data: PlayerData;
+ action: ViewType & { type: 'player' };
+ onClick: string;
+}): ReactElement {
+ const cardsGroup = data.cards.groupBy(card => card.type);
+ const cards = AllTokenTypes.filterMap<[TOKEN_TYPE, Card[]]>(type => {
+ const cards = cardsGroup[type];
+ if (cards?.length) return [type, cards];
+ });
+ const tokensEl = AllTokenTypes.filterMap(type => {
+ const count = data.tokens[type];
+ if (count) return ;
+ });
+
+ return (
+
+
+
+ ({data.points}
+ /15 )
+
+
+ {data.trainers.map(trainer => (
+
+ ))}
+
+
+
+
+
Tokens:{tokensEl.length === 0 ? ' - ' : null}
+ {tokensEl}
+
+
+ {cards.length || data.reserved.length ? (
+
+ {cards.length ? (
+
+
+ {cards.map(([tokenType, { length: count }]) => (
+
+ ))}
+
+
+ {cards.map(([_tokenType, card]) => (
+
+ ))}
+
+
+ ) : null}
+ {data.reserved.map(card => (
+
+ ))}
+
+ ) : null}
+ {action.active && action.action === VIEW_ACTION_TYPE.CLICK_RESERVE ? (
+ action.preset ? (
+
+ ) : (
+
{`You can't afford ${metadata.pokemon[action.id].name}...`}
+ )
+ ) : null}
+ {action.active && action.action === VIEW_ACTION_TYPE.TOO_MANY_TOKENS ? (
+
+
+ {
+ // eslint-disable-next-line max-len -- TODO $T
+ `You have too many tokens! The maximum you can have at a time is ${MAX_TOKEN_COUNT}; please discard at least ${action.discard}.` as ToTranslate
+ }
+
+
+
+ ) : null}
+
+ );
+}
+
+export function PlayerSummary({ data }: { data: PlayerData }): ReactElement {
+ const cards = data.cards.groupBy(card => card.type);
+ if (data.reserved.length > 0) cards[TOKEN_TYPE.DRAGON] = data.reserved;
+
+ const cardsEl = AllTokenTypes.filterMap(type => {
+ const count = cards[type]?.length;
+ if (count) return ;
+ });
+ const tokensEl = AllTokenTypes.filterMap(type => {
+ const count = data.tokens[type];
+ if (count) return ;
+ });
+
+ return (
+
+
+
+
+ ({data.points})
+
+
+
+ {data.trainers.map(trainer => (
+
+ ))}
+
+
+
+
+
Cards:{cardsEl.length === 0 ? ' - ' : null}
+ {cardsEl}
+
+
+
Tokens:{tokensEl.length === 0 ? ' - ' : null}
+ {tokensEl}
+
+
+
+ );
+}
+
+export function render(this: This, ctx: RenderCtx): ReactElement {
+ return (
+
+ {ctx.header}
+
+
+
+ {ctx.view.type === 'player' ? (
+ <>
+
+
+ >
+ ) : null}
+ {ctx.turns.map(turn =>
).space(
)}
+
+
+ );
+}
diff --git a/src/ps/games/splendor/types.ts b/src/ps/games/splendor/types.ts
new file mode 100644
index 00000000..0d3fd795
--- /dev/null
+++ b/src/ps/games/splendor/types.ts
@@ -0,0 +1,97 @@
+import type { TOKEN_TYPE, VIEW_ACTION_TYPE } from '@/ps/games/splendor/constants';
+
+export type Turn = string;
+
+export type TokenCount = Record;
+
+export type Metadata = {
+ pokemon: Record;
+ trainers: Record;
+ types: Record;
+};
+
+export type Card = {
+ id: string;
+ name: string;
+ tier: 1 | 2 | 3;
+ type: TOKEN_TYPE;
+ points: number;
+ cost: Partial;
+ art: string;
+};
+
+export type Deck = Card[];
+export type WildCards = Card[];
+
+export type Trainer = {
+ id: string;
+ name: string;
+ points: number;
+ types: Partial;
+ art: string;
+};
+
+export type Board = {
+ cards: Record<1 | 2 | 3, { wild: WildCards; deck: Deck }>;
+ trainers: Trainer[];
+ tokens: TokenCount;
+};
+
+export type PlayerData = {
+ id: string;
+ name: string;
+ points: number;
+ tokens: TokenCount;
+ cards: Card[];
+ reserved: Card[];
+ trainers: Trainer[];
+ out?: boolean;
+};
+
+type ActivePlayer = {
+ type: 'player';
+ active: true;
+ self: string;
+};
+
+export type ActionState =
+ | { action: VIEW_ACTION_TYPE.NONE }
+ | { action: VIEW_ACTION_TYPE.CLICK_TOKENS }
+ | ({ action: VIEW_ACTION_TYPE.CLICK_WILD; id: string; canReserve: boolean } & (
+ | { canBuy: true; preset: TokenCount }
+ | { canBuy: false; preset: null }
+ ))
+ | { action: VIEW_ACTION_TYPE.CLICK_RESERVE; id: string; preset: TokenCount | null }
+ | { action: VIEW_ACTION_TYPE.TOO_MANY_TOKENS; discard: number };
+
+export type ViewType =
+ | {
+ type: 'spectator';
+ active: false;
+ action: VIEW_ACTION_TYPE.GAME_END | null;
+ }
+ | {
+ type: 'player';
+ active: false;
+ self: string;
+ }
+ | (ActivePlayer & ActionState);
+
+export type State = {
+ turn: Turn;
+ board: Board;
+ playerData: Record;
+ actionState: ActionState;
+};
+
+export type RenderCtx = {
+ id: string;
+ board: Board;
+ header?: string;
+ dimHeader?: boolean;
+ view: ViewType;
+ turns: string[];
+ players: Record;
+};
+
+export type WinCtx = { type: 'win'; winner: { name: string; id: string; points: number } } | { type: 'draw' };
diff --git a/src/ps/games/test.tsx b/src/ps/games/test.tsx
new file mode 100644
index 00000000..4af22e1c
--- /dev/null
+++ b/src/ps/games/test.tsx
@@ -0,0 +1,118 @@
+import '@/globals';
+
+import { TOKEN_TYPE, VIEW_ACTION_TYPE } from '@/ps/games/splendor/constants';
+import { ansiToHtml } from '@/utils/ansiToHtml';
+import { cachebustDir } from '@/utils/cachebust';
+import { fsPath } from '@/utils/fsPath';
+import { jsxToHTML } from '@/utils/jsxToHTML';
+
+import type { Metadata, RenderCtx } from '@/ps/games/splendor/types';
+
+export const test: () => Promise = async () => {
+ try {
+ cachebustDir(fsPath('ps', 'games'));
+ const { render } = await import('@/ps/games/splendor/render');
+ const { default: metadata } = (await import('@/ps/games/splendor/metadata.json')) as unknown as { default: Metadata };
+
+ const MOCK_RENDER_CTX: RenderCtx = {
+ id: '#TEMP',
+ header: 'Your turn!',
+ board: {
+ cards: {
+ '1': {
+ wild: Object.values(metadata.pokemon)
+ .filter(mon => mon.tier === 1)
+ .sample(4, 1),
+ deck: [],
+ },
+ '2': {
+ wild: Object.values(metadata.pokemon)
+ .filter(mon => mon.tier === 2)
+ .sample(4, 2),
+ deck: [metadata.pokemon.celebi],
+ },
+ '3': {
+ wild: Object.values(metadata.pokemon)
+ .filter(mon => mon.tier === 3)
+ .sample(4, 6),
+ deck: [metadata.pokemon.celebi, metadata.pokemon.eevee],
+ },
+ },
+ trainers: Object.values(metadata.trainers).sample(5, 1),
+ tokens: {
+ [TOKEN_TYPE.FIRE]: 4,
+ [TOKEN_TYPE.GRASS]: 5,
+ [TOKEN_TYPE.DARK]: 7,
+ [TOKEN_TYPE.DRAGON]: 1,
+ [TOKEN_TYPE.WATER]: 0,
+ [TOKEN_TYPE.COLORLESS]: 2,
+ },
+ },
+ view: {
+ type: 'player',
+ active: true,
+ self: 'partman',
+ action: VIEW_ACTION_TYPE.CLICK_WILD,
+ id: 'klang',
+ canBuy: true,
+ preset: {
+ [TOKEN_TYPE.GRASS]: 2,
+ [TOKEN_TYPE.WATER]: 1,
+ [TOKEN_TYPE.FIRE]: 0,
+ [TOKEN_TYPE.COLORLESS]: 0,
+ [TOKEN_TYPE.DARK]: 0,
+ [TOKEN_TYPE.DRAGON]: 0,
+ },
+ canReserve: true,
+ // preset: null,
+ },
+ turns: ['partbot', 'partman'],
+ players: {
+ partbot: {
+ id: 'partbot',
+ name: 'PartBot',
+ points: 9,
+ tokens: {
+ [TOKEN_TYPE.FIRE]: 0,
+ [TOKEN_TYPE.GRASS]: 0,
+ [TOKEN_TYPE.DARK]: 0,
+ [TOKEN_TYPE.DRAGON]: 1,
+ [TOKEN_TYPE.WATER]: 3,
+ [TOKEN_TYPE.COLORLESS]: 0,
+ },
+ cards: [metadata.pokemon.murkrow, metadata.pokemon.tapulele],
+ trainers: [metadata.trainers.larry, metadata.trainers.siebold],
+ reserved: [metadata.pokemon.klink],
+ },
+ partman: {
+ id: 'partman',
+ name: 'PartMan',
+ points: 10,
+ tokens: {
+ [TOKEN_TYPE.FIRE]: 0,
+ [TOKEN_TYPE.GRASS]: 0,
+ [TOKEN_TYPE.DARK]: 0,
+ [TOKEN_TYPE.DRAGON]: 1,
+ [TOKEN_TYPE.WATER]: 3,
+ [TOKEN_TYPE.COLORLESS]: 0,
+ },
+ cards: [
+ metadata.pokemon.murkrow,
+ metadata.pokemon.tapulele,
+ metadata.pokemon.espeon,
+ metadata.pokemon.vulpix,
+ metadata.pokemon.marill,
+ metadata.pokemon.pichu,
+ metadata.pokemon.celebi,
+ ],
+ trainers: [metadata.trainers.larry],
+ reserved: [metadata.pokemon.klink],
+ },
+ },
+ };
+
+ return jsxToHTML(render.bind({ msg: 'test' })(MOCK_RENDER_CTX));
+ } catch (err) {
+ return err instanceof Error ? ansiToHtml(err.message) : 'Something went wrong!';
+ }
+};
diff --git a/src/ps/games/types.ts b/src/ps/games/types.ts
index e03b7e69..2dec9af3 100644
--- a/src/ps/games/types.ts
+++ b/src/ps/games/types.ts
@@ -42,6 +42,7 @@ export enum GamesList {
Othello = 'othello',
Scrabble = 'scrabble',
SnakesLadders = 'snakesladders',
+ Splendor = 'splendor',
}
export interface Player {
diff --git a/src/sentinel/registers/ps.ts b/src/sentinel/registers/ps.ts
index edc996c5..33a6620c 100644
--- a/src/sentinel/registers/ps.ts
+++ b/src/sentinel/registers/ps.ts
@@ -1,5 +1,6 @@
import { promises as fs } from 'fs';
+import { IS_ENABLED } from '@/enabled';
import { Games } from '@/ps/games';
import { reloadCommands } from '@/ps/loaders/commands';
import { LivePSHandlers, LivePSStuff } from '@/sentinel/live';
@@ -21,75 +22,77 @@ const PS_EVENT_HANDLERS = {
{ imports: (keyof typeof LivePSHandlers)[]; importPath: string; fileName: string /* TODO: remove fileName */ }
>;
-export const PS_REGISTERS: Register[] = [
- {
- label: 'commands',
- pattern: /\/ps\/commands\//,
- reload: async filepaths => {
- filepaths.forEach(cachebust);
- return reloadCommands();
- },
- },
+export const PS_REGISTERS: Register[] = IS_ENABLED.PS
+ ? [
+ {
+ label: 'commands',
+ pattern: /\/ps\/commands\//,
+ reload: async filepaths => {
+ filepaths.forEach(cachebust);
+ return reloadCommands();
+ },
+ },
- {
- label: 'games',
- pattern: /\/ps\/games\//,
- reload: async () => {
- ['types', 'game', 'index', 'render'].forEach(file => cachebust(`@/ps/games/${file}`));
- const games = await fs.readdir(fsPath('ps', 'games'), { withFileTypes: true });
- await Promise.all(
- games
- .filter(game => game.isDirectory())
- .map(async game => {
- const gameDir = game.name as GamesList;
- const files = await fs.readdir(fsPath('ps', 'games', gameDir));
- files.forEach(file => cachebust(fsPath('ps', 'games', gameDir, file)));
+ {
+ label: 'games',
+ pattern: /\/ps\/games\//,
+ reload: async () => {
+ ['types', 'game', 'index', 'render'].forEach(file => cachebust(`@/ps/games/${file}`));
+ const games = await fs.readdir(fsPath('ps', 'games'), { withFileTypes: true });
+ await Promise.all(
+ games
+ .filter(game => game.isDirectory())
+ .map(async game => {
+ const gameDir = game.name as GamesList;
+ const files = await fs.readdir(fsPath('ps', 'games', gameDir));
+ files.forEach(file => cachebust(fsPath('ps', 'games', gameDir, file)));
- const gameImport = await import(`@/ps/games/${gameDir}`);
- const { meta }: { meta: Meta } = gameImport;
- const { [meta.name.replaceAll(' ', '')]: instance } = gameImport;
+ const gameImport = await import(`@/ps/games/${gameDir}`);
+ const { meta }: { meta: Meta } = gameImport;
+ const { [meta.name.replaceAll(' ', '')]: instance } = gameImport;
- Games[gameDir] = { meta, instance };
- })
- );
+ Games[gameDir] = { meta, instance };
+ })
+ );
- const gameCommands = await fs.readdir(fsPath('ps', 'commands', 'games'));
- gameCommands.forEach(commandFile => cachebust(fsPath('ps', 'commands', 'games', commandFile)));
- await reloadCommands();
- },
- },
+ const gameCommands = await fs.readdir(fsPath('ps', 'commands', 'games'));
+ gameCommands.forEach(commandFile => cachebust(fsPath('ps', 'commands', 'games', commandFile)));
+ await reloadCommands();
+ },
+ },
- {
- label: 'commands-handler',
- pattern: /\/ps\/handlers\/commands/,
- reload: async () => {
- await Promise.all(
- (['parse', 'permissions', 'spoof']).map(async file => {
- const importPath = `@/ps/handlers/commands/${file}`;
- cachebust(importPath);
- const hotHandler = await import(importPath);
- LivePSStuff.commands[file] = hotHandler[file];
- })
- );
+ {
+ label: 'commands-handler',
+ pattern: /\/ps\/handlers\/commands/,
+ reload: async () => {
+ await Promise.all(
+ (['parse', 'permissions', 'spoof']).map(async file => {
+ const importPath = `@/ps/handlers/commands/${file}`;
+ cachebust(importPath);
+ const hotHandler = await import(importPath);
+ LivePSStuff.commands[file] = hotHandler[file];
+ })
+ );
- cachebust('@/ps/handlers/commands/customPerms');
- const { GROUPED_PERMS: newGroupedPerms } = await import('@/ps/handlers/commands/customPerms');
- LivePSStuff.commands.GROUPED_PERMS = newGroupedPerms;
+ cachebust('@/ps/handlers/commands/customPerms');
+ const { GROUPED_PERMS: newGroupedPerms } = await import('@/ps/handlers/commands/customPerms');
+ LivePSStuff.commands.GROUPED_PERMS = newGroupedPerms;
- cachebust('@/ps/handlers/commands');
- const { commandHandler } = await import('@/ps/handlers/commands');
- LivePSHandlers.commandHandler = commandHandler;
- },
- },
+ cachebust('@/ps/handlers/commands');
+ const { commandHandler } = await import('@/ps/handlers/commands');
+ LivePSHandlers.commandHandler = commandHandler;
+ },
+ },
- // other, generic event handlers
- ...Object.entries(PS_EVENT_HANDLERS).map(([label, handlerData]) => ({
- label,
- pattern: new RegExp(`\\/ps\\/handlers\\/${handlerData.fileName}`),
- reload: async () => {
- cachebust(handlerData.importPath);
- const hotHandler = await import(handlerData.importPath);
- handlerData.imports.forEach(namedImport => (LivePSHandlers[namedImport] = hotHandler[namedImport]));
- },
- })),
-];
+ // other, generic event handlers
+ ...Object.entries(PS_EVENT_HANDLERS).map(([label, handlerData]) => ({
+ label,
+ pattern: new RegExp(`\\/ps\\/handlers\\/${handlerData.fileName}`),
+ reload: async () => {
+ cachebust(handlerData.importPath);
+ const hotHandler = await import(handlerData.importPath);
+ handlerData.imports.forEach(namedImport => (LivePSHandlers[namedImport] = hotHandler[namedImport]));
+ },
+ })),
+ ]
+ : [];
diff --git a/src/types/common.ts b/src/types/common.ts
index efe22be4..941a93bc 100644
--- a/src/types/common.ts
+++ b/src/types/common.ts
@@ -8,6 +8,8 @@ export type RecursivePartial = {
[P in keyof T]?: T[P] extends (infer U)[] ? RecursivePartial[] : T[P] extends object | undefined ? RecursivePartial : T[P];
};
+export type SemiPartial, Required extends keyof T> = Partial & Pick;
+
export type Satisfies = B;
// Mongoose output as JSON
diff --git a/src/utils/cachebust.ts b/src/utils/cachebust.ts
index edc15f44..cc5d5b6b 100644
--- a/src/utils/cachebust.ts
+++ b/src/utils/cachebust.ts
@@ -7,7 +7,7 @@ export function cachebust(_filepath: string): boolean {
const filepath = _filepath.startsWith('/') ? _filepath : require.resolve(_filepath);
const cache = require.cache[filepath];
if (!cache) return false;
- emptyObject(cache.exports);
+ if (!filepath.endsWith('.json')) emptyObject(cache.exports);
cache.children.length = 0;
emptyObject(cache);
delete require.cache[filepath];
diff --git a/src/utils/components/ps/form.tsx b/src/utils/components/ps/form.tsx
index d505ee51..d6f35245 100644
--- a/src/utils/components/ps/form.tsx
+++ b/src/utils/components/ps/form.tsx
@@ -1,6 +1,10 @@
-import type { HTMLAttributes, ReactElement } from 'react';
+import type { DetailedHTMLProps, FormHTMLAttributes, ReactElement } from 'react';
-export function Form({ value, children, ...props }: HTMLAttributes & { value: string }): ReactElement {
+export function Form({
+ value,
+ children,
+ ...props
+}: DetailedHTMLProps, HTMLFormElement> & { value: string }): ReactElement {
return (