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 + + + + +
+
+ + + +
+
{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} + +
+ + ); + + 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 ( + + ); +} + +export function PokemonCard({ + data, + reserved, + onClick, + stackIndex, +}: { + data: Card; + reserved?: boolean | undefined; + onClick?: string | undefined; + stackIndex?: number | undefined; +}): ReactElement { + return ( + +
+ + {data.points || '\u200b'} + + {metadata.types[data.type].name} +
+ {reserved ? ( +
+ + RESERVED + +
+ ) : null} +
+ {(Object.entries(data.cost) as [TOKEN_TYPE, number][]).map(([tokenType, cost]) => ( +
+ +
+ ))} +
+
+ ); +} + +function FlippedCard({ data, count }: { data: Card; count: number }): ReactElement { + return ( +
+
+ ×{count} +
+
+ ); +} + +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 ( +
`${type}{${type}}`).join(' ')}`} + style={{ border: '1px solid', display: 'inline-block', padding: 12, borderRadius: 12, margin: 12 }} + autoComplete="off" + > + {types.map(type => ( +
+
+ +
+ +
+ ))} +
+ +
+
+ ); +} + +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 ? ( +
+ +
+ ) : 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 ? ( +
`${type}{${type}}`).join(' ')}`}> + {AllTokenTypes.map(tokenType => ( +
+ +
+ +
+ ))} +
+ +
+
+ ) : ( + <> + {AllTokenTypes.map(tokenType => ( + + ))} + {view.active ? ( + + ) : 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 (
{children} diff --git a/src/utils/emptyObject.ts b/src/utils/emptyObject.ts index 5391d92e..dd5199da 100644 --- a/src/utils/emptyObject.ts +++ b/src/utils/emptyObject.ts @@ -6,8 +6,7 @@ export function emptyObject>(obj: T): Record