diff --git a/nixpacks.toml b/nixpacks.toml index ca53346..0871c0f 100644 --- a/nixpacks.toml +++ b/nixpacks.toml @@ -2,7 +2,7 @@ cmds = ["npm run build"] [start] -cmd = "npx serve dist -l 80 -s" +cmd = "serve dist -l 80 -s" [healthcheck] cmd = "curl -f http://localhost:80/ || exit 1" diff --git a/package-lock.json b/package-lock.json index 215cc5d..771ecef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "canopy-game", "version": "0.0.1", + "dependencies": { + "serve": "^14.2.6" + }, "devDependencies": { "typescript": "^6.0.2", "vite": "^5.4.19" @@ -760,6 +763,381 @@ "dev": true, "license": "MIT" }, + "node_modules/@zeit/schemas": { + "version": "2.36.0", + "resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz", + "integrity": "sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arch": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", + "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/boxen": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.0.tgz", + "integrity": "sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==", + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^7.0.0", + "chalk": "^5.0.1", + "cli-boxes": "^3.0.0", + "string-width": "^5.1.2", + "type-fest": "^2.13.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.0.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/camelcase": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", + "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.0.1.tgz", + "integrity": "sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, + "node_modules/chalk-template/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk-template/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clipboardy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-3.0.0.tgz", + "integrity": "sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==", + "license": "MIT", + "dependencies": { + "arch": "^2.2.0", + "execa": "^5.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -799,6 +1177,51 @@ "@esbuild/win32-x64": "0.21.5" } }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -814,6 +1237,186 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-port-reachable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-port-reachable/-/is-port-reachable-4.0.0.tgz", + "integrity": "sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "license": "MIT", + "dependencies": { + "mime-db": "~1.33.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -833,6 +1436,72 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", + "license": "(WTFPL OR MIT)" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -869,6 +1538,61 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/registry-auth-token": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz", + "integrity": "sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==", + "license": "MIT", + "dependencies": { + "rc": "^1.1.6", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/registry-url": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-3.1.0.tgz", + "integrity": "sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==", + "license": "MIT", + "dependencies": { + "rc": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", @@ -914,6 +1638,102 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/serve": { + "version": "14.2.6", + "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.6.tgz", + "integrity": "sha512-QEjUSA+sD4Rotm1znR8s50YqA3kYpRGPmtd5GlFxbaL9n/FdUNbqMhxClqdditSk0LlZyA/dhud6XNRTOC9x2Q==", + "license": "MIT", + "dependencies": { + "@zeit/schemas": "2.36.0", + "ajv": "8.18.0", + "arg": "5.0.2", + "boxen": "7.0.0", + "chalk": "5.0.1", + "chalk-template": "0.4.0", + "clipboardy": "3.0.0", + "compression": "1.8.1", + "is-port-reachable": "4.0.0", + "serve-handler": "6.1.7", + "update-check": "1.5.4" + }, + "bin": { + "serve": "build/main.js" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/serve-handler": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.7.tgz", + "integrity": "sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==", + "license": "MIT", + "dependencies": { + "bytes": "3.0.0", + "content-disposition": "0.5.2", + "mime-types": "2.1.18", + "minimatch": "3.1.5", + "path-is-inside": "1.0.2", + "path-to-regexp": "3.3.0", + "range-parser": "1.2.0" + } + }, + "node_modules/serve-handler/node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -924,6 +1744,80 @@ "node": ">=0.10.0" } }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", @@ -938,6 +1832,25 @@ "node": ">=14.17" } }, + "node_modules/update-check": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/update-check/-/update-check-1.5.4.tgz", + "integrity": "sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==", + "license": "MIT", + "dependencies": { + "registry-auth-token": "3.3.2", + "registry-url": "3.1.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -997,6 +1910,53 @@ "optional": true } } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/widest-line": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "license": "MIT", + "dependencies": { + "string-width": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } } } } diff --git a/package.json b/package.json index 1769d7f..13031e1 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,14 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "start": "npx serve dist -l 80 -s", + "start": "serve dist -l 80 -s", "typecheck": "tsc --noEmit" }, "devDependencies": { "typescript": "^6.0.2", "vite": "^5.4.19" + }, + "dependencies": { + "serve": "^14.2.6" } } diff --git a/src/engine/rules-scoring.ts b/src/engine/rules-scoring.ts index 7ecc645..82c8695 100644 --- a/src/engine/rules-scoring.ts +++ b/src/engine/rules-scoring.ts @@ -1,363 +1,453 @@ -import type { EnergySimulation, GameState, NodeKey, PlayerId, RootBurst, RoundAnimation } from "./types"; +import type { + EnergySimulation, + GameState, + NodeKey, + PlayerId, + RootBurst, + RoundAnimation, +} from "./types"; import { keyFor, parseKey, shuffleArray } from "./utils"; import { buildChildrenMap, buildParentMap } from "./rules-board"; function getColumnRegion(state: GameState, column: number) { - const third = state.config.columns / 3; - if (column < third) { - return "left"; - } + const third = state.config.columns / 3; + if (column < third) { + return "left"; + } - if (column >= state.config.columns - third) { - return "right"; - } + if (column >= state.config.columns - third) { + return "right"; + } - return "center"; + return "center"; } function getLeafCounts(state: GameState) { - const childrenMap = buildChildrenMap(state); - const counts = state.players.map(() => 0); + const childrenMap = buildChildrenMap(state); + const counts = state.players.map(() => 0); - Array.from(state.nodes.entries()).forEach(([nodeKey, node]) => { - if (!(childrenMap.get(nodeKey)?.length)) { - counts[node.ownerId] += 1; - } - }); + Array.from(state.nodes.entries()).forEach(([nodeKey, node]) => { + if (!childrenMap.get(nodeKey)?.length) { + counts[node.ownerId] += 1; + } + }); - return counts; + return counts; } function getColumnPresence(state: GameState, column: number) { - const owners = new Set(); + const owners = new Set(); - for (let row = 0; row < state.config.rows; row += 1) { - const ownerId = state.nodes.get(keyFor(row, column))?.ownerId; - if (ownerId !== undefined) { - owners.add(ownerId); - } - } + for (let row = 0; row < state.config.rows; row += 1) { + const ownerId = state.nodes.get(keyFor(row, column))?.ownerId; + if (ownerId !== undefined) { + owners.add(ownerId); + } + } - return [...owners]; + return [...owners]; } function addRoundedHalfBonus(scores: number[], counts: number[]) { - counts.forEach((count, playerId) => { - if (count > 0) { - scores[playerId] += Math.ceil(count * 0.5); - } - }); + counts.forEach((count, playerId) => { + if (count > 0) { + scores[playerId] += Math.ceil(count * 0.5); + } + }); } -function applyColumnBaseEnergy(state: GameState, scores: number[], ownerId: PlayerId, playersPresent: PlayerId[]) { - const contested = playersPresent.length > 1; +function applyColumnBaseEnergy( + state: GameState, + scores: number[], + ownerId: PlayerId, + playersPresent: PlayerId[], +) { + const contested = playersPresent.length > 1; - if (!contested) { - scores[ownerId] += 1; - return; - } + if (!contested) { + scores[ownerId] += 1; + return; + } - if (state.activeRoundEffects.includes("stalemate")) { - return; - } + if (state.activeRoundEffects.includes("stalemate")) { + return; + } - if (state.activeRoundEffects.includes("split_light")) { - playersPresent.forEach((playerId) => { - scores[playerId] += 0.5; - }); - return; - } + if (state.activeRoundEffects.includes("split_light")) { + playersPresent.forEach((playerId) => { + scores[playerId] += 1; + }); + return; + } - scores[ownerId] += 1; + scores[ownerId] += 1; } -function applyWeatherEffects(state: GameState, scores: number[], energySimulation: EnergySimulation) { - if (state.activeRoundEffects.length === 0) { - return; - } +function applyWeatherEffects( + state: GameState, + scores: number[], + energySimulation: EnergySimulation, +) { + if (state.activeRoundEffects.length === 0) { + return; + } - const leafCounts = getLeafCounts(state); - const childrenMap = buildChildrenMap(state); - const tallestLeaves = state.players.map(() => null as number | null); + const leafCounts = getLeafCounts(state); + const childrenMap = buildChildrenMap(state); + const tallestLeaves = state.players.map(() => null as number | null); - Array.from(state.nodes.entries()).forEach(([nodeKey, node]) => { - const leafCount = leafCounts[node.ownerId]; - if (leafCount <= 0) { - return; - } + Array.from(state.nodes.entries()).forEach(([nodeKey, node]) => { + const leafCount = leafCounts[node.ownerId]; + if (leafCount <= 0) { + return; + } - if (childrenMap.get(nodeKey)?.length) { - return; - } + if (childrenMap.get(nodeKey)?.length) { + return; + } - const { row } = parseKey(nodeKey); - const currentTallest = tallestLeaves[node.ownerId]; - if (currentTallest === null || row < currentTallest) { - tallestLeaves[node.ownerId] = row; - } - }); + const { row } = parseKey(nodeKey); + const currentTallest = tallestLeaves[node.ownerId]; + if (currentTallest === null || row < currentTallest) { + tallestLeaves[node.ownerId] = row; + } + }); - state.activeRoundEffects.forEach((effectId) => { - if (effectId === "leaf_surge") { - leafCounts.forEach((count, playerId) => { - scores[playerId] += count; - }); - return; - } + state.activeRoundEffects.forEach((effectId) => { + if (effectId === "leaf_surge") { + leafCounts.forEach((count, playerId) => { + scores[playerId] += count; + }); + return; + } - if (effectId === "branching_season") { - leafCounts.forEach((count, playerId) => { - scores[playerId] += Math.max(0, count - 1); - }); - return; - } + if (effectId === "branching_season") { + // Find the player with the most leaves + let maxLeaves = 0; + let playerWithMostLeaves = -1; - if (effectId === "tall_reward") { - const bestRow = tallestLeaves.reduce((currentBest, row) => { - if (row === null) { - return currentBest; - } + leafCounts.forEach((count, playerId) => { + scores[playerId] += Math.max(0, count - 1); + if (count > maxLeaves) { + maxLeaves = count; + playerWithMostLeaves = playerId; + } + }); - if (currentBest === null || row < currentBest) { - return row; - } + // Give the player with most leaves 50% more energy (rounded down) + if (playerWithMostLeaves !== -1 && maxLeaves > 1) { + const bonus = Math.floor((maxLeaves - 1) * 0.5); + scores[playerWithMostLeaves] += bonus; + } + return; + } - return currentBest; - }, null); + if (effectId === "tall_reward" || effectId === "deep_roots") { + const bestRow = tallestLeaves.reduce( + (currentBest, row) => { + if (row === null) { + return currentBest; + } - if (bestRow !== null) { - tallestLeaves.forEach((row, playerId) => { - if (row === bestRow) { - scores[playerId] += 2; - } - }); - } - return; - } + if ( + (effectId === "deep_roots" && + (currentBest === null || row > currentBest)) || + (effectId === "tall_reward" && + (currentBest === null || row < currentBest)) + ) { + return row; + } - if (effectId === "wide_reach") { - const maxScore = Math.max(...energySimulation.scores); - energySimulation.scores.forEach((score, playerId) => { - if (score === maxScore && maxScore > 0) { - scores[playerId] += 2; - } - }); - return; - } + return currentBest; + }, + null, + ); - const westLightCounts = state.players.map(() => 0); - const eastLightCounts = state.players.map(() => 0); - const highNoonCounts = state.players.map(() => 0); + if (bestRow !== null) { + tallestLeaves.forEach((row, playerId) => { + if (row === bestRow) { + scores[playerId] += effectId === "deep_roots" ? 4 : 2; + } + }); + } + return; + } - energySimulation.columns.forEach((column) => { - if (!column.intercepted || column.ownerId === null) { - return; - } + if (effectId === "wide_reach") { + const maxScore = Math.max(...energySimulation.scores); + energySimulation.scores.forEach((score, playerId) => { + if (score === maxScore && maxScore > 0) { + scores[playerId] += 2; + } + }); + return; + } - const region = getColumnRegion(state, column.column); - if (region === "left") { - westLightCounts[column.ownerId] += 1; - } - if (region === "right") { - eastLightCounts[column.ownerId] += 1; - } - if (region === "center") { - highNoonCounts[column.ownerId] += 1; - } - if (effectId === "edge_bloom" && (column.column === 0 || column.column === state.config.columns - 1)) { - scores[column.ownerId] += 1; - } - }); + const westLightCounts = state.players.map(() => 0); + const eastLightCounts = state.players.map(() => 0); + const highNoonCounts = state.players.map(() => 0); - if (effectId === "west_light") { - addRoundedHalfBonus(scores, westLightCounts); - } - if (effectId === "east_light") { - addRoundedHalfBonus(scores, eastLightCounts); - } - if (effectId === "high_noon") { - addRoundedHalfBonus(scores, highNoonCounts); - } - }); + energySimulation.columns.forEach((column) => { + if (!column.intercepted || column.ownerId === null) { + return; + } + + const region = getColumnRegion(state, column.column); + if (region === "left") { + westLightCounts[column.ownerId] += 1; + } + if (region === "right") { + eastLightCounts[column.ownerId] += 1; + } + if (region === "center") { + highNoonCounts[column.ownerId] += 1; + } + if ( + effectId === "edge_bloom" && + (column.column === 0 || column.column === state.config.columns - 1) + ) { + scores[column.ownerId] += 1; + } + }); + + if (effectId === "west_light") { + addRoundedHalfBonus(scores, westLightCounts); + } + if (effectId === "east_light") { + addRoundedHalfBonus(scores, eastLightCounts); + } + if (effectId === "high_noon") { + addRoundedHalfBonus(scores, highNoonCounts); + } + }); } export function buildEnergySimulation(state: GameState): EnergySimulation { - const parentMap = buildParentMap(state); - const columns = []; - const scores = state.players.map(() => 0); + const parentMap = buildParentMap(state); + const columns = []; + const scores = state.players.map(() => 0); - for (let column = 0; column < state.config.columns; column += 1) { - let hitNodeKey: NodeKey | null = null; + for (let column = 0; column < state.config.columns; column += 1) { + let hitNodeKey: NodeKey | null = null; - for (let row = 0; row < state.config.rows; row += 1) { - const nodeKey = keyFor(row, column); - if (state.nodes.has(nodeKey)) { - hitNodeKey = nodeKey; - break; - } - } + for (let row = 0; row < state.config.rows; row += 1) { + const nodeKey = keyFor(row, column); + if (state.nodes.has(nodeKey)) { + hitNodeKey = nodeKey; + break; + } + } - if (!hitNodeKey) { - columns.push({ - column, - terminalRow: state.config.rows - 1, - intercepted: false, - ownerId: null, - playersPresent: [], - hitNode: null, - rootKey: null, - branchNodes: [], - branchEdges: [], - }); - continue; - } + if (!hitNodeKey) { + columns.push({ + column, + terminalRow: state.config.rows - 1, + intercepted: false, + ownerId: null, + playersPresent: [], + hitNode: null, + rootKey: null, + branchNodes: [], + branchEdges: [], + }); + continue; + } - const hitNode = parseKey(hitNodeKey); - const ownerId = state.nodes.get(hitNodeKey)?.ownerId as PlayerId; - const playersPresent = getColumnPresence(state, column); - const branchNodes = [hitNode]; - const branchEdges = []; - let cursor = hitNodeKey; + const hitNode = parseKey(hitNodeKey); + const ownerId = state.nodes.get(hitNodeKey)?.ownerId as PlayerId; + const playersPresent = getColumnPresence(state, column); + const branchNodes = [hitNode]; + const branchEdges = []; + let cursor = hitNodeKey; - while (parentMap.has(cursor)) { - const parentKey = parentMap.get(cursor) as NodeKey; - branchEdges.push({ from: parseKey(cursor), to: parseKey(parentKey) }); - branchNodes.push(parseKey(parentKey)); - cursor = parentKey; - } + while (parentMap.has(cursor)) { + const parentKey = parentMap.get(cursor) as NodeKey; + branchEdges.push({ from: parseKey(cursor), to: parseKey(parentKey) }); + branchNodes.push(parseKey(parentKey)); + cursor = parentKey; + } - applyColumnBaseEnergy(state, scores, ownerId, playersPresent); - columns.push({ - column, - terminalRow: hitNode.row, - intercepted: true, - ownerId, - playersPresent, - hitNode, - rootKey: cursor, - branchNodes, - branchEdges, - }); - } + applyColumnBaseEnergy(state, scores, ownerId, playersPresent); + columns.push({ + column, + terminalRow: hitNode.row, + intercepted: true, + ownerId, + playersPresent, + hitNode, + rootKey: cursor, + branchNodes, + branchEdges, + }); + } - const rootBurstMap = columns.reduce((map, column) => { - if (!column.intercepted || !column.rootKey) { - return map; - } + const rootBurstMap = columns.reduce((map, column) => { + if (!column.intercepted || !column.rootKey) { + return map; + } - const entry = map.get(column.rootKey) ?? { key: column.rootKey, playerId: column.ownerId as PlayerId, count: 0 }; - entry.count += 1; - map.set(column.rootKey, entry); - return map; - }, new Map()); - const rootBursts: RootBurst[] = [...rootBurstMap.values()]; + const entry = map.get(column.rootKey) ?? { + key: column.rootKey, + playerId: column.ownerId as PlayerId, + count: 0, + displayCount: 0, + }; + entry.count += 1; + map.set(column.rootKey, entry); + return map; + }, new Map()); + const rootBursts: RootBurst[] = [...rootBurstMap.values()]; - applyWeatherEffects(state, scores, { scores, columns, rootBursts }); + applyWeatherEffects(state, scores, { scores, columns, rootBursts }); - return { - scores, - columns, - rootBursts, - }; + const playerRootTotals = state.players.map(() => 0); + rootBursts.forEach((burst) => { + playerRootTotals[burst.playerId] += burst.count; + }); + + const playerLargestBurst = new Map(); + rootBursts.forEach((burst) => { + const current = playerLargestBurst.get(burst.playerId); + if (!current || burst.count > current.count) { + playerLargestBurst.set(burst.playerId, burst); + } + }); + + rootBursts.forEach((burst) => { + burst.displayCount = burst.count; + }); + + state.players.forEach((player) => { + const largestBurst = playerLargestBurst.get(player.id); + if (!largestBurst) { + return; + } + + const extraEarned = scores[player.id] - playerRootTotals[player.id] + 1; + largestBurst.displayCount += extraEarned; + }); + + return { + scores, + columns, + rootBursts, + }; } export function buildRoundAnimation( - state: GameState, - energySimulation: EnergySimulation, - sunbeamPlayerId: PlayerId | null, - diseaseKeys: NodeKey[], + state: GameState, + energySimulation: EnergySimulation, + sunbeamPlayerId: PlayerId | null, + diseaseKeys: NodeKey[], ): RoundAnimation { - const traces = energySimulation.columns - .filter((column) => column.intercepted) - .map((column) => ({ - playerId: column.ownerId as PlayerId, - verticalCells: Array.from({ length: column.terminalRow + 1 }, (_, row) => ({ row, column: column.column })), - ray: { - x: ((column.column + 0.5) / state.config.columns) * 100, - y: ((column.terminalRow + 0.5) / state.config.rows) * 100, - }, - rootKey: column.rootKey, - branchNodes: column.branchNodes, - })); + const traces = energySimulation.columns + .filter((column) => column.intercepted) + .map((column) => ({ + playerId: column.ownerId as PlayerId, + verticalCells: Array.from( + { length: column.terminalRow + 1 }, + (_, row) => ({ row, column: column.column }), + ), + ray: { + x: ((column.column + 0.5) / state.config.columns) * 100, + y: ((column.terminalRow + 0.5) / state.config.rows) * 100, + }, + rootKey: column.rootKey, + branchNodes: column.branchNodes, + })); - const bonusTrace = sunbeamPlayerId === null ? null : traces.find((trace) => trace.playerId === sunbeamPlayerId) ?? null; - const bonusBurst = bonusTrace ? energySimulation.rootBursts.find((burst) => burst.key === bonusTrace.rootKey) ?? null : null; + const bonusTrace = + sunbeamPlayerId === null + ? null + : (traces.find((trace) => trace.playerId === sunbeamPlayerId) ?? null); + const bonusBurst = bonusTrace + ? (energySimulation.rootBursts.find( + (burst) => burst.key === bonusTrace.rootKey, + ) ?? null) + : null; - return { - phase: "sunlight", - columns: energySimulation.columns, - traces, - rootBursts: energySimulation.rootBursts, - sunbeamPlayerId, - bonusTrace, - bonusBurst, - diseaseKeys, - }; + return { + phase: "sunlight", + columns: energySimulation.columns, + traces, + rootBursts: energySimulation.rootBursts, + sunbeamPlayerId, + bonusTrace, + bonusBurst, + diseaseKeys, + }; } export function scoreColumns(state: GameState) { - const energySimulation = buildEnergySimulation(state); - const columnResults = energySimulation.columns.map((column) => ({ - column: column.column, - ownerId: column.ownerId, - topRow: column.intercepted ? column.terminalRow : null, - tied: false, - })); + const energySimulation = buildEnergySimulation(state); + const columnResults = energySimulation.columns.map((column) => ({ + column: column.column, + ownerId: column.ownerId, + topRow: column.intercepted ? column.terminalRow : null, + tied: false, + })); - return { scores: energySimulation.scores, columnResults, energySimulation }; + return { scores: energySimulation.scores, columnResults, energySimulation }; } export function maybeRollSunbeam(state: GameState, scores: number[]) { - const nextGrowth = scores.map((score) => score + 1); - const { sunbeamChance } = state.randomEffects; + const nextGrowth = scores.map((score) => score + 1); + const { sunbeamChance } = state.randomEffects; - if (sunbeamChance <= 0 || Math.random() * 100 >= sunbeamChance) { - return { - nextGrowth, - event: null, - awardedPlayer: null, - }; - } + if (sunbeamChance <= 0 || Math.random() * 100 >= sunbeamChance) { + return { + nextGrowth, + event: null, + awardedPlayer: null, + }; + } - const awardedPlayer = Math.floor(Math.random() * state.players.length); - nextGrowth[awardedPlayer] += 1; + const awardedPlayer = Math.floor(Math.random() * state.players.length); + nextGrowth[awardedPlayer] += 1; - return { - nextGrowth, - awardedPlayer, - event: `${state.players[awardedPlayer].name} caught a stray sunbeam and gains +1 growth next round.`, - }; + return { + nextGrowth, + awardedPlayer, + event: `${state.players[awardedPlayer].name} caught a stray sunbeam and gains +1 growth next round.`, + }; } export function maybeRollDisease(state: GameState) { - const { diseaseChance } = state.randomEffects; - if (diseaseChance <= 0 || Math.random() * 100 >= diseaseChance) { - return { - killedKeys: [], - event: null, - }; - } + const { diseaseChance } = state.randomEffects; + if (diseaseChance <= 0 || Math.random() * 100 >= diseaseChance) { + return { + killedKeys: [], + event: null, + }; + } - const childrenMap = buildChildrenMap(state); - const parentMap = buildParentMap(state); - const twigKeys = Array.from(state.nodes.keys()).filter((nodeKey) => { - const { row } = parseKey(nodeKey); - return row !== state.config.rows - 1 && !childrenMap.has(nodeKey) && parentMap.has(nodeKey); - }); + const childrenMap = buildChildrenMap(state); + const parentMap = buildParentMap(state); + const twigKeys = Array.from(state.nodes.keys()).filter((nodeKey) => { + const { row } = parseKey(nodeKey); + return ( + row !== state.config.rows - 1 && + !childrenMap.has(nodeKey) && + parentMap.has(nodeKey) + ); + }); - if (twigKeys.length === 0) { - return { - killedKeys: [], - event: null, - }; - } + if (twigKeys.length === 0) { + return { + killedKeys: [], + event: null, + }; + } - const shuffled = shuffleArray(twigKeys); - const killCount = Math.min(shuffled.length, 1 + Math.floor(Math.random() * Math.min(3, shuffled.length))); - const killedKeys = shuffled.slice(0, killCount); + const shuffled = shuffleArray(twigKeys); + const killCount = Math.min( + shuffled.length, + 1 + Math.floor(Math.random() * Math.min(3, shuffled.length)), + ); + const killedKeys = shuffled.slice(0, killCount); - return { - killedKeys, - event: `Disease spread through ${killCount} twig node${killCount === 1 ? "" : "s"} before the next round.`, - }; + return { + killedKeys, + event: `Disease spread through ${killCount} twig node${killCount === 1 ? "" : "s"} before the next round.`, + }; } diff --git a/src/engine/rules-weather.ts b/src/engine/rules-weather.ts index d6a293a..a3f4500 100644 --- a/src/engine/rules-weather.ts +++ b/src/engine/rules-weather.ts @@ -3,8 +3,9 @@ import { shuffleArray } from "./utils"; export const WEATHER_CARDS: WeatherCardDefinition[] = [ { id: "leaf_surge", title: "Leaf Surge", description: "Each leaf gives +1." }, - { id: "branching_season", title: "Branching Season", description: "Each leaf after your first gives +1." }, - { id: "storehouse", title: "Storehouse", description: "Banking is enabled this round." }, + { id: "branching_season", title: "Branching Season", description: "Each leaf after your first gives +1. Player with most branches gets 50% more energy (rounded down)." }, + { id: "storehouse", title: "Storehouse", description: "Banked energy is safe but earns no interest. Lose 1 banked energy (min 0)." }, + { id: "compound_interest", title: "Compound Interest", description: "Gain 20% interest on all banked energy (rounded down)." }, { id: "sun_ladder", title: "Sun Ladder", description: "Your first 3 vertical growths cost 0." }, { id: "west_light", title: "West Light", description: "Left third energy gets +50%, rounded up." }, { id: "east_light", title: "East Light", description: "Right third energy gets +50%, rounded up." }, @@ -12,8 +13,9 @@ export const WEATHER_CARDS: WeatherCardDefinition[] = [ { id: "edge_bloom", title: "Edge Bloom", description: "Edge columns give +1." }, { id: "wide_reach", title: "Wide Reach", description: "Most columns gets +2." }, { id: "tall_reward", title: "Tall Reward", description: "Tallest leaf on the board gives +2." }, + { id: "deep_roots", title: "Deep Roots", description: "Shortest plant receives +4 energy." }, { id: "stalemate", title: "Stalemate", description: "Contested columns give no energy." }, - { id: "split_light", title: "Split Light", description: "Contested columns give half to each player there." }, + { id: "split_light", title: "Split Light", description: "Contested columns give 1 energy to each player there." }, ]; export const WEATHER_CARD_LOOKUP = new Map( @@ -22,18 +24,27 @@ export const WEATHER_CARD_LOOKUP = new Map export const WEATHER_OFFER_PAIRS: WeatherOfferPair[] = [ { id: "growth_mix", options: ["leaf_surge", "branching_season"] }, - { id: "tempo_tools", options: ["storehouse", "sun_ladder"] }, + { id: "banking_mix", options: ["storehouse", "compound_interest"] }, + { id: "tempo", options: ["sun_ladder", "edge_bloom"] }, { id: "side_bias", options: ["west_light", "east_light"] }, - { id: "shape_bias", options: ["high_noon", "edge_bloom"] }, - { id: "reward_shape", options: ["wide_reach", "tall_reward"] }, + { id: "shape", options: ["high_noon", "wide_reach"] }, + { id: "height", options: ["tall_reward", "deep_roots"] }, { id: "contest_soft", options: ["stalemate", "split_light"] }, ]; export function createWeatherDraft(state: GameState): WeatherDraftState { const rowSize = Math.min(WEATHER_OFFER_PAIRS.length, state.config.weatherDraftCount); + + // Rotate draft order based on round number to ensure fairness + const rotatedOrder = [...state.turnOrder]; + const rotation = (state.round - 1) % state.turnOrder.length; + if (rotation > 0) { + const start = rotatedOrder.splice(0, rotation); + rotatedOrder.push(...start); + } return { - playerOrder: [...state.turnOrder], + playerOrder: rotatedOrder, draftIndex: 0, offers: shuffleArray(WEATHER_OFFER_PAIRS).slice(0, rowSize), drafted: [], diff --git a/src/engine/state.ts b/src/engine/state.ts index 665bf5c..b7367c8 100644 --- a/src/engine/state.ts +++ b/src/engine/state.ts @@ -6,16 +6,47 @@ export function createDefaultPaletteOrder(playerCount: number) { return Array.from({ length: playerCount }, (_, index) => index % PLAYER_PALETTE.length); } +function getMinimumColumnsForEvenSpacing(playerCount: number, minColumns: number): number { + // We need columns to be divisible by (playerCount + 1) for even spacing + // Each player needs at least 1 column, so minimum is playerCount + // But for even spacing from edges, we need: columns % (playerCount + 1) === 0 + const spacingDivisor = playerCount + 1; + + // Start from at least minColumns or playerCount (whichever is larger) + let columns = Math.max(minColumns, playerCount); + + // Increase columns until it's divisible by (playerCount + 1) + while (columns % spacingDivisor !== 0) { + columns += 1; + } + + return columns; +} + export function createDefaultSeedInputs(playerCount: number, columns: number, startingNodesPerPlayer: number) { - const totalSeeds = playerCount * startingNodesPerPlayer; - const positions = Array.from({ length: totalSeeds }, (_, index) => Math.floor(((index + 0.5) * columns) / totalSeeds)); + // Calculate spacing to place players equidistant from each other and edges + // Formula: space = columns / (playerCount + 1) + // This ensures equal spacing between players and from edges + const spacing = columns / (playerCount + 1); return Array.from({ length: playerCount }, (_, playerId) => { - const start = playerId * startingNodesPerPlayer; - return positions - .slice(start, start + startingNodesPerPlayer) - .map((column) => String(column + 1)) - .join(", "); + const playerCenter = Math.round((playerId + 1) * spacing); + + // For multiple seeds per player, spread them around the center + if (startingNodesPerPlayer === 1) { + return String(playerCenter + 1); + } + + // For multiple seeds, alternate left and right from center + const positions = []; + for (let i = 0; i < startingNodesPerPlayer; i++) { + // Alternate: 0, +1, -1, +2, -2, etc. + const offset = i === 0 ? 0 : (i % 2 === 1 ? Math.ceil(i / 2) : -Math.ceil(i / 2)); + const pos = Math.max(1, Math.min(columns, playerCenter + offset + 1)); + positions.push(pos); + } + + return positions.join(", "); }); } @@ -80,19 +111,23 @@ export function createSetupState( initiativeMode: SetupState["initiativeMode"] = "fixed", biddingOrderRule: SetupState["biddingOrderRule"] = "rotating", weatherDraftEnabled = true, - weatherDraftCount = playerCount + 2, + weatherDraftCount = playerCount, + bankingEnabled = true, winCondition: SetupState["winCondition"] = "rounds", maxRounds = 12, topLeafTarget = 4, ): SetupState { - const clampedSeeds = Math.min(startingNodesPerPlayer, getMaxStartingNodesPerPlayer(playerCount, columns)); - const defaults = createDefaultSeedInputs(playerCount, columns, clampedSeeds); + // Adjust columns to ensure even spacing between players and edges + const adjustedColumns = getMinimumColumnsForEvenSpacing(playerCount, columns); + + const clampedSeeds = Math.min(startingNodesPerPlayer, getMaxStartingNodesPerPlayer(playerCount, adjustedColumns)); + const defaults = createDefaultSeedInputs(playerCount, adjustedColumns, clampedSeeds); const paletteDefaults = createDefaultPaletteOrder(playerCount); return { playerCount, playerNames: Array.from({ length: playerCount }, (_, index) => playerNames?.[index] ?? `Player ${index + 1}`), - columns, + columns: adjustedColumns, rows, startingNodesPerPlayer: clampedSeeds, sunbeamChance, @@ -103,9 +138,10 @@ export function createSetupState( biddingOrderRule, weatherDraftEnabled, weatherDraftCount: Math.max(1, weatherDraftCount), + bankingEnabled, winCondition, maxRounds, - topLeafTarget: Math.max(1, Math.min(columns, topLeafTarget)), + topLeafTarget: Math.max(1, Math.min(adjustedColumns, topLeafTarget)), }; } @@ -193,6 +229,7 @@ export function createInitialState(setup: SetupState): GameState { biddingOrderRule: setup.biddingOrderRule, weatherDraftEnabled: setup.weatherDraftEnabled, weatherDraftCount: setup.weatherDraftCount, + bankingEnabled: setup.bankingEnabled, winCondition: setup.winCondition, maxRounds: setup.maxRounds, topLeafTarget: setup.topLeafTarget, diff --git a/src/engine/types.ts b/src/engine/types.ts index 08c6960..a54372c 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -26,6 +26,7 @@ export type SetupState = { biddingOrderRule: "rotating" | "lowest_growth_income"; weatherDraftEnabled: boolean; weatherDraftCount: number; + bankingEnabled: boolean; winCondition: "rounds" | "top_leaves"; maxRounds: number; topLeafTarget: number; @@ -55,6 +56,7 @@ export type GameConfig = { biddingOrderRule: SetupState["biddingOrderRule"]; weatherDraftEnabled: boolean; weatherDraftCount: number; + bankingEnabled: boolean; winCondition: SetupState["winCondition"]; maxRounds: number; topLeafTarget: number; @@ -124,6 +126,7 @@ export type RootBurst = { key: NodeKey; playerId: PlayerId; count: number; + displayCount: number; }; export type EnergySimulation = { @@ -188,6 +191,7 @@ export type WeatherCardId = | "leaf_surge" | "branching_season" | "storehouse" + | "compound_interest" | "sun_ladder" | "west_light" | "east_light" @@ -195,6 +199,7 @@ export type WeatherCardId = | "edge_bloom" | "wide_reach" | "tall_reward" + | "deep_roots" | "stalemate" | "split_light"; diff --git a/src/main.js b/src/main.js deleted file mode 100644 index 85a3fc2..0000000 --- a/src/main.js +++ /dev/null @@ -1,1508 +0,0 @@ -import "./styles.css"; - -const STARTING_POINTS = 3; -const ROOT_SHIFT_COST = 1; -const ROUND_ANIMATION_SUN_MS = 900; -const ROUND_ANIMATION_BRANCH_MS = 1200; -const ROUND_ANIMATION_BONUS_MS = 900; -const PLAYER_PALETTE = [ - { name: "Coral", primary: "#ff6b8a", glow: "rgba(255, 107, 138, 0.35)" }, - { name: "Aqua", primary: "#4de0ff", glow: "rgba(77, 224, 255, 0.35)" }, - { name: "Amber", primary: "#ffbf47", glow: "rgba(255, 191, 71, 0.35)" }, - { name: "Mint", primary: "#6fffb0", glow: "rgba(111, 255, 176, 0.35)" }, - { name: "Violet", primary: "#b28dff", glow: "rgba(178, 141, 255, 0.35)" }, - { name: "Rose", primary: "#ff8dbf", glow: "rgba(255, 141, 191, 0.35)" }, -]; - -const app = document.querySelector("#app"); -let roundAnimationToken = 0; - -function keyFor(row, column) { - return `${row}:${column}`; -} - -function parseKey(key) { - const [row, column] = key.split(":").map(Number); - return { row, column }; -} - -function hexToRgb(hex) { - const value = hex.replace("#", ""); - const normalized = value.length === 3 - ? value.split("").map((part) => part + part).join("") - : value; - const int = Number.parseInt(normalized, 16); - return { - r: (int >> 16) & 255, - g: (int >> 8) & 255, - b: int & 255, - }; -} - -function tint(hex, alpha = 0.16) { - const { r, g, b } = hexToRgb(hex); - return `rgba(${r}, ${g}, ${b}, ${alpha})`; -} - -function wait(milliseconds) { - return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); -} - -function shuffleArray(items) { - const next = [...items]; - - for (let index = next.length - 1; index > 0; index -= 1) { - const swapIndex = Math.floor(Math.random() * (index + 1)); - [next[index], next[swapIndex]] = [next[swapIndex], next[index]]; - } - - return next; -} - -function createDefaultPaletteOrder(playerCount) { - return Array.from({ length: playerCount }, (_, index) => index % PLAYER_PALETTE.length); -} - -function createDefaultSeedInputs(playerCount, columns, startingNodesPerPlayer) { - const totalSeeds = playerCount * startingNodesPerPlayer; - const positions = Array.from({ length: totalSeeds }, (_, index) => { - return Math.floor(((index + 0.5) * columns) / totalSeeds); - }); - - return Array.from({ length: playerCount }, (_, playerId) => { - const start = playerId * startingNodesPerPlayer; - return positions - .slice(start, start + startingNodesPerPlayer) - .map((column) => String(column + 1)) - .join(", "); - }); -} - -function pickNearestOpenColumn(preferredColumn, columns, usedColumns) { - if (!usedColumns.has(preferredColumn)) { - return preferredColumn; - } - - for (let distance = 1; distance < columns; distance += 1) { - const left = preferredColumn - distance; - if (left >= 0 && !usedColumns.has(left)) { - return left; - } - - const right = preferredColumn + distance; - if (right < columns && !usedColumns.has(right)) { - return right; - } - } - - return preferredColumn; -} - -function createRandomizedSeedInputs(playerCount, columns, startingNodesPerPlayer) { - const zoneWidth = columns / playerCount; - const usedColumns = new Set(); - - return Array.from({ length: playerCount }, (_, playerId) => { - const picks = []; - - for (let seedIndex = 0; seedIndex < startingNodesPerPlayer; seedIndex += 1) { - const localRatio = (seedIndex + 1) / (startingNodesPerPlayer + 1); - const center = (playerId + localRatio) * zoneWidth; - const subZoneWidth = zoneWidth / (startingNodesPerPlayer + 1); - const maxJitter = Math.max(0.35, Math.min(zoneWidth * 0.22, subZoneWidth * 0.42)); - const jitter = (Math.random() * 2 - 1) * maxJitter; - const preferredColumn = Math.max(0, Math.min(columns - 1, Math.round(center + jitter - 0.5))); - const chosenColumn = pickNearestOpenColumn(preferredColumn, columns, usedColumns); - - usedColumns.add(chosenColumn); - picks.push(chosenColumn + 1); - } - - return picks.sort((left, right) => left - right).join(", "); - }); -} - -function getMaxStartingNodesPerPlayer(playerCount, columns) { - return Math.max(1, Math.floor(columns / playerCount)); -} - -function createSetupState( - playerCount = 3, - columns = 18, - rows = 16, - startingNodesPerPlayer = 1, - sunbeamChance = 0, - diseaseChance = 0, - seedInputs = null, - paletteOrder = null, - shuffleTurnOrder = true, -) { - const clampedSeeds = Math.min(startingNodesPerPlayer, getMaxStartingNodesPerPlayer(playerCount, columns)); - const defaults = createDefaultSeedInputs(playerCount, columns, clampedSeeds); - const paletteDefaults = createDefaultPaletteOrder(playerCount); - - return { - playerCount, - columns, - rows, - startingNodesPerPlayer: clampedSeeds, - sunbeamChance, - diseaseChance, - seedInputs: Array.from({ length: playerCount }, (_, index) => seedInputs?.[index] ?? defaults[index]), - paletteOrder: Array.from({ length: playerCount }, (_, index) => paletteOrder?.[index] ?? paletteDefaults[index]), - shuffleTurnOrder, - }; -} - -function createPlayers(playerCount, paletteOrder = createDefaultPaletteOrder(playerCount)) { - return Array.from({ length: playerCount }, (_, index) => { - const palette = PLAYER_PALETTE[paletteOrder[index] % PLAYER_PALETTE.length]; - return { - id: index, - name: `Player ${index + 1}`, - color: palette.primary, - glow: palette.glow, - totalScore: 0, - roundScore: 0, - growthPoints: STARTING_POINTS, - bankedPoints: 0, - bonusPoints: 0, - passed: false, - }; - }); -} - -function normalizeSeedInputs(setup) { - const assigned = new Set(); - const fallback = createDefaultSeedInputs(setup.playerCount, setup.columns, setup.startingNodesPerPlayer) - .map((input) => input.split(",").map((part) => Number(part.trim()) - 1)); - - return Array.from({ length: setup.playerCount }, (_, playerId) => { - const requested = (setup.seedInputs[playerId] ?? "") - .split(",") - .map((part) => Number.parseInt(part.trim(), 10) - 1) - .filter((column) => Number.isInteger(column) && column >= 0 && column < setup.columns); - - const uniqueColumns = []; - requested.forEach((column) => { - if (!assigned.has(column) && uniqueColumns.length < setup.startingNodesPerPlayer) { - assigned.add(column); - uniqueColumns.push(column); - } - }); - - fallback[playerId].forEach((column) => { - if (!assigned.has(column) && uniqueColumns.length < setup.startingNodesPerPlayer) { - assigned.add(column); - uniqueColumns.push(column); - } - }); - - for (let column = 0; column < setup.columns && uniqueColumns.length < setup.startingNodesPerPlayer; column += 1) { - if (!assigned.has(column)) { - assigned.add(column); - uniqueColumns.push(column); - } - } - - return uniqueColumns; - }); -} - -function createInitialState(setup) { - const playerPaletteOrder = setup.shuffleTurnOrder - ? shuffleArray(setup.paletteOrder) - : [...setup.paletteOrder]; - const players = createPlayers(setup.playerCount, playerPaletteOrder); - const nodes = new Map(); - const edges = []; - const seedColumnsByPlayer = normalizeSeedInputs(setup); - - seedColumnsByPlayer.forEach((seedColumns, index) => { - seedColumns.forEach((column) => { - nodes.set(keyFor(setup.rows - 1, column), { ownerId: index }); - }); - }); - - return { - config: { - columns: setup.columns, - rows: setup.rows, - playerCount: setup.playerCount, - startingNodesPerPlayer: setup.startingNodesPerPlayer, - playerPaletteOrder, - }, - players, - nodes, - edges, - round: 1, - activePlayerId: 0, - turnMoves: [], - selectedSource: null, - availableTargets: [], - animation: null, - randomEffects: { - sunbeamChance: setup.sunbeamChance, - diseaseChance: setup.diseaseChance, - }, - gameOver: false, - history: [ - `Round 1 begins on a ${setup.columns}x${setup.rows} board with ${setup.startingNodesPerPlayer} starting node${setup.startingNodesPerPlayer === 1 ? "" : "s"} each.`, - `${setup.shuffleTurnOrder ? "Turn order was randomized for this game." : "Turn order uses the setup order."}`, - ], - roundSummary: null, - }; -} - -let setup = createSetupState(); -let state = createInitialState(setup); -let isNewGameModalOpen = false; -let previousScoreSnapshot = null; - -function getScoreSnapshot() { - return state.players.map((player) => ({ - totalScore: player.totalScore, - roundScore: player.roundScore, - growthPoints: player.growthPoints, - bankedPoints: player.bankedPoints, - })); -} - -function getCurrentPlayer() { - return state.players[state.activePlayerId]; -} - -function getNodeOwner(row, column) { - return state.nodes.get(keyFor(row, column))?.ownerId ?? null; -} - -function buildParentMap() { - return new Map(state.edges.map((edge) => [keyFor(edge.to.row, edge.to.column), keyFor(edge.from.row, edge.from.column)])); -} - -function buildChildrenMap(ownerId = null) { - const childrenMap = new Map(); - - state.edges.forEach((edge) => { - if (ownerId !== null && edge.ownerId !== ownerId) { - return; - } - - const fromKey = keyFor(edge.from.row, edge.from.column); - const target = keyFor(edge.to.row, edge.to.column); - const entry = childrenMap.get(fromKey) ?? []; - entry.push(target); - childrenMap.set(fromKey, entry); - }); - - return childrenMap; -} - -function isRootNode(nodeKey, player) { - const { row } = parseKey(nodeKey); - return row === state.config.rows - 1 && state.nodes.get(nodeKey)?.ownerId === player.id && !buildParentMap().has(nodeKey); -} - -function collectSubtreeKeys(rootKey, player) { - const childrenMap = buildChildrenMap(player.id); - const subtree = new Set([rootKey]); - const queue = [rootKey]; - - while (queue.length > 0) { - const current = queue.shift(); - (childrenMap.get(current) ?? []).forEach((childKey) => { - if (!subtree.has(childKey)) { - subtree.add(childKey); - queue.push(childKey); - } - }); - } - - return subtree; -} - -function getRootShiftMove(sourceKey, delta, player) { - if (state.round !== 1 || player.growthPoints < ROOT_SHIFT_COST) { - return null; - } - - const parentMap = buildParentMap(); - const source = parseKey(sourceKey); - if (source.row !== state.config.rows - 1 || state.nodes.get(sourceKey)?.ownerId !== player.id || parentMap.has(sourceKey)) { - return null; - } - - const subtree = collectSubtreeKeys(sourceKey, player); - const movedNodes = []; - - for (const nodeKey of subtree) { - const node = parseKey(nodeKey); - const targetColumn = node.column + delta; - if (targetColumn < 0 || targetColumn >= state.config.columns) { - return null; - } - - const targetKey = keyFor(node.row, targetColumn); - if (!subtree.has(targetKey) && state.nodes.has(targetKey)) { - return null; - } - - movedNodes.push({ fromKey: nodeKey, toKey: targetKey, row: node.row, fromColumn: node.column, toColumn: targetColumn }); - } - - const movedEdges = state.edges - .filter((edge) => subtree.has(keyFor(edge.from.row, edge.from.column)) && subtree.has(keyFor(edge.to.row, edge.to.column))) - .map((edge) => ({ - before: { - from: { ...edge.from }, - to: { ...edge.to }, - ownerId: edge.ownerId, - }, - after: { - from: { row: edge.from.row, column: edge.from.column + delta }, - to: { row: edge.to.row, column: edge.to.column + delta }, - ownerId: edge.ownerId, - }, - })); - - return { - type: "shift", - cost: ROOT_SHIFT_COST, - direction: delta < 0 ? "left" : "right", - movedNodes, - movedEdges, - undoKeys: movedNodes.map((node) => node.toKey), - selectKey: keyFor(source.row, source.column + delta), - }; -} - -function getSelectedRootShiftMoves() { - const player = getCurrentPlayer(); - if (!state.selectedSource) { - return []; - } - - return [-1, 1] - .map((delta) => getRootShiftMove(state.selectedSource, delta, player)) - .filter(Boolean); -} - -function playerHasRootShiftMove(player) { - if (state.round !== 1 || player.growthPoints < ROOT_SHIFT_COST) { - return false; - } - - return Array.from(state.nodes.entries()).some(([nodeKey, node]) => { - if (node.ownerId !== player.id) { - return false; - } - - return Boolean(getRootShiftMove(nodeKey, -1, player) || getRootShiftMove(nodeKey, 1, player)); - }); -} - -function getLegalMovesForSource(sourceKey, player) { - const columns = state.config.columns; - const { row, column } = parseKey(sourceKey); - if (player.id !== getNodeOwner(row, column) || row === 0) { - return []; - } - - const moves = [ - { row: row - 1, column, cost: 1, direction: "vertical" }, - { row: row - 1, column: column - 1, cost: 2, direction: "left" }, - { row: row - 1, column: column + 1, cost: 2, direction: "right" }, - ]; - - return moves.filter((move) => { - if (move.column < 0 || move.column >= columns) { - return false; - } - - if (state.nodes.has(keyFor(move.row, move.column))) { - return false; - } - - return move.cost <= player.growthPoints; - }); -} - -function playerHasLegalMove(player) { - if (player.growthPoints <= 0) { - return false; - } - - return Array.from(state.nodes.entries()).some(([nodeKey, node]) => { - if (node.ownerId !== player.id) { - return false; - } - - return getLegalMovesForSource(nodeKey, player).length > 0; - }) || playerHasRootShiftMove(player); -} - -function findTurnMoveIndex(targetKey) { - return state.turnMoves.findIndex((move) => (move.undoKeys ?? [move.targetKey]).includes(targetKey)); -} - -function applyShiftMove(move, player) { - move.movedNodes.forEach((node) => { - state.nodes.delete(node.fromKey); - }); - - move.movedNodes.forEach((node) => { - state.nodes.set(node.toKey, { ownerId: player.id }); - }); - - move.movedEdges.forEach((edgeMove) => { - const edgeIndex = state.edges.findIndex((edge) => { - return edge.ownerId === edgeMove.before.ownerId - && edge.from.row === edgeMove.before.from.row - && edge.from.column === edgeMove.before.from.column - && edge.to.row === edgeMove.before.to.row - && edge.to.column === edgeMove.before.to.column; - }); - - if (edgeIndex !== -1) { - state.edges[edgeIndex] = { - from: { ...edgeMove.after.from }, - to: { ...edgeMove.after.to }, - ownerId: edgeMove.after.ownerId, - }; - } - }); - - player.growthPoints -= move.cost; - state.turnMoves.push(move); - state.history.unshift(`${player.name} shifted a root ${move.direction} for ${move.cost} point.`); - updateSelection(move.selectKey); - render(); -} - -function undoTurnMove(move, player) { - if (move.type === "shift") { - move.movedNodes.forEach((node) => { - state.nodes.delete(node.toKey); - }); - - move.movedNodes.forEach((node) => { - state.nodes.set(node.fromKey, { ownerId: player.id }); - }); - - move.movedEdges.forEach((edgeMove) => { - const edgeIndex = state.edges.findIndex((edge) => { - return edge.ownerId === edgeMove.after.ownerId - && edge.from.row === edgeMove.after.from.row - && edge.from.column === edgeMove.after.from.column - && edge.to.row === edgeMove.after.to.row - && edge.to.column === edgeMove.after.to.column; - }); - - if (edgeIndex !== -1) { - state.edges[edgeIndex] = { - from: { ...edgeMove.before.from }, - to: { ...edgeMove.before.to }, - ownerId: edgeMove.before.ownerId, - }; - } - }); - - player.growthPoints += move.cost; - return; - } - - state.nodes.delete(move.targetKey); - const edgeIndex = state.edges.findIndex((edge) => { - return edge.ownerId === player.id - && edge.from.row === move.from.row - && edge.from.column === move.from.column - && edge.to.row === move.to.row - && edge.to.column === move.to.column; - }); - - if (edgeIndex !== -1) { - state.edges.splice(edgeIndex, 1); - } - - player.growthPoints += move.cost; -} - -function applyDisease(killedKeys) { - const killed = new Set(killedKeys); - if (killed.size === 0) { - return; - } - - state.edges = state.edges.filter((edge) => { - return !killed.has(keyFor(edge.to.row, edge.to.column)) && !killed.has(keyFor(edge.from.row, edge.from.column)); - }); - killedKeys.forEach((key) => { - state.nodes.delete(key); - }); -} - -function buildEnergySimulation() { - const parentMap = buildParentMap(); - const columns = []; - const scores = state.players.map(() => 0); - - for (let column = 0; column < state.config.columns; column += 1) { - let hitNodeKey = null; - - for (let row = 0; row < state.config.rows; row += 1) { - const nodeKey = keyFor(row, column); - if (state.nodes.has(nodeKey)) { - hitNodeKey = nodeKey; - break; - } - } - - if (!hitNodeKey) { - columns.push({ - column, - terminalRow: state.config.rows - 1, - intercepted: false, - ownerId: null, - hitNode: null, - rootKey: null, - branchNodes: [], - branchEdges: [], - }); - continue; - } - - const hitNode = parseKey(hitNodeKey); - const ownerId = state.nodes.get(hitNodeKey).ownerId; - const branchNodes = [hitNode]; - const branchEdges = []; - let cursor = hitNodeKey; - - while (parentMap.has(cursor)) { - const parentKey = parentMap.get(cursor); - branchEdges.push({ from: parseKey(cursor), to: parseKey(parentKey) }); - branchNodes.push(parseKey(parentKey)); - cursor = parentKey; - } - - scores[ownerId] += 1; - columns.push({ - column, - terminalRow: hitNode.row, - intercepted: true, - ownerId, - hitNode, - rootKey: cursor, - branchNodes, - branchEdges, - }); - } - - const rootBursts = Array.from(columns.reduce((map, column) => { - if (!column.intercepted) { - return map; - } - - const entry = map.get(column.rootKey) ?? { key: column.rootKey, playerId: column.ownerId, count: 0 }; - entry.count += 1; - map.set(column.rootKey, entry); - return map; - }, new Map()).values()); - - return { - scores, - columns, - rootBursts, - }; -} - -function buildRoundAnimation(energySimulation, sunbeamPlayerId, diseaseKeys) { - const traces = energySimulation.columns - .filter((column) => column.intercepted) - .map((column) => ({ - playerId: column.ownerId, - verticalCells: Array.from({ length: column.terminalRow + 1 }, (_, row) => ({ row, column: column.column })), - ray: { - x: ((column.column + 0.5) / state.config.columns) * 100, - y: ((column.terminalRow + 0.5) / state.config.rows) * 100, - }, - rootKey: column.rootKey, - branchNodes: column.branchNodes, - })); - - const bonusTrace = sunbeamPlayerId === null - ? null - : traces.find((trace) => trace.playerId === sunbeamPlayerId) ?? null; - - const bonusBurst = bonusTrace - ? energySimulation.rootBursts.find((burst) => burst.key === bonusTrace.rootKey) ?? null - : null; - - return { - phase: "sunlight", - columns: energySimulation.columns, - traces, - rootBursts: energySimulation.rootBursts, - sunbeamPlayerId, - bonusTrace, - bonusBurst, - diseaseKeys, - }; -} - -function getColumnLeaders() { - return Array.from({ length: state.config.columns }, (_, column) => { - for (let row = 0; row < state.config.rows; row += 1) { - const owner = getNodeOwner(row, column); - if (owner !== null) { - return { ownerId: owner, row, tied: false }; - } - } - - return { ownerId: null, row: null, tied: false }; - }); -} - -function scoreColumns() { - const energySimulation = buildEnergySimulation(); - const columnResults = energySimulation.columns.map((column) => ({ - column: column.column, - ownerId: column.ownerId, - topRow: column.intercepted ? column.terminalRow : null, - tied: false, - })); - - return { scores: energySimulation.scores, columnResults, energySimulation }; -} - -function maybeRollSunbeam(scores) { - const nextGrowth = scores.map((score) => score + 1); - const { sunbeamChance } = state.randomEffects; - - if (sunbeamChance <= 0 || Math.random() * 100 >= sunbeamChance) { - return { - nextGrowth, - event: null, - awardedPlayer: null, - }; - } - - const awardedPlayer = Math.floor(Math.random() * state.players.length); - nextGrowth[awardedPlayer] += 1; - - return { - nextGrowth, - awardedPlayer, - event: `${state.players[awardedPlayer].name} caught a stray sunbeam and gains +1 growth next round.`, - }; -} - -function maybeRollDisease() { - const { diseaseChance } = state.randomEffects; - if (diseaseChance <= 0 || Math.random() * 100 >= diseaseChance) { - return { - killedKeys: [], - event: null, - }; - } - - const childrenMap = buildChildrenMap(); - const parentMap = buildParentMap(); - const twigKeys = Array.from(state.nodes.keys()).filter((nodeKey) => { - const { row } = parseKey(nodeKey); - return row !== state.config.rows - 1 && !childrenMap.has(nodeKey) && parentMap.has(nodeKey); - }); - - if (twigKeys.length === 0) { - return { - killedKeys: [], - event: null, - }; - } - - const shuffled = shuffleArray(twigKeys); - const killCount = Math.min(shuffled.length, 1 + Math.floor(Math.random() * Math.min(3, shuffled.length))); - const killedKeys = shuffled.slice(0, killCount); - - return { - killedKeys, - event: `Disease spread through ${killCount} twig node${killCount === 1 ? "" : "s"} before the next round.`, - }; -} - -function updateSelection(sourceKey = null) { - const player = getCurrentPlayer(); - state.selectedSource = sourceKey; - state.availableTargets = sourceKey ? getLegalMovesForSource(sourceKey, player) : []; -} - -function isInteractionLocked() { - return state.gameOver || Boolean(state.animation); -} - -function advanceTurn() { - if (state.players.every((player) => player.passed || !playerHasLegalMove(player))) { - endRound(); - return; - } - - let nextPlayerId = state.activePlayerId; - for (let step = 0; step < state.players.length; step += 1) { - nextPlayerId = (nextPlayerId + 1) % state.players.length; - const candidate = state.players[nextPlayerId]; - - if (!candidate.passed && playerHasLegalMove(candidate)) { - state.activePlayerId = nextPlayerId; - state.turnMoves = []; - updateSelection(null); - render(); - return; - } - } - - endRound(); -} - -function moveToFirstPlayableTurn() { - const nextPlayerId = state.players.findIndex((player) => playerHasLegalMove(player)); - - if (nextPlayerId === -1) { - state.gameOver = true; - state.history.unshift("No player can grow further. Final totals are locked."); - return false; - } - - state.activePlayerId = nextPlayerId; - state.turnMoves = []; - updateSelection(null); - return true; -} - -function undoMovesThrough(targetKey) { - const player = getCurrentPlayer(); - const moveIndex = findTurnMoveIndex(targetKey); - - if (moveIndex === -1) { - return false; - } - - const undoneMoves = state.turnMoves.slice(moveIndex).reverse(); - undoneMoves.forEach((move) => { - undoTurnMove(move, player); - }); - - state.turnMoves = state.turnMoves.slice(0, moveIndex); - state.history.unshift(`${player.name} rewound ${undoneMoves.length} move${undoneMoves.length === 1 ? "" : "s"} before ending the turn.`); - - const lastMove = state.turnMoves.at(-1); - const newSelection = lastMove?.selectKey ?? lastMove?.targetKey ?? null; - updateSelection(newSelection); - render(); - return true; -} - -function growTo(target) { - const player = getCurrentPlayer(); - if (!state.selectedSource) { - return; - } - - const source = parseKey(state.selectedSource); - const targetKey = keyFor(target.row, target.column); - if (state.nodes.has(targetKey) || target.cost > player.growthPoints) { - return; - } - - state.nodes.set(targetKey, { ownerId: player.id }); - state.edges.push({ - from: { row: source.row, column: source.column }, - to: { row: target.row, column: target.column }, - ownerId: player.id, - }); - state.turnMoves.push({ - type: "grow", - from: source, - to: { row: target.row, column: target.column }, - cost: target.cost, - targetKey, - undoKeys: [targetKey], - selectKey: targetKey, - }); - player.growthPoints -= target.cost; - state.history.unshift( - `${player.name} grew ${target.direction === "vertical" ? "upward" : `diagonally ${target.direction}`} for ${target.cost} point${target.cost === 1 ? "" : "s"}.` - ); - - updateSelection(targetKey); - render(); -} - -function shiftSelectedRoot(delta) { - const player = getCurrentPlayer(); - if (!state.selectedSource) { - return; - } - - const move = getRootShiftMove(state.selectedSource, delta, player); - if (!move) { - return; - } - - applyShiftMove(move, player); -} - -function endTurn() { - const player = getCurrentPlayer(); - if (player.growthPoints > 0) { - const confirmed = window.confirm( - `${player.name} still has ${player.growthPoints} growth point${player.growthPoints === 1 ? "" : "s"}. End the turn and lose them?` - ); - - if (!confirmed) { - return; - } - } - - const lostGrowth = player.growthPoints; - state.turnMoves = []; - player.growthPoints = 0; - player.passed = true; - state.history.unshift( - `${player.name} ended their turn${lostGrowth > 0 ? ` and let ${lostGrowth} growth point${lostGrowth === 1 ? "" : "s"} wither` : ""}.` - ); - advanceTurn(); -} - -function bankGrowthAndEndTurn() { - const player = getCurrentPlayer(); - if (player.growthPoints <= 0) { - endTurn(); - return; - } - - player.bankedPoints += player.growthPoints; - state.history.unshift( - `${player.name} banked ${player.growthPoints} growth point${player.growthPoints === 1 ? "" : "s"} for next round.` - ); - player.growthPoints = 0; - state.turnMoves = []; - player.passed = true; - advanceTurn(); -} - -async function endRound() { - const { scores, columnResults, energySimulation } = scoreColumns(); - const { nextGrowth, event: sunbeamEvent, awardedPlayer } = maybeRollSunbeam(scores); - const { killedKeys, event: diseaseEvent } = maybeRollDisease(); - const token = ++roundAnimationToken; - - state.animation = buildRoundAnimation(energySimulation, awardedPlayer, killedKeys); - render(); - - await wait(ROUND_ANIMATION_SUN_MS); - if (token !== roundAnimationToken) { - return; - } - - state.animation = { ...state.animation, phase: "branches" }; - render(); - - await wait(ROUND_ANIMATION_BRANCH_MS); - if (token !== roundAnimationToken) { - return; - } - - if (awardedPlayer !== null) { - state.animation = { ...state.animation, phase: "bonus" }; - render(); - - await wait(ROUND_ANIMATION_BONUS_MS); - if (token !== roundAnimationToken) { - return; - } - } - - state.animation = { ...state.animation, phase: "events" }; - render(); - - await wait(900); - if (token !== roundAnimationToken) { - return; - } - - applyDisease(killedKeys); - - state.players.forEach((player, index) => { - player.roundScore = scores[index]; - player.totalScore += scores[index]; - player.bonusPoints = nextGrowth[index] - scores[index]; - player.growthPoints = nextGrowth[index] + player.bankedPoints; - player.bankedPoints = 0; - player.passed = false; - }); - - state.roundSummary = { - scores, - columnResults, - event: [sunbeamEvent, diseaseEvent].filter(Boolean).join(" "), - }; - - state.history.unshift( - `Round ${state.round} scored. ${state.players.map((player) => `${player.name}: ${player.roundScore}`).join(" | ")}` - ); - [sunbeamEvent, diseaseEvent].filter(Boolean).forEach((eventText) => { - state.history.unshift(eventText); - }); - - state.animation = null; - - const boardFull = state.nodes.size >= state.config.columns * state.config.rows || state.players.every((player) => !playerHasLegalMove(player)); - if (boardFull) { - state.gameOver = true; - state.history.unshift("The canopy is complete. Final totals are locked."); - render(); - return; - } - - state.round += 1; - state.history.unshift(`Round ${state.round} begins.`); - moveToFirstPlayableTurn(); - render(); -} - -function resetGame() { - roundAnimationToken += 1; - state = createInitialState(setup); - moveToFirstPlayableTurn(); - render(); -} - -function openNewGameModal() { - isNewGameModalOpen = true; - render(); -} - -function closeNewGameModal() { - isNewGameModalOpen = false; - render(); -} - -function startNewGameFromModal() { - isNewGameModalOpen = false; - resetGame(); -} - -function finishGameNow() { - if (state.gameOver) { - return; - } - - roundAnimationToken += 1; - state.animation = null; - const { scores, columnResults } = scoreColumns(); - state.players.forEach((player, index) => { - player.roundScore = scores[index]; - player.totalScore += scores[index]; - }); - state.roundSummary = { scores, columnResults, event: null }; - state.gameOver = true; - state.history.unshift("Game ended manually. Final scores tallied from the current canopy."); - render(); -} - -function getTargetForCell(row, column) { - return state.availableTargets.find((target) => target.row === row && target.column === column) ?? null; -} - -function isPendingTurnNode(row, column) { - const nodeKey = keyFor(row, column); - return state.turnMoves.some((move) => (move.undoKeys ?? [move.targetKey]).includes(nodeKey)); -} - -function moveSetupPlayer(fromIndex, toIndex) { - if (toIndex < 0 || toIndex >= setup.playerCount) { - return; - } - - [setup.paletteOrder[fromIndex], setup.paletteOrder[toIndex]] = [setup.paletteOrder[toIndex], setup.paletteOrder[fromIndex]]; - [setup.seedInputs[fromIndex], setup.seedInputs[toIndex]] = [setup.seedInputs[toIndex], setup.seedInputs[fromIndex]]; - render(); -} - -function randomizeStartingLocations() { - setup.seedInputs = createRandomizedSeedInputs(setup.playerCount, setup.columns, setup.startingNodesPerPlayer); - render(); -} - -function renderNewGameModal() { - if (!isNewGameModalOpen) { - return ""; - } - - const maxSeeds = getMaxStartingNodesPerPlayer(setup.playerCount, setup.columns); - const previewPlayers = createPlayers(setup.playerCount, setup.paletteOrder); - - return ` - - `; -} - -function renderScoreboard() { - return state.players.map((player, index) => { - const isActive = index === state.activePlayerId && !state.gameOver; - const previous = previousScoreSnapshot?.[index]; - const totalChanged = previous && previous.totalScore !== player.totalScore; - const sunlightChanged = previous && previous.roundScore !== player.roundScore; - const growthChanged = previous && previous.growthPoints !== player.growthPoints; - const bankChanged = previous && previous.bankedPoints !== player.bankedPoints; - return ` -
-
- -

