From 2ce04c7bbbd1c6e795a83613910f780c9613b369 Mon Sep 17 00:00:00 2001 From: Tim Bendt Date: Wed, 8 Apr 2026 16:29:50 -0400 Subject: [PATCH] wip first --- .gitignore | 2 + index.html | 12 + package-lock.json | 987 +++++++++++++++++++++++++++++ package.json | 14 + plan.md | 504 +++++++++++++++ src/main.js | 1508 +++++++++++++++++++++++++++++++++++++++++++++ src/styles.css | 684 ++++++++++++++++++++ 7 files changed, 3711 insertions(+) create mode 100644 .gitignore create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 plan.md create mode 100644 src/main.js create mode 100644 src/styles.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/index.html b/index.html new file mode 100644 index 0000000..ffea47c --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + Canopy + + + +
+ + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..41fa371 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,987 @@ +{ + "name": "canopy-game", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "canopy-game", + "version": "0.0.1", + "devDependencies": { + "vite": "^5.4.19" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..22b2bfe --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "canopy-game", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "vite": "^5.4.19" + } +} diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..727ba7b --- /dev/null +++ b/plan.md @@ -0,0 +1,504 @@ +# Canopy Refactor And Rules Expansion Plan + +## Goals + +- Convert the game from JavaScript to TypeScript. +- Refactor the single-file implementation into small rules and UI modules. +- Fix the turn-order leverage problem with a public initiative seat draft. +- Add a public weather/effect card draft-ban phase to break symmetry. +- Add an alternate per-turn income mode with extra positional costs for balance. + +## Recommended Foundation + +- Use plain TypeScript with typed domain models and pure rules modules. +- Do not migrate to a board-game framework during this feature pass. +- Keep rendering simple and browser-native for now. +- Treat the game as a deterministic rules engine with DOM rendering layered on top. + +Why this approach: + +- The game is turn-based and rules-heavy, not entity-heavy. +- ECS-style libraries are a poor fit for this problem. +- `boardgame.io` is the main browser board-game framework, but it would expand scope too much for this refactor. +- If formal phase handling becomes painful later, `XState` is the best follow-up addition. + +## Card System Goals + +The weather/effect cards should: + +- be readable from a TV screen +- be resolved with perfect information +- create asymmetric incentives each round +- be strong enough to disrupt mirror play +- avoid too much arithmetic or long text +- mostly modify incentives, not replace the core game + +## Card Market Structure + +Recommended round flow: + +1. Determine round initiative order. +2. Reveal `playerCount + 2` public cards. +3. In initiative order, each player takes exactly one action: + - draft one card for the round, or + - ban one card from the row +4. Drafted cards become active global rules for the round. +5. Banned cards are removed. +6. Undrafted cards are discarded. + +Recommended limits: + +- Start with 8 to 14 total cards. +- Keep active cards per round low, ideally 1 to 3. +- Prefer cards with one sentence and one number. + +## Candidate Weather / Effect Cards + +These are written to fit a large shared screen. The title should be the main thing players read; the rule text should stay short. + +### Economy / Position Cards + +- **Leaf Surge**: Each leaf gains `+1` energy. +- **Branching Season**: Each branch beyond your first gives `+1` energy. +- **West Light**: Left third columns give `+1` energy. +- **East Light**: Right third columns give `+1` energy. +- **High Noon**: Center third columns give `+1` energy. +- **Sun Ladder**: Straight-up growth costs `-1` once per turn. +- **Storehouse**: Banking is enabled this round. +- **Shade Cloth**: Your covered nodes still count for `1` energy. + +### Conflict / Interaction Cards + +- **Sap Theft**: When your branch crosses an opponent branch, gain `+1` energy. +- **Hedge Trimmer**: Branches above the lowest player height are cut down before scoring. +- **Disease Sweep**: Before each turn, that player loses `1` energy. +- **Stalemate**: Contested columns give no energy. +- **Split Light**: Contested columns give half energy to each tied player. +- **Shared Light**: Contested columns give full energy to every tied player. + +### Additional Cards Worth Considering + +- **Root Pulse**: Root shifts cost `0` this round. +- **Still Soil**: Root shifts are disabled this round. +- **Thin Air**: Diagonal growth costs `+1`. +- **Tailwind**: Diagonal growth costs `-1` once per turn. +- **Canopy Tax**: The tallest branch of each player costs `+1` to extend. +- **Undergrowth**: The first growth each turn costs `0`. +- **Dry Season**: Banking loses `1` energy when used. +- **Tall Reward**: Your tallest leaf gains `+2` energy. +- **Wide Reward**: If you control the most columns, gain `+2` energy. +- **Twig Bloom**: New leaves created this round gain `+1` energy when scored. +- **Heavy Shade**: Columns with 2 or more players present count as contested. +- **Sunbreak**: Empty columns next to your branch give `+1` energy. + +## Best Initial Card Set + +For the first implementation, avoid the cards that require the most new geometry or crossing logic. Start with cards that reuse existing board evaluation. + +### Phase 1 card set + +- **Leaf Surge**: Each leaf gains `+1` energy. +- **Branching Season**: Each branch beyond your first gives `+1` energy. +- **West Light**: Left third columns give `+1` energy. +- **East Light**: Right third columns give `+1` energy. +- **High Noon**: Center third columns give `+1` energy. +- **Sun Ladder**: Straight-up growth costs `-1` once per turn. +- **Storehouse**: Banking is enabled this round. +- **Stalemate**: Contested columns give no energy. +- **Split Light**: Contested columns give half energy to each tied player. +- **Shared Light**: Contested columns give full energy to every tied player. + +### Phase 2 card set + +Add later after the rules core is stable: + +- **Shade Cloth** +- **Sap Theft** +- **Hedge Trimmer** +- **Disease Sweep** +- **Root Pulse** +- **Still Soil** + +Why split it this way: + +- Phase 1 mostly changes scoring and move costs. +- Phase 2 needs new concepts like coverage protection, branch crossing checks, pruning, or per-turn drain. + +## Rules Clarifications To Lock Down + +### Initiative seat draft + +- Players use a pure seat-selection token, not growth bidding. +- Players choose specific seat numbers. +- Seats are drafted publicly in bidding order, so no seat conflict resolution is needed. +- Bidding order is determined by setup rule: + - rotating from a random starting anchor, or + - lowest lifetime growth income first + +### Lifetime growth income + +Track all growth ever gained. + +This should include: + +- starting growth at game start +- round-end sunlight income +- per-turn sunlight income +- seat bonus growth +- card-generated growth +- random event growth like sunbeam bonuses +- recovered or protected growth if the game records that as gain + +This should not double-count: + +- banked energy carried into a later round after it was already counted when first gained + +Implementation note: + +- Add a dedicated `lifetimeGrowthIncome` stat to each player. +- Update it only at the moment growth is awarded. + +### Banking + +Current open issue: + +- Banking should likely become conditional on a round card rather than always-on. + +Recommended rule: + +- By default, banking is disabled. +- `Storehouse` enables banking for that round. + +Why: + +- This helps reduce the economy snowball. +- It gives the weather deck a meaningful strategic lever. + +### Contested columns + +The current game reads column ownership by the top intercepting node. Weather cards that talk about contested columns require a clear definition. + +Recommended definition: + +- A column is contested if 2 or more players have a node in that column and they are tied under the active contest rule. + +Open design task: + +- Decide whether contest is based on equal top row, any shared presence, or special card-defined presence. + +Recommended default: + +- Contest means two or more players share the best topmost position for that column. + +This keeps the rule closest to current sunlight logic. + +### Leaves and branches + +These terms need firm technical definitions before implementation. + +Recommended definitions: + +- `leaf`: a node with no children +- `branch`: a root-to-leaf path, or more simply for scoring, each leaf represents one branch + +Recommended implementation simplification: + +- Use `leaf count` instead of true branch count in the first pass. +- Rename card text if needed to match the actual rule. + +Example: + +- Instead of `Branching Season: Each branch beyond your first gives +1 energy`, consider `Leaf Burst: Each leaf after your first gives +1 energy`. + +This is much easier to explain and compute. + +### Branch crossing + +`Sap Theft` needs a precise crossing rule. + +Risk: + +- The current data model stores branch segments, but crossing detection between diagonal edges is new logic. + +Recommendation: + +- Do not include crossing-based cards in phase 1. +- If added later, count only actual geometric edge intersections between different players' segments. + +### Hedge Trimmer + +This card is flavorful but potentially destructive and swingy. + +Risks: + +- Requires pruning nodes and edges from the board. +- Can invalidate pending move assumptions. +- Needs a clear “same level” definition. + +Recommendation: + +- Delay to phase 2. +- Define it as: `Before scoring, all nodes above the shortest tallest-player height are removed.` +- Only apply between turns or at round end, never mid-turn. + +### Disease Sweep + +This can be implemented in more than one way. + +Choices: + +- lose growth before each turn +- lose sunlight before end-of-round tally +- kill twigs as the current disease event does + +Recommendation: + +- For a card, use the simple economy version: `Before each turn, that player loses 1 energy.` +- Keep the existing random disease event separate. + +## Implementation Risks + +### Player identity and turn order are currently coupled + +Current risk: + +- `player.id` is treated as both a stable identity and an array index. + +Plan: + +- Keep `players` in stable id order. +- Introduce `turnOrder: PlayerId[]` for seats and active order. +- Update turn flow and render helpers to use `turnOrder` where needed. + +### The game needs explicit phases + +Current risk: + +- The code uses ad hoc flow control plus animation locks. + +Plan: + +- Add a typed phase model: + - `initiative_bid` + - `weather_draft` + - `turn` + - `round_end` + - `game_over` + +### The code is currently one large file + +Current risk: + +- Rules, state, rendering, and DOM events are tightly mixed. + +Plan: + +- Convert to TypeScript first. +- Extract pure rules before feature work. + +### Per-turn income changes the economy deeply + +Current risk: + +- Existing logic assumes income is only awarded between rounds. + +Plan: + +- Keep `round_end` income as the stable default. +- Add `per_turn` as an alternate rules path after the initiative and weather phases are working. +- Add positional surcharges to slow runaway chaining. + +### TV readability + +Current risk: + +- Too many active modifiers will be hard to track. + +Plan: + +- Cap active cards per round. +- Keep card titles short. +- Show active effects in one compact panel with icons or one-line summaries. + +### Banking may conflict with weather cards + +Current risk: + +- If banking is always available and also appears on a card, the card has no value. + +Plan: + +- Make banking conditional on round effects, or convert the card into a banking bonus instead of availability. + +### Randomness vs strategy + +Current risk: + +- Too much randomness in the weather row can obscure good play. + +Plan: + +- Start with a small curated deck. +- Reveal a modest row each round. +- Consider a rotating deck instead of full shuffle if needed after testing. + +## Proposed TypeScript Module Structure + +- `src/main.ts` +- `src/constants.ts` +- `src/state/types.ts` +- `src/state/createGame.ts` +- `src/state/reducer.ts` +- `src/rules/board.ts` +- `src/rules/moves.ts` +- `src/rules/scoring.ts` +- `src/rules/initiative.ts` +- `src/rules/weather.ts` +- `src/rules/turnFlow.ts` +- `src/ui/renderBoard.ts` +- `src/ui/renderSidebar.ts` +- `src/ui/renderScoreboard.ts` +- `src/ui/renderModals.ts` +- `src/app/events.ts` + +## Proposed Type Model + +Core types to introduce early: + +- `PlayerId` +- `NodeKey` +- `Setup` +- `GameState` +- `Player` +- `Phase` +- `TurnMove` +- `WeatherCardId` +- `WeatherCardDefinition` +- `ActiveRoundEffect` + +New setup fields: + +- `initiativeMode: "fixed" | "bid"` +- `biddingOrderRule: "rotating" | "lowest_growth_income"` +- `incomeTiming: "round_end" | "per_turn"` +- `weatherDraftEnabled: boolean` + +New player fields: + +- `lifetimeGrowthIncome: number` + +New game state fields: + +- `phase` +- `turnOrder` +- `seatChoices` +- `initiativeAnchorPlayerId` +- `weatherRow` +- `draftedWeather` +- `bannedWeather` +- `activeRoundEffects` + +## Execution Order + +### Milestone 1: TypeScript conversion and safe refactor + +1. Add TypeScript and config. +2. Convert `src/main.js` to `src/main.ts` without changing behavior. +3. Extract constants and types. +4. Extract pure board, move, and scoring helpers. +5. Keep current gameplay working exactly as-is. + +### Milestone 2: Turn-order refactor + +1. Separate stable player identity from seat order. +2. Introduce `turnOrder`. +3. Update active-player logic to walk `turnOrder`. +4. Add explicit `phase` handling. + +### Milestone 3: Initiative draft phase + +1. Add setup controls for initiative mode and bidding-order rule. +2. Add lifetime growth income tracking. +3. Implement rotating bid order. +4. Implement lowest-growth-income bid order. +5. Add public seat selection modal. +6. Apply seat growth bonuses. + +### Milestone 4: Weather draft-ban phase + +1. Define weather card data in `rules/weather.ts`. +2. Add setup toggle for weather phase. +3. Implement weather row generation. +4. Implement draft-or-ban action in seat order. +5. Render active effects clearly. +6. Apply phase 1 weather cards in scoring and move cost logic. + +### Milestone 5: Per-turn income mode + +1. Add setup control for income timing. +2. Implement payout to the active player immediately after their turn. +3. Keep round-end mode intact. +4. Verify banking and pass logic still make sense. + +### Milestone 6: Distance-based balancing + +1. Add root-distance surcharge support. +2. Start with a simple formula such as: + - `extraCost = floor((abs(dx) + abs(dy)) / 4)` +3. Apply only in `per_turn` mode at first. +4. Tune after playtesting. + +### Milestone 7: Phase 2 weather cards + +1. Add crossing-based cards if still desired. +2. Add hedge-trimming/pruning cards. +3. Add disease drain cards. +4. Rebalance text and values based on playtests. + +## Testing Plan + +Add rule-level tests as soon as the core logic is modularized. + +Priority coverage: + +- legal growth moves +- root shift legality +- scoring by column +- contested column resolution modes +- seat draft ordering +- lifetime growth income tracking +- weather draft resolution +- weather effect application +- per-turn payout behavior +- distance surcharge cost calculation + +## Recommended First Shipping Configuration + +- initiative mode: `bid` +- bidding order rule: `rotating` +- income timing: `round_end` +- weather draft: `enabled` +- banking: enabled only through `Storehouse` +- weather deck: only phase 1 cards + +Why this first: + +- It directly attacks the symmetry problem. +- It fixes turn-order leverage without also changing all economy timing at once. +- It keeps the new concepts readable for group play. + +## Summary + +The most important design shift is this: + +- turn order becomes a public seat draft +- each round gains a public asymmetric card market +- mirror play is no longer automatically safe because players can deny and reshape incentives before growth begins + +The best implementation strategy is: + +- TypeScript first +- modular rules second +- initiative phase third +- weather card system fourth +- per-turn economy mode after the game flow is stable diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..85a3fc2 --- /dev/null +++ b/src/main.js @@ -0,0 +1,1508 @@ +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()} +
+ +
+ ${renderNewGameModal()} + `; + + attachEvents(); + previousScoreSnapshot = getScoreSnapshot(); +} + +render(); diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..5004716 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,684 @@ +: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; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: transparent; +} + +button, +input { + font: inherit; +} + +input[type="number"], +input[type="text"] { + width: 100%; + min-height: 2.8rem; + padding: 0.7rem 0.85rem; + border-radius: 0.9rem; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.05); + color: #f4f7fb; +} + +button { + border: 0; + cursor: pointer; +} + +#app { + min-height: 100vh; +} + +.layout { + min-height: 100vh; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.scoreboard { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.75rem; +} + +.scoreboard--bottom { + align-items: end; +} + +.score-card { + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 1.25rem; + padding: 0.8rem 1rem; + 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); +} + +.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); +} + +.score-card__head, +.score-card__numbers, +.panel__title-row, +.button-row, +.setup-grid, +.toggle-row, +.active-turn { + display: flex; + align-items: center; +} + +.score-card__head, +.panel__title-row, +.button-row, +.toggle-row { + justify-content: space-between; +} + +.score-card__head h2, +.panel h1, +.panel h2, +.active-turn h2 { + margin: 0; +} + +.score-card__numbers { + margin-top: 0.65rem; + gap: 1rem; +} + +.score-card__numbers div { + display: grid; + gap: 0.15rem; +} + +.score-card__numbers span, +.eyebrow, +label span, +.log-list p, +.status-panel p, +.active-turn p { + color: rgba(231, 238, 247, 0.72); +} + +.score-card__numbers strong { + font-size: 1.35rem; +} + +.score-value { + display: inline-block; +} + +.score-value.changed { + animation: score-pop 0.7s ease; +} + +.player-dot { + width: 0.95rem; + height: 0.95rem; + border-radius: 999px; + background: var(--player-color); + box-shadow: 0 0 18px var(--player-glow); +} + +.game-area { + flex: 1; + display: grid; + grid-template-columns: minmax(0, 1.9fr) minmax(320px, 0.85fr); + gap: 0.85rem; + min-height: 0; + align-items: start; +} + +.board-shell, +.panel { + border-radius: 1.5rem; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(9, 16, 29, 0.72); + backdrop-filter: blur(20px); +} + +.board-shell { + min-height: 0; + padding: 0.8rem; +} + +.board { + position: relative; + width: 100%; + height: auto; + max-height: calc(100vh - 10.5rem); + aspect-ratio: var(--board-columns) / var(--board-rows); + display: grid; + gap: 0.32rem; + margin: 0 auto; +} + +.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; +} + +.board__fx { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 4; +} + +.cell { + position: relative; + background: rgba(255, 255, 255, 0.03); + border-radius: 0.8rem; + border: 1px solid rgba(255, 255, 255, 0.05); + overflow: hidden; + z-index: 2; +} + +.cell__shade { + 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; +} + +.cell__node, +.cell__target-label { + position: absolute; + inset: 50% auto auto 50%; + transform: translate(-50%, -50%); +} + +.cell__node { + width: min(2.5vw, 1.6rem); + height: min(2.5vw, 1.6rem); + min-width: 1rem; + min-height: 1rem; + border-radius: 50%; + background: var(--node-color); + box-shadow: 0 0 0 0.2rem rgba(255, 255, 255, 0.06), 0 0 1.2rem var(--node-glow); +} + +.cell.selected { + 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); +} + +.cell.pending .cell__node { + box-shadow: 0 0 0 0.22rem rgba(255, 255, 255, 0.08), 0 0 1.4rem var(--node-glow), 0 0 2rem rgba(255, 255, 255, 0.08); +} + +.board__drop-layer { + 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; +} + +.board__energy-cell { + position: absolute; + border-radius: 0.8rem; + 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); +} + +.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); +} + +.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); +} + +.board--bonus .board__drop--bonus { + 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; +} + +.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); +} + +.board__drop-spark { + 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__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__root-burst text { + fill: #08111c; + font-size: 2.1px; + font-weight: 800; +} + +.board__disease-mark circle { + 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; +} + +.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__sunbeam-burst text { + fill: #08111c; + font-size: 2.1px; + font-weight: 800; +} + +.cell.target { + border-color: rgba(255, 255, 255, 0.22); + background: rgba(255, 255, 255, 0.05); +} + +.cell.target:hover { + transform: translateY(-1px); +} + +.cell__target-label { + width: 2rem; + height: 2rem; + 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; +} + +.sidebar { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.panel { + padding: 1rem; +} + +.controls-panel { + display: grid; + gap: 0.8rem; +} + +.panel__actions { + display: flex; + gap: 0.55rem; +} + +.modal-backdrop { + position: fixed; + inset: 0; + display: grid; + place-items: center; + padding: 1.25rem; + background: rgba(3, 8, 16, 0.72); + backdrop-filter: blur(14px); + z-index: 20; +} + +.modal { + width: min(1180px, 100%); + max-height: min(92vh, 980px); + overflow: auto; +} + +.modal-setup-grid, +.modal-grid { + display: grid; + gap: 0.9rem; +} + +.modal-setup-grid { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.modal-grid { + margin-top: 0.9rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.modal-actions { + margin-top: 0.9rem; + justify-content: flex-end; +} + +.eyebrow { + margin: 0 0 0.3rem; + font-size: 0.82rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.setup-grid { + gap: 1rem; + align-items: end; +} + +.setup-grid label { + flex: 1; + display: grid; + gap: 0.35rem; +} + +.seed-editor { + display: grid; + gap: 0.65rem; + padding: 0.8rem; + border-radius: 1rem; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.seed-row { + display: grid; + gap: 0.35rem; +} + +.order-row, +.order-row__label, +.order-row__actions { + display: flex; + align-items: center; +} + +.order-row { + justify-content: space-between; + gap: 0.75rem; +} + +.order-row__label { + gap: 0.6rem; + font-weight: 600; +} + +.order-row__actions { + gap: 0.4rem; +} + +.mini-button { + min-height: 2rem; + padding: 0.35rem 0.65rem; + border-radius: 0.7rem; + background: rgba(255, 255, 255, 0.08); + color: #f4f7fb; + border: 1px solid rgba(255, 255, 255, 0.1); + font-weight: 600; +} + +.seed-help { + margin: 0; + color: rgba(231, 238, 247, 0.72); +} + +.randomize-button { + width: 100%; +} + +input[type="range"] { + width: 100%; +} + +.active-turn { + flex-direction: column; + align-items: flex-start; + gap: 0.35rem; + padding: 0.8rem; + border-radius: 1rem; + 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)); +} + +.root-shift-row { + display: flex; + gap: 0.55rem; + flex-wrap: wrap; +} + +.root-shift-button { + min-height: 2.2rem; +} + +.button-row { + gap: 0.75rem; +} + +.button-row button, +.ghost-button { + min-height: 2.7rem; + padding: 0.65rem 0.9rem; + border-radius: 0.95rem; + background: #f4f7fb; + color: #0a1020; + font-weight: 700; +} + +.ghost-button, +#finish-game { + 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; +} + +.log-list { + display: grid; + gap: 0.55rem; + max-height: 18vh; + overflow: auto; +} + +.log-list p, +.status-panel p, +.active-turn p { + margin: 0; +} + +.event-note { + color: #ffd577; +} + +@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); + } +} + +@keyframes pop-fade { + 0% { + opacity: 0; + transform: scale(0.65); + } + + 25% { + opacity: 1; + transform: scale(1); + } + + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes energy-cell-flash { + 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); + } +} + +@media (max-width: 1100px) { + .game-area { + grid-template-columns: 1fr; + } + + .modal-grid { + grid-template-columns: 1fr; + } + + .board { + max-height: none; + width: 100%; + } +} + +@media (max-width: 720px) { + .layout { + padding: 1rem; + } + + .scoreboard { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .score-card__numbers { + gap: 1rem; + } + + .cell__node { + width: 1rem; + height: 1rem; + } +}