${player.name}

-
-
-
- Total - ${player.totalScore} -
-
- Sunlight - ${player.roundScore} -
-
- Energy - ${player.growthPoints} -
-
- Bank - ${player.bankedPoints} -
-
-
- `; - }).join(""); -} - -function renderAnimationOverlay(columns, rows) { - if (!state.animation) { - return ""; - } - - const showSunlightWave = state.animation.phase === "sunlight"; - const showBranchFlow = state.animation.phase !== "sunlight"; - - const sunlightCells = showSunlightWave ? state.animation.columns.flatMap((columnState) => { - const flashColor = columnState.ownerId === null ? "rgba(255, 241, 186, 0.9)" : state.players[columnState.ownerId].color; - const verticalCells = Array.from({ length: columnState.terminalRow + 1 }, (_, row) => ({ row, column: columnState.column })); - - return verticalCells.map((node, index) => { - const left = (node.column / columns) * 100; - const top = (node.row / rows) * 100; - const width = (1 / columns) * 100; - const height = (1 / rows) * 100; - return `
`; - }); - }).join("") : ""; - - const flashes = showBranchFlow ? state.animation.traces.flatMap((trace) => { - const player = state.players[trace.playerId]; - const nodes = trace.branchNodes; - - return nodes.map((node, index) => { - const left = (node.column / columns) * 100; - const top = (node.row / rows) * 100; - const width = (1 / columns) * 100; - const height = (1 / rows) * 100; - return ` -
- `; - }); - }).join("") : ""; - - const roots = state.animation.rootBursts.map((burst) => { - const player = state.players[burst.playerId]; - const root = parseKey(burst.key); - const x = ((root.column + 0.5) / columns) * 100; - const y = ((root.row + 0.5) / rows) * 100; - return `+${burst.count}`; - }).join(""); - - const disease = state.animation.diseaseKeys.map((key, index) => { - const node = parseKey(key); - const x = ((node.column + 0.5) / columns) * 100; - const y = ((node.row + 0.5) / rows) * 100; - return ``; - }).join(""); - - const bonusSunbeam = !state.animation.bonusTrace - ? "" - : ` -
- - - - -
- `; - - const bonusFlashes = !state.animation.bonusTrace - ? "" - : state.animation.bonusTrace.branchNodes.map((node, index) => { - const left = (node.column / columns) * 100; - const top = (node.row / rows) * 100; - const width = (1 / columns) * 100; - const height = (1 / rows) * 100; - return `
`; - }).join(""); - - const sunbeam = state.animation.bonusBurst === null || state.animation.bonusBurst === undefined - ? "" - : (() => { - const root = parseKey(state.animation.bonusBurst.key); - const x = ((root.column + 0.5) / columns) * 100; - const y = ((root.row + 0.5) / rows) * 100; - return `+1`; - })(); - - return ` - - - - `; -} - -function renderBoard() { - const columns = state.config.columns; - const rows = state.config.rows; - const columnLeaders = getColumnLeaders(); - const parentMap = buildParentMap(); - const lines = state.edges.map((edge) => { - const player = state.players[edge.ownerId]; - const x1 = ((edge.from.column + 0.5) / columns) * 100; - const y1 = ((edge.from.row + 0.5) / rows) * 100; - const x2 = ((edge.to.column + 0.5) / columns) * 100; - const y2 = ((edge.to.row + 0.5) / rows) * 100; - - return ``; - }).join(""); - - const cells = Array.from({ length: rows }, (_, row) => { - return Array.from({ length: columns }, (_, column) => { - const ownerId = getNodeOwner(row, column); - const player = ownerId === null ? null : state.players[ownerId]; - const target = getTargetForCell(row, column); - const pending = isPendingTurnNode(row, column); - const nodeKey = keyFor(row, column); - const isRoot = ownerId !== null && row === rows - 1 && !parentMap.has(nodeKey); - const columnLeader = columnLeaders[column]; - const background = columnLeader.ownerId === null || columnLeader.tied - ? "transparent" - : tint(state.players[columnLeader.ownerId].color); - - return ` - - `; - }).join(""); - }).join(""); - - return ` -
-
- - ${cells} - ${renderAnimationOverlay(columns, rows)} -
-
- `; -} - -function renderSidebar() { - const player = getCurrentPlayer(); - const rootShiftMoves = getSelectedRootShiftMoves(); - const boardLocked = isInteractionLocked(); - const nextGrowthText = state.roundSummary - ? state.players.map((current) => `${current.name}: ${current.growthPoints}`).join(" | ") - : "Next round growth = 1 + columns owned + any banked growth."; - - return ` - - `; -} - -function attachEvents() { - document.querySelectorAll(".cell").forEach((cell) => { - cell.addEventListener("click", () => { - if (isInteractionLocked()) { - return; - } - - const row = Number(cell.dataset.row); - const column = Number(cell.dataset.column); - const currentPlayer = getCurrentPlayer(); - const ownerId = getNodeOwner(row, column); - const target = getTargetForCell(row, column); - const nodeKey = keyFor(row, column); - - if (target) { - growTo(target); - return; - } - - if (ownerId === currentPlayer.id && undoMovesThrough(nodeKey)) { - return; - } - - if (ownerId === currentPlayer.id) { - const sourceKey = nodeKey; - if (state.selectedSource === sourceKey) { - updateSelection(null); - } else { - updateSelection(sourceKey); - } - render(); - } - }); - }); - - document.querySelector("#new-game")?.addEventListener("click", openNewGameModal); - document.querySelector("#close-new-game")?.addEventListener("click", closeNewGameModal); - document.querySelector("#cancel-new-game")?.addEventListener("click", closeNewGameModal); - document.querySelector("#start-new-game")?.addEventListener("click", startNewGameFromModal); - document.querySelector("#new-game-modal-backdrop")?.addEventListener("click", (event) => { - if (event.target.id === "new-game-modal-backdrop") { - closeNewGameModal(); - } - }); - document.querySelector("#end-turn")?.addEventListener("click", endTurn); - document.querySelector("#bank-turn")?.addEventListener("click", bankGrowthAndEndTurn); - document.querySelector("#finish-game")?.addEventListener("click", finishGameNow); - document.querySelector("#player-count")?.addEventListener("input", (event) => { - const output = event.currentTarget.parentElement.querySelector("strong"); - output.textContent = event.currentTarget.value; - setup = createSetupState(Number(event.currentTarget.value), setup.columns, setup.rows, setup.startingNodesPerPlayer, setup.sunbeamChance, setup.diseaseChance, setup.seedInputs, setup.paletteOrder, setup.shuffleTurnOrder); - render(); - }); - document.querySelector("#column-count")?.addEventListener("change", (event) => { - const columns = Number(event.currentTarget.value); - if (!Number.isInteger(columns)) { - return; - } - - setup = createSetupState(setup.playerCount, Math.max(6, Math.min(24, columns)), setup.rows, setup.startingNodesPerPlayer, setup.sunbeamChance, setup.diseaseChance, setup.seedInputs, setup.paletteOrder, setup.shuffleTurnOrder); - render(); - }); - document.querySelector("#row-count")?.addEventListener("change", (event) => { - const rows = Number(event.currentTarget.value); - if (!Number.isInteger(rows)) { - return; - } - - setup = createSetupState(setup.playerCount, setup.columns, Math.max(6, Math.min(24, rows)), setup.startingNodesPerPlayer, setup.sunbeamChance, setup.diseaseChance, setup.seedInputs, setup.paletteOrder, setup.shuffleTurnOrder); - render(); - }); - document.querySelector("#starting-nodes")?.addEventListener("change", (event) => { - const nextValue = Number(event.currentTarget.value); - if (!Number.isInteger(nextValue)) { - return; - } - - setup = createSetupState(setup.playerCount, setup.columns, setup.rows, Math.max(1, nextValue), setup.sunbeamChance, setup.diseaseChance, setup.seedInputs, setup.paletteOrder, setup.shuffleTurnOrder); - render(); - }); - document.querySelector("#sunbeam-chance")?.addEventListener("change", (event) => { - setup.sunbeamChance = Math.max(0, Math.min(100, Number(event.currentTarget.value) || 0)); - state.randomEffects.sunbeamChance = setup.sunbeamChance; - }); - document.querySelector("#disease-chance")?.addEventListener("change", (event) => { - setup.diseaseChance = Math.max(0, Math.min(100, Number(event.currentTarget.value) || 0)); - state.randomEffects.diseaseChance = setup.diseaseChance; - }); - document.querySelector("#shuffle-order-toggle")?.addEventListener("change", (event) => { - setup.shuffleTurnOrder = event.currentTarget.checked; - }); - document.querySelectorAll(".seed-input").forEach((input) => { - input.addEventListener("input", (event) => { - const playerId = Number(event.currentTarget.dataset.playerId); - setup.seedInputs[playerId] = event.currentTarget.value; - }); - }); - document.querySelector("#randomize-starting-locations")?.addEventListener("click", randomizeStartingLocations); - document.querySelectorAll("[data-move-player]").forEach((button) => { - button.addEventListener("click", () => { - const fromIndex = Number(button.dataset.movePlayer); - const direction = button.dataset.direction === "up" ? -1 : 1; - moveSetupPlayer(fromIndex, fromIndex + direction); - }); - }); - document.querySelectorAll("[data-root-shift]").forEach((button) => { - button.addEventListener("click", () => { - shiftSelectedRoot(Number(button.dataset.rootShift)); - }); - }); -} - -function render() { - app.innerHTML = ` -
-
- ${renderBoard()} - ${renderSidebar()} -
-
- ${renderScoreboard()} -
-
- ${renderNewGameModal()} - `; - - attachEvents(); - previousScoreSnapshot = getScoreSnapshot(); -} - -render(); diff --git a/src/main.ts b/src/main.ts index d50decc..0e83c32 100644 --- a/src/main.ts +++ b/src/main.ts @@ -68,6 +68,7 @@ let isNewGameModalOpen = false; let previousScoreSnapshot: ScoreSnapshot[] | null = null; let setupTab: "board" | "rules" | "events" | "players" = "board"; let draggedSetupSeed: { playerId: number; seedIndex: number } | null = null; +let isDraftPanelDocked = false; function rebuildSetup(overrides: Partial = {}) { setup = createSetupState( @@ -84,12 +85,32 @@ function rebuildSetup(overrides: Partial = {}) { overrides.biddingOrderRule ?? setup.biddingOrderRule, overrides.weatherDraftEnabled ?? setup.weatherDraftEnabled, overrides.weatherDraftCount ?? setup.weatherDraftCount, + overrides.bankingEnabled ?? setup.bankingEnabled, overrides.winCondition ?? setup.winCondition, overrides.maxRounds ?? setup.maxRounds, overrides.topLeafTarget ?? setup.topLeafTarget, ); } +function escapeHtml(value: unknown) { + return String(value).replace(/[&<>"']/g, (character) => { + switch (character) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case "\"": + return """; + case "'": + return "'"; + default: + return character; + } + }); +} + function getLiveExposureScores() { return buildEnergySimulation(state).scores; } @@ -142,6 +163,14 @@ function getCurrentPlayer() { return state.players[state.activePlayerId]; } +function getTreeOpacity(playerId: number) { + if (state.gameOver) { + return 1; + } + + return playerId === state.activePlayerId ? 1 : 0.8; +} + function getPlayerById(playerId: number) { return state.players.find((player) => player.id === playerId) ?? null; } @@ -152,18 +181,19 @@ function getOrderedPlayers(playerIds: number[]) { function getTurnLabel() { if (state.phase === "initiative" && state.initiativeDraft) { - return `${getCurrentPlayer().name} drafts initiative`; + return `${escapeHtml(getCurrentPlayer().name)} drafts initiative`; } if (state.phase === "weather" && state.weatherDraft) { - return `${getCurrentPlayer().name} drafts weather`; + return `${escapeHtml(getCurrentPlayer().name)} drafts weather`; } - return state.gameOver ? "Game Over" : `${getCurrentPlayer().name}'s turn`; + return state.gameOver ? "Game Over" : `${escapeHtml(getCurrentPlayer().name)}'s turn`; } function isBankingEnabled() { - return state.activeRoundEffects.includes("storehouse"); + // Banking is enabled if setup allows it OR if storehouse effect is active + return state.config.bankingEnabled || state.activeRoundEffects.includes("storehouse"); } function awardGrowth(player: Player, amount: number) { @@ -393,6 +423,7 @@ function startWeatherDraft() { } state.phase = "weather"; + isDraftPanelDocked = false; state.turnMoves = []; updateSelection(null); state.weatherDraft = createWeatherDraft(state); @@ -402,6 +433,7 @@ function startWeatherDraft() { function startInitiativeDraft() { state.phase = "initiative"; + isDraftPanelDocked = false; state.turnMoves = []; updateSelection(null); state.initiativeDraft = createInitiativeDraft(state); @@ -516,26 +548,31 @@ function finalizeWeatherDraft() { function chooseWeatherAction(offerId: string, cardId: WeatherCardId, action: "draft" | "ban") { const draft = state.weatherDraft; - if (!draft || !isWeatherCardAvailable(draft, offerId, cardId)) { + const offer = draft?.offers.find((entry) => entry.id === offerId); + if (!draft || !offer) { return; } - - const offer = draft.offers.find((entry) => entry.id === offerId); const otherCardId = offer?.options.find((option) => option !== cardId) ?? null; const playerId = getCurrentWeatherPlayerId(draft); if (action === "draft") { + if (!isWeatherCardAvailable(draft, offerId, cardId)) { + return; + } + const card = getWeatherCard(cardId); draft.drafted.push(cardId); state.history.unshift(`${state.players[playerId].name} drafted ${card?.title ?? cardId}.`); + if (otherCardId && !draft.locked.includes(otherCardId)) { + draft.locked.push(otherCardId); + } } else { - const card = getWeatherCard(cardId); - draft.banned.push(cardId); - state.history.unshift(`${state.players[playerId].name} banned ${card?.title ?? cardId}.`); - } - - if (otherCardId && !draft.locked.includes(otherCardId)) { - draft.locked.push(otherCardId); + offer.options.forEach((option) => { + if (!draft.banned.includes(option)) { + draft.banned.push(option); + } + }); + state.history.unshift(`${state.players[playerId].name} banned both cards in an offer.`); } if (draft.draftIndex >= draft.playerOrder.length - 1) { @@ -736,6 +773,22 @@ async function endRound() { player.roundScore = scores[index]; player.totalScore += scores[index]; player.bonusPoints = nextGrowth[index] - scores[index]; + + // Apply banking effects from weather cards + const hasStorehouse = state.activeRoundEffects.includes("storehouse"); + const hasCompoundInterest = state.activeRoundEffects.includes("compound_interest"); + + if (hasStorehouse) { + // Storehouse: Lose 1 banked energy (min 0) + player.bankedPoints = Math.max(0, player.bankedPoints - 1); + } + + if (hasCompoundInterest) { + // Compound Interest: Gain 20% interest (rounded down) + const interest = Math.floor(player.bankedPoints * 0.2); + player.bankedPoints += interest; + } + player.growthPoints = player.bankedPoints; player.lifetimeGrowthIncome += nextGrowth[index]; player.growthPoints += nextGrowth[index]; @@ -914,7 +967,7 @@ function renderNewGameModal() { - + ` : ""} @@ -1021,7 +1078,7 @@ function renderNewGameModal() {
- +
@@ -1049,15 +1106,15 @@ function renderNewGameModal() {
${previewPlayers.map((currentPlayer, index) => ` `).join("")}
` : ""}
- + + ${renderScoreFlightOverlay()} ${renderNewGameModal()} ${renderInitiativeModal()} ${renderWeatherDraftModal()} `; attachEvents(); + requestAnimationFrame(() => { + positionScoreFlightBadges(); + }); previousScoreSnapshot = getScoreSnapshot(); } diff --git a/src/styles/globals.css b/src/styles/globals.css index 99f0518..8c9ace6 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -1,405 +1,481 @@ /* Grid-based TV-Optimized Layout Framework */ :root { - color-scheme: dark; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - background: - radial-gradient(circle at top, rgba(48, 72, 104, 0.35), transparent 42%), - linear-gradient(180deg, #0b1220 0%, #070b13 100%); - color: #f4f7fb; - - /* Layout constants */ - --bottom-bar-height: 100px; - --sidebar-min-width: 280px; - --sidebar-max-width: 380px; - --gap-size: 0.75rem; - --padding-size: 0.75rem; + color-scheme: dark; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", sans-serif; + background: + radial-gradient(circle at top, rgba(48, 72, 104, 0.35), transparent 42%), + linear-gradient(180deg, #0b1220 0%, #070b13 100%); + color: #f4f7fb; + + /* Layout constants */ + --bottom-bar-height: 100px; + --sidebar-min-width: 280px; + --sidebar-max-width: 380px; + --gap-size: 0.75rem; + --padding-size: 0.75rem; } * { - box-sizing: border-box; - margin: 0; - padding: 0; + box-sizing: border-box; + margin: 0; + padding: 0; } -html, body { - height: 100%; - width: 100%; - overflow: hidden; - background: transparent; +html, +body { + height: 100%; + width: 100%; + overflow: hidden; + background: transparent; } /* Main App Container - fills viewport accounting for browser chrome */ #app { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - padding: 0.5rem; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 0.5rem; } #app > .layout { - width: 100%; - height: calc(100vh - 100px); - max-width: 100%; - max-height: calc(100vh - 100px); - min-width: 0; - min-height: 0; + width: 100%; + height: calc(100vh - 100px); + max-width: 100%; + max-height: calc(100vh - 100px); + min-width: 0; + min-height: 0; } /* Main Layout Grid */ .layout { - width: 100%; - height: 100%; - display: grid; - grid-template-areas: - "main sidebar" - "bottom bottom"; - grid-template-columns: 1fr minmax(var(--sidebar-min-width), var(--sidebar-max-width)); - grid-template-rows: 1fr var(--bottom-bar-height); - gap: var(--gap-size); - padding: var(--padding-size); - overflow: hidden; + width: 100%; + height: 100%; + display: grid; + grid-template-areas: + "main sidebar" + "bottom bottom"; + grid-template-columns: 1fr minmax( + var(--sidebar-min-width), + var(--sidebar-max-width) + ); + grid-template-rows: 1fr var(--bottom-bar-height); + gap: var(--gap-size); + padding: var(--padding-size); + overflow: hidden; } /* Game Area - Main left section */ .game-area { - grid-area: main; - display: flex; - flex-direction: column; - min-width: 0; - min-height: 0; - overflow: hidden; + grid-area: main; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: hidden; } /* Board shell fills the game area */ .board-shell { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - border-radius: 1.25rem; - border: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(9, 16, 29, 0.72); - backdrop-filter: blur(20px); - padding: 0.5rem; - min-height: 0; - overflow: hidden; + flex: 1; + display: flex; + align-items: center; + justify-content: center; + border-radius: 1.25rem; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(9, 16, 29, 0.72); + backdrop-filter: blur(20px); + padding: 0.5rem; + min-height: 0; + overflow: hidden; } /* Board - fits within shell */ .board { - position: relative; - width: auto; - height: 100%; - max-width: 100%; - max-height: 100%; - aspect-ratio: var(--board-columns) / var(--board-rows); - display: grid; - grid-template-columns: repeat(var(--board-columns), minmax(0, 1fr)); - grid-template-rows: repeat(var(--board-rows), minmax(0, 1fr)); - gap: clamp(2px, 0.3cqmin, 4px); - margin: 0 auto; + position: relative; + width: auto; + height: 100%; + max-width: 100%; + max-height: 100%; + aspect-ratio: var(--board-columns) / var(--board-rows); + display: grid; + grid-template-columns: repeat(var(--board-columns), minmax(0, 1fr)); + grid-template-rows: repeat(var(--board-rows), minmax(0, 1fr)); + gap: clamp(2px, 0.3cqmin, 4px); + margin: 0 auto; } /* Sidebar - Right panel */ .sidebar { - grid-area: sidebar; - display: flex; - flex-direction: column; - gap: 0.75rem; - min-width: 0; - min-height: 0; - height: 100%; + grid-area: sidebar; + display: flex; + flex-direction: column; + gap: 0.75rem; + min-width: 0; + min-height: 0; + height: 100%; } /* Bottom bar - Fixed height player scores */ .scoreboard { - grid-area: bottom; - display: grid; - grid-template-columns: repeat(var(--player-count, 3), 1fr); - gap: 0.75rem; - height: 100%; - overflow: hidden; + grid-area: bottom; + display: grid; + grid-template-columns: repeat(var(--player-count, 3), 1fr); + gap: 0.75rem; + height: 100%; + overflow: hidden; } .score-card { - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 1rem; - padding: 1.2rem 1.25rem; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.02)); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); - display: flex; - flex-direction: column; - justify-content: flex-start; - gap: 0.2rem; - min-height: 0; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 1rem; + padding: 1.2rem 1.25rem; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.08), + rgba(255, 255, 255, 0.02) + ); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); + display: flex; + flex-direction: column; + justify-content: flex-start; + gap: 0.2rem; + min-height: 0; } .score-card.active { - border-color: color-mix(in srgb, var(--player-color) 55%, white); - box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08), 0 0 24px var(--player-glow); + border-color: color-mix(in srgb, var(--player-color) 55%, white); + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.08), + 0 0 24px var(--player-glow); } /* Sidebar panels */ .panel { - border-radius: 1.25rem; - border: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(9, 16, 29, 0.72); - backdrop-filter: blur(20px); - padding: 0.75rem; - display: flex; - flex-direction: column; - min-height: 0; + border-radius: 1.1rem; + border: 1px solid rgba(255, 255, 255, 0.05); + background: rgba(9, 16, 29, 0.72); + backdrop-filter: blur(20px); + padding: 2px; + display: flex; + flex-direction: column; + min-height: 0; } .controls-panel { - flex: 0 0 auto; - display: flex; - flex-direction: column; - gap: 0.5rem; + flex: 0 0 auto; + display: flex; + flex-direction: column; + gap: 0.5rem; } .status-panel--weather { - flex: 0 0 auto; + flex: 0 0 auto; } .accordion-panel { - flex: 1 1 auto; - overflow: auto; + flex: 1 1 auto; + overflow: auto; } .accordion-panel--top { - flex: 0 0 auto; - overflow: visible; + flex: 0 0 auto; + overflow: visible; } .finish-panel { - margin-top: auto; + margin-top: auto; } .finish-panel__actions { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.6rem; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.6rem; } .log-panel { - max-height: 88px; - flex-shrink: 0; + max-height: 88px; + flex-shrink: 0; } /* Cell styling */ .cell { - position: relative; - background: rgba(255, 255, 255, 0.03); - border-radius: clamp(4px, 15%, 0.6rem); - border: 1px solid rgba(255, 255, 255, 0.05); - overflow: hidden; - min-width: 0; - min-height: 0; - aspect-ratio: 1 / 1; + position: relative; + background: rgba(255, 255, 255, 0.03); + border-radius: clamp(4px, 15%, 0.6rem); + border: 1px solid rgba(255, 255, 255, 0.05); + overflow: hidden; + min-width: 0; + min-height: 0; + aspect-ratio: 1 / 1; } .cell__shade { - position: absolute; - inset: 0; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), var(--column-tint)); + position: absolute; + inset: 0; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.03), + var(--column-tint) + ); } .cell__root-ring { - position: absolute; - inset: 18% 18%; - border: 1px dashed rgba(255, 255, 255, 0.28); - border-radius: 999px; + position: absolute; + inset: 18% 18%; + border: 1px dashed rgba(255, 255, 255, 0.28); + border-radius: 999px; + opacity: var(--tree-opacity, 1); } .cell__node, .cell__target-label { - position: absolute; - inset: 50% auto auto 50%; - transform: translate(-50%, -50%); + position: absolute; + inset: 50% auto auto 50%; + transform: translate(-50%, -50%); } .cell__node { - width: clamp(8px, 35%, 1.2rem); - height: clamp(8px, 35%, 1.2rem); - border-radius: 50%; - background: var(--node-color); - box-shadow: 0 0 0 0.15rem rgba(255, 255, 255, 0.06), 0 0 1rem var(--node-glow); - aspect-ratio: 1 / 1; + width: clamp(8px, 35%, 1.2rem); + height: clamp(8px, 35%, 1.2rem); + border-radius: 50%; + background: var(--node-color); + box-shadow: + 0 0 0 0.15rem rgba(255, 255, 255, 0.06), + 0 0 1rem var(--node-glow); + aspect-ratio: 1 / 1; + opacity: var(--tree-opacity, 1); } .cell.selected { - border-color: rgba(255, 255, 255, 0.55); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.22); + border-color: rgba(255, 255, 255, 0.55); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.22); } .cell.pending { - border-color: rgba(255, 255, 255, 0.28); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12), 0 0 18px rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.28); + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.12), + 0 0 18px rgba(255, 255, 255, 0.08); } .cell.pending .cell__node { - box-shadow: 0 0 0 0.18rem rgba(255, 255, 255, 0.08), 0 0 1.1rem var(--node-glow), 0 0 1.5rem rgba(255, 255, 255, 0.08); + box-shadow: + 0 0 0 0.18rem rgba(255, 255, 255, 0.08), + 0 0 1.1rem var(--node-glow), + 0 0 1.5rem rgba(255, 255, 255, 0.08); } .cell.target { - border-color: rgba(255, 255, 255, 0.22); - background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.22); + background: rgba(255, 255, 255, 0.05); } .cell.target:hover { - transform: translateY(-1px); + transform: translateY(-1px); } .cell__target-label { - width: min(60%, 1.5rem); - height: min(60%, 1.5rem); - display: grid; - place-items: center; - border-radius: 999px; - background: rgba(255, 255, 255, 0.09); - border: 1px solid rgba(255, 255, 255, 0.18); - font-weight: 700; - font-size: clamp(0.6rem, 2cqmin, 0.9rem); + width: min(60%, 1.5rem); + height: min(60%, 1.5rem); + display: grid; + place-items: center; + border-radius: 999px; + background: rgba(255, 255, 255, 0.09); + border: 1px solid rgba(255, 255, 255, 0.18); + font-weight: 700; + font-size: clamp(0.6rem, 2cqmin, 0.9rem); } /* Board overlays */ .board__lines { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - pointer-events: none; - filter: drop-shadow(0 0 8px rgba(255, 255, 255, 0.18)); - z-index: 1; + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + filter: drop-shadow(0 0 8px rgba(255, 255, 255, 0.18)); + z-index: 1; } .board__fx { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - pointer-events: none; - z-index: 4; + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 4; } .board__drop-layer { - position: absolute; - inset: 0; - pointer-events: none; - overflow: hidden; - z-index: 5; + position: absolute; + inset: 0; + pointer-events: none; + overflow: hidden; + z-index: 5; } .board__energy-layer { - position: absolute; - inset: 0; - pointer-events: none; - z-index: 4; + position: absolute; + inset: 0; + pointer-events: none; + z-index: 4; } .board__energy-cell { - position: absolute; - border-radius: clamp(4px, 15%, 0.6rem); - background: - radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.95), color-mix(in srgb, var(--flash-color) 70%, #ffe08a) 34%, rgba(255, 224, 138, 0.18) 72%, transparent 100%), - linear-gradient(180deg, rgba(255, 255, 255, 0.18), transparent); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12), 0 0 18px color-mix(in srgb, var(--flash-color) 55%, #ffe08a); - opacity: 0; + position: absolute; + border-radius: clamp(4px, 15%, 0.6rem); + background: + radial-gradient( + circle at 50% 50%, + rgba(255, 255, 255, 0.95), + color-mix(in srgb, var(--flash-color) 70%, #ffe08a) 34%, + rgba(255, 224, 138, 0.18) 72%, + transparent 100% + ), linear-gradient(180deg, rgba(255, 255, 255, 0.18), transparent); + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.12), + 0 0 18px color-mix(in srgb, var(--flash-color) 55%, #ffe08a); + opacity: 0; } .board__energy-cell--sunlight { - inset: 12%; - background: - linear-gradient(180deg, rgba(255, 244, 214, 0.05), rgba(255, 244, 214, 0.008)), - radial-gradient(circle at 50% 50%, rgba(255, 242, 196, 0.11), color-mix(in srgb, var(--flash-color) 10%, #ffe3a3) 42%, rgba(255, 221, 128, 0.03) 72%, transparent 100%); - box-shadow: inset 0 0 0 1px rgba(255, 245, 224, 0.02), 0 0 6px color-mix(in srgb, var(--flash-color) 8%, #ffe3a3); + inset: 12%; + background: + linear-gradient( + 180deg, + rgba(255, 244, 214, 0.05), + rgba(255, 244, 214, 0.008) + ), + radial-gradient( + circle at 50% 50%, + rgba(255, 242, 196, 0.11), + color-mix(in srgb, var(--flash-color) 10%, #ffe3a3) 42%, + rgba(255, 221, 128, 0.03) 72%, + transparent 100% + ); + box-shadow: + inset 0 0 0 1px rgba(255, 245, 224, 0.02), + 0 0 6px color-mix(in srgb, var(--flash-color) 8%, #ffe3a3); } .board--sunlight .board__energy-cell--sunlight, .board--branches .board__energy-cell, .board--bonus .board__energy-cell--bonus { - animation: energy-cell-flash 0.48s ease forwards; - animation-delay: var(--flash-delay, 0ms); + animation: energy-cell-flash 0.48s ease forwards; + animation-delay: var(--flash-delay, 0ms); } .board__energy-cell--bonus { - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18), 0 0 22px rgba(255, 216, 94, 0.9); + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.18), + 0 0 22px rgba(255, 216, 94, 0.9); } .board--bonus .board__drop--bonus { - animation: sunlight-drop 0.85s cubic-bezier(0.2, 0.7, 0.2, 1) forwards; + animation: sunlight-drop 0.85s cubic-bezier(0.2, 0.7, 0.2, 1) forwards; } .board__drop-core, .board__drop-spark { - position: absolute; - inset: 0; - border-radius: 999px; + position: absolute; + inset: 0; + border-radius: 999px; } .board__drop-core { - background: radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.98), color-mix(in srgb, var(--drop-color) 72%, #ffe38a) 45%, rgba(255, 227, 138, 0.18) 100%); - box-shadow: 0 0 18px color-mix(in srgb, var(--drop-color) 40%, #ffe38a), 0 0 32px rgba(255, 236, 174, 0.65); + background: radial-gradient( + circle at 35% 30%, + rgba(255, 255, 255, 0.98), + color-mix(in srgb, var(--drop-color) 72%, #ffe38a) 45%, + rgba(255, 227, 138, 0.18) 100% + ); + box-shadow: + 0 0 18px color-mix(in srgb, var(--drop-color) 40%, #ffe38a), + 0 0 32px rgba(255, 236, 174, 0.65); } .board__drop-spark { - inset: 35%; - border: 1px solid rgba(255, 248, 220, 0.95); - opacity: 0.9; + inset: 35%; + border: 1px solid rgba(255, 248, 220, 0.95); + opacity: 0.9; } -.board__drop-spark--a { transform: translate(-0.8rem, -0.15rem) scale(0.55); } -.board__drop-spark--b { transform: translate(0.75rem, -0.1rem) scale(0.45); } -.board__drop-spark--c { transform: translate(0.1rem, -0.75rem) scale(0.35); } - -.board__root-burst, -.board__disease-mark, -.board__sunbeam-burst { - opacity: 0; +.board__drop-spark--a { + transform: translate(-0.8rem, -0.15rem) scale(0.55); +} +.board__drop-spark--b { + transform: translate(0.75rem, -0.1rem) scale(0.45); +} +.board__drop-spark--c { + transform: translate(0.1rem, -0.75rem) scale(0.35); } -.board__root-burst circle, -.board__sunbeam-burst { - fill: color-mix(in srgb, var(--burst-color, #ffd85e) 70%, white); - stroke: rgba(255, 255, 255, 0.65); - stroke-width: 0.35; +.board__disease-mark { + opacity: 0; } -.board__root-burst text { - fill: #08111c; - font-size: 2.1px; - font-weight: 800; +.score-flight-layer { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 9999; + isolation: isolate; + contain: strict; + transform: translateZ(0); + backface-visibility: hidden; +} + +.score-flight-badge { + position: absolute; + transform: translate(-50%, -50%); + min-width: 2.8rem; + height: 2.8rem; + padding: 0 0.64rem; + display: grid; + place-items: center; + border-radius: 999px; + background: color-mix(in srgb, var(--burst-color, #ffd85e) 70%, white); + border: 1px solid rgba(255, 255, 255, 0.65); + color: #08111c; + font-size: 1.05rem; + font-weight: 800; + line-height: 1; + box-shadow: 0 0 20px + color-mix(in srgb, var(--burst-color, #ffd85e) 32%, transparent); + z-index: 100; + opacity: 0; + animation: score-flight 2.2s cubic-bezier(0.12, 0.8, 0.22, 1) forwards; + animation-delay: var(--flight-delay, 0ms); + will-change: transform; } .board__disease-mark circle { - fill: rgba(162, 255, 142, 0.2); - stroke: rgba(162, 255, 142, 0.9); - stroke-width: 0.35; + fill: rgba(162, 255, 142, 0.2); + stroke: rgba(162, 255, 142, 0.9); + stroke-width: 0.35; } .board__disease-mark path { - stroke: rgba(162, 255, 142, 1); - stroke-width: 0.5; - stroke-linecap: round; + stroke: rgba(162, 255, 142, 1); + stroke-width: 0.5; + stroke-linecap: round; } -.board--branches .board__root-burst, -.board--events .board__root-burst, .board--events .board__disease-mark, -.board--bonus .board__sunbeam-burst, -.board--events .board__sunbeam-burst { - animation: pop-fade 0.8s ease forwards; - animation-delay: var(--trace-delay, 0ms); +.board--events .board__disease-mark { + animation: pop-fade 1.35s cubic-bezier(0.18, 0.9, 0.28, 1) forwards; + animation-delay: var(--trace-delay, 0ms); } .board__sunbeam-burst text { - fill: #08111c; - font-size: 2.1px; - font-weight: 800; + fill: #08111c; + font-size: 2.1px; + font-weight: 800; } /* Score card content */ @@ -410,47 +486,47 @@ html, body { .setup-grid, .toggle-row, .active-turn { - display: flex; - align-items: center; + display: flex; + align-items: center; } .score-card__head, .panel__title-row, .button-row, .toggle-row { - justify-content: space-between; + justify-content: space-between; } .score-card__identity { - display: flex; - align-items: center; - gap: 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; } .score-card__head h2, .panel h1, .panel h2, .active-turn h2 { - margin: 0; - font-size: clamp(0.9rem, 2.5cqmin, 1.2rem); + margin: 0; + font-size: clamp(0.9rem, 2.5cqmin, 1.2rem); } .score-card__numbers { - margin-top: 0.5rem; - gap: 0.75rem; + margin-top: 0.5rem; + gap: 0.75rem; } .score-card__footer { - margin-top: 0.5rem; - padding-top: 0.4rem; - border-top: 1px solid rgba(255, 255, 255, 0.08); - color: rgba(231, 238, 247, 0.72); - font-size: 0.75rem; + margin-top: 0.5rem; + padding-top: 0.4rem; + border-top: 1px solid rgba(255, 255, 255, 0.08); + color: rgba(231, 238, 247, 0.72); + font-size: 0.75rem; } .score-card__numbers div { - display: grid; - gap: 0.1rem; + display: grid; + gap: 0.1rem; } .score-card__numbers span, @@ -460,932 +536,1165 @@ label span, .status-panel p, .active-turn p, .effect-empty { - color: rgba(231, 238, 247, 0.72); + color: rgba(231, 238, 247, 0.72); } .score-card__meta { - font-size: 0.75rem; - color: rgba(231, 238, 247, 0.7); + font-size: 0.75rem; + color: rgba(231, 238, 247, 0.7); } .score-card__numbers strong { - font-size: 1.2rem; + font-size: 1.2rem; } .score-card__numbers span { - text-transform: uppercase; - letter-spacing: 0.04em; - font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 0.72rem; } .score-value { - display: inline-block; + display: inline-block; } .score-value.changed { - animation: score-pop 0.7s ease; + animation: score-pop 0.7s ease; +} + +.score-value--landing { + animation: score-land 0.5s cubic-bezier(0.18, 0.9, 0.24, 1); } .player-dot { - width: 0.85rem; - height: 0.85rem; - border-radius: 999px; - background: var(--player-color); - box-shadow: 0 0 16px var(--player-glow); + width: 0.85rem; + height: 0.85rem; + border-radius: 999px; + background: var(--player-color); + box-shadow: 0 0 16px var(--player-glow); } /* Sidebar content */ .panel__actions { - display: flex; - gap: 0.5rem; + display: flex; + gap: 0.5rem; } .eyebrow { - margin: 0 0 0.25rem; - font-size: 0.75rem; - letter-spacing: 0.1em; - text-transform: uppercase; + margin: 0 0 0.25rem; + font-size: 0.75rem; + letter-spacing: 0.1em; + text-transform: uppercase; } .button-row { - gap: 0.5rem; + gap: 0.5rem; } .button-row button, .primary-button, .ghost-button { - min-height: 2.4rem; - padding: 0.5rem 0.75rem; - border-radius: 0.85rem; - background: #f4f7fb; - color: #0a1020; - font-weight: 700; - font-size: 0.9rem; - border: none; - cursor: pointer; + min-height: 2.4rem; + padding: 0.5rem 0.75rem; + border-radius: 0.85rem; + background: #f4f7fb; + color: #0a1020; + font-weight: 700; + font-size: 0.9rem; + border: none; + cursor: pointer; } .primary-button { - background: #f4f7fb; - color: #0a1020; - border: none; + background: #f4f7fb; + color: #0a1020; + border: none; } .ghost-button, #finish-game { - background: rgba(255, 255, 255, 0.08); - color: #f4f7fb; - border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.08); + color: #f4f7fb; + border: 1px solid rgba(255, 255, 255, 0.1); } button:disabled { - opacity: 0.45; - cursor: not-allowed; + opacity: 0.45; + cursor: not-allowed; } .log-list { - display: grid; - gap: 0.4rem; - font-size: 0.8rem; - overflow: auto; + display: grid; + gap: 0.4rem; + font-size: 0.8rem; + overflow: auto; } .log-list p, .status-panel p, .active-turn p { - margin: 0; + margin: 0; } .event-note { - color: #ffd577; + color: #ffd577; } .active-effects { - display: grid; - gap: 0.55rem; - margin-top: 0.4rem; + display: grid; + gap: 0.55rem; + margin-top: 0.4rem; } .effect-chip { - display: grid; - gap: 0.18rem; - min-height: 3.4rem; - padding: 0.75rem 0.9rem; - border-radius: 1rem; - background: linear-gradient(180deg, rgba(255, 208, 96, 0.14), rgba(255, 208, 96, 0.04)); - border: 1px solid rgba(255, 208, 96, 0.2); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); + display: grid; + gap: 0.18rem; + min-height: 3.4rem; + padding: 0.75rem 0.9rem; + border-radius: 1rem; + background: linear-gradient( + 180deg, + rgba(255, 208, 96, 0.14), + rgba(255, 208, 96, 0.04) + ); + border: 1px solid rgba(255, 208, 96, 0.2); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); } .effect-chip__title { - font-size: 1rem; - font-weight: 700; - color: #f4f7fb; + font-size: 1rem; + font-weight: 700; + color: #f4f7fb; } .effect-chip__rule { - font-size: 0.78rem; - color: rgba(231, 238, 247, 0.76); + font-size: 0.78rem; + color: rgba(231, 238, 247, 0.76); } .active-turn { - flex-direction: column; - align-items: flex-start; - gap: 0.15rem; - padding: 0.6rem; - border-radius: 0.85rem; - background: linear-gradient(135deg, color-mix(in srgb, var(--player-color) 22%, rgba(255, 255, 255, 0.04)), rgba(255, 255, 255, 0.04)); - border: 1px solid color-mix(in srgb, var(--player-color) 35%, rgba(255, 255, 255, 0.08)); + flex-direction: column; + align-items: flex-start; + gap: 0.15rem; + padding: 0.6rem; + border-radius: 0.85rem; + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--player-color) 22%, rgba(255, 255, 255, 0.04)), + rgba(255, 255, 255, 0.04) + ); + border: 1px solid + color-mix(in srgb, var(--player-color) 35%, rgba(255, 255, 255, 0.08)); } .economy-line { - margin-top: 0.4rem; + margin-top: 0.4rem; } .accordion { - border-radius: 0.85rem; - border: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(255, 255, 255, 0.03); - overflow: hidden; + border-radius: 0.85rem; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.03); + overflow: hidden; } .accordion[open] { - background: rgba(255, 255, 255, 0.045); + background: rgba(255, 255, 255, 0.045); } .accordion summary { - list-style: none; - cursor: pointer; - padding: 0.7rem 0.85rem; - font-weight: 700; - color: #f4f7fb; - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.75rem; + list-style: none; + cursor: pointer; + padding: 0.7rem 0.85rem; + font-weight: 700; + color: #f4f7fb; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; } .accordion summary::after { - content: "+"; - color: rgba(231, 238, 247, 0.62); - font-size: 1rem; - line-height: 1; + content: "+"; + color: rgba(231, 238, 247, 0.62); + font-size: 1rem; + line-height: 1; } .accordion[open] summary::after { - content: "-"; + content: "-"; } .accordion summary::-webkit-details-marker { - display: none; + display: none; } .accordion__content { - display: grid; - gap: 0.45rem; - padding: 0 0.85rem 0.8rem; + display: grid; + gap: 0.45rem; + padding: 0 0.85rem 0.8rem; } .accordion__content p { - margin: 0; - color: rgba(231, 238, 247, 0.76); - font-size: 0.84rem; + margin: 0; + color: rgba(231, 238, 247, 0.76); + font-size: 0.84rem; } /* Form elements */ button, input, select { - font: inherit; + font: inherit; } input[type="number"], input[type="text"], select { - width: 100%; - min-height: 2.4rem; - padding: 0.5rem 0.75rem; - border-radius: 0.75rem; - border: 1px solid rgba(255, 255, 255, 0.12); - background: rgba(255, 255, 255, 0.05); - color: #f4f7fb; - font-size: 0.9rem; + width: 100%; + min-height: 2.4rem; + padding: 0.5rem 0.75rem; + border-radius: 0.75rem; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.05); + color: #f4f7fb; + font-size: 0.9rem; } input[type="range"] { - width: 100%; + width: 100%; } /* Modal styles */ .modal-backdrop { - position: fixed; - inset: 0; - display: grid; - place-items: center; - padding: 1rem; - background: rgba(3, 8, 16, 0.72); - backdrop-filter: blur(14px); - z-index: 20; + position: fixed; + inset: 0; + display: grid; + place-items: center; + padding: 1rem; + background: rgba(3, 8, 16, 0.72); + backdrop-filter: blur(14px); + z-index: 20; } .modal { - width: min(1180px, 100%); - max-height: min(92%, 980px); - overflow: auto; - border-radius: 1.25rem; - border: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(9, 16, 29, 0.72); - backdrop-filter: blur(20px); - padding: 1rem; + width: min(1180px, 100%); + max-height: min(92%, 980px); + overflow: auto; + border-radius: 1.25rem; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(9, 16, 29, 0.72); + backdrop-filter: blur(20px); + padding: 1rem; } /* Setup Modal - Redesigned */ .setup-modal { - display: flex; - flex-direction: column; - max-width: 960px; - padding: 0; + display: flex; + flex-direction: column; + max-width: 960px; + padding: 0; } .modal-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 1rem; - padding: 1.6rem 1.75rem 1rem; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1.6rem 1.75rem 1rem; } .modal-header__title h1 { - font-size: 1.5rem; - font-weight: 700; - margin: 0; + font-size: 1.5rem; + font-weight: 700; + margin: 0; } .setup-tabs { - display: flex; - justify-content: center; - gap: 0.65rem; - padding: 0 1.75rem 1.1rem; + display: flex; + justify-content: center; + gap: 0.65rem; + padding: 0 1.75rem 1.1rem; } .setup-tab { - appearance: none; - width: 4.25rem; - min-width: 4.25rem; - min-height: 4.25rem; - display: grid; - place-items: center; - gap: 0.2rem; - padding: 0.55rem; - border-radius: 1rem; - background: rgba(255, 255, 255, 0.025); - color: rgba(231, 238, 247, 0.68); - border: 1px solid transparent; - cursor: pointer; - transition: background 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease; + appearance: none; + width: 4.25rem; + min-width: 4.25rem; + min-height: 4.25rem; + display: grid; + place-items: center; + gap: 0.2rem; + padding: 0.55rem; + border-radius: 1rem; + background: rgba(255, 255, 255, 0.025); + color: rgba(231, 238, 247, 0.68); + border: 1px solid transparent; + cursor: pointer; + transition: + background 120ms ease, + border-color 120ms ease, + color 120ms ease, + transform 120ms ease; } .setup-tab--active { - background: rgba(255, 255, 255, 0.08); - color: #f4f7fb; - border-color: rgba(255, 255, 255, 0.08); - box-shadow: inset 0 -2px 0 rgba(255, 208, 96, 0.75); + background: rgba(255, 255, 255, 0.08); + color: #f4f7fb; + border-color: rgba(255, 255, 255, 0.08); + box-shadow: inset 0 -2px 0 rgba(255, 208, 96, 0.75); } .setup-tab:hover { - background: rgba(255, 255, 255, 0.05); - color: rgba(244, 247, 251, 0.9); + background: rgba(255, 255, 255, 0.05); + color: rgba(244, 247, 251, 0.9); } .setup-tab span:first-child { - font-size: 1.05rem; + font-size: 1.05rem; } .setup-tab span:last-child { - font-size: 0.68rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - line-height: 1.1; - text-align: center; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + line-height: 1.1; + text-align: center; } .modal-body { - flex: 1; - overflow-y: auto; - padding: 1.75rem; - display: flex; - flex-direction: column; - gap: 1.9rem; + flex: 1; + overflow-y: auto; + padding: 1.75rem; + display: flex; + flex-direction: column; + gap: 1.9rem; } .modal-footer { - display: flex; - justify-content: flex-end; - gap: 0.75rem; - padding: 1.1rem 1.75rem 1.5rem; + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding: 1.1rem 1.75rem 1.5rem; } /* Setup Sections */ .setup-section { - display: flex; - flex-direction: column; - gap: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; } .setup-section__title { - font-size: 0.875rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: rgba(255, 255, 255, 0.6); - margin: 0; + font-size: 0.875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: rgba(255, 255, 255, 0.6); + margin: 0; } .setup-section__help { - font-size: 0.875rem; - color: rgba(255, 255, 255, 0.5); - margin: 0; + font-size: 0.875rem; + color: rgba(255, 255, 255, 0.5); + margin: 0; } /* Setup Grid */ .setup-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 1.15rem; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1.15rem; } .setup-grid--2col { - grid-template-columns: repeat(2, 1fr); + grid-template-columns: repeat(2, 1fr); } .setup-grid--3col { - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(3, 1fr); } @media (max-width: 600px) { - .setup-tabs { - flex-wrap: wrap; - } + .setup-tabs { + flex-wrap: wrap; + } - .setup-grid, - .setup-grid--2col, - .setup-grid--3col { - grid-template-columns: 1fr; - } + .setup-grid, + .setup-grid--2col, + .setup-grid--3col { + grid-template-columns: 1fr; + } } /* Setup Field */ .setup-field { - display: flex; - flex-direction: column; - gap: 0.55rem; + display: flex; + flex-direction: column; + gap: 0.55rem; } .setup-field__label { - font-size: 0.86rem; - font-weight: 600; - color: rgba(255, 255, 255, 0.76); + font-size: 0.86rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.76); } .setup-field__input { - display: flex; - align-items: center; - gap: 0.75rem; + display: flex; + align-items: center; + gap: 0.75rem; } .setup-field__value { - font-size: 1.125rem; - font-weight: 700; - min-width: 1.5rem; + font-size: 1.125rem; + font-weight: 700; + min-width: 1.5rem; } .setup-stepper { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.75rem; - padding: 0.45rem; - border-radius: 1rem; - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.08); + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.45rem; + border-radius: 1rem; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); } .setup-stepper__value { - flex: 1; - text-align: center; - font-size: 1.35rem; - font-weight: 800; + flex: 1; + text-align: center; + font-size: 1.35rem; + font-weight: 800; } .stepper-button { - appearance: none; - width: 3.25rem; - min-width: 3.25rem; - min-height: 3.25rem; - border-radius: 0.95rem; - border: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(255, 255, 255, 0.08); - color: #f4f7fb; - font-size: 1.4rem; - font-weight: 800; - line-height: 1; - cursor: pointer; + appearance: none; + width: 3.25rem; + min-width: 3.25rem; + min-height: 3.25rem; + border-radius: 0.95rem; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.08); + color: #f4f7fb; + font-size: 1.4rem; + font-weight: 800; + line-height: 1; + cursor: pointer; } .setup-field--range input[type="range"] { - flex: 1; + flex: 1; } .setup-field--checkbox { - flex-direction: row; - align-items: center; - gap: 0.75rem; + flex-direction: row; + align-items: center; + gap: 0.75rem; } .setup-field--checkbox .setup-field__label { - margin: 0; + margin: 0; } .setup-field input[type="number"], .setup-field input[type="text"], .setup-field select { - width: 100%; - min-height: 3.25rem; - padding: 0.8rem 0.95rem; - border-radius: 1rem; - border: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(255, 255, 255, 0.06); - color: #f4f7fb; - font-size: 1rem; + width: 100%; + min-height: 3.25rem; + padding: 0.8rem 0.95rem; + border-radius: 1rem; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.06); + color: #f4f7fb; + font-size: 1rem; } .setup-field input[type="checkbox"] { - width: 1.25rem; - height: 1.25rem; - accent-color: #4a9eff; + width: 1.25rem; + height: 1.25rem; + accent-color: #4a9eff; } /* Player List */ .player-list { - display: flex; - flex-direction: column; - gap: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.5rem; } .start-strip { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(2.4rem, 1fr)); - gap: 0.35rem; - padding: 0.85rem; - border-radius: 1rem; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.06); + display: flex; + flex-wrap: nowrap; + gap: 0.35rem; + padding: 0.85rem; + border-radius: 1rem; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + overflow-x: auto; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.2) transparent; +} + +.start-strip::-webkit-scrollbar { + height: 6px; +} + +.start-strip::-webkit-scrollbar-track { + background: transparent; +} + +.start-strip::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; } .start-strip__slot { - min-height: 4.2rem; - display: grid; - align-content: start; - justify-items: center; - gap: 0.4rem; - padding: 0.45rem 0.2rem; - border-radius: 0.8rem; - background: rgba(255, 255, 255, 0.025); + min-width: 1.8rem; + min-height: 3.5rem; + display: grid; + align-content: start; + justify-items: center; + gap: 0.4rem; + padding: 0.45rem 0.2rem; + border-radius: 0.8rem; + background: rgba(255, 255, 255, 0.025); + flex-shrink: 0; } .start-strip__label { - font-size: 0.7rem; - color: rgba(231, 238, 247, 0.58); + font-size: 0.7rem; + color: rgba(231, 238, 247, 0.58); } .start-marker { - appearance: none; - width: 2rem; - height: 2rem; - border-radius: 999px; - border: 1px solid color-mix(in srgb, var(--player-color) 55%, white); - background: var(--player-color); - color: #08111c; - font-weight: 800; - cursor: grab; - box-shadow: 0 0 0 0.15rem rgba(255, 255, 255, 0.06), 0 0 0.9rem var(--player-glow); + appearance: none; + width: 1.5rem; + height: 1.5rem; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--player-color) 55%, white); + background: var(--player-color); + color: #08111c; + font-weight: 800; + font-size: 0.7rem; + cursor: grab; + box-shadow: + 0 0 0 0.15rem rgba(255, 255, 255, 0.06), + 0 0 0.9rem var(--player-glow); + flex-shrink: 0; } .start-marker:active { - cursor: grabbing; + cursor: grabbing; } .player-row { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.8rem 1rem; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.05); - border-radius: 0.9rem; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.8rem 1rem; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 0.9rem; } .player-row__info { - display: flex; - align-items: center; - gap: 0.625rem; + display: flex; + align-items: center; + gap: 0.625rem; } .player-row__name { - font-weight: 600; + font-weight: 600; } .player-name-input { - min-width: 0; - max-width: 100%; - min-height: 3.25rem; + min-width: 0; + max-width: 100%; + min-height: 3.25rem; } .player-row__actions { - display: flex; - gap: 0.375rem; + display: flex; + gap: 0.375rem; } .player-row__actions .mini-button { - min-height: 1.75rem; - padding: 0.25rem 0.5rem; - font-size: 0.875rem; + min-height: 1.75rem; + padding: 0.25rem 0.5rem; + font-size: 0.875rem; } /* Mini button for player reordering */ .mini-button { - appearance: none; - min-height: 2rem; - padding: 0.375rem 0.625rem; - border-radius: 0.5rem; - background: rgba(255, 255, 255, 0.08); - color: #f4f7fb; - border: 1px solid rgba(255, 255, 255, 0.1); - font-weight: 600; - font-size: 0.875rem; - cursor: pointer; + appearance: none; + min-height: 2rem; + padding: 0.375rem 0.625rem; + border-radius: 0.5rem; + background: rgba(255, 255, 255, 0.08); + color: #f4f7fb; + border: 1px solid rgba(255, 255, 255, 0.1); + font-weight: 600; + font-size: 0.875rem; + cursor: pointer; } .mini-button:hover { - background: rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.12); } .mini-button:disabled { - opacity: 0.35; - cursor: not-allowed; + opacity: 0.35; + cursor: not-allowed; } .draft-panel { - position: fixed; - top: 1rem; - right: 1rem; - width: max(320px, 30%); - max-width: calc(100% - 2rem); - max-height: calc(100% - 9.5rem); - overflow: auto; - z-index: 24; - border-color: rgba(255, 255, 255, 0.12); - background: rgba(9, 16, 29, 0.5); - backdrop-filter: blur(18px); - box-shadow: 0 16px 48px rgba(0, 0, 0, 0.32); + position: fixed; + top: 1rem; + right: 1rem; + width: min(1100px, calc(100vw - 2rem)); + max-width: calc(100% - 2rem); + max-height: calc(100% - 7.25rem); + overflow: auto; + z-index: 24; + border-color: rgba(255, 255, 255, 0.12); + background: rgba(9, 16, 29, 0.5); + backdrop-filter: blur(18px); + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.32); + transition: + width 180ms ease, + max-height 180ms ease; +} + +.draft-panel--docked { + width: max(320px, 30%); +} + +.draft-panel__toggle { + min-width: 2.5rem; + min-height: 2.5rem; + padding: 0; + font-size: 1.1rem; } .initiative-order-row { - display: flex; - flex-wrap: wrap; - gap: 0.55rem; + display: flex; + flex-wrap: wrap; + gap: 0.55rem; } .initiative-pill { - padding: 0.45rem 0.7rem; - border-radius: 999px; - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.08); - color: rgba(231, 238, 247, 0.76); + padding: 0.45rem 0.7rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); + color: rgba(231, 238, 247, 0.76); } .initiative-pill--active { - border-color: color-mix(in srgb, var(--player-color) 62%, white); - box-shadow: 0 0 18px color-mix(in srgb, var(--player-color) 40%, transparent); - color: #f4f7fb; + border-color: color-mix(in srgb, var(--player-color) 62%, white); + box-shadow: 0 0 18px color-mix(in srgb, var(--player-color) 40%, transparent); + color: #f4f7fb; } .initiative-bonus-note { - margin: 0.2rem 0 0; - padding: 0.55rem 0.7rem; - border-radius: 0.8rem; - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.08); - color: rgba(231, 238, 247, 0.8); - font-size: 0.82rem; + margin: 0.2rem 0 0; + padding: 0.55rem 0.7rem; + border-radius: 0.8rem; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); + color: rgba(231, 238, 247, 0.8); + font-size: 0.82rem; } .initiative-bonus-note--active { - border-color: rgba(255, 208, 96, 0.28); - background: rgba(255, 208, 96, 0.09); - color: #f4f7fb; + border-color: rgba(255, 208, 96, 0.28); + background: rgba(255, 208, 96, 0.09); + color: #f4f7fb; } .initiative-seat-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 0.8rem; - margin-top: 0.9rem; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.8rem; + margin-top: 0.9rem; } .initiative-seat { - appearance: none; - min-height: 8rem; - padding: 0.9rem; - border-radius: 1rem; - display: grid; - gap: 0.35rem; - text-align: left; - background: rgba(255, 255, 255, 0.06); - color: #f4f7fb; - border: 1px solid rgba(255, 255, 255, 0.1); - cursor: pointer; + appearance: none; + min-height: 8rem; + padding: 0.9rem; + border-radius: 1rem; + display: grid; + gap: 0.35rem; + text-align: left; + background: rgba(255, 255, 255, 0.06); + color: #f4f7fb; + border: 1px solid rgba(255, 255, 255, 0.1); + cursor: pointer; } .initiative-seat--taken { - opacity: 0.68; + opacity: 0.68; } .weather-key { - display: grid; - gap: 0.25rem; - color: rgba(231, 238, 247, 0.78); - font-size: 0.86rem; + display: grid; + gap: 0.25rem; + color: rgba(231, 238, 247, 0.78); + font-size: 0.86rem; +} + +.weather-draft-header { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.weather-draft-instructions { + font-size: 0.9rem; + color: rgba(231, 238, 247, 0.9); + margin: 0; +} + +.weather-draft-actions { + display: flex; + gap: 1.5rem; + padding: 0.5rem 0; +} + +.weather-draft-action { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.85rem; + color: rgba(231, 238, 247, 0.8); +} + +.weather-draft-action strong { + color: #f4f7fb; + font-weight: 700; +} + +.weather-draft-order { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.75rem; + align-items: flex-start; +} + +.weather-draft-player { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.2rem; + padding: 0.4rem 0.6rem; + border-radius: 0.6rem; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + min-width: 4rem; +} + +.weather-draft-player--active { + border-color: color-mix(in srgb, var(--player-color) 55%, white); + background: color-mix( + in srgb, + var(--player-color) 15%, + rgba(255, 255, 255, 0.08) + ); + box-shadow: 0 0 12px color-mix(in srgb, var(--player-color) 30%, transparent); +} + +.weather-draft-player__name { + font-weight: 600; + font-size: 0.85rem; + color: #f4f7fb; +} + +.weather-draft-player__label { + font-size: 0.7rem; + color: rgba(231, 238, 247, 0.6); + text-transform: uppercase; + letter-spacing: 0.05em; } .weather-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 0.9rem; - margin-top: 0.9rem; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.9rem; + margin-top: 0.9rem; +} + +.draft-panel--docked .weather-grid { + grid-template-columns: 1fr; } .weather-card { - display: grid; - gap: 0.8rem; - padding: 0.95rem; - border-radius: 1rem; - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.08); + display: grid; + gap: 0.8rem; + padding: 0.95rem; + border-radius: 1rem; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); } .weather-card h2, .weather-card p { - margin: 0; + margin: 0; } .weather-pair { - display: grid; - gap: 0.7rem; + display: grid; + gap: 0.7rem; +} + +.weather-offer-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 5.2rem; + gap: 0.7rem; + align-items: stretch; } .weather-pair__divider { - font-size: 0.7rem; - letter-spacing: 0.08em; - text-transform: uppercase; - color: rgba(231, 238, 247, 0.42); - text-align: center; + font-size: 0.7rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(231, 238, 247, 0.42); + text-align: center; } .weather-pair__option { - padding: 0.75rem 0.8rem; - border-radius: 0.85rem; - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.08); - transition: transform 120ms ease, opacity 120ms ease, padding 120ms ease; + padding: 0.75rem 0.8rem; + border-radius: 0.85rem; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + transition: + transform 120ms ease, + opacity 120ms ease, + padding 120ms ease; } .weather-pair__option--drafted { - border-color: rgba(130, 224, 182, 0.55); + border-color: rgba(130, 224, 182, 0.55); } .weather-pair__option--banned { - border-color: rgba(255, 128, 128, 0.45); - opacity: 0.72; + border-color: rgba(255, 128, 128, 0.45); + opacity: 0.72; } .weather-pair__option--locked { - padding: 0.5rem 0.65rem; - opacity: 0.56; - transform: scale(0.96); + padding: 0.5rem 0.65rem; + opacity: 0.56; + transform: scale(0.96); } .weather-pair__option--locked h2 { - font-size: 1.05rem; + font-size: 1.05rem; } .weather-pair__option--locked p { - font-size: 0.88rem; + font-size: 0.88rem; } .weather-card--drafted { - border-color: rgba(130, 224, 182, 0.55); + border-color: rgba(130, 224, 182, 0.55); } .weather-card--banned { - border-color: rgba(255, 128, 128, 0.45); - opacity: 0.72; + border-color: rgba(255, 128, 128, 0.45); + opacity: 0.72; } .weather-card__actions { - display: grid; - grid-template-columns: 1fr; - gap: 0.45rem; - margin-top: 0.75rem; + display: grid; + grid-template-columns: 1fr; + gap: 0.45rem; + margin-top: 0.75rem; } .weather-card__status { - margin-top: 0.75rem; - font-weight: 700; - color: rgba(231, 238, 247, 0.8); + margin-top: 0.75rem; + font-weight: 700; + color: rgba(231, 238, 247, 0.8); } .weather-action { - appearance: none; - min-width: 0; - display: flex; - align-items: center; - gap: 0.7rem; - padding: 0.58rem 0.72rem; - border-radius: 0.95rem; - color: #f4f7fb; - border: 1px solid rgba(255, 255, 255, 0.12); - background: rgba(255, 255, 255, 0.06); - text-align: left; - cursor: pointer; + appearance: none; + min-width: 0; + display: flex; + align-items: center; + gap: 0.7rem; + padding: 0.58rem 0.72rem; + border-radius: 0.95rem; + color: #f4f7fb; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.06); + text-align: left; + cursor: pointer; } .weather-action span { - display: grid; - gap: 0.12rem; - min-width: 0; + display: grid; + gap: 0.12rem; + min-width: 0; } .weather-action strong { - display: block; - overflow-wrap: anywhere; - font-size: 0.9rem; + display: block; + overflow-wrap: anywhere; + font-size: 0.9rem; } .weather-action__icon { - width: 1.75rem; - height: 1.75rem; - display: grid; - place-items: center; - border-radius: 999px; - font-size: 0.9rem; - line-height: 1; - background: rgba(255, 255, 255, 0.12); - flex: 0 0 auto; + width: 1.75rem; + height: 1.75rem; + display: grid; + place-items: center; + border-radius: 999px; + font-size: 0.9rem; + line-height: 1; + background: rgba(255, 255, 255, 0.12); + flex: 0 0 auto; } .weather-action--draft { - background: linear-gradient(180deg, rgba(255, 208, 96, 0.2), rgba(255, 208, 96, 0.08)); - border-color: rgba(255, 208, 96, 0.4); + background: linear-gradient( + 180deg, + rgba(255, 208, 96, 0.2), + rgba(255, 208, 96, 0.08) + ); + border-color: rgba(255, 208, 96, 0.4); } .weather-action--draft .weather-action__icon { - background: rgba(255, 208, 96, 0.22); + background: rgba(255, 208, 96, 0.22); } .weather-action--ban { - background: linear-gradient(180deg, rgba(255, 110, 110, 0.16), rgba(255, 110, 110, 0.06)); - border-color: rgba(255, 110, 110, 0.32); + background: linear-gradient( + 180deg, + rgba(255, 110, 110, 0.16), + rgba(255, 110, 110, 0.06) + ); + border-color: rgba(255, 110, 110, 0.32); } .weather-action--ban .weather-action__icon { - background: rgba(255, 110, 110, 0.18); + background: rgba(255, 110, 110, 0.18); +} + +.weather-action--ban-both { + align-self: stretch; + justify-content: center; + text-align: center; + background: linear-gradient( + 180deg, + rgba(255, 110, 110, 0.16), + rgba(255, 110, 110, 0.06) + ); + border-color: rgba(255, 110, 110, 0.32); + flex-direction: column; + gap: 0.5rem; +} + +.weather-action--ban-both .weather-action__icon { + width: 2rem; + height: 2rem; + background: rgba(255, 110, 110, 0.18); } .weather-action:hover, .initiative-seat:hover { - transform: translateY(-1px); + transform: translateY(-1px); } /* Animations */ @keyframes sunlight-drop { - 0% { - opacity: 0; - top: -0.9rem; - transform: scale(0.65); - } - 12% { - opacity: 1; - } - 85% { - opacity: 1; - top: calc(var(--drop-end) - 0.55rem); - transform: scale(1); - } - 100% { - opacity: 0; - top: calc(var(--drop-end) - 0.55rem); - transform: scale(1.25); - } + 0% { + opacity: 0; + top: -0.9rem; + transform: scale(0.65); + } + 12% { + opacity: 1; + } + 85% { + opacity: 1; + top: calc(var(--drop-end) - 0.55rem); + transform: scale(1); + } + 100% { + opacity: 0; + top: calc(var(--drop-end) - 0.55rem); + transform: scale(1.25); + } } @keyframes pop-fade { - 0% { - opacity: 0; - transform: scale(0.65); - } - 25% { - opacity: 1; - transform: scale(1); - } - 100% { - opacity: 1; - transform: scale(1); - } + 0% { + opacity: 0; + transform: translate(-50%, -50%) scale(0.4); + } + 18% { + opacity: 1; + transform: translate(-50%, -50%) scale(1.12); + } + 40% { + opacity: 0.95; + transform: translate(-50%, -50%) scale(1); + } + 100% { + opacity: 0; + transform: translate(-50%, -50%) scale(1); + } } @keyframes energy-cell-flash { - 0% { - opacity: 0; - } - 20% { - opacity: 0.98; - } - 100% { - opacity: 0; - } + 0% { + opacity: 0; + } + 20% { + opacity: 0.98; + } + 100% { + opacity: 0; + } } @keyframes score-pop { - 0% { - transform: scale(0.88); - color: #fff7d6; - text-shadow: 0 0 0 rgba(255, 235, 153, 0); - } - 35% { - transform: scale(1.18); - color: #ffe480; - text-shadow: 0 0 16px rgba(255, 228, 128, 0.9); - } - 100% { - transform: scale(1); - color: inherit; - text-shadow: 0 0 0 rgba(255, 235, 153, 0); - } + 0% { + transform: scale(0.88); + color: #fff7d6; + text-shadow: 0 0 0 rgba(255, 235, 153, 0); + } + 35% { + transform: scale(1.18); + color: #ffe480; + text-shadow: 0 0 16px rgba(255, 228, 128, 0.9); + } + 100% { + transform: scale(1); + color: inherit; + text-shadow: 0 0 0 rgba(255, 235, 153, 0); + } +} + +@keyframes score-flight { + 0% { + opacity: 0; + transform: translate(-50%, -50%) scale(0.35); + } + 10% { + opacity: 1; + transform: translate(-50%, -50%) scale(1.16); + } + 48% { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } + 72% { + opacity: 0.96; + transform: translate( + calc(-50% + var(--flight-mid-x)), + calc(-50% + var(--flight-mid-y)) + ) + scale(1); + } + 100% { + opacity: 0; + transform: translate( + calc(-50% + var(--flight-x)), + calc(-50% + var(--flight-y)) + ) + scale(0.86); + } +} + +@keyframes score-land { + 0% { + transform: scale(1); + color: inherit; + text-shadow: 0 0 0 rgba(255, 230, 153, 0); + } + 40% { + transform: scale(1.22); + color: #fff2b0; + text-shadow: 0 0 16px rgba(255, 226, 120, 0.85); + } + 100% { + transform: scale(1); + color: inherit; + text-shadow: 0 0 0 rgba(255, 230, 153, 0); + } } /* Fullscreen mode adjustments */ :fullscreen #app > *, :-webkit-full-screen #app > *, :-moz-full-screen #app > * { - max-height: 100vh; - padding: 0; + max-height: 100vh; + padding: 0; } /* Responsive adjustments */ @media (max-width: 900px) { - :root { - --bottom-bar-height: 80px; - --sidebar-min-width: 240px; - } - - .layout { - grid-template-columns: 1fr minmax(var(--sidebar-min-width), 320px); - gap: 0.5rem; - padding: 0.5rem; - } - - .score-card { - padding: 1rem 1.25rem; - } - - .score-card__head h2 { - font-size: 0.9rem; - } - - .score-card__numbers strong { - font-size: 1rem; - } + :root { + --bottom-bar-height: 80px; + --sidebar-min-width: 240px; + } + + .layout { + grid-template-columns: 1fr minmax(var(--sidebar-min-width), 320px); + gap: 0.5rem; + padding: 0.5rem; + } + + .score-card { + padding: 1rem 1.25rem; + } + + .score-card__head h2 { + font-size: 0.9rem; + } + + .score-card__numbers strong { + font-size: 1rem; + } + + .draft-panel, + .draft-panel--docked { + width: calc(100vw - 2rem); + } + + .weather-grid, + .draft-panel--docked .weather-grid { + grid-template-columns: 1fr; + } } @media (max-width: 700px) { - .layout { - grid-template-areas: - "main" - "sidebar" - "bottom"; - grid-template-columns: 1fr; - grid-template-rows: 2fr auto var(--bottom-bar-height); - } - - .sidebar { - flex-direction: row; - gap: 0.5rem; - } - - .panel { - flex: 1; - min-width: 0; - } + .layout { + grid-template-areas: + "main" + "sidebar" + "bottom"; + grid-template-columns: 1fr; + grid-template-rows: 2fr auto var(--bottom-bar-height); + } + + .sidebar { + flex-direction: row; + gap: 0.5rem; + } + + .panel { + flex: 1; + min-width: 0; + } }