Compare commits

..

No commits in common. "d6a55d9c138e93945d233e5e1613e2a7d17dd4d3" and "b54f36c0e897dbb9b55997602ff4b681ca0ec071" have entirely different histories.

26 changed files with 288 additions and 1644 deletions

1
.gitignore vendored
View file

@ -1 +0,0 @@
node_modules/

View file

@ -2,24 +2,6 @@
Open `index.html` in a browser. Open `index.html` in a browser.
Production uses the committed `dist/game.js` bundle. Edit source files under
`src/`, then run `npm install` once and `npm run build` after JavaScript
changes to regenerate the bundle.
## Static server cache setup
Serve `index.html`, the bundled JavaScript, and CSS with cache validation
instead of import query strings. This prevents stale files while keeping reloads
fast.
Use the example in `deploy/` for your HTTP server:
- nginx: include `deploy/nginx-cache.conf` inside the server block.
- Apache: copy or include `deploy/apache-cache.htaccess` from the site root.
- Caddy: adapt `deploy/Caddyfile-cache`.
After deploying, purge any CDN or reverse-proxy cache once.
## Current baseline ## Current baseline
- Build, move, box-select, sell, and repair factory equipment during Build phase. - Build, move, box-select, sell, and repair factory equipment during Build phase.

View file

@ -1,9 +0,0 @@
# Adapt these directives inside the Caddy site block that serves this static app.
encode gzip zstd
@html path /index.html
header @html Cache-Control "no-cache"
@assets path *.js *.css
header @assets Cache-Control "no-cache"

View file

@ -1,17 +0,0 @@
# Use this from the static site root when served by Apache with .htaccess enabled.
FileETag MTime Size
<IfModule mod_headers.c>
<Files "index.html">
Header set Cache-Control "no-cache"
</Files>
<FilesMatch "\.(js|css)$">
Header set Cache-Control "no-cache"
</FilesMatch>
</IfModule>
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/css application/javascript text/javascript
</IfModule>

View file

@ -1,20 +0,0 @@
# Include this in the nginx server block that serves this static app.
#
# Example:
# server {
# root /var/www/zunda-shiwake;
# include /var/www/zunda-shiwake/deploy/nginx-cache.conf;
# }
etag on;
gzip on;
gzip_types text/css application/javascript text/javascript;
location = /index.html {
add_header Cache-Control "no-cache";
}
location ~* \.(js|css)$ {
add_header Cache-Control "no-cache";
gzip_static on;
}

61
dist/game.js vendored

File diff suppressed because one or more lines are too long

View file

@ -12,9 +12,9 @@
<canvas id="gameCanvas" width="1440" height="900" aria-label="Chick Sorter game canvas"></canvas> <canvas id="gameCanvas" width="1440" height="900" aria-label="Chick Sorter game canvas"></canvas>
<div class="hud hud-top-left" aria-label="Run status"> <div class="hud hud-top-left" aria-label="Run status">
<div class="hud-card cash"><span>CASH</span><strong id="money">・・250</strong></div> <div class="hud-card cash"><span>CASH</span><strong id="money">¥250</strong></div>
<div class="hud-card"><span>TIME</span><strong id="timeLeft">60.0s</strong></div> <div class="hud-card"><span>TIME</span><strong id="timeLeft">60.0s</strong></div>
<div class="hud-card profit"><span>NET</span><strong id="turnProfit">・・0</strong></div> <div class="hud-card profit"><span>NET</span><strong id="turnProfit">¥0</strong></div>
<div class="hud-card"><span>DAY</span><strong id="turn">1</strong></div> <div class="hud-card"><span>DAY</span><strong id="turn">1</strong></div>
<div class="hud-card compact"><span>PHASE</span><strong id="phaseLabel">Title</strong></div> <div class="hud-card compact"><span>PHASE</span><strong id="phaseLabel">Title</strong></div>
<div class="hud-card combo"><span>COMBO</span><strong id="comboCount">0</strong></div> <div class="hud-card combo"><span>COMBO</span><strong id="comboCount">0</strong></div>
@ -46,20 +46,25 @@
<section class="equipment-section"> <section class="equipment-section">
<h2>Add / Edit Equipment</h2> <h2>Add / Edit Equipment</h2>
<div class="large-tools"> <div class="large-tools">
<button id="buildConveyorButton" class="tool-button" type="button"><strong>Conveyor</strong><span>・・30 / tile</span></button> <button id="buildConveyorButton" class="tool-button" type="button"><strong>Conveyor</strong><span>¥30 / tile</span></button>
<button id="buildBoostConveyorButton" class="tool-button" type="button"><strong>Boost Conveyor</strong><span>・・50 / tile</span></button> <button id="buildEggFarmButton" class="tool-button" type="button"><strong>Egg Farm</strong><span>¥250</span></button>
<button id="buildEggFarmButton" class="tool-button" type="button"><strong>Egg Farm</strong><span>・・250</span></button> <button id="buildAutoScannerButton" class="tool-button" type="button"><strong>Auto Scanner</strong><span>¥360 / 2.25s cooldown</span></button>
<button id="buildAutoScannerButton" class="tool-button" type="button"><strong>Auto Scanner</strong><span>¥360 / 3.5s cooldown</span></button> <button id="buildManualScannerButton" class="tool-button" type="button"><strong>Manual Scanner</strong><span>¥260</span></button>
<button id="buildManualScannerButton" class="tool-button" type="button"><strong>Manual Scanner</strong><span>・・260</span></button> <button id="buildMixerButton" class="tool-button" type="button"><strong>Mixer</strong><span>¥450</span></button>
<button id="buildMixerButton" class="tool-button" type="button"><strong>Mixer</strong><span>・・450</span></button> <button id="buildTrashButton" class="tool-button" type="button"><strong>Waste Shredder</strong><span>¥240</span></button>
<button id="buildTrashButton" class="tool-button" type="button"><strong>Waste Shredder</strong><span>・・240</span></button> <button id="buildTruckButton" class="tool-button" type="button"><strong>Truck</strong><span>¥450</span></button>
<button id="buildTruckButton" class="tool-button" type="button"><strong>Truck</strong><span>・・450</span></button>
<button id="eraseButton" class="tool-button danger" type="button"><strong>Sell</strong><span>Sell or refund equipment</span></button> <button id="eraseButton" class="tool-button danger" type="button"><strong>Sell</strong><span>Sell or refund equipment</span></button>
<button id="hireRepairmanButton" class="tool-button repair" type="button"><strong>Hire Repairman</strong><span>・・100 / next day</span></button> <button id="hireRepairmanButton" class="tool-button repair" type="button"><strong>Hire Repairman</strong><span>¥100 / next day</span></button>
<button id="expandGridButton" class="tool-button expand" type="button"><strong>Expand Grid</strong><span>¥1,300 / +10×10</span><small class="tool-flavor">Canvas buttons buy adjacent right or upper 10×10 lots. New ground includes blocked no-build tiles.</small></button>
<div class="history-tools"><button id="undoButton" class="tool-button small" type="button"><strong>Undo</strong><span>Ctrl+Z</span></button><button id="redoButton" class="tool-button small" type="button"><strong>Redo</strong><span>Ctrl+Y</span></button></div> <div class="history-tools"><button id="undoButton" class="tool-button small" type="button"><strong>Undo</strong><span>Ctrl+Z</span></button><button id="redoButton" class="tool-button small" type="button"><strong>Redo</strong><span>Ctrl+Y</span></button></div>
</div> </div>
<div id="buildStatus" class="mini-box">Build tools unlock after each day.</div> <div id="buildStatus" class="mini-box">Build tools unlock after each day.</div>
</section> </section>
<section class="contract-section">
<h2>Irregular One-Day Event</h2>
<div id="contractPanel" class="contract-card empty">Irregular forced events appear during Build phase.</div>
</section>
<section class="rules-section compact-rules"> <section class="rules-section compact-rules">
<h2>Status</h2> <h2>Status</h2>
<div id="turnSummary" class="mini-box"></div> <div id="turnSummary" class="mini-box"></div>
@ -83,7 +88,6 @@
<label class="debug-check"><input id="debugInfiniteCash" type="checkbox" /> Infinite money</label> <label class="debug-check"><input id="debugInfiniteCash" type="checkbox" /> Infinite money</label>
<label class="debug-day">Day <input id="debugDayInput" type="number" min="1" step="1" value="1" /></label> <label class="debug-day">Day <input id="debugDayInput" type="number" min="1" step="1" value="1" /></label>
<button id="debugSetDayButton" type="button">Set Day</button> <button id="debugSetDayButton" type="button">Set Day</button>
<button id="debugZeroTimerButton" type="button">Timer 0</button>
<label class="debug-card">Card <select id="debugCardSelect"></select></label> <label class="debug-card">Card <select id="debugCardSelect"></select></label>
<button id="debugGrantCardButton" type="button">Grant Card</button> <button id="debugGrantCardButton" type="button">Grant Card</button>
<button id="debugGrantAllCardsButton" type="button">All Cards</button> <button id="debugGrantAllCardsButton" type="button">All Cards</button>
@ -101,6 +105,6 @@
</div> </div>
</div> </div>
<script type="module" src="./dist/game.js"></script> <script type="module" src="./src/game.js?v=27.6-direct-conveyor-chicks"></script>
</body> </body>
</html> </html>

496
package-lock.json generated
View file

@ -1,496 +0,0 @@
{
"name": "zunda-shiwake",
"version": "27.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "zunda-shiwake",
"version": "27.10.0",
"devDependencies": {
"esbuild": "^0.25.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
}
}
}

View file

@ -1,13 +0,0 @@
{
"name": "zunda-shiwake",
"version": "27.6.0",
"private": true,
"type": "module",
"scripts": {
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
"build": "npm run clean && esbuild src/game.js --bundle --format=esm --target=es2020 --minify --outfile=dist/game.js"
},
"devDependencies": {
"esbuild": "^0.25.0"
}
}

View file

@ -43,7 +43,7 @@ export const BALANCE = {
fairiesTribute: { fairiesTribute: {
perDay: 10 perDay: 10
}, },
incomeUpgradeRate: 1.10, incomeUpgradeRate: 1.05,
explosionDamageDivisor: 30, explosionDamageDivisor: 30,
maleTruckFinePerHalfDay: 30, maleTruckFinePerHalfDay: 30,
shredderBonus: { shredderBonus: {
@ -53,7 +53,7 @@ export const BALANCE = {
}, },
production: { production: {
poopRate: 0.08, poopRate: 0.08,
autoScannerCooldown: 3.5, autoScannerCooldown: 2.25,
autoScannerUpgradeRate: 0.95, autoScannerUpgradeRate: 0.95,
autoScannerMinCooldown: 0.5, autoScannerMinCooldown: 0.5,
eggSpawnRanges: [ eggSpawnRanges: [
@ -112,7 +112,7 @@ export const BALANCE = {
rarity: 'common', rarity: 'common',
type: 'equipmentUpgrade', type: 'equipmentUpgrade',
target: 'trash', target: 'trash',
description: 'Shred poops for a little money. Max 30 upgrades.', description: 'Choose SHREDDER and raise it by 1 level. Bonus chance increases by the current upgrade count. Max 30 upgrades.',
tags: ['POOP', 'ACTIVE'] tags: ['POOP', 'ACTIVE']
}, },
{ {
@ -171,48 +171,6 @@ export const BALANCE = {
description: 'Conveyor speed +7.5%.', description: 'Conveyor speed +7.5%.',
tags: ['CONVEYOR', 'PASSIVE'] tags: ['CONVEYOR', 'PASSIVE']
}, },
{
id: 'sparePartsBin',
title: 'SPARE PARTS BIN',
rarity: 'common',
type: 'instant',
description: 'Restores 2% durability to every machine. A box of almost-compatible parts.',
tags: ['MAINTENANCE', 'ACTIVE'],
weightBase: 100,
weightLossPerCopy: 2
},
{
id: 'dudRefund',
title: 'DUD REFUND',
rarity: 'common',
type: 'instant',
description: 'DUDs now come with paperwork. Gain JPY 50 per copy when you pick a DUD.',
tags: ['CARD', 'ECONOMY', 'PASSIVE']
},
{
id: 'freeReroll',
title: 'FREE REROLL',
rarity: 'common',
type: 'instant',
description: 'The first bad ideas are free.',
tags: ['CARD', 'PASSIVE']
},
{
id: 'composter',
title: 'COMPOSTER',
rarity: 'common',
type: 'instant',
description: 'Turns shredder waste into suspicious fertilizer. Gain JPY 3 per copy when SHREDDER processes poop.',
tags: ['POOP', 'ECONOMY', 'PASSIVE']
},
{
id: 'scannerQueueSpacing',
title: 'Scanner Queue Spacing',
rarity: 'common',
type: 'instant',
description: 'Manual and Auto Scanner queue spacing -15%. No card limit.',
tags: ['SCANNER', 'PASSIVE']
},
{ {
id: 'extraEggOutlet', id: 'extraEggOutlet',
title: 'Extra Egg Outlet', title: 'Extra Egg Outlet',
@ -236,7 +194,7 @@ export const BALANCE = {
rarity: 'rare', rarity: 'rare',
type: 'cellAction', type: 'cellAction',
target: 'blockedCell', target: 'blockedCell',
description: 'Remove any 3 unbuildable cells', description: 'Remove any 3 blocked cells with a burst effect.',
tags: ['RISK', 'RARE', 'ONE-SHOT'] tags: ['RISK', 'RARE', 'ONE-SHOT']
}, },
{ {
@ -325,11 +283,6 @@ export const BALANCE = {
preventiveMaintenance: 0, preventiveMaintenance: 0,
dudFilter: 0, dudFilter: 0,
durabilityCoating: 0, durabilityCoating: 0,
sparePartsBin: 0,
dudRefund: 0,
freeReroll: 0,
composter: 0,
scannerQueueSpacing: 0,
laborExploitation: 0, laborExploitation: 0,
usedMachineActive: false, usedMachineActive: false,
rescueLoanCharges: 0, rescueLoanCharges: 0,
@ -377,10 +330,6 @@ export const BALANCE = {
id: 'conveyor', type: 'conveyor', name: 'Conveyor', shortName: 'Belt', price: 30, id: 'conveyor', type: 'conveyor', name: 'Conveyor', shortName: 'Belt', price: 30,
buildable: true, upgradeable: false buildable: true, upgradeable: false
}, },
boostConveyor: {
id: 'boostConveyor', type: 'conveyor', name: 'Boost Conveyor', shortName: 'BOOST BELT', price: 75,
buildable: true, upgradeable: false, speedMultiplier: 2
},
eggFarm: { eggFarm: {
id: 'eggFarm', type: 'eggFarm', name: 'Egg Farm', shortName: 'EGG', price: 250, id: 'eggFarm', type: 'eggFarm', name: 'Egg Farm', shortName: 'EGG', price: 250,
buildable: true, upgradeable: true, maxLevel: 4, upgradeCosts: [null, 300, 720, 1600] buildable: true, upgradeable: true, maxLevel: 4, upgradeCosts: [null, 300, 720, 1600]

View file

@ -24,7 +24,7 @@ export const EGG_SPAWN_RANGES = BALANCE.production.eggSpawnRanges;
export const GRID = BALANCE.grid; export const GRID = BALANCE.grid;
export const FACILITY_DEFS = BALANCE.facilities; export const FACILITY_DEFS = BALANCE.facilities;
export const BUILD_TOOL_IDS = ['conveyor', 'boostConveyor', 'eggFarm', 'autoScanner', 'manualScanner', 'mixer', 'trash', 'truck']; export const BUILD_TOOL_IDS = ['conveyor', 'eggFarm', 'autoScanner', 'manualScanner', 'mixer', 'trash', 'truck'];
export const MACHINE_FACILITY_IDS = ['mixer', 'trash', 'truck']; export const MACHINE_FACILITY_IDS = ['mixer', 'trash', 'truck'];
export const INCOME_FACILITY_IDS = ['mixer', 'truck']; export const INCOME_FACILITY_IDS = ['mixer', 'truck'];

View file

@ -33,19 +33,17 @@ export function createEggFarm(game, col, row) {
export function createScanner(game, col, row, kind) { export function createScanner(game, col, row, kind) {
const cost = kind === 'auto' ? FACILITY_DEFS.autoScanner.price : FACILITY_DEFS.manualScanner.price; const cost = kind === 'auto' ? FACILITY_DEFS.autoScanner.price : FACILITY_DEFS.manualScanner.price;
const usedManualSlots = new Set(game.scanners.filter(s => s.kind === 'manual').map(s => s.slot)); const manualCount = game.scanners.filter(s => s.kind === 'manual').length;
let manualSlot = 0;
while (usedManualSlots.has(manualSlot)) manualSlot += 1;
return { return {
type: 'scanner', id: game.nextId++, kind, type: 'scanner', id: game.nextId++, kind,
slot: kind === 'manual' ? manualSlot : null, slot: kind === 'manual' ? manualCount : null,
role: kind === 'manual' ? manualSlot % 2 : 0, role: manualCount % 2,
col, row, level: 1, col, row, level: 1,
queue: [], cooldown: 0, queue: [], cooldown: 0,
price: cost, price: cost,
builtSession: game.buildSession, builtSession: game.buildSession,
autoMode: 'standard', autoMode: 'standard',
keys: kind === 'manual' ? manualKeysForSlot(manualSlot) : null keys: kind === 'manual' ? manualKeysForSlot(manualCount) : null
}; };
} }

View file

@ -33,9 +33,6 @@ export function newTurnStats() {
chemicalWeaponSubsidy: 0, chemicalWeaponSubsidy: 0,
rescueLoan: 0, rescueLoan: 0,
cardRerollCost: 0, cardRerollCost: 0,
dudRefundIncome: 0,
composterIncome: 0,
sparePartsRepair: 0,
mixerPoopFine: 0, mixerPoopFine: 0,
truckPoopFine: 0, truckPoopFine: 0,
maleTruckFine: 0, maleTruckFine: 0,
@ -77,9 +74,6 @@ export function newTotalStats() {
manualComboFailure: 0, manualComboFailure: 0,
repairWorkerWages: 0, repairWorkerWages: 0,
cardRerollCost: 0, cardRerollCost: 0,
dudRefundIncome: 0,
composterIncome: 0,
sparePartsRepair: 0,
mixerPoopFine: 0, mixerPoopFine: 0,
truckPoopFine: 0, truckPoopFine: 0,
maleTruckFine: 0, maleTruckFine: 0,
@ -141,7 +135,7 @@ export function createGame() {
lastExplodedComponent: new Map(), lastExplodedComponent: new Map(),
contractOffer: null, contractOffer: null,
contractActive: null, contractActive: null,
cardEffects: { bearing: 0, legalWork: 0, fairiesFlatteryNext: 0, crowdedFarming: 0, extraEggOutlet: 0, hatchingFeed: 0, safetyCover: 0, recyclingSubsidy: 0, chemicalWeaponSubsidy: 0, preventiveMaintenance: 0, dudFilter: 0, durabilityCoating: 0, sparePartsBin: 0, dudRefund: 0, freeReroll: 0, composter: 0, scannerQueueSpacing: 0, laborExploitation: 0, usedMachineActive: false, rescueLoanCharges: 0, loans: [] }, cardEffects: { bearing: 0, legalWork: 0, fairiesFlatteryNext: 0, crowdedFarming: 0, extraEggOutlet: 0, hatchingFeed: 0, safetyCover: 0, recyclingSubsidy: 0, chemicalWeaponSubsidy: 0, preventiveMaintenance: 0, dudFilter: 0, durabilityCoating: 0, laborExploitation: 0, usedMachineActive: false, rescueLoanCharges: 0, loans: [] },
cardDraft: { pending: false, choices: [], rerolls: 0, picksRemaining: 0 }, cardDraft: { pending: false, choices: [], rerolls: 0, picksRemaining: 0 },
cardTargetPick: null, cardTargetPick: null,
manualCombo: { count: 0, lastBonus: 0 }, manualCombo: { count: 0, lastBonus: 0 },
@ -188,15 +182,15 @@ export function defaultFacilities() {
const INITIAL_CONVEYOR_BACKBONE = Object.freeze([ const INITIAL_CONVEYOR_BACKBONE = Object.freeze([
// Egg Farm -> S1 top input. The S1/S2 rows were moved down so unrelated // Egg Farm -> S1 top input. The S1/S2 rows were moved down so unrelated
// belt streams keep at least one blank tile between side-by-side flows. // belt streams keep at least one blank tile between side-by-side flows.
[1, 1], [2, 1], [3, 1], [4, 1], [5, 1], [6, 1], [7, 1], [8, 1], [8, 2], [1, 1], [2, 1], [3, 1], [4, 1], [5, 1], [6, 1], [7, 1], [8, 1], [8, 2], [8, 3],
// S1 left output -> Mixer receiver on the left grid edge. // S1 left output -> Mixer receiver on the left grid edge.
[7, 4], [6, 4], [5, 4], [4, 4], [3, 4], [2, 4], [1, 4], [0, 4], [7, 4], [6, 4], [5, 4], [4, 4], [3, 4], [2, 4], [1, 4], [0, 4],
// S1 right output -> S2 top input. // S1 right output -> S2 top input.
[10, 4], [11, 4], [12, 4], [13, 4], [14, 4], [15, 4], [15, 5], [9, 4], [10, 4], [11, 4], [12, 4], [13, 4], [14, 4], [15, 4], [15, 5], [15, 6],
// S2 left output -> Waste Shredder receiver on bottom grid edge. // S2 left output -> Waste Shredder receiver on bottom grid edge.
[14, 7], [13, 7], [12, 7], [12, 8], [12, 9], [14, 7], [13, 7], [12, 7], [12, 8], [12, 9],
// S2 right output -> Truck receiver on the bottom grid edge. // S2 right output -> Truck receiver on the bottom grid edge.
[17, 7], [18, 7], [18, 8], [18, 9] [16, 7], [17, 7], [18, 7], [18, 8], [18, 9]
]); ]);
const INITIAL_SCANNERS = Object.freeze([ const INITIAL_SCANNERS = Object.freeze([
@ -219,19 +213,13 @@ function addConveyorTile(game, col, row, options = {}) {
uses: 0, uses: 0,
durability: BALANCE.maintenance.durability.conveyor, durability: BALANCE.maintenance.durability.conveyor,
maintenanceType: 'conveyor', maintenanceType: 'conveyor',
kind: options.kind || 'conveyor',
speedMultiplier: options.speedMultiplier || 1,
dir: options.dir || null, dir: options.dir || null,
outDirs: Array.isArray(options.outDirs) ? [...options.outDirs] : [], outDirs: Array.isArray(options.outDirs) ? [...options.outDirs] : []
branchMode: options.branchMode || 'random'
}); });
} else if (options.dir || options.outDirs) { } else if (options.dir || options.outDirs) {
const meta = game.conveyorMeta.get(k); const meta = game.conveyorMeta.get(k);
if (options.dir) meta.dir = options.dir; if (options.dir) meta.dir = options.dir;
if (Array.isArray(options.outDirs)) meta.outDirs = [...new Set([...(meta.outDirs || []), ...options.outDirs])]; if (Array.isArray(options.outDirs)) meta.outDirs = [...new Set([...(meta.outDirs || []), ...options.outDirs])];
if (!meta.branchMode) meta.branchMode = 'random';
if (!meta.kind) meta.kind = 'conveyor';
if (!meta.speedMultiplier) meta.speedMultiplier = 1;
} }
} }
@ -253,21 +241,10 @@ function markInitialConveyorDirection(game, from, to) {
} }
function scannerPortCells(scanner) { function scannerPortCells(scanner) {
return [
{ col: scanner.col, row: scanner.row - 2 },
{ col: scanner.col - 1, row: scanner.row },
{ col: scanner.col + 2, row: scanner.row }
].filter(p => inGrid(p.col, p.row));
}
function scannerBodyCells(scanner) {
return [ return [
{ col: scanner.col, row: scanner.row - 1 }, { col: scanner.col, row: scanner.row - 1 },
{ col: scanner.col + 1, row: scanner.row - 1 }, { col: scanner.col - 1, row: scanner.row },
{ col: scanner.col, row: scanner.row }, { col: scanner.col + 1, row: scanner.row }
{ col: scanner.col + 1, row: scanner.row },
{ col: scanner.col, row: scanner.row + 1 },
{ col: scanner.col + 1, row: scanner.row + 1 }
].filter(p => inGrid(p.col, p.row)); ].filter(p => inGrid(p.col, p.row));
} }
@ -284,7 +261,7 @@ function protectedInitialCells(game) {
const reserved = new Set([...game.conveyorTiles]); const reserved = new Set([...game.conveyorTiles]);
const add = p => { if (p && inGrid(p.col, p.row)) reserved.add(key(p.col, p.row)); }; const add = p => { if (p && inGrid(p.col, p.row)) reserved.add(key(p.col, p.row)); };
for (const scanner of game.scanners) { for (const scanner of game.scanners) {
for (const p of scannerBodyCells(scanner)) add(p); add(scanner);
for (const p of scannerPortCells(scanner)) add(p); for (const p of scannerPortCells(scanner)) add(p);
} }
for (const farm of game.eggFarms) { for (const farm of game.eggFarms) {

View file

@ -56,7 +56,7 @@ export function equipmentName(id) {
export function buildToolPriceText(id) { export function buildToolPriceText(id) {
const def = FACILITY_DEFS[id]; const def = FACILITY_DEFS[id];
if (!def) return ''; if (!def) return '';
if (id === 'conveyor' || id === 'boostConveyor') return `${yen(def.price)} / tile`; if (id === 'conveyor') return `${yen(def.price)} / tile`;
if (id === 'autoScanner') return `${yen(def.price)} / ${AUTO_SCANNER_COOLDOWN.toFixed(1)}s cooldown`; if (id === 'autoScanner') return `${yen(def.price)} / ${AUTO_SCANNER_COOLDOWN.toFixed(1)}s cooldown`;
if (id === 'trash') return yen(def.price); if (id === 'trash') return yen(def.price);
return yen(def.price); return yen(def.price);
@ -65,9 +65,8 @@ export function buildToolPriceText(id) {
export function buildToolFlavorText(id) { export function buildToolFlavorText(id) {
const flavors = { const flavors = {
conveyor: 'A narrow green belt. Routes decide whether chicks live, ship, or become invoices.', conveyor: 'A narrow green belt. Routes decide whether chicks live, ship, or become invoices.',
boostConveyor: 'Moves chicks twice as fast. Wears down like a normal belt.',
eggFarm: 'A tiny gatehouse producing questionable eggs on schedule.', eggFarm: 'A tiny gatehouse producing questionable eggs on schedule.',
autoScanner: 'Slower than hands without upgrades.', autoScanner: 'An automated judge. Faster than hands, still very sure of itself.',
manualScanner: 'A manual checkpoint. The operator is the algorithm.', manualScanner: 'A manual checkpoint. The operator is the algorithm.',
mixer: 'Male chicks become revenue here. Do not feed it poop.', mixer: 'Male chicks become revenue here. Do not feed it poop.',
trash: 'A polite shredder for poop and other regrets.', trash: 'A polite shredder for poop and other regrets.',

View file

@ -3,7 +3,7 @@ import { buildToolButtonHtml } from './core/text.js';
import { createGame, resetLayout, newTurnStats } from './core/state.js'; import { createGame, resetLayout, newTurnStats } from './core/state.js';
import { clamp, pointToCell, cellCenter, key, yen } from './core/utils.js'; import { clamp, pointToCell, cellCenter, key, yen } from './core/utils.js';
import { expansionLots, lotBounds } from './core/gridExpansion.js'; import { expansionLots, lotBounds } from './core/gridExpansion.js';
import { commitFactoryGraphForDay, facilityConnectionIssues, scannerConnector } from './systems/routing.js'; import { commitFactoryGraphForDay, facilityConnectionIssues } from './systems/routing.js';
import { applyRevenue, collectChemicalWeaponSubsidy, collectFairiesTribute, collectLoanRepayments, collectZundaTax, settleTruckRevenue } from './systems/economy.js'; import { applyRevenue, collectChemicalWeaponSubsidy, collectFairiesTribute, collectLoanRepayments, collectZundaTax, settleTruckRevenue } from './systems/economy.js';
import { undo, redo } from './systems/history.js'; import { undo, redo } from './systems/history.js';
import { drawAll } from './render/draw.js'; import { drawAll } from './render/draw.js';
@ -28,12 +28,12 @@ const ui = {
hoverTooltip: document.getElementById('hoverTooltip'), hoverTooltip: document.getElementById('hoverTooltip'),
manualScannerMonitor: document.getElementById('manualScannerMonitor'), manualScannerMonitor: document.getElementById('manualScannerMonitor'),
hireRepairmanButton: document.getElementById('hireRepairmanButton'), hireRepairmanButton: document.getElementById('hireRepairmanButton'),
expandGridButton: document.getElementById('expandGridButton'),
debug: { debug: {
panel: document.getElementById('debugPanel'), panel: document.getElementById('debugPanel'),
infiniteCash: document.getElementById('debugInfiniteCash'), infiniteCash: document.getElementById('debugInfiniteCash'),
dayInput: document.getElementById('debugDayInput'), dayInput: document.getElementById('debugDayInput'),
setDay: document.getElementById('debugSetDayButton'), setDay: document.getElementById('debugSetDayButton'),
zeroTimer: document.getElementById('debugZeroTimerButton'),
cardSelect: document.getElementById('debugCardSelect'), cardSelect: document.getElementById('debugCardSelect'),
grantCard: document.getElementById('debugGrantCardButton'), grantCard: document.getElementById('debugGrantCardButton'),
grantAllCards: document.getElementById('debugGrantAllCardsButton'), grantAllCards: document.getElementById('debugGrantAllCardsButton'),
@ -41,7 +41,7 @@ const ui = {
}, },
buttons: { buttons: {
s1Left: document.getElementById('scanner1MixerButton'), s1Right: document.getElementById('scanner1TruckButton'), s2Left: document.getElementById('scanner2MixerButton'), s2Right: document.getElementById('scanner2TruckButton'), s1Left: document.getElementById('scanner1MixerButton'), s1Right: document.getElementById('scanner1TruckButton'), s2Left: document.getElementById('scanner2MixerButton'), s2Right: document.getElementById('scanner2TruckButton'),
nextTurn: document.getElementById('nextTurnButton'), conveyor: document.getElementById('buildConveyorButton'), boostConveyor: document.getElementById('buildBoostConveyorButton'), eggFarm: document.getElementById('buildEggFarmButton'), autoScanner: document.getElementById('buildAutoScannerButton'), manualScanner: document.getElementById('buildManualScannerButton'), mixer: document.getElementById('buildMixerButton'), trash: document.getElementById('buildTrashButton'), truck: document.getElementById('buildTruckButton'), erase: document.getElementById('eraseButton'), undo: document.getElementById('undoButton'), redo: document.getElementById('redoButton') nextTurn: document.getElementById('nextTurnButton'), conveyor: document.getElementById('buildConveyorButton'), eggFarm: document.getElementById('buildEggFarmButton'), autoScanner: document.getElementById('buildAutoScannerButton'), manualScanner: document.getElementById('buildManualScannerButton'), mixer: document.getElementById('buildMixerButton'), trash: document.getElementById('buildTrashButton'), truck: document.getElementById('buildTruckButton'), erase: document.getElementById('eraseButton'), undo: document.getElementById('undoButton'), redo: document.getElementById('redoButton')
} }
}; };
@ -49,7 +49,6 @@ const ui = {
function initializeStaticText() { function initializeStaticText() {
const buttonToolMap = { const buttonToolMap = {
conveyor: 'conveyor', conveyor: 'conveyor',
boostConveyor: 'boostConveyor',
eggFarm: 'eggFarm', eggFarm: 'eggFarm',
autoScanner: 'autoScanner', autoScanner: 'autoScanner',
manualScanner: 'manualScanner', manualScanner: 'manualScanner',
@ -106,22 +105,9 @@ function debugSetDay() {
updateDebugReadout(`Day set to ${day}.`); updateDebugReadout(`Day set to ${day}.`);
} }
function debugZeroTimer() {
game.timeLeft = 0;
if (game.phase === 'running') closeFarmShutters();
floating(game, canvas.width / 2 - game.view.x, 86 - game.view.y, 'TIMER 0', THEME.warn);
uiSystem.updatePanels();
uiSystem.updateUI();
updateDebugReadout('Timer set to 0.');
}
function debugGrantSelectedCard() { function debugGrantSelectedCard() {
const id = ui.debug.cardSelect?.value; const id = ui.debug.cardSelect?.value;
const result = debugGrantCard(game, id); const result = debugGrantCard(game, id);
if (ui.debug.grantCard) {
ui.debug.grantCard.textContent = result.ok ? 'Granted' : 'Grant failed';
window.setTimeout(() => { if (ui.debug.grantCard) ui.debug.grantCard.textContent = 'Grant Card'; }, 900);
}
uiSystem.updatePanels(); uiSystem.updatePanels();
uiSystem.updateUI(); uiSystem.updateUI();
updateDebugReadout(result.reason); updateDebugReadout(result.reason);
@ -385,11 +371,10 @@ function resetCameraToFactoryStart() {
game.view.y = 190 - anchor.y; game.view.y = 190 - anchor.y;
clampCamera(); clampCamera();
} }
function startPan(event) { const p = rawCanvasPoint(event); ensureView(); game.pan = { start: p, viewX: game.view.x, viewY: game.view.y, moved: false }; } function startPan(event) { const p = rawCanvasPoint(event); ensureView(); game.pan = { start: p, viewX: game.view.x, viewY: game.view.y }; }
function updatePan(event) { function updatePan(event) {
if (!game.pan) return; if (!game.pan) return;
const p = rawCanvasPoint(event); const p = rawCanvasPoint(event);
if (Math.hypot(p.x - game.pan.start.x, p.y - game.pan.start.y) > 5) game.pan.moved = true;
ensureView(); ensureView();
game.view.x = game.pan.viewX + p.x - game.pan.start.x; game.view.x = game.pan.viewX + p.x - game.pan.start.x;
game.view.y = game.pan.viewY + p.y - game.pan.start.y; game.view.y = game.pan.viewY + p.y - game.pan.start.y;
@ -455,19 +440,6 @@ function clickSelect(event) {
uiSystem.updatePanels(); uiSystem.updatePanels();
const obj = build.selectedObject(); const obj = build.selectedObject();
if (obj?.type === 'scanner' && obj.kind === 'manual') build.showManualScannerMenu(obj); if (obj?.type === 'scanner' && obj.kind === 'manual') build.showManualScannerMenu(obj);
if (obj?.type === 'scanner' && obj.kind === 'auto') build.showAutoScannerMenu(obj);
}
function cancelBuildAction() {
game.buildTool = null;
game.selected = null;
game.multiSelected = [];
game.selectionBox = null;
if (game.cardTargetPick?.mode === 'autoScannerMenu') game.cardTargetPick = null;
uiSystem.hideModal();
hideHoverTooltip();
uiSystem.updatePanels();
uiSystem.updateUI();
} }
function escapeHtml(value) { function escapeHtml(value) {
@ -488,8 +460,6 @@ function chickDisplay(chick) {
function miniScannerGrid(scanner, chick) { function miniScannerGrid(scanner, chick) {
const farmCells = new Set(game.eggFarms.map(farm => key(farm.col, farm.row))); const farmCells = new Set(game.eggFarms.map(farm => key(farm.col, farm.row)));
const scannerCells = new Map(game.scanners.map(s => [key(s.col, s.row), s])); const scannerCells = new Map(game.scanners.map(s => [key(s.col, s.row), s]));
const inputCell = scannerConnector(scanner, 'inputA');
const inputKey = inputCell ? key(inputCell.col, inputCell.row) : null;
const facilityEntryCells = new Set(Object.values(game.facilities || {}).map(f => f.entry ? key(f.entry.col, f.entry.row) : null).filter(Boolean)); const facilityEntryCells = new Set(Object.values(game.facilities || {}).map(f => f.entry ? key(f.entry.col, f.entry.row) : null).filter(Boolean));
const activeSex = chick?.sex || ''; const activeSex = chick?.sex || '';
const activeLabel = activeSex === 'poop' ? '💩' : activeSex === 'male' ? '♂' : activeSex === 'female' ? '♀' : ''; const activeLabel = activeSex === 'poop' ? '💩' : activeSex === 'male' ? '♂' : activeSex === 'female' ? '♀' : '';
@ -503,7 +473,7 @@ function miniScannerGrid(scanner, chick) {
classes.push('scanner'); classes.push('scanner');
if (activeSex) classes.push(activeSex); if (activeSex) classes.push(activeSex);
label = activeLabel || 'S'; label = activeLabel || 'S';
} else if (k === inputKey) { } else if (row === scanner.row - 1 && col === scanner.col) {
classes.push('input'); classes.push('input');
label = 'IN'; label = 'IN';
} else if (farmCells.has(k)) { } else if (farmCells.has(k)) {
@ -587,28 +557,13 @@ canvas.addEventListener('pointerdown', event => {
const world = canvasPoint(event); const world = canvasPoint(event);
if (event.button === 2) { startPan(event); return; } if (event.button === 2) { startPan(event); return; }
if (event.button !== 0) return; if (event.button !== 0) return;
if (game.cardTargetPick?.mode === 'autoScannerMenu') { cancelBuildAction(); return; }
if (game.cardTargetPick?.pending) { cardSystem.chooseTargetAtPoint(world); return; } if (game.cardTargetPick?.pending) { cardSystem.chooseTargetAtPoint(world); return; }
const expansionOffer = build.expansionOfferAtPoint?.(world); const expansionOffer = build.expansionOfferAtPoint?.(world);
if (expansionOffer) { build.buyGridExpansion(expansionOffer); clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); return; } if (expansionOffer) { build.buyGridExpansion(expansionOffer); clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); return; }
if (build.cycleBranchModeAtPoint?.(world)) { uiSystem.updatePanels(); uiSystem.updateUI(); return; } if (game.buildTool === 'erase') { build.eraseAtPoint(world); return; }
const clickedCell = pointToCell(world.x, world.y);
const clickedHit = build.equipmentAtPoint(world);
if (!game.buildTool && clickedHit?.type === 'conveyor' && clickedCell && build.cycleSingleConveyorDirection?.(clickedCell)) {
uiSystem.updatePanels();
uiSystem.updateUI();
return;
}
if (game.buildTool === 'erase') {
if (!build.equipmentAtPoint(world)) { cancelBuildAction(); return; }
build.eraseAtPoint(world);
return;
}
if (game.buildTool) { if (game.buildTool) {
const cell = pointToCell(world.x, world.y); if (game.buildTool === 'conveyor') {
if (!cell && !['mixer', 'trash', 'truck'].includes(game.buildTool)) { cancelBuildAction(); return; } build.beginConveyorDrag(pointToCell(world.x, world.y));
if (game.buildTool === 'conveyor' || game.buildTool === 'boostConveyor') {
build.beginConveyorDrag(cell);
return; return;
} }
const hit = build.equipmentAtPoint(world); const hit = build.equipmentAtPoint(world);
@ -625,7 +580,7 @@ canvas.addEventListener('pointermove', event => {
if (game.pan && (event.buttons & 2)) updatePan(event); if (game.pan && (event.buttons & 2)) updatePan(event);
if (game.groupDrag && (event.buttons & 1)) build.updateGroupDrag(event); if (game.groupDrag && (event.buttons & 1)) build.updateGroupDrag(event);
if (game.selectionBox && (event.buttons & 1)) build.updateSelectionBox(event); if (game.selectionBox && (event.buttons & 1)) build.updateSelectionBox(event);
if ((game.buildTool === 'conveyor' || game.buildTool === 'boostConveyor') && (event.buttons & 1)) build.continueConveyorDrag(pointToCell(canvasPoint(event).x, canvasPoint(event).y)); if (game.buildTool === 'conveyor' && (event.buttons & 1)) build.continueConveyorDrag(pointToCell(canvasPoint(event).x, canvasPoint(event).y));
if (game.buildTool === 'erase' && (event.buttons & 1)) build.eraseAtPoint(canvasPoint(event)); if (game.buildTool === 'erase' && (event.buttons & 1)) build.eraseAtPoint(canvasPoint(event));
}); });
canvas.addEventListener('pointerup', event => { canvas.addEventListener('pointerup', event => {
@ -634,8 +589,7 @@ canvas.addEventListener('pointerup', event => {
if (!game.groupDrag.committed) clickSelect(event); if (!game.groupDrag.committed) clickSelect(event);
build.finishGroupDrag(); build.finishGroupDrag();
} }
if (event.button === 2 && game.pan && !game.pan.moved && game.buildTool) cancelBuildAction(); if (game.buildTool === 'conveyor') build.endConveyorDrag?.();
if (game.buildTool === 'conveyor' || game.buildTool === 'boostConveyor') build.endConveyorDrag?.();
game.groupDrag = null; game.pan = null; game.groupDrag = null; game.pan = null;
try { canvas.releasePointerCapture(event.pointerId); } catch (_) { /* noop */ } try { canvas.releasePointerCapture(event.pointerId); } catch (_) { /* noop */ }
}); });
@ -644,14 +598,14 @@ canvas.addEventListener('pointercancel', () => { build?.endConveyorDrag?.(); gam
document.addEventListener('pointerdown', event => { document.addEventListener('pointerdown', event => {
if (!ui.modal.classList.contains('visible') || !ui.modal.classList.contains('equipment-popover')) return; if (!ui.modal.classList.contains('visible') || !ui.modal.classList.contains('equipment-popover')) return;
if (event.target.closest('#modal .modal-card')) return; if (event.target.closest('#modal .modal-card')) return;
cancelBuildAction(); uiSystem.hideModal();
}, true); }, true);
ui.buttons.s1Left.addEventListener('click', () => chicks.sortSlot(0, 'left')); ui.buttons.s1Right.addEventListener('click', () => chicks.sortSlot(0, 'right')); ui.buttons.s2Left.addEventListener('click', () => chicks.sortSlot(1, 'left')); ui.buttons.s2Right.addEventListener('click', () => chicks.sortSlot(1, 'right')); ui.buttons.s1Left.addEventListener('click', () => chicks.sortSlot(0, 'left')); ui.buttons.s1Right.addEventListener('click', () => chicks.sortSlot(0, 'right')); ui.buttons.s2Left.addEventListener('click', () => chicks.sortSlot(1, 'left')); ui.buttons.s2Right.addEventListener('click', () => chicks.sortSlot(1, 'right'));
ui.buttons.nextTurn.addEventListener('click', startNextTurn); ui.buttons.conveyor.addEventListener('click', () => build.setBuildTool('conveyor')); ui.buttons.boostConveyor.addEventListener('click', () => build.setBuildTool('boostConveyor')); ui.buttons.eggFarm.addEventListener('click', () => build.setBuildTool('eggFarm')); ui.buttons.autoScanner.addEventListener('click', () => build.setBuildTool('autoScanner')); ui.buttons.manualScanner.addEventListener('click', () => build.setBuildTool('manualScanner')); ui.buttons.mixer.addEventListener('click', () => build.setBuildTool('mixer')); ui.buttons.trash.addEventListener('click', () => build.setBuildTool('trash')); ui.buttons.truck.addEventListener('click', () => build.setBuildTool('truck')); ui.buttons.erase.addEventListener('click', () => build.setBuildTool('erase')); ui.buttons.nextTurn.addEventListener('click', startNextTurn); ui.buttons.conveyor.addEventListener('click', () => build.setBuildTool('conveyor')); ui.buttons.eggFarm.addEventListener('click', () => build.setBuildTool('eggFarm')); ui.buttons.autoScanner.addEventListener('click', () => build.setBuildTool('autoScanner')); ui.buttons.manualScanner.addEventListener('click', () => build.setBuildTool('manualScanner')); ui.buttons.mixer.addEventListener('click', () => build.setBuildTool('mixer')); ui.buttons.trash.addEventListener('click', () => build.setBuildTool('trash')); ui.buttons.truck.addEventListener('click', () => build.setBuildTool('truck')); ui.buttons.erase.addEventListener('click', () => build.setBuildTool('erase'));
ui.hireRepairmanButton?.addEventListener('click', hireRepairman); ui.hireRepairmanButton?.addEventListener('click', hireRepairman);
ui.expandGridButton?.addEventListener('click', () => { build?.buyGridExpansion?.('right'); clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); });
ui.debug.setDay?.addEventListener('click', debugSetDay); ui.debug.setDay?.addEventListener('click', debugSetDay);
ui.debug.zeroTimer?.addEventListener('click', debugZeroTimer);
ui.debug.grantCard?.addEventListener('click', debugGrantSelectedCard); ui.debug.grantCard?.addEventListener('click', debugGrantSelectedCard);
ui.debug.grantAllCards?.addEventListener('click', debugGrantAllCards); ui.debug.grantAllCards?.addEventListener('click', debugGrantAllCards);
ui.buttons.undo.addEventListener('click', () => { if (undo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } }); ui.buttons.redo.addEventListener('click', () => { if (redo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } }); ui.buttons.undo.addEventListener('click', () => { if (undo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } }); ui.buttons.redo.addEventListener('click', () => { if (redo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } });
@ -672,11 +626,7 @@ window.addEventListener('keydown', event => {
} }
if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && name === 'z') { event.preventDefault(); if (undo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } } if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && name === 'z') { event.preventDefault(); if (undo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } }
if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && (name === 'y' || (event.shiftKey && name === 'z'))) { event.preventDefault(); if (redo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } } if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && (name === 'y' || (event.shiftKey && name === 'z'))) { event.preventDefault(); if (redo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } }
if (event.key === 'Escape' && game.cardTargetPick?.pending) { if (event.key === 'Escape' && game.cardTargetPick?.pending) { event.preventDefault(); cardSystem.cancelTargetPick(); }
event.preventDefault();
if (game.cardTargetPick.mode === 'autoScannerMenu') cancelBuildAction();
else cardSystem.cancelTargetPick();
}
}); });

View file

@ -1,7 +1,7 @@
import { DIRS, GRID, THEME, EFFECT_PRIORITY, VERSION } from '../core/config.js'; import { DIRS, GRID, THEME, EFFECT_PRIORITY, VERSION } from '../core/config.js';
import { expansionCost, expansionLots, lotBounds, expansionButtonBounds, isOwnedCell } from '../core/gridExpansion.js'; import { expansionCost, expansionLots, lotBounds, expansionButtonBounds, isOwnedCell } from '../core/gridExpansion.js';
import { key, parseKey, cellCenter, mixHex, randomBetween, yen } from '../core/utils.js'; import { key, parseKey, pointToCell, cellCenter, mixHex, randomBetween, yen } from '../core/utils.js';
import { scannerCenter, scannerConnector, scannerOutputs, destinationLabel, destinationColor, scannerPortHasConveyor, disconnectedBuildWarnings } from '../systems/routing.js'; import { getConveyorNeighbors, routeFromFarmToScanner, outputRoute, scannerCenter, scannerConnector, scannerOutputs, destinationLabel, destinationColor, scannerPortHasConveyor, disconnectedBuildWarnings } from '../systems/routing.js';
import { upgradedMixerPrice, upgradedTruckPrice } from '../systems/economy.js'; import { upgradedMixerPrice, upgradedTruckPrice } from '../systems/economy.js';
import { cardTargetBounds } from '../systems/cards.js'; import { cardTargetBounds } from '../systems/cards.js';
import { wearRatio } from '../systems/maintenance.js'; import { wearRatio } from '../systems/maintenance.js';
@ -225,10 +225,8 @@ function drawConveyors(ctx, game) {
const seen = new Set(); const seen = new Set();
for (const k of game.conveyorTiles) { for (const k of game.conveyorTiles) {
const { col, row } = parseKey(k); const { col, row } = parseKey(k);
for (const d of DIRS) { for (const n of getConveyorNeighbors(game, col, row)) {
const n = { col: col + d.dc, row: row + d.dr };
const nk = key(n.col, n.row); const nk = key(n.col, n.row);
if (!game.conveyorTiles.has(nk)) continue;
const e = [k, nk].sort().join('|'); const e = [k, nk].sort().join('|');
if (seen.has(e)) continue; if (seen.has(e)) continue;
seen.add(e); seen.add(e);
@ -256,154 +254,74 @@ function drawConveyors(ctx, game) {
const directionMarkers = collectConveyorDirectionMarkers(game); const directionMarkers = collectConveyorDirectionMarkers(game);
for (const k of game.conveyorTiles) { for (const k of game.conveyorTiles) {
const c = cellCenter(...Object.values(parseKey(k))); const c = cellCenter(...Object.values(parseKey(k)));
const meta = game.conveyorMeta.get(k) || {};
const ratio = componentRatio(game, k); const ratio = componentRatio(game, k);
const baseFill = meta.kind === 'boostConveyor' ? '#fff2b8' : THEME.white; ctx.fillStyle = ratio > 0.5 ? mixHex(THEME.white, '#ffd6d6', Math.max(0, (ratio - .5) / .5)) : THEME.white;
ctx.fillStyle = ratio > 0.5 ? mixHex(baseFill, '#ffd6d6', Math.max(0, (ratio - .5) / .5)) : baseFill;
ctx.strokeStyle = THEME.ink; ctx.lineWidth = 2; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 2;
rect(ctx, c.x - 8, c.y - 8, 16, 16, true, true); rect(ctx, c.x - 8, c.y - 8, 16, 16, true, true);
if (meta.kind === 'boostConveyor') { drawDirtOverlay(ctx, c.x - 14, c.y - 14, 28, 28, { meta: game.conveyorMeta.get(k) });
ctx.save();
ctx.strokeStyle = THEME.warn;
ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(c.x - 15, c.y + 13); ctx.lineTo(c.x - 3, c.y - 13); ctx.lineTo(c.x + 2, c.y - 2); ctx.lineTo(c.x + 15, c.y - 13); ctx.stroke();
ctx.restore();
}
drawDirtOverlay(ctx, c.x - 14, c.y - 14, 28, 28, { meta });
drawSelection(ctx, game, 'conveyor', k, c.x, c.y, 38, 38); drawSelection(ctx, game, 'conveyor', k, c.x, c.y, 38, 38);
drawConveyorDirectionMarkers(ctx, c, directionMarkers.get(k) || []); drawConveyorDirectionMarkers(ctx, c, directionMarkers.get(k) || []);
const connectedDirs = connectedConveyorDirs(game, k);
drawBranchModeIcon(ctx, c, meta, branchSelectableEntranceDirs(game, k, connectedDirs), connectedDirs);
if (ratio > 0.5) label(ctx, c.x, c.y - 15, `${Math.floor(ratio * 100)}%`, THEME.danger); if (ratio > 0.5) label(ctx, c.x, c.y - 15, `${Math.floor(ratio * 100)}%`, THEME.danger);
} }
ctx.restore(); ctx.restore();
} }
function reachableConveyorDirs(game, k, meta) { function cellKeyFromPoint(game, p) {
const p = parseKey(k); const cell = pointToCell(p.x, p.y);
const neighborNames = DIRS if (!cell) return null;
.filter(d => game.conveyorTiles.has(key(p.col + d.dc, p.row + d.dr))) const center = cellCenter(cell.col, cell.row);
.map(d => d.name); if (Math.hypot(center.x - p.x, center.y - p.y) > GRID.cell * 0.38) return null;
const explicit = [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))] const k = key(cell.col, cell.row);
.filter(name => neighborNames.includes(name)); return game.conveyorTiles.has(k) ? k : null;
const inferred = neighborNames;
return explicit.length ? explicit : inferred;
} }
function addMarkerFromRoute(game, markers, route) {
function connectedConveyorDirs(game, k) { if (!route || route.length < 2) return;
const p = parseKey(k); for (let i = 0; i < route.length - 1; i += 1) {
return DIRS const a = route[i], b = route[i + 1];
.filter(d => game.conveyorTiles.has(key(p.col + d.dc, p.row + d.dr))) const ak = cellKeyFromPoint(game, a);
.map(d => d.name); const bk = cellKeyFromPoint(game, b);
} if (!ak || !bk || ak === bk) continue;
const ac = parseKey(ak), bc = parseKey(bk);
function neighborPointsIntoCell(game, p, dirName) { const dc = bc.col - ac.col, dr = bc.row - ac.row;
const d = DIRS.find(item => item.name === dirName); if (Math.abs(dc) + Math.abs(dr) !== 1) continue;
if (!d) return false; const angle = Math.atan2(dr, dc);
const meta = game.conveyorMeta?.get?.(key(p.col + d.dc, p.row + d.dr)); if (!markers.has(ak)) markers.set(ak, []);
if (!meta) return false; const list = markers.get(ak);
const names = [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))]; if (!list.some(x => Math.abs(Math.sin((x - angle) / 2)) < 0.01)) list.push(angle);
return names.includes(d.opposite);
}
function branchExitDirs(game, k, dirs, connectedDirs = connectedConveyorDirs(game, k), branchMode = 'random') {
if (connectedDirs.length < 3) return dirs;
const p = parseKey(k);
const entrances = new Set(connectedDirs.filter(dir => neighborPointsIntoCell(game, p, dir)));
if (branchMode && branchMode !== 'random' && connectedDirs.includes(branchMode)) {
const selectedEntrances = new Set(entrances);
selectedEntrances.add(branchMode);
if (connectedDirs.filter(dir => !selectedEntrances.has(dir)).length >= 2) entrances.add(branchMode);
} }
const exits = connectedDirs.filter(dir => !entrances.has(dir));
if (exits.length) return exits;
const fallback = dirs.filter(dir => !entrances.has(dir));
return fallback.length ? fallback : dirs;
} }
function explicitConveyorAngles(meta) {
function branchSelectableEntranceDirs(game, k, connectedDirs = connectedConveyorDirs(game, k)) {
if (connectedDirs.length < 3) return [];
const p = parseKey(k);
const physicalEntrances = new Set(connectedDirs.filter(dir => neighborPointsIntoCell(game, p, dir)));
return connectedDirs.filter(dir => {
const entrances = new Set(physicalEntrances);
entrances.add(dir);
return connectedDirs.filter(item => !entrances.has(item)).length >= 2;
});
}
function effectiveConveyorDirs(game, k, meta) {
const p = parseKey(k);
const neighborNames = DIRS
.filter(d => game.conveyorTiles.has(key(p.col + d.dc, p.row + d.dr)))
.map(d => d.name);
const explicit = [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))]
.filter(name => neighborNames.includes(name));
const inferred = neighborNames;
return explicit.length ? explicit : inferred;
}
function drawBranchModeIcon(ctx, center, meta, dirs, connectedDirs = dirs) {
if (connectedDirs.length < 3 || dirs.length < 2) return;
ctx.save();
const mode = dirs.includes(meta.branchMode) ? meta.branchMode : 'random';
const isFixed = mode !== 'random';
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.lineWidth = 2;
ctx.fillStyle = 'rgba(255,255,255,.9)';
ctx.strokeStyle = isFixed ? THEME.warn : THEME.green;
rect(ctx, center.x - 17, center.y - 17, 34, 34, true, true);
ctx.strokeStyle = THEME.ink;
ctx.lineWidth = 2;
ctx.fillStyle = THEME.ink;
if (mode !== 'random') {
const d = DIRS.find(item => item.name === mode);
drawDirectionTriangle(ctx, center.x, center.y, d?.angle || 0, 9, 6);
} else {
ctx.beginPath();
ctx.moveTo(center.x - 10, center.y);
ctx.lineTo(center.x - 2, center.y);
ctx.lineTo(center.x + 8, center.y - 8);
ctx.moveTo(center.x - 2, center.y);
ctx.lineTo(center.x + 8, center.y + 8);
ctx.stroke();
drawDirectionTriangle(ctx, center.x + 9, center.y - 9, -Math.PI / 4, 4, 3);
drawDirectionTriangle(ctx, center.x + 9, center.y + 9, Math.PI / 4, 4, 3);
}
ctx.restore();
}
function explicitConveyorMarkerDirs(game, k, meta) {
if (!meta) return []; if (!meta) return [];
const p = parseKey(k); const names = [];
const neighborNames = DIRS if (Array.isArray(meta.outDirs)) names.push(...meta.outDirs);
.filter(d => game.conveyorTiles.has(key(p.col + d.dc, p.row + d.dr))) if (meta.dir) names.push(meta.dir);
.map(d => d.name); return [...new Set(names)]
const explicit = [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))] .map(name => DIRS.find(d => d.name === name)?.angle)
.filter(name => neighborNames.includes(name)); .filter(angle => Number.isFinite(angle));
const dirs = branchExitDirs(game, k, explicit.length ? explicit : effectiveConveyorDirs(game, k, meta), neighborNames, meta.branchMode || 'random');
return dirs
.map(name => DIRS.find(d => d.name === name))
.filter(Boolean);
} }
function collectConveyorDirectionMarkers(game) { function collectConveyorDirectionMarkers(game) {
const markers = new Map(); const markers = new Map();
for (const [k, meta] of game.conveyorMeta || []) { for (const [k, meta] of game.conveyorMeta || []) {
const dirs = explicitConveyorMarkerDirs(game, k, meta); const angles = explicitConveyorAngles(meta);
if (dirs.length) markers.set(k, dirs); if (angles.length) markers.set(k, angles);
}
for (const farm of game.eggFarms) addMarkerFromRoute(game, markers, routeFromFarmToScanner(game, farm)?.route);
for (const scanner of game.scanners) {
for (const side of ['left', 'right']) addMarkerFromRoute(game, markers, outputRoute(game, side, scannerCenter(scanner), scanner.id)?.route);
} }
return markers; return markers;
} }
function drawConveyorDirectionMarkers(ctx, center, dirs) { function drawConveyorDirectionMarkers(ctx, center, angles) {
const limited = dirs.slice(0, 4); const limited = angles.slice(0, 3);
if (!limited.length) return; if (!limited.length) return;
ctx.save(); ctx.save();
ctx.fillStyle = THEME.ink; ctx.fillStyle = THEME.ink;
ctx.globalAlpha = 0.86; ctx.globalAlpha = 0.86;
limited.forEach(d => { limited.forEach((angle, index) => {
drawDirectionTriangle(ctx, center.x + d.dc * 12, center.y + d.dr * 12, d.angle, 6, 5); const offset = (index - (limited.length - 1) / 2) * 4;
const nx = Math.cos(angle + Math.PI / 2), ny = Math.sin(angle + Math.PI / 2);
drawDirectionTriangle(ctx, center.x + nx * offset, center.y + ny * offset, angle, 6, 5);
}); });
ctx.restore(); ctx.restore();
} }
@ -481,39 +399,39 @@ function drawScanner(ctx, scanner, game) {
ctx.lineWidth = Math.min(14, 5 + Math.floor(combo / 10)); ctx.lineWidth = Math.min(14, 5 + Math.floor(combo / 10));
ctx.shadowColor = ctx.strokeStyle; ctx.shadowColor = ctx.strokeStyle;
ctx.shadowBlur = Math.min(28, 8 + combo); ctx.shadowBlur = Math.min(28, 8 + combo);
rect(ctx, c.x - 54, c.y - 76, 108, 152, false, true); rect(ctx, c.x - 60, c.y - 44, 120, 88, false, true);
ctx.restore(); ctx.restore();
} }
const img = scanner.kind === 'auto' ? assets.scannerAuto : assets.scannerManual; const img = scanner.kind === 'auto' ? assets.scannerAuto : assets.scannerManual;
if (drawImageIfLoaded(ctx, img, c.x - 50, c.y - 73, 100, 146)) { if (drawImageIfLoaded(ctx, img, c.x - 54, c.y - 38, 108, 76)) {
if (scanner.kind === 'auto') drawDirtOverlay(ctx, c.x - 50, c.y - 73, 100, 146, scanner); if (scanner.kind === 'auto') drawDirtOverlay(ctx, c.x - 54, c.y - 38, 108, 76, scanner);
else drawManualKeyboardIcon(ctx, scanner, c); else drawManualKeyboardIcon(ctx, scanner, c);
drawSelection(ctx, game, 'scanner', scanner.id, c.x, c.y, 108, 154); drawSelection(ctx, game, 'scanner', scanner.id, c.x, c.y, 116, 84);
ctx.restore(); return; ctx.restore(); return;
} }
ctx.fillStyle = scanner.kind === 'auto' ? '#d8ffe2' : THEME.white; ctx.fillStyle = scanner.kind === 'auto' ? '#d8ffe2' : THEME.white;
ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4;
rect(ctx, c.x - 50, c.y - 73, 100, 146, true, true); rect(ctx, c.x - 52, c.y - 36, 104, 72, true, true);
ctx.fillStyle = scanner.kind === 'auto' ? THEME.green : THEME.ink; ctx.fillStyle = scanner.kind === 'auto' ? THEME.green : THEME.ink;
ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'center';
ctx.fillText(`${scanner.kind === 'auto' ? 'AUTO' : `S${(scanner.slot ?? 0) + 1}`}`, c.x, c.y - 48); ctx.fillText(`${scanner.kind === 'auto' ? 'AUTO' : `S${(scanner.slot ?? 0) + 1}`}`, c.x, c.y - 14);
ctx.font = '900 11px ui-monospace, monospace'; ctx.font = '900 11px ui-monospace, monospace';
const leftKey = scanner.keys?.left?.label || (scanner.slot === 0 ? 'A' : 'Left'); const leftKey = scanner.keys?.left?.label || (scanner.slot === 0 ? 'A' : 'Left');
const rightKey = scanner.keys?.right?.label || (scanner.slot === 0 ? 'D' : 'Right'); const rightKey = scanner.keys?.right?.label || (scanner.slot === 0 ? 'D' : 'Right');
ctx.fillText(scanner.role === 0 ? `${leftKey}:M ${rightKey}:NEXT` : `${leftKey}:WASTE ${rightKey}:TRUCK`, c.x, c.y - 28); ctx.fillText(scanner.role === 0 ? `${leftKey}:M ${rightKey}:NEXT` : `${leftKey}:WASTE ${rightKey}:TRUCK`, c.x, c.y + 3);
const q = scanner.queue.length; const q = scanner.queue.length;
if (scanner.kind === 'manual') { if (scanner.kind === 'manual') {
drawManualKeyboardIcon(ctx, scanner, c); drawManualKeyboardIcon(ctx, scanner, c);
ctx.fillStyle = q > 0 ? THEME.green : THEME.muted; ctx.fillStyle = q > 0 ? THEME.green : THEME.muted;
ctx.font = '900 10px ui-monospace, monospace'; ctx.font = '900 10px ui-monospace, monospace';
ctx.fillText(`Q:${q}`, c.x, c.y + 54); ctx.fillText(`Q:${q}`, c.x, c.y + 32);
} else { } else {
ctx.fillStyle = q > 0 ? THEME.green : THEME.muted; ctx.fillStyle = q > 0 ? THEME.green : THEME.muted;
ctx.font = '900 12px ui-monospace, monospace'; ctx.font = '900 12px ui-monospace, monospace';
ctx.fillText(`Q:${q} CD:${scanner.cooldown.toFixed(1)}`, c.x, c.y + 54); ctx.fillText(`Q:${q} CD:${scanner.cooldown.toFixed(1)}`, c.x, c.y + 25);
drawDirtOverlay(ctx, c.x - 50, c.y - 73, 100, 146, scanner); drawDirtOverlay(ctx, c.x - 52, c.y - 36, 104, 72, scanner);
} }
drawSelection(ctx, game, 'scanner', scanner.id, c.x, c.y, 108, 154); drawSelection(ctx, game, 'scanner', scanner.id, c.x, c.y, 116, 84);
ctx.restore(); ctx.restore();
} }
@ -527,7 +445,7 @@ function drawManualKeyboardIcon(ctx, scanner, c) {
labels[1].text = scanner.keys?.right?.label || labels[1].text; labels[1].text = scanner.keys?.right?.label || labels[1].text;
const pressedSide = scanner.keyPressTime > 0 ? scanner.keyPressSide : null; const pressedSide = scanner.keyPressTime > 0 ? scanner.keyPressSide : null;
const baseX = c.x - 30; const baseX = c.x - 30;
const baseY = c.y + 18; const baseY = c.y + 8;
ctx.save(); ctx.save();
ctx.lineWidth = 3; ctx.lineWidth = 3;
ctx.fillStyle = '#f7fff5'; ctx.fillStyle = '#f7fff5';
@ -588,13 +506,9 @@ function receiverTitle(id, game) {
if (id === 'truck') return { title: `IN: ${targetForTruck(game).toUpperCase()}`, type: targetForTruck(game), color: THEME.truckPink }; if (id === 'truck') return { title: `IN: ${targetForTruck(game).toUpperCase()}`, type: targetForTruck(game), color: THEME.truckPink };
return { title: 'IN', type: 'female', color: THEME.green }; return { title: 'IN', type: 'female', color: THEME.green };
} }
function facilityKind(fOrId) { function drawFacilityReceiver(ctx, game, id) {
return typeof fOrId === 'string' ? fOrId : (fOrId?.baseId || fOrId?.id || ''); const f = game.facilities[id];
}
function drawFacilityReceiver(ctx, game, facility) {
const f = typeof facility === 'string' ? game.facilities[facility] : facility;
if (!f?.entry) return; if (!f?.entry) return;
const id = facilityKind(f);
const c = cellCenter(f.entry.col, f.entry.row); const c = cellCenter(f.entry.col, f.entry.row);
const info = receiverTitle(id, game); const info = receiverTitle(id, game);
ctx.save(); ctx.save();
@ -613,13 +527,12 @@ function drawFacilityReceiver(ctx, game, facility) {
ctx.restore(); ctx.restore();
} }
function drawFacilities(ctx, game) { function drawFacilities(ctx, game) {
for (const f of Object.values(game.facilities || {})) { if (game.facilities.mixer) drawMixer(ctx, game);
const kind = facilityKind(f); if (game.facilities.trash) drawTrash(ctx, game);
if (kind === 'mixer') drawMixer(ctx, game, f); if (game.facilities.truck) drawTruck(ctx, game);
else if (kind === 'trash') drawTrash(ctx, game, f); drawFacilityReceiver(ctx, game, 'mixer');
else if (kind === 'truck') drawTruck(ctx, game, f); drawFacilityReceiver(ctx, game, 'trash');
} drawFacilityReceiver(ctx, game, 'truck');
for (const f of Object.values(game.facilities || {})) drawFacilityReceiver(ctx, game, f);
} }
function drawExternalDuct(ctx, f) { function drawExternalDuct(ctx, f) {
if (!f?.entry) return; if (!f?.entry) return;
@ -638,10 +551,11 @@ function drawExternalDuct(ctx, f) {
ctx.beginPath(); ctx.moveTo(c.x, c.y); ctx.lineTo(bx, by); ctx.stroke(); ctx.beginPath(); ctx.moveTo(c.x, c.y); ctx.lineTo(bx, by); ctx.stroke();
ctx.restore(); ctx.restore();
} }
function drawMixer(ctx, game, m = game.facilities.mixer) { function drawMixer(ctx, game) {
const m = game.facilities.mixer;
ctx.save(); ctx.save();
drawExternalDuct(ctx, m); drawExternalDuct(ctx, m);
if (drawImageIfLoaded(ctx, assets.mixer, m.x, m.y, m.w, m.h)) { drawDirtOverlay(ctx, m.x, m.y, m.w, m.h, m); drawSelection(ctx, game, 'facility', m.id, m.x + m.w / 2, m.y + m.h / 2, m.w + 12, m.h + 12); ctx.restore(); return; } if (drawImageIfLoaded(ctx, assets.mixer, m.x, m.y, m.w, m.h)) { drawDirtOverlay(ctx, m.x, m.y, m.w, m.h, m); drawSelection(ctx, game, 'facility', 'mixer', m.x + m.w / 2, m.y + m.h / 2, m.w + 12, m.h + 12); ctx.restore(); return; }
ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, m.x, m.y, m.w, m.h, true, true); ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, m.x, m.y, m.w, m.h, true, true);
ctx.fillStyle = THEME.ink; ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`MIXER L${m.level}`, m.x + m.w / 2, m.y + 28); ctx.fillStyle = THEME.ink; ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`MIXER L${m.level}`, m.x + m.w / 2, m.y + 28);
ctx.font = '900 12px ui-monospace, monospace'; ctx.fillText(`PAY ${yen(upgradedMixerPrice(game))}`, m.x + m.w / 2, m.y + 48); ctx.font = '900 12px ui-monospace, monospace'; ctx.fillText(`PAY ${yen(upgradedMixerPrice(game))}`, m.x + m.w / 2, m.y + 48);
@ -649,25 +563,27 @@ function drawMixer(ctx, game, m = game.facilities.mixer) {
ctx.strokeStyle = THEME.green; ctx.lineWidth = 5; ctx.strokeStyle = THEME.green; ctx.lineWidth = 5;
for (let i = 0; i < 3; i += 1) { ctx.beginPath(); ctx.arc(m.x + m.w / 2, m.y + 78, 16 + i * 8, 0, Math.PI * 1.5); ctx.stroke(); } for (let i = 0; i < 3; i += 1) { ctx.beginPath(); ctx.arc(m.x + m.w / 2, m.y + 78, 16 + i * 8, 0, Math.PI * 1.5); ctx.stroke(); }
drawDirtOverlay(ctx, m.x, m.y, m.w, m.h, m); drawDirtOverlay(ctx, m.x, m.y, m.w, m.h, m);
drawSelection(ctx, game, 'facility', m.id, m.x + m.w / 2, m.y + m.h / 2, m.w + 12, m.h + 12); drawSelection(ctx, game, 'facility', 'mixer', m.x + m.w / 2, m.y + m.h / 2, m.w + 12, m.h + 12);
ctx.restore(); ctx.restore();
} }
function drawTrash(ctx, game, t = game.facilities.trash) { function drawTrash(ctx, game) {
const t = game.facilities.trash;
ctx.save(); ctx.save();
drawExternalDuct(ctx, t); drawExternalDuct(ctx, t);
if (drawImageIfLoaded(ctx, assets.shredder, t.x, t.y, t.w, t.h)) { drawDirtOverlay(ctx, t.x, t.y, t.w, t.h, t); drawSelection(ctx, game, 'facility', t.id, t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12); ctx.restore(); return; } if (drawImageIfLoaded(ctx, assets.shredder, t.x, t.y, t.w, t.h)) { drawDirtOverlay(ctx, t.x, t.y, t.w, t.h, t); drawSelection(ctx, game, 'facility', 'trash', t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12); ctx.restore(); return; }
ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, t.x, t.y, t.w, t.h, true, true); ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, t.x, t.y, t.w, t.h, true, true);
ctx.fillStyle = THEME.ink; ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`SHREDDER L${t.level}`, t.x + t.w / 2, t.y + 25); ctx.fillStyle = THEME.ink; ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`SHREDDER L${t.level}`, t.x + t.w / 2, t.y + 25);
for (let i = 0; i < 7; i += 1) { ctx.fillRect(t.x + 34 + i * 16, t.y + 48, 7, t.h - 66); } for (let i = 0; i < 7; i += 1) { ctx.fillRect(t.x + 34 + i * 16, t.y + 48, 7, t.h - 66); }
drawTargetBadge(ctx, t.x + 14, t.y + t.h - 42, t.w - 28, 'SEND POOP', 'poop', 'shredder'); drawTargetBadge(ctx, t.x + 14, t.y + t.h - 42, t.w - 28, 'SEND POOP', 'poop', 'shredder');
drawDirtOverlay(ctx, t.x, t.y, t.w, t.h, t); drawDirtOverlay(ctx, t.x, t.y, t.w, t.h, t);
drawSelection(ctx, game, 'facility', t.id, t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12); drawSelection(ctx, game, 'facility', 'trash', t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12);
ctx.restore(); ctx.restore();
} }
function drawTruck(ctx, game, t = game.facilities.truck) { function drawTruck(ctx, game) {
const t = game.facilities.truck;
ctx.save(); ctx.save();
drawExternalDuct(ctx, t); drawExternalDuct(ctx, t);
if (drawImageIfLoaded(ctx, assets.truck, t.x, t.y, t.w, t.h)) { drawSelection(ctx, game, 'facility', t.id, t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12); ctx.restore(); return; } if (drawImageIfLoaded(ctx, assets.truck, t.x, t.y, t.w, t.h)) { drawSelection(ctx, game, 'facility', 'truck', t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12); ctx.restore(); return; }
ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, t.x, t.y, t.w, t.h, true, true); ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, t.x, t.y, t.w, t.h, true, true);
ctx.fillStyle = THEME.ink; ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`TRUCK L${t.level}`, t.x + t.w / 2, t.y + 24); ctx.fillStyle = THEME.ink; ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`TRUCK L${t.level}`, t.x + t.w / 2, t.y + 24);
ctx.font = '900 11px ui-monospace, monospace'; ctx.fillText(`UNIT ${yen(upgradedTruckPrice(game))}`, t.x + t.w / 2, t.y + 42); ctx.font = '900 11px ui-monospace, monospace'; ctx.fillText(`UNIT ${yen(upgradedTruckPrice(game))}`, t.x + t.w / 2, t.y + 42);
@ -675,7 +591,7 @@ function drawTruck(ctx, game, t = game.facilities.truck) {
drawTargetBadge(ctx, t.x + 15, t.y + 50, t.w - 30, `SEND ${truckTargetType.toUpperCase()}`, truckTargetType, game.contractOffer && !game.contractActive ? 'next event' : 'truck cargo'); drawTargetBadge(ctx, t.x + 15, t.y + 50, t.w - 30, `SEND ${truckTargetType.toUpperCase()}`, truckTargetType, game.contractOffer && !game.contractActive ? 'next event' : 'truck cargo');
ctx.fillStyle = THEME.greenSoft; rect(ctx, t.x + 13, t.y + 88, t.w - 26, t.h - 103, true, false); ctx.fillStyle = THEME.greenSoft; rect(ctx, t.x + 13, t.y + 88, t.w - 26, t.h - 103, true, false);
for (const cargo of game.truckCargo) cargo.sex === 'poop' ? drawTinyPoop(ctx, t.x + cargo.x, t.y + cargo.y) : drawTinyChick(ctx, t.x + cargo.x, t.y + cargo.y, cargo.sex, cargo.sex === 'male'); for (const cargo of game.truckCargo) cargo.sex === 'poop' ? drawTinyPoop(ctx, t.x + cargo.x, t.y + cargo.y) : drawTinyChick(ctx, t.x + cargo.x, t.y + cargo.y, cargo.sex, cargo.sex === 'male');
drawSelection(ctx, game, 'facility', t.id, t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12); drawSelection(ctx, game, 'facility', 'truck', t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12);
ctx.restore(); ctx.restore();
} }
function drawChicks(ctx, game, activeQueuedChick) { function drawChicks(ctx, game, activeQueuedChick) {
@ -784,9 +700,7 @@ function drawCardTargetOverlay(ctx, canvas, game) {
ctx.strokeStyle = THEME.ink; ctx.strokeStyle = THEME.ink;
ctx.lineWidth = 3; ctx.lineWidth = 3;
const remaining = Math.max(1, game.cardTargetPick?.remaining || 1); const remaining = Math.max(1, game.cardTargetPick?.remaining || 1);
const msg = game.cardTargetPick?.mode === 'autoScannerMenu' const msg = isBlockedCellMode ? `CLICK BLOCKED CELL: ${remaining} LEFT. PRESS ESC TO CANCEL.` : 'CLICK AN UPGRADEABLE MACHINE. PRESS ESC TO CANCEL.';
? 'AUTO SCANNER ROUTE SETTINGS. CLICK EMPTY AREA TO CLOSE.'
: isBlockedCellMode ? `CLICK BLOCKED CELL: ${remaining} LEFT. PRESS ESC TO CANCEL.` : 'CLICK AN UPGRADEABLE MACHINE. PRESS ESC TO CANCEL.';
rect(ctx, (216 - (game.view?.x || 0)) / scale, (96 - (game.view?.y || 0)) / scale, 560 / scale, 42 / scale, true, true); rect(ctx, (216 - (game.view?.x || 0)) / scale, (96 - (game.view?.y || 0)) / scale, 560 / scale, 42 / scale, true, true);
ctx.fillStyle = THEME.ink; ctx.fillStyle = THEME.ink;
ctx.font = '900 12px ui-monospace, monospace'; ctx.font = '900 12px ui-monospace, monospace';

View file

@ -1,12 +1,12 @@
import { BALANCE } from '../core/balance.js'; import { BALANCE } from '../core/balance.js';
import { DIRS, MACHINE_FACILITY_IDS, GRID, THEME } from '../core/config.js'; import { MACHINE_FACILITY_IDS, GRID, THEME } from '../core/config.js';
import { getSpawnRange } from '../core/state.js'; import { getSpawnRange } from '../core/state.js';
import { createEggFarm, createScanner, createFacility } from '../core/entities.js'; import { createEggFarm, createScanner, createFacility } from '../core/entities.js';
import { generateBlockedCellsInRect } from '../core/mapGen.js'; import { generateBlockedCellsInRect } from '../core/mapGen.js';
import { expansionCost, expansionLot, expansionLots, lotFromChunk, lotBounds, lotContainsPoint, cellsInLot, isOwnedCell, countOwnedCells, shiftRowIndexedSet, shiftRowIndexedMap, ownChunk } from '../core/gridExpansion.js'; import { expansionCost, expansionLot, expansionLots, lotFromChunk, lotBounds, lotContainsPoint, cellsInLot, isOwnedCell, countOwnedCells, shiftRowIndexedSet, shiftRowIndexedMap, ownChunk } from '../core/gridExpansion.js';
import { key, parseKey, pointToCell, cellCenter, yen, directionNameBetweenCells } from '../core/utils.js'; import { key, parseKey, pointToCell, cellCenter, yen, directionNameBetweenCells } from '../core/utils.js';
import { TEXT, equipmentName } from '../core/text.js'; import { TEXT, equipmentName } from '../core/text.js';
import { farmAt, scannerAt, scannerCenter, scannerFootprintCells, routeFromFarmToScanner, refreshRoutingAfterEdit, disconnectedBuildWarnings } from './routing.js'; import { farmAt, scannerAt, scannerCenter, routeFromFarmToScanner, refreshRoutingAfterEdit, disconnectedBuildWarnings } from './routing.js';
import { buildPrice, equipmentBasePrice, incomeMultiplier, mixerPoopPenalty, refundCash, spendCash, truckPoopPenalty, upgradedMixerPrice, upgradedTruckPrice, resaleValueFor, shredderUpgradeCount, shredderBonusChance, shredderBonusMaxCards } from './economy.js'; import { buildPrice, equipmentBasePrice, incomeMultiplier, mixerPoopPenalty, refundCash, spendCash, truckPoopPenalty, upgradedMixerPrice, upgradedTruckPrice, resaleValueFor, shredderUpgradeCount, shredderBonusChance, shredderBonusMaxCards } from './economy.js';
import { autoScannerCooldownSeconds } from './cards.js'; import { autoScannerCooldownSeconds } from './cards.js';
import { record } from './history.js'; import { record } from './history.js';
@ -19,11 +19,6 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
let lastConveyorBuildCell = null; let lastConveyorBuildCell = null;
let conveyorDragRecordedDirectionEdit = false; let conveyorDragRecordedDirectionEdit = false;
let conveyorDragStartCell = null;
let conveyorDragMoved = false;
const BRANCH_MODES = ['random', 'up', 'right', 'down', 'left'];
const BRANCH_LABELS = { random: 'RND', up: 'UP', right: 'RT', down: 'DN', left: 'LF' };
const BRANCH_MARKER = { w: 40, h: 40, y: 0 };
function gridExpansionCost() { return expansionCost(game); } function gridExpansionCost() { return expansionCost(game); }
@ -133,20 +128,6 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
return false; return false;
} }
function scannerPlacementBlocked(col, row, moving = null) {
const draft = { col, row };
for (const cell of scannerFootprintCells(draft)) {
if (!pointInGrid(cell.col, cell.row) || isBlockedCell(cell.col, cell.row)) return true;
const k = key(cell.col, cell.row);
const existingFarm = farmAt(game, cell.col, cell.row);
if (existingFarm) return true;
const existingScanner = scannerAt(game, cell.col, cell.row);
if (existingScanner && !(moving?.type === 'scanner' && moving.ref?.id === existingScanner.id)) return true;
if (game.conveyorTiles.has(k)) return true;
}
return false;
}
function selectedObject() { function selectedObject() {
if (!game.selected) return null; if (!game.selected) return null;
if (game.selected.type === 'eggFarm') return game.eggFarms.find(f => f.id === game.selected.id) || null; if (game.selected.type === 'eggFarm') return game.eggFarms.find(f => f.id === game.selected.id) || null;
@ -160,13 +141,12 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
if (!obj) return 'Equipment'; if (!obj) return 'Equipment';
if (obj.type === 'eggFarm') return `${equipmentName('eggFarm')} #${obj.id}`; if (obj.type === 'eggFarm') return `${equipmentName('eggFarm')} #${obj.id}`;
if (obj.type === 'scanner') return `${obj.kind === 'auto' ? equipmentName('autoScanner') : equipmentName('manualScanner')} #${obj.id}`; if (obj.type === 'scanner') return `${obj.kind === 'auto' ? equipmentName('autoScanner') : equipmentName('manualScanner')} #${obj.id}`;
if (obj.type === 'conveyor') return game.conveyorMeta.get(obj.id)?.kind === 'boostConveyor' ? equipmentName('boostConveyor') : equipmentName('conveyor'); if (obj.type === 'conveyor') return equipmentName('conveyor');
if (obj.type === 'facility') return obj.name; if (obj.type === 'facility') return obj.name;
return 'Equipment'; return 'Equipment';
} }
function equipmentPrice(hit) { function equipmentPrice(hit) {
if (hit?.type === 'conveyor') return game.conveyorMeta?.get(hit.oldKey || hit.ref?.id)?.price || equipmentBasePrice(hit);
return equipmentBasePrice(hit); return equipmentBasePrice(hit);
} }
@ -191,9 +171,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
if (hit?.type === 'eggFarm' || obj.type === 'eggFarm') { if (hit?.type === 'eggFarm' || obj.type === 'eggFarm') {
return game.eggFarms.length <= 1 ? 'Cannot sell the last EGG FARM.' : ''; return game.eggFarms.length <= 1 ? 'Cannot sell the last EGG FARM.' : '';
} }
if ((hit?.type === 'facility' || obj.type === 'facility') && ['mixer', 'trash', 'truck'].includes(obj.baseId || obj.id)) { if ((hit?.type === 'facility' || obj.type === 'facility') && ['mixer', 'trash', 'truck'].includes(obj.id)) {
const kind = obj.baseId || obj.id; const count = Object.values(game.facilities).filter(f => f.id === obj.id).length;
const count = Object.values(game.facilities).filter(f => (f.baseId || f.id) === kind).length;
if (count <= 1) return `Cannot sell the last ${obj.shortName || obj.name || obj.id}.`; if (count <= 1) return `Cannot sell the last ${obj.shortName || obj.name || obj.id}.`;
} }
return ''; return '';
@ -214,70 +193,9 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
if (!meta) return []; if (!meta) return [];
if (!Array.isArray(meta.outDirs)) meta.outDirs = []; if (!Array.isArray(meta.outDirs)) meta.outDirs = [];
meta.outDirs = [...new Set(meta.outDirs.filter(dir => ['right', 'left', 'down', 'up'].includes(dir)))]; meta.outDirs = [...new Set(meta.outDirs.filter(dir => ['right', 'left', 'down', 'up'].includes(dir)))];
if (!BRANCH_MODES.includes(meta.branchMode)) meta.branchMode = 'random';
if (!meta.kind) meta.kind = 'conveyor';
if (!meta.speedMultiplier) meta.speedMultiplier = meta.kind === 'boostConveyor' ? 2 : 1;
return meta.outDirs; return meta.outDirs;
} }
function conveyorNeighborOutDirs(cell) {
if (!cell) return [];
return DIRS.filter(d => game.conveyorTiles.has(key(cell.col + d.dc, cell.row + d.dr))).map(d => d.name);
}
function isBranchCell(cell) {
return conveyorNeighborOutDirs(cell).length >= 3;
}
function neighborPointsIntoCell(cell, dirName) {
const d = DIRS.find(item => item.name === dirName);
if (!cell || !d) return false;
const nk = key(cell.col + d.dc, cell.row + d.dr);
const meta = game.conveyorMeta.get(nk);
if (!meta) return false;
const incoming = d.opposite;
const dirs = [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))];
return dirs.includes(incoming);
}
function reachableOutDirsForKey(k) {
if (!game.conveyorTiles.has(k)) return [];
const cell = parseKey(k);
const meta = game.conveyorMeta.get(k);
const explicit = normalizeOutDirs(meta);
const neighbors = conveyorNeighborOutDirs(cell);
const explicitReachable = [...new Set([...explicit, meta?.dir].filter(Boolean))]
.filter(dir => neighbors.includes(dir));
const inferredExits = neighbors;
const reachable = explicitReachable.length ? explicitReachable : inferredExits;
if (meta) {
meta.outDirs = explicit.filter(dir => neighbors.includes(dir));
if (meta.dir && !neighbors.includes(meta.dir)) meta.dir = meta.outDirs[0] || null;
if (meta.branchMode !== 'random' && !neighbors.includes(meta.branchMode)) meta.branchMode = 'random';
}
return reachable;
}
function branchExitDirsForKey(k) {
if (!game.conveyorTiles.has(k)) return [];
const cell = parseKey(k);
const connected = conveyorNeighborOutDirs(cell);
if (!isBranchCell(cell)) return connected.length ? connected : reachableOutDirsForKey(k);
return connected;
}
function branchSelectableEntranceDirsForKey(k) {
const cell = parseKey(k);
const connected = branchExitDirsForKey(k);
if (connected.length < 3) return [];
const physicalEntrances = new Set(connected.filter(dir => neighborPointsIntoCell(cell, dir)));
return connected.filter(dir => {
const entrances = new Set(physicalEntrances);
entrances.add(dir);
return connected.filter(item => !entrances.has(item)).length >= 2;
});
}
function markConveyorDirection(from, to) { function markConveyorDirection(from, to) {
const dir = directionNameBetweenCells(from, to); const dir = directionNameBetweenCells(from, to);
if (!dir) return false; if (!dir) return false;
@ -294,100 +212,6 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
return changed; return changed;
} }
function cycleSingleConveyorDirection(cell) {
const path = conveyorRunPathFrom(cell);
if (path.length < 2) return cycleIsolatedConveyorDirection(cell);
record(game);
applyRunDirection(path);
refreshRoutingAfterEdit(game);
updatePanels();
return true;
}
function cycleIsolatedConveyorDirection(cell) {
if (!cell) return false;
const k = key(cell.col, cell.row);
const meta = game.conveyorMeta.get(k);
if (!meta || isBranchCell(cell)) return false;
const choices = DIRS.map(d => d.name);
const current = meta.dir && choices.includes(meta.dir) ? meta.dir : null;
const next = choices[(Math.max(-1, choices.indexOf(current)) + 1) % choices.length];
record(game);
meta.dir = next;
meta.outDirs = [next];
meta.branchMode = 'random';
refreshRoutingAfterEdit(game);
updatePanels();
return true;
}
function conveyorNeighborCells(cell) {
if (!cell) return [];
return DIRS
.map(d => ({ col: cell.col + d.dc, row: cell.row + d.dr }))
.filter(p => game.conveyorTiles.has(key(p.col, p.row)));
}
function conveyorRunPathFrom(cell) {
if (!cell || !game.conveyorTiles.has(key(cell.col, cell.row)) || isBranchCell(cell)) return [];
const neighbors = conveyorNeighborCells(cell);
if (!neighbors.length || neighbors.length > 2) return [];
const walk = (first) => {
const path = [];
const visited = new Set([key(cell.col, cell.row)]);
let prev = cell;
let cur = first;
let guard = 0;
while (cur && guard < 200) {
guard += 1;
const curKey = key(cur.col, cur.row);
if (visited.has(curKey)) break;
visited.add(curKey);
path.push(cur);
if (isBranchCell(cur)) break;
const nexts = conveyorNeighborCells(cur).filter(p => !(p.col === prev.col && p.row === prev.row));
if (nexts.length !== 1) break;
prev = cur;
cur = nexts[0];
}
return path;
};
const before = neighbors[0] ? walk(neighbors[0]).reverse() : [];
const after = neighbors[1] ? walk(neighbors[1]) : [];
return [...before, cell, ...after];
}
function directionInMeta(meta, dir) {
return !!meta && [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))].includes(dir);
}
function applyRunDirection(path) {
let forward = 0, backward = 0;
for (let i = 0; i < path.length - 1; i += 1) {
const a = path[i], b = path[i + 1];
const fwd = directionNameBetweenCells(a, b);
const back = directionNameBetweenCells(b, a);
if (directionInMeta(game.conveyorMeta.get(key(a.col, a.row)), fwd)) forward += 1;
if (directionInMeta(game.conveyorMeta.get(key(b.col, b.row)), back)) backward += 1;
}
const target = forward > backward ? [...path].reverse() : path;
for (let i = 0; i < target.length - 1; i += 1) {
const from = target[i];
if (isBranchCell(from)) continue;
const to = target[i + 1];
const meta = game.conveyorMeta.get(key(from.col, from.row));
const dir = directionNameBetweenCells(from, to);
if (!meta || !dir) continue;
meta.dir = dir;
meta.outDirs = [dir];
meta.branchMode = 'random';
}
}
function conveyorToolDef() {
return BALANCE.facilities[game.buildTool === 'boostConveyor' ? 'boostConveyor' : 'conveyor'];
}
function buildConveyorCell(cell, incomingDir = null) { function buildConveyorCell(cell, incomingDir = null) {
if (!cell) return { ok: false, reason: TEXT.fail.outOfGrid }; if (!cell) return { ok: false, reason: TEXT.fail.outOfGrid };
const { col, row } = cell; const { col, row } = cell;
@ -395,9 +219,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
if (game.conveyorTiles.has(k)) return { ok: false, exists: true }; if (game.conveyorTiles.has(k)) return { ok: false, exists: true };
if (isBlockedCell(col, row)) return { ok: false, reason: 'Cannot build on blocked ground.' }; if (isBlockedCell(col, row)) return { ok: false, reason: 'Cannot build on blocked ground.' };
if (farmAt(game, col, row) || scannerAt(game, col, row)) return { ok: false, reason: TEXT.fail.cellOccupied }; if (farmAt(game, col, row) || scannerAt(game, col, row)) return { ok: false, reason: TEXT.fail.cellOccupied };
const toolDef = conveyorToolDef(); const cost = buildPrice('conveyor', game);
const kind = toolDef.id || 'conveyor';
const cost = buildPrice(kind, game);
if (game.cash < cost) return { ok: false, reason: TEXT.fail.notEnoughCash }; if (game.cash < cost) return { ok: false, reason: TEXT.fail.notEnoughCash };
record(game); record(game);
spendCash(game, cost); spendCash(game, cost);
@ -409,11 +231,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
uses: 0, uses: 0,
durability: durabilityCapFor(game, 'conveyor', quality.durabilityBaseMultiplier), durability: durabilityCapFor(game, 'conveyor', quality.durabilityBaseMultiplier),
maintenanceType: 'conveyor', maintenanceType: 'conveyor',
kind,
speedMultiplier: toolDef.speedMultiplier || 1,
dir: incomingDir || null, dir: incomingDir || null,
outDirs: [], outDirs: [],
branchMode: 'random',
...quality ...quality
}); });
game.selected = { type: 'conveyor', id: k }; game.selected = { type: 'conveyor', id: k };
@ -427,7 +246,6 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
const exists = game.conveyorTiles.has(currentKey); const exists = game.conveyorTiles.has(currentKey);
const previous = lastConveyorBuildCell; const previous = lastConveyorBuildCell;
if (previous && previous.col === cell.col && previous.row === cell.row) return; if (previous && previous.col === cell.col && previous.row === cell.row) return;
if (previous) conveyorDragMoved = true;
if (exists) { if (exists) {
if (previous && markConveyorDirection(previous, cell)) { if (previous && markConveyorDirection(previous, cell)) {
@ -462,8 +280,6 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function beginConveyorDrag(cell) { function beginConveyorDrag(cell) {
conveyorDragRecordedDirectionEdit = false; conveyorDragRecordedDirectionEdit = false;
conveyorDragStartCell = cell ? { col: cell.col, row: cell.row } : null;
conveyorDragMoved = false;
lastConveyorBuildCell = null; lastConveyorBuildCell = null;
handleConveyorDragCell(cell); handleConveyorDragCell(cell);
} }
@ -483,11 +299,6 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
} }
function endConveyorDrag() { function endConveyorDrag() {
if (!conveyorDragMoved && conveyorDragStartCell && game.conveyorTiles.has(key(conveyorDragStartCell.col, conveyorDragStartCell.row))) {
cycleSingleConveyorDirection(conveyorDragStartCell);
}
conveyorDragStartCell = null;
conveyorDragMoved = false;
lastConveyorBuildCell = null; lastConveyorBuildCell = null;
conveyorDragRecordedDirectionEdit = false; conveyorDragRecordedDirectionEdit = false;
} }
@ -495,14 +306,14 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function buildAtCell(cell) { function buildAtCell(cell) {
if (!cell) return fail(TEXT.fail.outOfGrid); if (!cell) return fail(TEXT.fail.outOfGrid);
const { col, row } = cell; const { col, row } = cell;
if (game.buildTool === 'conveyor' || game.buildTool === 'boostConveyor') { if (game.buildTool === 'conveyor') {
const result = buildConveyorCell(cell, null); const result = buildConveyorCell(cell, null);
if (!result.ok) return fail(result.reason || TEXT.fail.cellOccupied); if (!result.ok) return fail(result.reason || TEXT.fail.cellOccupied);
refreshRoutingAfterEdit(game); refreshRoutingAfterEdit(game);
return; return;
} }
if (isBlockedCell(col, row)) return fail('Cannot build on blocked ground.'); if (isBlockedCell(col, row)) return fail('Cannot build on blocked ground.');
if ((game.buildTool === 'manualScanner' || game.buildTool === 'autoScanner') ? scannerPlacementBlocked(col, row) : isEquipmentCell(col, row)) return fail(TEXT.fail.cellOccupied); if (isEquipmentCell(col, row)) return fail(TEXT.fail.cellOccupied);
if (game.buildTool === 'eggFarm') buildFarm(col, row); if (game.buildTool === 'eggFarm') buildFarm(col, row);
else if (game.buildTool === 'manualScanner') buildScanner(col, row, 'manual'); else if (game.buildTool === 'manualScanner') buildScanner(col, row, 'manual');
else if (game.buildTool === 'autoScanner') buildScanner(col, row, 'auto'); else if (game.buildTool === 'autoScanner') buildScanner(col, row, 'auto');
@ -510,7 +321,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function buildAtPoint(p) { function buildAtPoint(p) {
const cell = pointToCell(p.x, p.y); const cell = pointToCell(p.x, p.y);
if (['conveyor', 'boostConveyor', 'eggFarm', 'manualScanner', 'autoScanner'].includes(game.buildTool)) return buildAtCell(cell); if (['conveyor', 'eggFarm', 'manualScanner', 'autoScanner'].includes(game.buildTool)) return buildAtCell(cell);
if (MACHINE_FACILITY_IDS.includes(game.buildTool)) return buildFacility(p, game.buildTool); if (MACHINE_FACILITY_IDS.includes(game.buildTool)) return buildFacility(p, game.buildTool);
} }
@ -531,7 +342,6 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
if (kind === 'manual' && game.scanners.filter(s => s.kind === 'manual').length >= 8) return fail('Manual Scanner limit reached (8 max).'); if (kind === 'manual' && game.scanners.filter(s => s.kind === 'manual').length >= 8) return fail('Manual Scanner limit reached (8 max).');
const cost = kind === 'auto' ? buildPrice('autoScanner', game) : buildPrice('manualScanner', game); const cost = kind === 'auto' ? buildPrice('autoScanner', game) : buildPrice('manualScanner', game);
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash); if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
if (scannerPlacementBlocked(col, row)) return fail(TEXT.fail.cellOccupied);
record(game); record(game);
spendCash(game, cost); spendCash(game, cost);
const scanner = applyBuildQuality(createScanner(game, col, row, kind)); const scanner = applyBuildQuality(createScanner(game, col, row, kind));
@ -544,18 +354,16 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function buildFacility(p, id) { function buildFacility(p, id) {
const cost = buildPrice(id, game); const cost = buildPrice(id, game);
if (game.facilities[id]) return fail(TEXT.fail.facilityExists);
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash); if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
const f = applyBuildQuality(createFacility(game, id, p, cost)); const f = applyBuildQuality(createFacility(game, id, p, cost));
const storageId = game.facilities[id] ? `${id}:${game.nextId++}` : id;
f.baseId = id;
f.id = storageId;
if (f.entry && isBlockedCell(f.entry.col, f.entry.row)) return fail('Receiver cell is blocked.'); if (f.entry && isBlockedCell(f.entry.col, f.entry.row)) return fail('Receiver cell is blocked.');
if (facilityOverlaps(f)) return fail(TEXT.fail.facilityOverlap); if (facilityOverlaps(f)) return fail(TEXT.fail.facilityOverlap);
record(game); record(game);
spendCash(game, cost); spendCash(game, cost);
game.facilities[storageId] = f; game.facilities[id] = f;
refreshRoutingAfterEdit(game); refreshRoutingAfterEdit(game);
game.selected = { type: 'facility', id: storageId }; game.selected = { type: 'facility', id };
floating(game, p.x, p.y - 14, `-${yen(cost)}`, THEME.ink); floating(game, p.x, p.y - 14, `-${yen(cost)}`, THEME.ink);
} }
@ -563,7 +371,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
const resale = resaleValueFor(hit, game); const resale = resaleValueFor(hit, game);
if (resale.amount <= 0) return; if (resale.amount <= 0) return;
refundCash(game, resale.amount); refundCash(game, resale.amount);
const label = resale.sameBuild ? 'REFUND' : 'SOLD'; const label = resale.sameBuild ? 'REFUND' : 'SOLD 50%';
floating(game, x, y - 16, `${label} +${yen(resale.amount)}`, THEME.green); floating(game, x, y - 16, `${label} +${yen(resale.amount)}`, THEME.green);
} }
@ -653,7 +461,6 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
} }
function hideModal() { function hideModal() {
if (game.cardTargetPick?.mode === 'autoScannerMenu') game.cardTargetPick = null;
ui.modal.classList.remove('visible', 'equipment-popover'); ui.modal.classList.remove('visible', 'equipment-popover');
ui.modal.style.removeProperty('--popover-x'); ui.modal.style.removeProperty('--popover-x');
ui.modal.style.removeProperty('--popover-y'); ui.modal.style.removeProperty('--popover-y');
@ -661,26 +468,13 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function showAutoScannerMenu(scanner) { function showAutoScannerMenu(scanner) {
ui.modalTitle.textContent = 'Auto Scanner'; ui.modalTitle.textContent = 'Auto Scanner';
game.cardTargetPick = { ui.modalBody.innerHTML = `<p>Standard auto scanner.</p><ul><li>Role 0: male left, others right.</li><li>Role 1: poop left, others right.</li><li>Cooldown: ${autoScannerCooldownSeconds(scanner).toFixed(1)}s.</li></ul>`;
pending: true,
mode: 'autoScannerMenu',
cardId: null,
targetKeys: [`scanner:${scanner.id}`],
remaining: 1
};
ui.modalBody.innerHTML = `
<div class="equipment-menu-lines compact">
<p>Left / right output routing for this AUTO scanner.</p>
<p>Current: ${scanner.role === 0 ? 'Male left / others right' : 'Poop left / others right'}</p>
<p>Cooldown: ${autoScannerCooldownSeconds(scanner).toFixed(1)}s.</p>
</div>`;
ui.modalActions.innerHTML = ''; ui.modalActions.innerHTML = '';
const r0 = modalButton('Male Left / Others Right', () => { record(game); scanner.role = 0; hideModal(); updatePanels(); }, scanner.role === 0 ? 'facility-action warn' : 'facility-action'); const r0 = modalButton('Set Role 0', () => { record(game); scanner.role = 0; hideModal(); updatePanels(); });
const r1 = modalButton('Poop Left / Others Right', () => { record(game); scanner.role = 1; hideModal(); updatePanels(); }, scanner.role === 1 ? 'facility-action warn' : 'facility-action'); const r1 = modalButton('Set Role 1', () => { record(game); scanner.role = 1; hideModal(); updatePanels(); });
ui.modalActions.append(r0, r1, modalButton('Close', hideModal, 'facility-action')); ui.modalActions.append(r0, r1);
positionEquipmentPopover(scanner); positionEquipmentPopover(scanner);
ui.modal.classList.add('visible', 'equipment-popover'); ui.modal.classList.add('visible', 'equipment-popover');
updatePanels();
} }
function formatKeyBinding(binding) { function formatKeyBinding(binding) {
@ -713,6 +507,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
<div class="equipment-menu-lines compact"> <div class="equipment-menu-lines compact">
<p>Left route key: <b>${left}</b></p> <p>Left route key: <b>${left}</b></p>
<p>Right route key: <b>${right}</b></p> <p>Right route key: <b>${right}</b></p>
<p>Role: ${scanner.role === 0 ? 'Male left / others right' : 'Poop left / others right'}</p>
${message ? `<p class="cash-positive">${message}</p>` : ''} ${message ? `<p class="cash-positive">${message}</p>` : ''}
${conflict ? `<p class="cash-negative">${conflict}</p>` : ''} ${conflict ? `<p class="cash-negative">${conflict}</p>` : ''}
</div>`; </div>`;
@ -794,7 +589,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
ui.modalTitle.textContent = selectedTitle(obj); ui.modalTitle.textContent = selectedTitle(obj);
ui.modalBody.innerHTML = ` ui.modalBody.innerHTML = `
<div class="equipment-menu-lines compact">${lines.map(x => `<p>${x}</p>`).join('')}</div> <div class="equipment-menu-lines compact">${lines.map(x => `<p>${x}</p>`).join('')}</div>
${['mixer', 'truck'].includes(obj.baseId || obj.id) ? '<p class="formula-box compact">Income/Fine = ceil(base x 1.10^upgrades)</p>' : ''}`; ${['mixer', 'truck'].includes(obj.id) ? '<p class="formula-box compact">Income/Fine = ceil(base × 1.05^upgrades)</p>' : ''}`;
ui.modalActions.innerHTML = ''; ui.modalActions.innerHTML = '';
if (obj.type === 'scanner' && obj.kind === 'auto') ui.modalActions.appendChild(modalButton('Auto Menu', () => showAutoScannerMenu(obj), 'facility-action warn')); if (obj.type === 'scanner' && obj.kind === 'auto') ui.modalActions.appendChild(modalButton('Auto Menu', () => showAutoScannerMenu(obj), 'facility-action warn'));
if (obj.type === 'scanner' && obj.kind === 'manual') ui.modalActions.appendChild(modalButton('Set Keys', () => showManualScannerMenu(obj), 'facility-action warn')); if (obj.type === 'scanner' && obj.kind === 'manual') ui.modalActions.appendChild(modalButton('Set Keys', () => showManualScannerMenu(obj), 'facility-action warn'));
@ -830,68 +625,14 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
if (f.entry) { const c = cellCenter(f.entry.col, f.entry.row); boxes.push({ id: `facilityPort:${id}`, price: equipmentPrice({ type: 'facility', ref: f }), x: c.x - 18, y: c.y - 18, w: 36, h: 36 }); } if (f.entry) { const c = cellCenter(f.entry.col, f.entry.row); boxes.push({ id: `facilityPort:${id}`, price: equipmentPrice({ type: 'facility', ref: f }), x: c.x - 18, y: c.y - 18, w: 36, h: 36 }); }
} }
for (const farm of game.eggFarms) { const c = cellCenter(farm.col, farm.row); boxes.push({ id: `eggFarm:${farm.id}`, price: equipmentPrice({ type: 'eggFarm', ref: farm }), x: c.x - 24, y: c.y - 24, w: 48, h: 48 }); } for (const farm of game.eggFarms) { const c = cellCenter(farm.col, farm.row); boxes.push({ id: `eggFarm:${farm.id}`, price: equipmentPrice({ type: 'eggFarm', ref: farm }), x: c.x - 24, y: c.y - 24, w: 48, h: 48 }); }
for (const scanner of game.scanners) { const c = scannerCenter(scanner); boxes.push({ id: `scanner:${scanner.id}`, price: equipmentPrice({ type: 'scanner', ref: scanner }), x: c.x - 50, y: c.y - 73, w: 100, h: 146 }); } for (const scanner of game.scanners) { const c = scannerCenter(scanner); boxes.push({ id: `scanner:${scanner.id}`, price: equipmentPrice({ type: 'scanner', ref: scanner }), x: c.x - 52, y: c.y - 36, w: 104, h: 72 }); }
for (const k of game.conveyorTiles) { for (const k of game.conveyorTiles) { const p = parseKey(k); const c = cellCenter(p.col, p.row); boxes.push({ id: `conveyor:${k}`, price: buildPrice('conveyor', game), x: c.x - 22, y: c.y - 22, w: 44, h: 44 }); }
const p = parseKey(k);
const c = cellCenter(p.col, p.row);
const meta = game.conveyorMeta.get(k) || {};
boxes.push({ id: `conveyor:${k}`, price: meta.price || buildPrice(meta.kind === 'boostConveyor' ? 'boostConveyor' : 'conveyor', game), x: c.x - 22, y: c.y - 22, w: 44, h: 44 });
}
return boxes; return boxes;
} }
function branchModeLabel(mode) {
return BRANCH_LABELS[mode] || BRANCH_LABELS.random;
}
function branchOutDirsForKey(k) {
return branchExitDirsForKey(k);
}
function isBranchKey(k) {
if (!game.conveyorTiles.has(k)) return false;
return isBranchCell(parseKey(k));
}
function branchSwitcherAtPoint(p) {
if (game.phase !== 'build' || game.cardDraft?.pending || game.cardTargetPick?.pending) return null;
for (const k of game.conveyorTiles) {
if (!isBranchKey(k)) continue;
const cell = parseKey(k);
const c = cellCenter(cell.col, cell.row);
const left = c.x - BRANCH_MARKER.w / 2;
const top = c.y + BRANCH_MARKER.y - BRANCH_MARKER.h / 2;
if (p.x >= left && p.x <= left + BRANCH_MARKER.w && p.y >= top && p.y <= top + BRANCH_MARKER.h) return k;
}
return null;
}
function cycleBranchModeAtPoint(p) {
const k = branchSwitcherAtPoint(p);
if (!k) return false;
cycleBranchModeForKey(k);
return true;
}
function cycleBranchModeForKey(k) {
const meta = game.conveyorMeta.get(k);
if (!meta) return false;
const reachable = branchSelectableEntranceDirsForKey(k);
if (reachable.length < 2) return false;
record(game);
const current = BRANCH_MODES.includes(meta.branchMode) ? meta.branchMode : 'random';
const modes = ['random', ...DIRS.map(d => d.name).filter(mode => reachable.includes(mode))];
meta.branchMode = modes[(Math.max(0, modes.indexOf(current)) + 1) % modes.length];
game.selected = null;
game.multiSelected = [];
updatePanels();
return true;
}
function selectedInfoLines(obj) { function selectedInfoLines(obj) {
ensureMaintenanceState(game); ensureMaintenanceState(game);
const lines = []; const lines = [];
const facilityKind = obj?.type === 'facility' ? (obj.baseId || obj.id) : '';
if (obj.type === 'eggFarm') { if (obj.type === 'eggFarm') {
const [min, max] = getSpawnRange(obj); const [min, max] = getSpawnRange(obj);
lines.push(`Price: ${yen(equipmentPrice(obj))}`); lines.push(`Price: ${yen(equipmentPrice(obj))}`);
@ -903,7 +644,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
lines.push(`Price: ${yen(equipmentPrice(obj))}`); lines.push(`Price: ${yen(equipmentPrice(obj))}`);
lines.push(`Sale value: ${yen(resaleValueFor({ type: obj.type, ref: obj }, game).amount)}`); lines.push(`Sale value: ${yen(resaleValueFor({ type: obj.type, ref: obj }, game).amount)}`);
lines.push(`Type: ${obj.kind.toUpperCase()} / Standard`); lines.push(`Type: ${obj.kind.toUpperCase()} / Standard`);
if (obj.kind === 'auto') lines.push(`Role: ${obj.role === 0 ? 'Male left / Others right' : 'Poop left / Others right'}`); lines.push(`Role: ${obj.role === 0 ? 'Male left / Others right' : 'Poop left / Others right'}`);
lines.push(`Queue: ${obj.queue.length}`); lines.push(`Queue: ${obj.queue.length}`);
if (obj.kind === 'auto') lines.push(`Cooldown: ${obj.cooldown.toFixed(1)}s / ${autoScannerCooldownSeconds(obj, game).toFixed(1)}s`); if (obj.kind === 'auto') lines.push(`Cooldown: ${obj.cooldown.toFixed(1)}s / ${autoScannerCooldownSeconds(obj, game).toFixed(1)}s`);
if (obj.kind === 'auto') lines.push(`Durability: ${remainingPercent(obj)}% | Delay x${autoScannerDelayMultiplier(obj).toFixed(2)}`); if (obj.kind === 'auto') lines.push(`Durability: ${remainingPercent(obj)}% | Delay x${autoScannerDelayMultiplier(obj).toFixed(2)}`);
@ -915,31 +656,25 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
const c = comp ? game.congestion.get(comp) : null; const c = comp ? game.congestion.get(comp) : null;
lines.push(`Price: ${yen(equipmentPrice({ type: 'conveyor', oldKey: obj.id, ref: obj }))}`); lines.push(`Price: ${yen(equipmentPrice({ type: 'conveyor', oldKey: obj.id, ref: obj }))}`);
lines.push(`Cell: ${obj.id}`); lines.push(`Cell: ${obj.id}`);
lines.push(`Type: ${meta?.kind === 'boostConveyor' ? 'BOOST BELT' : 'Normal Belt'} | Speed x${Number(meta?.speedMultiplier || 1).toFixed(1)}`);
lines.push(`Congestion: ${c ? Math.floor(c.ratio * 100) : 0}%`); lines.push(`Congestion: ${c ? Math.floor(c.ratio * 100) : 0}%`);
lines.push(`Durability: ${remainingPercent({ meta })}% | Speed x${performanceFactor({ meta }).toFixed(2)}`); lines.push(`Durability: ${remainingPercent({ meta })}% | Speed x${performanceFactor({ meta }).toFixed(2)}`);
if (isBranchKey(obj.id)) { lines.push('Flow follows drawn directions. Branches choose randomly; full branches are avoided when possible.');
lines.push(`Branch mode: ${branchModeLabel(meta?.branchMode || 'random')}`);
lines.push('Use the Branch button below or the on-belt branch badge.');
} else {
lines.push('Branch mode: needs 3+ conveyor connections.');
}
lines.push(`Sale value: ${yen(resaleValueFor({ type: 'conveyor', oldKey: obj.id, ref: obj }, game).amount)}`); lines.push(`Sale value: ${yen(resaleValueFor({ type: 'conveyor', oldKey: obj.id, ref: obj }, game).amount)}`);
} else if (obj.type === 'facility') { } else if (obj.type === 'facility') {
lines.push(`Price: ${yen(equipmentPrice(obj))}`); lines.push(`Price: ${yen(equipmentPrice(obj))}`);
{ const reason = lastProtectedSaleReason({ type: obj.type, ref: obj }); lines.push(reason ? `Sale: blocked (${reason})` : `Sale value: ${yen(resaleValueFor({ type: obj.type, ref: obj }, game).amount)}`); } { const reason = lastProtectedSaleReason({ type: obj.type, ref: obj }); lines.push(reason ? `Sale: blocked (${reason})` : `Sale value: ${yen(resaleValueFor({ type: obj.type, ref: obj }, game).amount)}`); }
lines.push(['mixer', 'truck', 'trash'].includes(obj.id) ? `Level: ${obj.level} / no cap` : `Level: ${obj.level}`); lines.push(['mixer', 'truck', 'trash'].includes(obj.id) ? `Level: ${obj.level} / no cap` : `Level: ${obj.level}`);
if (facilityKind === 'truck') lines.push('Durability: none | Always normal'); if (obj.id === 'truck') lines.push('Durability: none | Always normal');
if (['mixer', 'trash'].includes(facilityKind)) lines.push(`Durability: ${remainingPercent(obj)}% | Extra delay ${facilityProcessingDelay(game, obj).toFixed(2)}s`); if (['mixer', 'trash'].includes(obj.id)) lines.push(`Durability: ${remainingPercent(obj)}% | Extra delay ${facilityProcessingDelay(game, obj.id).toFixed(2)}s`);
if (facilityKind === 'mixer') { if (obj.id === 'mixer') {
lines.push(`Income: ${yen(upgradedMixerPrice(game))} per chick | upgrade x${incomeMultiplier(game, 'mixer').toFixed(3)}`); lines.push(`Income: ${yen(upgradedMixerPrice(game))} per chick | upgrade x${incomeMultiplier(game, 'mixer').toFixed(3)}`);
lines.push(`Poop fine: -${yen(mixerPoopPenalty(game))}`); lines.push(`Poop fine: -${yen(mixerPoopPenalty(game))}`);
} }
if (facilityKind === 'truck') { if (obj.id === 'truck') {
lines.push(`Income: ${yen(upgradedTruckPrice(game))} per target cargo | upgrade x${incomeMultiplier(game, 'truck').toFixed(3)}`); lines.push(`Income: ${yen(upgradedTruckPrice(game))} per target cargo | upgrade x${incomeMultiplier(game, 'truck').toFixed(3)}`);
lines.push(`Poop shipment fine: -${yen(truckPoopPenalty(game))}`); lines.push(`Poop shipment fine: -${yen(truckPoopPenalty(game))}`);
} }
if (facilityKind === 'trash') { if (obj.id === 'trash') {
const cards = shredderUpgradeCount(game); const cards = shredderUpgradeCount(game);
const chance = shredderBonusChance(cards); const chance = shredderBonusChance(cards);
lines.push(`Bonus cards: ${cards}/${shredderBonusMaxCards()}`); lines.push(`Bonus cards: ${cards}/${shredderBonusMaxCards()}`);
@ -961,14 +696,13 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function flavorText(obj) { function flavorText(obj) {
if (!obj) return ''; if (!obj) return '';
if (obj.type === 'conveyor' && game.conveyorMeta.get(obj.id)?.kind === 'boostConveyor') return 'Moves chicks twice as fast. Wears down like a normal belt.';
if (obj.type === 'conveyor') return 'A stubborn belt tile. It only cares about the next cell.'; if (obj.type === 'conveyor') return 'A stubborn belt tile. It only cares about the next cell.';
if (obj.type === 'eggFarm') return 'A tiny gatehouse producing questionable eggs on schedule.'; if (obj.type === 'eggFarm') return 'A tiny gatehouse producing questionable eggs on schedule.';
if (obj.type === 'scanner' && obj.kind === 'auto') return 'Slower than hands without upgrades.'; if (obj.type === 'scanner' && obj.kind === 'auto') return 'An automated judge. Faster than hands, still very sure of itself.';
if (obj.type === 'scanner') return 'A manual checkpoint. The operator is the algorithm.'; if (obj.type === 'scanner') return 'A manual checkpoint. The operator is the algorithm.';
if (obj.type === 'facility' && (obj.baseId || obj.id) === 'mixer') return 'Male chicks become revenue here. Do not feed it poop.'; if (obj.type === 'facility' && obj.id === 'mixer') return 'Male chicks become revenue here. Do not feed it poop.';
if (obj.type === 'facility' && (obj.baseId || obj.id) === 'trash') return 'Poop goes in. Sometimes coins come out, for reasons best left unaudited.'; if (obj.type === 'facility' && obj.id === 'trash') return 'Poop goes in. Sometimes coins come out, for reasons best left unaudited.';
if (obj.type === 'facility' && (obj.baseId || obj.id) === 'truck') return 'The shipping endpoint. Correct cargo pays; wrong cargo complains.'; if (obj.type === 'facility' && obj.id === 'truck') return 'The shipping endpoint. Correct cargo pays; wrong cargo complains.';
return 'Factory equipment.'; return 'Factory equipment.';
} }
@ -984,8 +718,6 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
selectedInfoLines, flavorText, disconnectedWarningFor, selectedInfoLines, flavorText, disconnectedWarningFor,
gridExpansionCost, buyGridExpansion, expansionOfferAtPoint, gridExpansionCost, buyGridExpansion, expansionOfferAtPoint,
beginConveyorDrag, continueConveyorDrag, endConveyorDrag, beginConveyorDrag, continueConveyorDrag, endConveyorDrag,
cycleSingleConveyorDirection,
branchSwitcherAtPoint, cycleBranchModeAtPoint, cycleBranchModeForKey,
removeSelected, switchScannerRole, showManualScannerMenu, removeSelected, switchScannerRole, showManualScannerMenu,
showAutoScannerMenu, showSelectedMenu, setBuildTool, fail, showAutoScannerMenu, showSelectedMenu, setBuildTool, fail,
isEquipmentCell, equipmentHitBoxes, isEquipmentCell, equipmentHitBoxes,

View file

@ -3,7 +3,7 @@ import { nextSpawnDelay } from '../core/state.js';
import { cellCenter, key, yen } from '../core/utils.js'; import { cellCenter, key, yen } from '../core/utils.js';
import { applyPenalty, applyRevenue, upgradedMixerPrice, upgradedTruckPrice, shredderUpgradeCount, rawShredderUpgradeCount, shredderBonusChance, shredderBonusMaxCards } from './economy.js'; import { applyPenalty, applyRevenue, upgradedMixerPrice, upgradedTruckPrice, shredderUpgradeCount, rawShredderUpgradeCount, shredderBonusChance, shredderBonusMaxCards } from './economy.js';
import { floating, shake, eraseEffect, shockwave, sparkBurst, smokeBurst } from './effects.js'; import { floating, shake, eraseEffect, shockwave, sparkBurst, smokeBurst } from './effects.js';
import { averageConveyorPerformance, autoScannerDelayMultiplier, ensureMaintenanceState, repairAllEquipment } from './maintenance.js'; import { averageConveyorPerformance, autoScannerDelayMultiplier, ensureMaintenanceState } from './maintenance.js';
import { scannerCenter, refreshRoutingAfterEdit } from './routing.js'; import { scannerCenter, refreshRoutingAfterEdit } from './routing.js';
const COMMON_WEIGHT = CARD_BALANCE.commonWeight; const COMMON_WEIGHT = CARD_BALANCE.commonWeight;
@ -31,7 +31,6 @@ export function ensureCardState(game) {
if (game.cardEffects.drawBonus != null) delete game.cardEffects.drawBonus; if (game.cardEffects.drawBonus != null) delete game.cardEffects.drawBonus;
if (!game.cardDraft) game.cardDraft = { pending: false, choices: [], rerolls: 0, picksRemaining: 0 }; if (!game.cardDraft) game.cardDraft = { pending: false, choices: [], rerolls: 0, picksRemaining: 0 };
if (game.cardDraft.picksRemaining == null) game.cardDraft.picksRemaining = game.cardDraft.pending ? 1 : 0; if (game.cardDraft.picksRemaining == null) game.cardDraft.picksRemaining = game.cardDraft.pending ? 1 : 0;
if (game.cardDraft.freeRerolls == null) game.cardDraft.freeRerolls = 0;
return game.cardEffects; return game.cardEffects;
} }
@ -45,16 +44,11 @@ export function extraEggOutletCount(_game, farm = null) {
return Math.min(3, Math.max(0, Number(farm?.extraEggOutlet) || 0)); return Math.min(3, Math.max(0, Number(farm?.extraEggOutlet) || 0));
} }
export function scannerQueueSpacingMultiplier(game) {
return Math.pow(0.85, effectCount(game, 'scannerQueueSpacing'));
}
export function conveyorSpeedForGame(game, conveyorKey = null) { export function conveyorSpeedForGame(game, conveyorKey = null) {
const effects = ensureCardState(game); const effects = ensureCardState(game);
const speed = CONVEYOR_SPEED * Math.pow(BEARING_SPEED_MULTIPLIER, Math.max(0, effects.bearing || 0)); const speed = CONVEYOR_SPEED * Math.pow(BEARING_SPEED_MULTIPLIER, Math.max(0, effects.bearing || 0));
const beltMultiplier = conveyorKey ? Math.max(0.1, Number(game?.conveyorMeta?.get?.(conveyorKey)?.speedMultiplier) || 1) : 1;
const perf = conveyorKey ? 1 : averageConveyorPerformance(game); const perf = conveyorKey ? 1 : averageConveyorPerformance(game);
return Math.min(CONVEYOR_SPEED_MAX, speed * perf) * beltMultiplier; return Math.min(CONVEYOR_SPEED_MAX, speed * perf);
} }
export function autoScannerCooldownSeconds(scanner, game = null) { export function autoScannerCooldownSeconds(scanner, game = null) {
@ -118,7 +112,7 @@ function targetLabel(game, target) {
if (target.type === 'scanner') { if (target.type === 'scanner') {
return `AUTO #${target.id} L${target.level} -> L${target.level + 1} | CD ${autoScannerCooldownSeconds(target, game).toFixed(1)}s -> ${autoScannerCooldownSeconds({ ...target, level: target.level + 1 }, game).toFixed(1)}s`; return `AUTO #${target.id} L${target.level} -> L${target.level + 1} | CD ${autoScannerCooldownSeconds(target, game).toFixed(1)}s -> ${autoScannerCooldownSeconds({ ...target, level: target.level + 1 }, game).toFixed(1)}s`;
} }
if (target.type === 'facility' && (target.baseId || target.id) === 'mixer') { if (target.type === 'facility' && target.id === 'mixer') {
const beforeLevel = target.level; const beforeLevel = target.level;
const before = upgradedMixerPrice(game); const before = upgradedMixerPrice(game);
target.level = beforeLevel + 1; target.level = beforeLevel + 1;
@ -126,7 +120,7 @@ function targetLabel(game, target) {
target.level = beforeLevel; target.level = beforeLevel;
return `MIXER L${target.level} -> L${target.level + 1} | ${yen(before)} -> ${yen(after)}`; return `MIXER L${target.level} -> L${target.level + 1} | ${yen(before)} -> ${yen(after)}`;
} }
if (target.type === 'facility' && (target.baseId || target.id) === 'truck') { if (target.type === 'facility' && target.id === 'truck') {
const beforeLevel = target.level; const beforeLevel = target.level;
const before = upgradedTruckPrice(game); const before = upgradedTruckPrice(game);
target.level = beforeLevel + 1; target.level = beforeLevel + 1;
@ -161,11 +155,9 @@ export function targetsForCard(game, cardOrId) {
.filter(f => Math.max(0, Number(f.extraEggOutlet) || 0) < 3) .filter(f => Math.max(0, Number(f.extraEggOutlet) || 0) < 3)
.map(f => ({ type: 'eggOutlet', id: f.id, col: f.col, row: f.row, farm: f })); .map(f => ({ type: 'eggOutlet', id: f.id, col: f.col, row: f.row, farm: f }));
if (card.target === 'autoScanner') return game.scanners.filter(s => s.kind === 'auto'); if (card.target === 'autoScanner') return game.scanners.filter(s => s.kind === 'auto');
if (card.target === 'mixer') return Object.values(game.facilities || {}).filter(f => (f.baseId || f.id) === 'mixer'); if (card.target === 'mixer') return game.facilities.mixer ? [game.facilities.mixer] : [];
if (card.target === 'truck') return Object.values(game.facilities || {}).filter(f => (f.baseId || f.id) === 'truck'); if (card.target === 'truck') return game.facilities.truck ? [game.facilities.truck] : [];
if (card.target === 'trash') return rawShredderUpgradeCount(game) < shredderBonusMaxCards() if (card.target === 'trash') return game.facilities.trash && rawShredderUpgradeCount(game) < shredderBonusMaxCards() ? [game.facilities.trash] : [];
? Object.values(game.facilities || {}).filter(f => (f.baseId || f.id) === 'trash')
: [];
return []; return [];
} }
@ -198,22 +190,17 @@ function availableCards(game) {
}); });
} }
function cardWeight(game, card) { function rarityWeight(card) {
if (card.weightBase != null) {
const held = effectCount(game, card.id);
return Math.max(0, Number(card.weightBase) - Math.max(0, Number(card.weightLossPerCopy) || 0) * held);
}
if (card.rarity === 'ultraRare') return ULTRA_RARE_WEIGHT; if (card.rarity === 'ultraRare') return ULTRA_RARE_WEIGHT;
if (card.rarity === 'rare') return RARE_WEIGHT; if (card.rarity === 'rare') return RARE_WEIGHT;
return COMMON_WEIGHT; return COMMON_WEIGHT;
} }
function weightedPick(game, pool) { function weightedPick(pool) {
const total = pool.reduce((sum, card) => sum + cardWeight(game, card), 0); const total = pool.reduce((sum, card) => sum + rarityWeight(card), 0);
if (total <= 0) return pool[Math.floor(Math.random() * pool.length)];
let roll = Math.random() * total; let roll = Math.random() * total;
for (const card of pool) { for (const card of pool) {
roll -= cardWeight(game, card); roll -= rarityWeight(card);
if (roll <= 0) return card; if (roll <= 0) return card;
} }
return pool[pool.length - 1]; return pool[pool.length - 1];
@ -260,7 +247,7 @@ export function dealCards(game, count = BASE_DRAFT_SIZE) {
let pool = [...source]; let pool = [...source];
while (choices.length < count && source.length) { while (choices.length < count && source.length) {
if (!pool.length) pool = [...source]; if (!pool.length) pool = [...source];
const picked = weightedPick(game, pool); const picked = weightedPick(pool);
choices.push(picked); choices.push(picked);
const i = pool.findIndex(card => card.id === picked.id); const i = pool.findIndex(card => card.id === picked.id);
if (i >= 0) pool.splice(i, 1); if (i >= 0) pool.splice(i, 1);
@ -290,7 +277,7 @@ function boundsForTarget(target) {
} }
if (target.type === 'scanner') { if (target.type === 'scanner') {
const c = scannerCenter(target); const c = scannerCenter(target);
return { x: c.x - 54, y: c.y - 77, w: 108, h: 154, cx: c.x, cy: c.y }; return { x: c.x - 58, y: c.y - 42, w: 116, h: 84, cx: c.x, cy: c.y };
} }
if (target.type === 'facility') { if (target.type === 'facility') {
return { x: target.x, y: target.y, w: target.w, h: target.h, cx: target.x + target.w / 2, cy: target.y + target.h / 2 }; return { x: target.x, y: target.y, w: target.w, h: target.h, cx: target.x + target.w / 2, cy: target.y + target.h / 2 };
@ -300,13 +287,6 @@ function boundsForTarget(target) {
export function cardTargetBounds(game) { export function cardTargetBounds(game) {
if (!game.cardTargetPick?.pending) return []; if (!game.cardTargetPick?.pending) return [];
if (game.cardTargetPick.mode === 'autoScannerMenu') {
const allowed = new Set(game.cardTargetPick.targetKeys || []);
return game.scanners
.filter(scanner => scanner.kind === 'auto' && allowed.has(targetKey(scanner)))
.map(scanner => ({ key: targetKey(scanner), label: 'AUTO ROUTE SETTINGS', target: scanner, bounds: boundsForTarget(scanner) }))
.filter(item => item.bounds);
}
const allowed = new Set(game.cardTargetPick.targetKeys || []); const allowed = new Set(game.cardTargetPick.targetKeys || []);
return targetsForCard(game, game.cardTargetPick.cardId) return targetsForCard(game, game.cardTargetPick.cardId)
.filter(target => allowed.has(targetKey(target))) .filter(target => allowed.has(targetKey(target)))
@ -364,17 +344,6 @@ function applyInstantCard(game, card) {
if (card.id === 'preventiveMaintenance') inc('preventiveMaintenance'); if (card.id === 'preventiveMaintenance') inc('preventiveMaintenance');
if (card.id === 'dudFilter') inc('dudFilter'); if (card.id === 'dudFilter') inc('dudFilter');
if (card.id === 'durabilityCoating') { inc('durabilityCoating'); ensureMaintenanceState(game); } if (card.id === 'durabilityCoating') { inc('durabilityCoating'); ensureMaintenanceState(game); }
if (card.id === 'sparePartsBin') {
inc('sparePartsBin');
const repaired = repairAllEquipment(game, 0.02);
game.stats.sparePartsRepair = (game.stats.sparePartsRepair || 0) + repaired;
game.totals.sparePartsRepair = (game.totals.sparePartsRepair || 0) + repaired;
floating(game, GRID.x + GRID.cols * GRID.cell / 2, GRID.y - 30, 'REPAIR +2%', THEME.green);
}
if (card.id === 'dudRefund') inc('dudRefund');
if (card.id === 'freeReroll') inc('freeReroll');
if (card.id === 'composter') inc('composter');
if (card.id === 'scannerQueueSpacing') inc('scannerQueueSpacing');
if (card.id === 'flattery') inc('fairiesFlatteryNext'); if (card.id === 'flattery') inc('fairiesFlatteryNext');
if (card.id === 'usedMachine') effects.usedMachineActive = true; if (card.id === 'usedMachine') effects.usedMachineActive = true;
if (card.id === 'newMachine') effects.usedMachineActive = false; if (card.id === 'newMachine') effects.usedMachineActive = false;
@ -405,18 +374,6 @@ function incomeAtLevel(base, level) {
return Math.ceil(base * Math.pow(ECONOMY.incomeUpgradeRate, Math.max(0, level - 1))); return Math.ceil(base * Math.pow(ECONOMY.incomeUpgradeRate, Math.max(0, level - 1)));
} }
function incomeUpgradePreview(game, id, base) {
const targets = targetsForCard(game, id === 'mixer' ? 'upgradeMixer' : 'upgradeTruck');
const target = targets[0];
if (!target) return { before: incomeAtLevel(base, 1), after: incomeAtLevel(base, 2) };
const before = id === 'mixer' ? upgradedMixerPrice(game) : upgradedTruckPrice(game);
const beforeLevel = target.level || 1;
target.level = beforeLevel + 1;
const after = id === 'mixer' ? upgradedMixerPrice(game) : upgradedTruckPrice(game);
target.level = beforeLevel;
return { before, after };
}
function cardDescription(game, card) { function cardDescription(game, card) {
const e = ensureCardState(game); const e = ensureCardState(game);
if (card.id === 'upgradeEgg') { if (card.id === 'upgradeEgg') {
@ -431,12 +388,12 @@ function cardDescription(game, card) {
return `Choose one AUTO SCANNER. Level +1. Cooldown ${before.toFixed(2)}s -> ${after.toFixed(2)}s.`; return `Choose one AUTO SCANNER. Level +1. Cooldown ${before.toFixed(2)}s -> ${after.toFixed(2)}s.`;
} }
if (card.id === 'upgradeMixer') { if (card.id === 'upgradeMixer') {
const preview = incomeUpgradePreview(game, 'mixer', ECONOMY.income.mixer); const level = game.facilities?.mixer?.level || 1;
return `Choose one MIXER. Earning +10%. Male-chick income ${yen(preview.before)} -> ${yen(preview.after)}.`; return `MIXER level +1. Male-chick income ${yen(incomeAtLevel(ECONOMY.income.mixer, level))} -> ${yen(incomeAtLevel(ECONOMY.income.mixer, level + 1))}.`;
} }
if (card.id === 'upgradeTruck') { if (card.id === 'upgradeTruck') {
const preview = incomeUpgradePreview(game, 'truck', ECONOMY.income.truck); const level = game.facilities?.truck?.level || 1;
return `Choose one TRUCK. Earning +10%. Correct shipment income ${yen(preview.before)} -> ${yen(preview.after)}.`; return `TRUCK level +1. Correct shipment income ${yen(incomeAtLevel(ECONOMY.income.truck, level))} -> ${yen(incomeAtLevel(ECONOMY.income.truck, level + 1))}.`;
} }
if (card.id === 'upgradeTrash') { if (card.id === 'upgradeTrash') {
const before = shredderUpgradeCount(game); const before = shredderUpgradeCount(game);
@ -475,11 +432,6 @@ function cardDescription(game, card) {
return `Held ${held}. DUD chance per card ${formatPercent(before)} -> ${formatPercent(after)}.`; return `Held ${held}. DUD chance per card ${formatPercent(before)} -> ${formatPercent(after)}.`;
} }
if (card.id === 'durabilityCoating') return `Held ${e.durabilityCoating || 0}. After pick: maximum durability ${formatPercent(Math.pow(1.03, (e.durabilityCoating || 0) + 1))}.`; if (card.id === 'durabilityCoating') return `Held ${e.durabilityCoating || 0}. After pick: maximum durability ${formatPercent(Math.pow(1.03, (e.durabilityCoating || 0) + 1))}.`;
if (card.id === 'sparePartsBin') return `Held ${e.sparePartsBin || 0}. Immediately restores 2% durability to every maintained machine. Offer weight: ${Math.max(0, 100 - 2 * Math.max(0, e.sparePartsBin || 0))}.`;
if (card.id === 'dudRefund') return `Held ${e.dudRefund || 0}. After pick: DUD clicks award ${yen(50 * ((e.dudRefund || 0) + 1))}.`;
if (card.id === 'freeReroll') return `Held ${e.freeReroll || 0}. After pick: each card screen starts with ${(e.freeReroll || 0) + 1} free reroll${(e.freeReroll || 0) + 1 === 1 ? '' : 's'}.`;
if (card.id === 'composter') return `Held ${e.composter || 0}. After pick: SHREDDER poop grants ${yen(3 * ((e.composter || 0) + 1))}.`;
if (card.id === 'scannerQueueSpacing') return `Held ${e.scannerQueueSpacing || 0}. After pick: Manual/Auto Scanner queue spacing ${formatPercent(Math.pow(0.85, (e.scannerQueueSpacing || 0) + 1))}.`;
if (card.id === 'flattery') return `Next Fairies tribute -50%. Held for next tribute: ${e.fairiesFlatteryNext || 0}.`; if (card.id === 'flattery') return `Next Fairies tribute -50%. Held for next tribute: ${e.fairiesFlatteryNext || 0}.`;
if (card.id === 'usedMachine') return 'Enable Used Machines: future equipment costs 50%, has no SELL refund, and starts with 60% durability.'; if (card.id === 'usedMachine') return 'Enable Used Machines: future equipment costs 50%, has no SELL refund, and starts with 60% durability.';
if (card.id === 'newMachine') return 'Cancel Used Machines. Future equipment returns to normal price, refund, and durability.'; if (card.id === 'newMachine') return 'Cancel Used Machines. Future equipment returns to normal price, refund, and durability.';
@ -504,7 +456,7 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
function prepareDraft() { function prepareDraft() {
ensureCardState(game); ensureCardState(game);
game.cardTargetPick = null; game.cardTargetPick = null;
game.cardDraft = { pending: true, choices: dealCards(game, BASE_DRAFT_SIZE), rerolls: 0, freeRerolls: effectCount(game, 'freeReroll'), picksRemaining: 1 }; game.cardDraft = { pending: true, choices: dealCards(game, BASE_DRAFT_SIZE), rerolls: 0, picksRemaining: 1 };
} }
function finishDraft() { function finishDraft() {
@ -544,13 +496,6 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
} }
function chooseDud(card, buttonEl) { function chooseDud(card, buttonEl) {
const refund = effectCount(game, 'dudRefund') * 50;
if (refund > 0) {
const amount = applyRevenue(game, refund);
game.stats.dudRefundIncome = (game.stats.dudRefundIncome || 0) + amount;
game.totals.dudRefundIncome = (game.totals.dudRefundIncome || 0) + amount;
floating(game, GRID.x + GRID.cols * GRID.cell / 2, GRID.y - 30, `DUD REFUND +${yen(amount)}`, THEME.green);
}
shake(game, 34, 0.62); shake(game, 34, 0.62);
if (document.body?.animate) { if (document.body?.animate) {
document.body.animate([ document.body.animate([
@ -621,7 +566,8 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
const card = cardById(game.cardTargetPick.cardId); const card = cardById(game.cardTargetPick.cardId);
const target = targetAtPoint(game, p); const target = targetAtPoint(game, p);
if (!card || !target) { if (!card || !target) {
cancelTargetPick(); const label = game.cardTargetPick.mode === 'blockedCell' ? 'SELECT BLOCKED CELL' : 'SELECT UPGRADE TARGET';
floating(game, p.x, p.y - 18, label, THEME.danger);
return true; return true;
} }
if (card.id === 'dynamite') { if (card.id === 'dynamite') {
@ -641,14 +587,6 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
} }
function doReroll() { function doReroll() {
const free = Math.max(0, game.cardDraft?.freeRerolls || 0);
if (free > 0) {
game.cardDraft.freeRerolls = free - 1;
game.cardDraft.rerolls += 1;
redrawChoices(BASE_DRAFT_SIZE);
showDraft();
return;
}
const cost = rerollCost(game); const cost = rerollCost(game);
if (game.cash < cost) return; if (game.cash < cost) return;
applyPenalty(game, cost); applyPenalty(game, cost);
@ -679,7 +617,6 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
if (!game.cardDraft.choices?.length) redrawChoices(BASE_DRAFT_SIZE); if (!game.cardDraft.choices?.length) redrawChoices(BASE_DRAFT_SIZE);
const choices = game.cardDraft.choices || []; const choices = game.cardDraft.choices || [];
const cost = rerollCost(game); const cost = rerollCost(game);
const free = Math.max(0, game.cardDraft?.freeRerolls || 0);
const remaining = Math.max(1, game.cardDraft.picksRemaining || 1); const remaining = Math.max(1, game.cardDraft.picksRemaining || 1);
ui.modalTitle.textContent = 'Choose Upgrade Card'; ui.modalTitle.textContent = 'Choose Upgrade Card';
ui.modalBody.innerHTML = `<p class="muted-card-note">Choose ${remaining} card${remaining === 1 ? '' : 's'} before Build phase. Dud cards can be flicked away without consuming a pick. Equipment cards target machines; Dynamite targets blocked cells.</p><div id="cardChoices" class="card-choices"></div>`; ui.modalBody.innerHTML = `<p class="muted-card-note">Choose ${remaining} card${remaining === 1 ? '' : 's'} before Build phase. Dud cards can be flicked away without consuming a pick. Equipment cards target machines; Dynamite targets blocked cells.</p><div id="cardChoices" class="card-choices"></div>`;
@ -689,18 +626,11 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
const reroll = document.createElement('button'); const reroll = document.createElement('button');
reroll.type = 'button'; reroll.type = 'button';
reroll.className = 'facility-action warn reroll-button'; reroll.className = 'facility-action warn reroll-button';
reroll.textContent = free > 0 ? `Reroll (FREE x${free})` : `Reroll ${yen(cost)}`; reroll.textContent = `Reroll ${yen(cost)}`;
reroll.disabled = free <= 0 && game.cash < cost; reroll.disabled = game.cash < cost;
reroll.title = free > 0 ? `${free} free reroll${free === 1 ? '' : 's'} remaining.` : (reroll.disabled ? 'Not enough cash' : ''); reroll.title = reroll.disabled ? 'Not enough cash' : `Reroll count today: ${(game.cardDraft.rerolls || 0) + 1}`;
reroll.addEventListener('click', doReroll); reroll.addEventListener('click', doReroll);
ui.modalActions.appendChild(reroll); ui.modalActions.appendChild(reroll);
const skip = document.createElement('button');
skip.type = 'button';
skip.className = 'facility-action';
skip.textContent = 'No Card';
skip.title = 'Skip this upgrade pick and enter Build phase.';
skip.addEventListener('click', finishDraft);
ui.modalActions.appendChild(skip);
ui.modal.classList.remove('equipment-popover'); ui.modal.classList.remove('equipment-popover');
ui.modal.classList.add('visible'); ui.modal.classList.add('visible');
} }

View file

@ -4,7 +4,7 @@ import { nextSpawnDelay } from '../core/state.js';
import { key, parseKey, pointToCell, cellCenter, randomBetween, yen, inGrid } from '../core/utils.js'; import { key, parseKey, pointToCell, cellCenter, randomBetween, yen, inGrid } from '../core/utils.js';
import { scannerById, scannerBySlot, scannerCenter, scannerConnector, nearestConveyorKey, buildConveyorComponents, autoSideFor, ensureFactoryGraph } from './routing.js'; import { scannerById, scannerBySlot, scannerCenter, scannerConnector, nearestConveyorKey, buildConveyorComponents, autoSideFor, ensureFactoryGraph } from './routing.js';
import { applyMixerIncome, applyMixerPoopFine, applyTruckPoopFine, applyWrongTruckFine, upgradedTruckPrice, applyShredderBonus, applyRevenue } from './economy.js'; import { applyMixerIncome, applyMixerPoopFine, applyTruckPoopFine, applyWrongTruckFine, upgradedTruckPrice, applyShredderBonus, applyRevenue } from './economy.js';
import { autoScannerCooldownSeconds, eggProductionDelayMultiplier, extraEggOutletCount, scannerQueueSpacingMultiplier } from './cards.js'; import { autoScannerCooldownSeconds, eggProductionDelayMultiplier, extraEggOutletCount } from './cards.js';
import { productionMultiplier, truckTarget, isTargetTruckCargo, shouldFineMaleTruck } from './contracts.js'; import { productionMultiplier, truckTarget, isTargetTruckCargo, shouldFineMaleTruck } from './contracts.js';
import { floating, shake, spawnPulse, scannerPulse, meatEffect, sludgeEffect, shredEffect, truckLoadEffect, shockwave, sparkBurst, smokeBurst, flyingDebris, rageEffect } from './effects.js'; import { floating, shake, spawnPulse, scannerPulse, meatEffect, sludgeEffect, shredEffect, truckLoadEffect, shockwave, sparkBurst, smokeBurst, flyingDebris, rageEffect } from './effects.js';
import { countAutoSorted, countCorrect, countMistake, countMixer, countPoopDestination, countPoopSpawned, countTrash, countTruckCargo } from './stats.js'; import { countAutoSorted, countCorrect, countMistake, countMixer, countPoopDestination, countPoopSpawned, countTrash, countTruckCargo } from './stats.js';
@ -104,7 +104,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
} }
updateScannerQueues(dt); updateScannerQueues(dt);
updateCongestion(); updateCongestion();
checkCongestionExplosions(); if (game.timeLeft > 0 || game.shutdownTimeLeft > 0) checkCongestionExplosions();
if (game.timeLeft <= 0) { if (game.timeLeft <= 0) {
if (game.chicks.length === 0) completeTurn(); if (game.chicks.length === 0) completeTurn();
else if (game.shutdownTimeLeft <= 0) blowOffRemainingForCleanup(); else if (game.shutdownTimeLeft <= 0) blowOffRemainingForCleanup();
@ -172,14 +172,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
const dirs = []; const dirs = [];
if (Array.isArray(meta.outDirs)) dirs.push(...meta.outDirs); if (Array.isArray(meta.outDirs)) dirs.push(...meta.outDirs);
if (meta.dir) dirs.push(meta.dir); if (meta.dir) dirs.push(meta.dir);
const explicit = [...new Set(dirs)].filter(dir => DIRS.some(d => d.name === dir)); return [...new Set(dirs)].filter(dir => DIRS.some(d => d.name === dir));
const cell = parseKey(cellKey);
const inferred = DIRS
.filter(d => movementOptionFromDir(cell, d.name))
.map(d => d.name);
const cleanExplicit = explicit.filter(dir => inferred.includes(dir));
const inferredExits = inferred;
return cleanExplicit.length ? cleanExplicit : inferredExits;
} }
function dirByName(name) { function dirByName(name) {
@ -187,7 +180,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
} }
function scannerAtBodyCell(col, row) { function scannerAtBodyCell(col, row) {
return game.scanners.find(scanner => scanner.col <= col && col <= scanner.col + 1 && scanner.row - 1 <= row && row <= scanner.row + 1) || null; return game.scanners.find(scanner => scanner.col === col && scanner.row === row) || null;
} }
function scannerReceivingFrom(cell, dirName) { function scannerReceivingFrom(cell, dirName) {
@ -203,10 +196,6 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
return Object.entries(game.facilities || {}).find(([, f]) => f?.entry && f.entry.col === cell.col && f.entry.row === cell.row) || null; return Object.entries(game.facilities || {}).find(([, f]) => f?.entry && f.entry.col === cell.col && f.entry.row === cell.row) || null;
} }
function facilityKind(f) {
return f?.baseId || f?.id || '';
}
function dirExitsToFacility(facility, dirName) { function dirExitsToFacility(facility, dirName) {
return (facility.side === 'left' && dirName === 'left') return (facility.side === 'left' && dirName === 'left')
|| (facility.side === 'right' && dirName === 'right') || (facility.side === 'right' && dirName === 'right')
@ -230,41 +219,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
return null; return null;
} }
function neighborPointsIntoCell(cell, dirName) { function pickMovementOption(chick, cell, dirs) {
const d = dirByName(dirName);
if (!d) return false;
const meta = game.conveyorMeta?.get?.(key(cell.col + d.dc, cell.row + d.dr));
if (!meta) return false;
const names = [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))];
return names.includes(d.opposite);
}
function conveyorNeighborDirNames(cell) {
return DIRS
.filter(d => inGrid(cell.col + d.dc, cell.row + d.dr) && game.conveyorTiles.has(key(cell.col + d.dc, cell.row + d.dr)))
.map(d => d.name);
}
function branchExitDirNames(cell, dirs, branchMode = 'random') {
const connected = conveyorNeighborDirNames(cell);
if (connected.length < 3) return dirs;
const entrances = new Set(connected.filter(dir => neighborPointsIntoCell(cell, dir)));
if (branchMode && branchMode !== 'random' && branchMode !== 'pass' && connected.includes(branchMode)) {
const selectedEntrances = new Set(entrances);
selectedEntrances.add(branchMode);
if (connected.filter(dir => !selectedEntrances.has(dir)).length >= 2) entrances.add(branchMode);
}
const exits = connected.filter(dir => !entrances.has(dir));
if (exits.length) return exits;
const fallback = dirs.filter(dir => !entrances.has(dir));
return fallback.length ? fallback : dirs;
}
function optionAvailableForBranch(chick, opt) {
return opt && (opt.type === 'facility' || !targetBlockedByChick(chick, opt.target));
}
function pickMovementOption(chick, cell, dirs, branchMode = 'random') {
const candidates = dirs.map(dir => movementOptionFromDir(cell, dir)).filter(Boolean); const candidates = dirs.map(dir => movementOptionFromDir(cell, dir)).filter(Boolean);
if (!candidates.length) return null; if (!candidates.length) return null;
const moving = candidates.filter(opt => opt.type !== 'facility'); const moving = candidates.filter(opt => opt.type !== 'facility');
@ -287,27 +242,24 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
if (!cell) return removeChick(index, 'OFF GRID'); if (!cell) return removeChick(index, 'OFF GRID');
const currentKey = key(cell.col, cell.row); const currentKey = key(cell.col, cell.row);
if (!game.conveyorTiles.has(currentKey)) return removeChick(index, 'OFF BELT'); if (!game.conveyorTiles.has(currentKey)) return removeChick(index, 'OFF BELT');
const meta = game.conveyorMeta?.get(currentKey) || {}; const dirs = conveyorOutDirNames(currentKey);
const dirs = branchExitDirNames(cell, conveyorOutDirNames(currentKey), meta.branchMode || 'random');
if (!dirs.length) { if (!dirs.length) {
chick.stoppedTimer = 0.25; chick.stoppedTimer = 0.25;
return; return;
} }
const option = pickMovementOption(chick, cell, dirs, meta.branchMode || 'random'); const option = pickMovementOption(chick, cell, dirs);
if (!option) { if (!option) {
chick.stoppedTimer = 0.25; chick.stoppedTimer = 0.25;
return; return;
} }
if (option.type === 'facility') { if (option.type === 'facility') {
const kind = facilityKind(option.facility); if (option.facilityId === 'mixer') resolveMixer(index);
if (kind === 'mixer') resolveMixer(index, option.facility); else if (option.facilityId === 'truck') resolveTruck(index);
else if (kind === 'truck') resolveTruck(index, option.facility); else if (option.facilityId === 'trash') resolveTrash(index);
else if (kind === 'trash') resolveTrash(index, option.facility);
else removeChick(index, 'DONE'); else removeChick(index, 'DONE');
return; return;
} }
if (option.type === 'scanner') chick.pendingScannerId = option.scanner.id; if (option.type === 'scanner') chick.pendingScannerId = option.scanner.id;
chick.prevConveyorCell = currentKey;
setSingleSegmentRoute(chick, option.target); setSingleSegmentRoute(chick, option.target);
} }
@ -366,7 +318,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
function positionScannerQueue(scanner) { function positionScannerQueue(scanner) {
scanner.queue = scanner.queue.filter(id => game.chicks.some(ch => ch.id === id && ch.stage === 'queued')); scanner.queue = scanner.queue.filter(id => game.chicks.some(ch => ch.id === id && ch.stage === 'queued'));
const spacing = GRID.cell * 0.86 * scannerQueueSpacingMultiplier(game); const spacing = GRID.cell * 0.86;
scanner.queue.forEach((id, idx) => { scanner.queue.forEach((id, idx) => {
const chick = game.chicks.find(ch => ch.id === id); const chick = game.chicks.find(ch => ch.id === id);
if (!chick) return; if (!chick) return;
@ -545,7 +497,8 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
return chick; return chick;
} }
function resolveMixer(index, mixer = game.facilities.mixer) { function resolveMixer(index) {
const mixer = game.facilities.mixer;
if (mixer?.processingCooldown > 0) { if (mixer?.processingCooldown > 0) {
const chick = game.chicks[index]; const chick = game.chicks[index];
if (chick) chick.stoppedTimer = 0.25; if (chick) chick.stoppedTimer = 0.25;
@ -555,8 +508,8 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
if (!chick) return; if (!chick) return;
const { x, y } = chick; const { x, y } = chick;
countMixer(game); countMixer(game);
recordFacilityProcess(game, mixer); recordFacilityProcess(game, 'mixer');
setFacilityCooldownAfterProcess(game, mixer); setFacilityCooldownAfterProcess(game, 'mixer');
if (chick.sex === 'poop') { if (chick.sex === 'poop') {
const penalty = applyMixerPoopFine(game); const penalty = applyMixerPoopFine(game);
countPoopDestination(game, 'mixer'); countPoopDestination(game, 'mixer');
@ -572,11 +525,11 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
floating(game, x, y - 18, `+${yen(amount)}`, THEME.green); floating(game, x, y - 18, `+${yen(amount)}`, THEME.green);
} }
function resolveTruck(index, truck = game.facilities.truck) { function resolveTruck(index) {
const chick = takeChick(index); const chick = takeChick(index);
if (!chick) return; if (!chick) return;
const { x, y } = chick; const { x, y } = chick;
addTruckCargo(chick.sex, truck); addTruckCargo(chick.sex);
truckLoadEffect(game, x, y, chick.sex); truckLoadEffect(game, x, y, chick.sex);
countTruckCargo(game, chick.sex); countTruckCargo(game, chick.sex);
const target = truckTarget(game); const target = truckTarget(game);
@ -614,7 +567,8 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
} }
} }
function resolveTrash(index, trash = game.facilities.trash) { function resolveTrash(index) {
const trash = game.facilities.trash;
if (trash?.processingCooldown > 0) { if (trash?.processingCooldown > 0) {
const chick = game.chicks[index]; const chick = game.chicks[index];
if (chick) chick.stoppedTimer = 0.25; if (chick) chick.stoppedTimer = 0.25;
@ -625,20 +579,13 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
const { x, y } = chick; const { x, y } = chick;
shredEffect(game, x, y, chick.sex); shredEffect(game, x, y, chick.sex);
countTrash(game); countTrash(game);
recordFacilityProcess(game, trash); recordFacilityProcess(game, 'trash');
setFacilityCooldownAfterProcess(game, trash); setFacilityCooldownAfterProcess(game, 'trash');
const bonus = applyShredderBonus(game); const bonus = applyShredderBonus(game);
const bonusText = bonus.amount > 0 ? ` +${yen(bonus.amount)}` : ''; const bonusText = bonus.amount > 0 ? ` +${yen(bonus.amount)}` : '';
if (chick.sex === 'poop') { if (chick.sex === 'poop') {
countPoopDestination(game, 'trash'); countPoopDestination(game, 'trash');
countCorrect(game); countCorrect(game);
const compost = Math.max(0, Number(game.cardEffects?.composter) || 0) * 3;
if (compost > 0) {
const compostAmount = applyRevenue(game, compost);
game.stats.composterIncome = (game.stats.composterIncome || 0) + compostAmount;
game.totals.composterIncome = (game.totals.composterIncome || 0) + compostAmount;
floating(game, x, y - 36, `COMPOST +${yen(compostAmount)}`, THEME.green);
}
floating(game, x, y - 18, `CLEAN${bonusText}`, bonus.amount > 0 ? THEME.green : THEME.green); floating(game, x, y - 18, `CLEAN${bonusText}`, bonus.amount > 0 ? THEME.green : THEME.green);
} else { } else {
countMistake(game); countMistake(game);
@ -652,8 +599,8 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
floating(game, chick.x, chick.y - 12, label, THEME.muted); floating(game, chick.x, chick.y - 12, label, THEME.muted);
} }
function addTruckCargo(sex, truck = game.facilities.truck) { function addTruckCargo(sex) {
const t = truck || game.facilities.truck; const t = game.facilities.truck;
if (!t) return; if (!t) return;
game.truckCargo.push({ sex, x: randomBetween(22, t.w - 22), y: randomBetween(66, t.h - 24) }); game.truckCargo.push({ sex, x: randomBetween(22, t.w - 22), y: randomBetween(66, t.h - 24) });
if (game.truckCargo.length > 45) game.truckCargo.shift(); if (game.truckCargo.length > 45) game.truckCargo.shift();
@ -695,8 +642,6 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
} }
} }
if (comp.ratio >= 1) { if (comp.ratio >= 1) {
const victims = chicksInComponent(comp.id);
if (!victims.length || victims.some(chick => chick.stage !== 'queued' && chick.stoppedTimer <= 0.2)) continue;
const last = game.lastExplodedComponent.get(comp.id) || 0; const last = game.lastExplodedComponent.get(comp.id) || 0;
const now = performance.now(); const now = performance.now();
if (now - last > 900) { if (now - last > 900) {

View file

@ -21,7 +21,7 @@ function explosionDamageMultiplier(game) {
export function equipmentBasePrice(objOrHit) { export function equipmentBasePrice(objOrHit) {
const obj = objOrHit?.ref || objOrHit || {}; const obj = objOrHit?.ref || objOrHit || {};
const type = objOrHit?.type || obj.type; const type = objOrHit?.type || obj.type;
if (type === 'conveyor') return obj.price || FACILITY_DEFS[obj.kind]?.price || FACILITY_DEFS.conveyor.price; if (type === 'conveyor') return FACILITY_DEFS.conveyor.price;
if (type === 'eggFarm') return obj.price || FACILITY_DEFS.eggFarm.price; if (type === 'eggFarm') return obj.price || FACILITY_DEFS.eggFarm.price;
if (type === 'scanner') return obj.price || (obj.kind === 'auto' ? FACILITY_DEFS.autoScanner.price : FACILITY_DEFS.manualScanner.price); if (type === 'scanner') return obj.price || (obj.kind === 'auto' ? FACILITY_DEFS.autoScanner.price : FACILITY_DEFS.manualScanner.price);
if (type === 'facility') return obj.price || FACILITY_DEFS[obj.id]?.price || 0; if (type === 'facility') return obj.price || FACILITY_DEFS[obj.id]?.price || 0;
@ -34,10 +34,7 @@ export function buildPrice(id, game = null) {
} }
export function facilityUpgradeCount(game, id) { export function facilityUpgradeCount(game, id) {
if (!game?.facilities) return 0; return Math.max(0, (game.facilities?.[id]?.level || 1) - 1);
return Object.values(game.facilities)
.filter(f => (f?.baseId || f?.id) === id)
.reduce((sum, f) => sum + Math.max(0, (f.level || 1) - 1), 0);
} }
export function incomeMultiplier(game, id) { export function incomeMultiplier(game, id) {

View file

@ -64,11 +64,10 @@ export function restore(game, text) {
game.conveyorTiles = new Set(data.conveyorTiles || []); game.conveyorTiles = new Set(data.conveyorTiles || []);
game.conveyorMeta = new Map(data.conveyorMeta || []); game.conveyorMeta = new Map(data.conveyorMeta || []);
game.branchCounters = new Map(data.branchCounters || []); game.branchCounters = new Map(data.branchCounters || []);
game.cardEffects = data.cardEffects || { bearing: 0, legalWork: 0, fairiesFlatteryNext: 0, scannerQueueSpacing: 0 }; game.cardEffects = data.cardEffects || { bearing: 0, legalWork: 0, flattery: 0 };
if (game.cardEffects.drawBonus != null) delete game.cardEffects.drawBonus; if (game.cardEffects.drawBonus != null) delete game.cardEffects.drawBonus;
game.cardDraft = data.cardDraft || { pending: false, choices: [], rerolls: 0, picksRemaining: 0 }; game.cardDraft = data.cardDraft || { pending: false, choices: [], rerolls: 0, picksRemaining: 0 };
if (game.cardDraft.picksRemaining == null) game.cardDraft.picksRemaining = game.cardDraft.pending ? 1 : 0; if (game.cardDraft.picksRemaining == null) game.cardDraft.picksRemaining = game.cardDraft.pending ? 1 : 0;
if (game.cardDraft.freeRerolls == null) game.cardDraft.freeRerolls = 0;
game.cardTargetPick = data.cardTargetPick || null; game.cardTargetPick = data.cardTargetPick || null;
game.groupDrag = null; game.groupDrag = null;
game.selectionBox = null; game.selectionBox = null;

View file

@ -48,9 +48,6 @@ export function ensureMaintenanceState(game) {
for (const [k, meta] of game.conveyorMeta || []) { for (const [k, meta] of game.conveyorMeta || []) {
if (meta.uses == null) meta.uses = 0; if (meta.uses == null) meta.uses = 0;
if (meta.maintenanceType == null) meta.maintenanceType = 'conveyor'; if (meta.maintenanceType == null) meta.maintenanceType = 'conveyor';
if (!meta.kind) meta.kind = 'conveyor';
if (!meta.speedMultiplier) meta.speedMultiplier = meta.kind === 'boostConveyor' ? 2 : 1;
if (!meta.branchMode) meta.branchMode = 'random';
const desired = durabilityCapFor(game, 'conveyor', qualityMultiplierOf(meta)); const desired = durabilityCapFor(game, 'conveyor', qualityMultiplierOf(meta));
if (meta.durability == null || meta.durability < desired) meta.durability = desired; if (meta.durability == null || meta.durability < desired) meta.durability = desired;
} }
@ -141,20 +138,9 @@ export function eggSpawnDelayMultiplier(farm) {
return delayMultiplier(farm); return delayMultiplier(farm);
} }
function facilityKind(target) { export function facilityProcessingDelay(game, id) {
return target?.baseId || target?.id || target;
}
function facilityFromTarget(game, target) {
if (!target) return null;
if (typeof target === 'object') return target;
return game.facilities?.[target] || null;
}
export function facilityProcessingDelay(game, target) {
ensureMaintenanceState(game); ensureMaintenanceState(game);
const f = facilityFromTarget(game, target); const f = game.facilities?.[id];
const id = facilityKind(f || target);
if (!f || !['mixer', 'trash'].includes(id)) return 0; if (!f || !['mixer', 'trash'].includes(id)) return 0;
attachMaintenance(f, id); attachMaintenance(f, id);
const t = Math.max(0, delayMultiplier(f) - 1); const t = Math.max(0, delayMultiplier(f) - 1);
@ -183,26 +169,25 @@ export function recordAutoScan(game, scanner) {
scanner.maintenance.uses = Math.min(scanner.maintenance.durability, (scanner.maintenance.uses || 0) + degradationUseMultiplier(game)); scanner.maintenance.uses = Math.min(scanner.maintenance.durability, (scanner.maintenance.uses || 0) + degradationUseMultiplier(game));
} }
export function recordFacilityProcess(game, target) { export function recordFacilityProcess(game, id) {
ensureMaintenanceState(game); ensureMaintenanceState(game);
const f = facilityFromTarget(game, target); const f = game.facilities?.[id];
const id = facilityKind(f || target);
if (!f || !['mixer', 'trash'].includes(id)) return; if (!f || !['mixer', 'trash'].includes(id)) return;
attachMaintenance(f, id); attachMaintenance(f, id);
f.maintenance.uses = Math.min(f.maintenance.durability, (f.maintenance.uses || 0) + degradationUseMultiplier(game)); f.maintenance.uses = Math.min(f.maintenance.durability, (f.maintenance.uses || 0) + degradationUseMultiplier(game));
} }
export function setFacilityCooldownAfterProcess(game, target) { export function setFacilityCooldownAfterProcess(game, id) {
const f = facilityFromTarget(game, target); const f = game.facilities?.[id];
if (!f) return 0; if (!f) return 0;
const delay = facilityProcessingDelay(game, f); const delay = facilityProcessingDelay(game, id);
f.processingCooldown = Math.max(f.processingCooldown || 0, delay); f.processingCooldown = Math.max(f.processingCooldown || 0, delay);
return delay; return delay;
} }
export function updateProcessingCooldowns(game, dt) { export function updateProcessingCooldowns(game, dt) {
for (const f of Object.values(game.facilities || {})) { for (const id of ['mixer', 'trash']) {
if (!['mixer', 'trash'].includes(f.baseId || f.id)) continue; const f = game.facilities?.[id];
if (f?.processingCooldown > 0) f.processingCooldown = Math.max(0, f.processingCooldown - dt); if (f?.processingCooldown > 0) f.processingCooldown = Math.max(0, f.processingCooldown - dt);
} }
} }
@ -210,7 +195,7 @@ export function updateProcessingCooldowns(game, dt) {
export function equipmentMaintenanceTargets(game) { export function equipmentMaintenanceTargets(game) {
ensureMaintenanceState(game); ensureMaintenanceState(game);
const targets = []; const targets = [];
for (const [k, meta] of game.conveyorMeta || []) targets.push({ type: 'conveyor', key: k, meta, label: `${meta.kind === 'boostConveyor' ? 'Boost Belt' : 'Belt'} ${k}`, center: cellCenter(parseKey(k).col, parseKey(k).row) }); for (const [k, meta] of game.conveyorMeta || []) targets.push({ type: 'conveyor', key: k, meta, label: `Belt ${k}`, center: cellCenter(parseKey(k).col, parseKey(k).row) });
for (const farm of game.eggFarms || []) targets.push({ type: 'eggFarm', ref: farm, label: `EGG #${farm.id}`, center: cellCenter(farm.col, farm.row) }); for (const farm of game.eggFarms || []) targets.push({ type: 'eggFarm', ref: farm, label: `EGG #${farm.id}`, center: cellCenter(farm.col, farm.row) });
for (const scanner of (game.scanners || []).filter(s => s.kind === 'auto')) targets.push({ type: 'autoScanner', ref: scanner, label: `AUTO #${scanner.id}`, center: cellCenter(scanner.col, scanner.row) }); for (const scanner of (game.scanners || []).filter(s => s.kind === 'auto')) targets.push({ type: 'autoScanner', ref: scanner, label: `AUTO #${scanner.id}`, center: cellCenter(scanner.col, scanner.row) });
for (const id of ['mixer', 'trash']) { for (const id of ['mixer', 'trash']) {
@ -226,18 +211,6 @@ function targetDurability(t) { return t.meta ? durabilityOf(t) : durabilityOf(t.
function reduceTargetUses(t, amount) { setUses(t.meta ? t : t.ref, Math.max(0, targetUses(t) - amount)); } function reduceTargetUses(t, amount) { setUses(t.meta ? t : t.ref, Math.max(0, targetUses(t) - amount)); }
function targetKey(t) { return t.type === 'conveyor' ? `conveyor:${t.key}` : `${t.type}:${t.ref?.id || t.ref?.type || t.type}`; } function targetKey(t) { return t.type === 'conveyor' ? `conveyor:${t.key}` : `${t.type}:${t.ref?.id || t.ref?.type || t.type}`; }
export function repairAllEquipment(game, percent = 0.02) {
ensureMaintenanceState(game);
const pct = Math.max(0, Number(percent) || 0);
let repaired = 0;
for (const t of equipmentMaintenanceTargets(game)) {
const before = targetUses(t);
reduceTargetUses(t, targetDurability(t) * pct);
repaired += Math.max(0, before - targetUses(t)) / targetDurability(t) * 100;
}
return repaired;
}
export function hireRepairmanForNextDay(game) { export function hireRepairmanForNextDay(game) {
ensureMaintenanceState(game); ensureMaintenanceState(game);
if (game.repairman.hiredForNextDay) return { ok: false, reason: 'Repairman already hired.' }; if (game.repairman.hiredForNextDay) return { ok: false, reason: 'Repairman already hired.' };

View file

@ -5,29 +5,11 @@ import { key, parseKey, inGrid, cellCenter, distance, sameCell } from '../core/u
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Object lookup // Object lookup
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export function scannerFootprintCells(scanner) { export function scannerCenter(scanner) { return cellCenter(scanner.col, scanner.row); }
if (!scanner) return [];
return [
{ col: scanner.col, row: scanner.row - 1 },
{ col: scanner.col + 1, row: scanner.row - 1 },
{ col: scanner.col, row: scanner.row },
{ col: scanner.col + 1, row: scanner.row },
{ col: scanner.col, row: scanner.row + 1 },
{ col: scanner.col + 1, row: scanner.row + 1 }
];
}
export function scannerCenter(scanner) {
const c = cellCenter(scanner.col, scanner.row);
return { x: c.x + GRID.cell / 2, y: c.y };
}
export function farmAt(game, col, row) { return game.eggFarms.find(f => f.col === col && f.row === row) || null; } export function farmAt(game, col, row) { return game.eggFarms.find(f => f.col === col && f.row === row) || null; }
export function scannerAt(game, col, row) { return game.scanners.find(s => scannerFootprintCells(s).some(p => p.col === col && p.row === row)) || null; } export function scannerAt(game, col, row) { return game.scanners.find(s => s.col === col && s.row === row) || null; }
export function scannerById(game, id) { return game.scanners.find(s => s.id === id) || null; } export function scannerById(game, id) { return game.scanners.find(s => s.id === id) || null; }
export function scannerBySlot(game, slot) { return game.scanners.find(s => s.kind === 'manual' && s.slot === slot) || null; } export function scannerBySlot(game, slot) { return game.scanners.find(s => s.kind === 'manual' && s.slot === slot) || null; }
function facilityKind(f) { return f?.baseId || f?.id || ''; }
function facilityEntriesForKind(game, kind) {
return Object.entries(game.facilities || {}).filter(([, f]) => facilityKind(f) === kind);
}
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Port definitions. Ports prefer the exact connector cell, but also accept // Port definitions. Ports prefer the exact connector cell, but also accept
@ -35,10 +17,10 @@ function facilityEntriesForKind(game, kind) {
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export function scannerConnector(scanner, type) { export function scannerConnector(scanner, type) {
return { return {
inputA: { col: scanner.col, row: scanner.row - 2 }, inputA: { col: scanner.col, row: scanner.row - 1 },
inputB: null, inputB: null,
left: { col: scanner.col - 1, row: scanner.row }, left: { col: scanner.col - 1, row: scanner.row },
right: { col: scanner.col + 2, row: scanner.row } right: { col: scanner.col + 1, row: scanner.row }
}[type]; }[type];
} }
@ -72,23 +54,21 @@ function graphPortConveyorCells(game, point, blocked = [], preferred = []) {
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export function scannerInputCells(game, scanner) { export function scannerInputCells(game, scanner) {
const connector = scannerConnector(scanner, 'inputA'); const connector = scannerConnector(scanner, 'inputA');
if (!connector || !inGrid(connector.col, connector.row)) return []; return visualPortConveyorCells(game, connector, [ { col: scanner.col, row: scanner.row } ]);
return game.conveyorTiles.has(key(connector.col, connector.row)) ? [{ ...connector, viaTolerance: false }] : [];
} }
function outputStartCells(game, scanner, side) { function outputStartCells(game, scanner, side) {
const connector = scannerConnector(scanner, side); const connector = scannerConnector(scanner, side);
return visualPortConveyorCells(game, connector, scannerFootprintCells(scanner)); return visualPortConveyorCells(game, connector, [ { col: scanner.col, row: scanner.row } ]);
} }
export function facilityEntryPoint(game, dest) { export function facilityEntryPoint(game, dest) {
const f = game.facilities[dest] || facilityEntriesForKind(game, dest)[0]?.[1]; const f = game.facilities[dest];
if (!f) return null; if (!f) return null;
if (f.entry) return cellCenter(f.entry.col, f.entry.row); if (f.entry) return cellCenter(f.entry.col, f.entry.row);
const kind = facilityKind(f) || dest; if (dest === 'mixer') return { x: f.x + f.w, y: f.y + f.h * 0.52 };
if (kind === 'mixer') return { x: f.x + f.w, y: f.y + f.h * 0.52 }; if (dest === 'truck') return { x: f.x, y: f.y + f.h * 0.52 };
if (kind === 'truck') return { x: f.x, y: f.y + f.h * 0.52 }; if (dest === 'trash') return { x: f.x + f.w / 2, y: f.y + 8 };
if (kind === 'trash') return { x: f.x + f.w / 2, y: f.y + 8 };
return { x: f.x + f.w / 2, y: f.y + f.h / 2 }; return { x: f.x + f.w / 2, y: f.y + f.h / 2 };
} }
@ -122,37 +102,6 @@ function conveyorOutDirNames(game, cellKey) {
return [...new Set(dirs)].filter(dir => DIRS.some(d => d.name === dir)); return [...new Set(dirs)].filter(dir => DIRS.some(d => d.name === dir));
} }
function neighborPointsIntoCell(game, p, dirName) {
const d = DIRS.find(item => item.name === dirName);
if (!d) return false;
const meta = game.conveyorMeta?.get?.(graphNodeKey(p.col + d.dc, p.row + d.dr));
if (!meta) return false;
const names = [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))];
return names.includes(d.opposite);
}
function inferredConveyorOutDirNames(game, cellKey) {
const p = graphCell(cellKey);
const meta = game.conveyorMeta?.get?.(cellKey) || {};
const neighbors = DIRS
.filter(d => game.conveyorTiles.has(graphNodeKey(p.col + d.dc, p.row + d.dr)))
.map(d => d.name);
const explicit = conveyorOutDirNames(game, cellKey).filter(dir => neighbors.includes(dir));
const inferred = neighbors;
const dirs = explicit.length ? explicit : inferred;
if (neighbors.length < 3) return dirs;
const entrances = new Set(neighbors.filter(dir => neighborPointsIntoCell(game, p, dir)));
if (meta.branchMode && meta.branchMode !== 'random' && neighbors.includes(meta.branchMode)) {
const selectedEntrances = new Set(entrances);
selectedEntrances.add(meta.branchMode);
if (neighbors.filter(dir => !selectedEntrances.has(dir)).length >= 2) entrances.add(meta.branchMode);
}
const exits = neighbors.filter(dir => !entrances.has(dir));
if (exits.length) return exits;
const fallback = dirs.filter(dir => !entrances.has(dir));
return fallback.length ? fallback : dirs;
}
function emptyFactoryGraph(game) { function emptyFactoryGraph(game) {
return { return {
version: game.routingVersion || 0, version: game.routingVersion || 0,
@ -191,8 +140,8 @@ export function buildFactoryGraph(game) {
for (const k of graph.cells) { for (const k of graph.cells) {
const p = graphCell(k); const p = graphCell(k);
const outDirs = inferredConveyorOutDirNames(game, k); const explicitDirs = conveyorOutDirNames(game, k);
const dirs = DIRS.filter(d => outDirs.includes(d.name)); const dirs = explicitDirs.length ? DIRS.filter(d => explicitDirs.includes(d.name)) : DIRS;
for (const d of dirs) { for (const d of dirs) {
const nk = graphNodeKey(p.col + d.dc, p.row + d.dr); const nk = graphNodeKey(p.col + d.dc, p.row + d.dr);
if (graph.cells.has(nk)) graph.adjacency.get(k).push({ key: nk, dir: d.name }); if (graph.cells.has(nk)) graph.adjacency.get(k).push({ key: nk, dir: d.name });
@ -242,10 +191,8 @@ function indexGraphPorts(game, graph) {
const ports = {}; const ports = {};
for (const type of ['inputA', 'left', 'right']) { for (const type of ['inputA', 'left', 'right']) {
const cell = scannerConnector(scanner, type); const cell = scannerConnector(scanner, type);
const cells = type === 'inputA' const cells = graphPortConveyorCells(game, cell, [ { col: scanner.col, row: scanner.row } ])
? (cell && graph.cells.has(graphNodeKey(cell.col, cell.row)) ? [{ ...cell }] : []) .filter(p => graph.cells.has(graphNodeKey(p.col, p.row)));
: graphPortConveyorCells(game, cell, scannerFootprintCells(scanner))
.filter(p => graph.cells.has(graphNodeKey(p.col, p.row)));
const cellKeys = cells.map(p => graphNodeKey(p.col, p.row)); const cellKeys = cells.map(p => graphNodeKey(p.col, p.row));
const connected = cellKeys.length > 0; const connected = cellKeys.length > 0;
ports[type] = { type, cell, cells, keys: cellKeys, key: cellKeys[0] || null, connected }; ports[type] = { type, cell, cells, keys: cellKeys, key: cellKeys[0] || null, connected };
@ -318,6 +265,13 @@ function dirBetween(a, b) {
return DIRS.find(d => d.dc === dc && d.dr === dr)?.name || null; return DIRS.find(d => d.dc === dc && d.dr === dr)?.name || null;
} }
function isCrossInGraph(graph, cellKey) {
const ns = graph.adjacency.get(cellKey) || [];
const names = new Set(ns.map(n => n.dir));
return ns.length === 4 && names.has('left') && names.has('right') && names.has('up') && names.has('down');
}
// A four-way cross conveyor is an overpass/crossing. Chicks never turn there.
export function bfsAllRoutes(game, start, isGoal) { export function bfsAllRoutes(game, start, isGoal) {
const graph = ensureFactoryGraph(game); const graph = ensureFactoryGraph(game);
const startK = graphNodeKey(start.col, start.row); const startK = graphNodeKey(start.col, start.row);
@ -348,6 +302,7 @@ export function bfsAllRoutes(game, start, isGoal) {
for (const n of graph.adjacency.get(cur.key) || []) { for (const n of graph.adjacency.get(cur.key) || []) {
const next = graphCell(n.key); const next = graphCell(n.key);
const outDir = dirBetween(cur, next); const outDir = dirBetween(cur, next);
if (cur.incoming !== 'none' && isCrossInGraph(graph, cur.key) && outDir !== cur.incoming) continue;
const nextDepth = cur.depth + 1; const nextDepth = cur.depth + 1;
if (nextDepth > bestGoalDepth) continue; if (nextDepth > bestGoalDepth) continue;
const nextStateK = `${n.key}|${outDir}`; const nextStateK = `${n.key}|${outDir}`;
@ -569,23 +524,20 @@ function routeToNextScanner(game, scanner, side, fromPoint, connector, starts, a
function routeToFacility(game, scanner, side, fromPoint, connector, starts, dest, advance) { function routeToFacility(game, scanner, side, fromPoint, connector, starts, dest, advance) {
const graph = ensureFactoryGraph(game); const graph = ensureFactoryGraph(game);
const ports = [...graph.facilityPorts.entries()] const port = graph.facilityPorts.get(dest);
.filter(([id, port]) => port?.connected && facilityKind(game.facilities?.[id]) === dest); if (!port?.connected) return null;
if (!ports.length) return null;
const candidates = []; const candidates = [];
for (const start of starts) { for (const start of starts) {
for (const [facilityId, port] of ports) { const goalKeys = (port.keys || [port.key]).filter(Boolean);
const goalKeys = (port.keys || [port.key]).filter(Boolean); if (!goalKeys.length) continue;
if (!goalKeys.length) continue; const isGoal = p => goalKeys.includes(graphNodeKey(p.col, p.row));
const isGoal = p => goalKeys.includes(graphNodeKey(p.col, p.row)); isGoal.cacheKey = `toFacility:${start.col},${start.row}:${dest}:v${graph.version}`;
isGoal.cacheKey = `toFacility:${start.col},${start.row}:${facilityId}:v${graph.version}`; const routes = bfsAllRoutes(game, start, isGoal);
const routes = bfsAllRoutes(game, start, isGoal); for (const cells of routes) candidates.push({ key: `start${key(start.col, start.row)}:${dest}:${cells.length}:${key(cells[cells.length - 1].col, cells[cells.length - 1].row)}`, cells });
for (const cells of routes) candidates.push({ key: `start${key(start.col, start.row)}:${facilityId}:${cells.length}:${key(cells[cells.length - 1].col, cells[cells.length - 1].row)}`, cells, facilityId });
}
} }
const chosen = chooseRoundRobin(game, `scanner:${scanner.id}:${side}:${dest}`, candidates, advance); const chosen = chooseRoundRobin(game, `scanner:${scanner.id}:${side}:${dest}`, candidates, advance);
if (!chosen) return null; if (!chosen) return null;
return { destination: dest, facilityId: chosen.facilityId, route: routeWithConnector(fromPoint, connector, chosen.cells, facilityEntryPoint(game, chosen.facilityId)) }; return { destination: dest, route: routeWithConnector(fromPoint, connector, chosen.cells, facilityEntryPoint(game, dest)) };
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@ -627,7 +579,8 @@ export function eggFarmHasValidRoute(game, farm) {
export function facilityHasValidRoute(game, dest) { export function facilityHasValidRoute(game, dest) {
const graph = ensureFactoryGraph(game); const graph = ensureFactoryGraph(game);
return [...graph.facilityPorts.entries()].some(([id, port]) => port?.connected && facilityKind(game.facilities?.[id]) === dest); if (!graph.facilityPorts.get(dest)?.connected) return false;
return game.scanners.some(scanner => scannerOutputReachesDestination(game, scanner, dest));
} }
export function disconnectedBuildWarnings(game) { export function disconnectedBuildWarnings(game) {
@ -637,10 +590,8 @@ export function disconnectedBuildWarnings(game) {
if (!eggFarmHasValidRoute(game, farm)) warnings.push({ type: 'eggFarm', id: farm.id, ref: farm, message: 'This Egg will not produce chicks' }); if (!eggFarmHasValidRoute(game, farm)) warnings.push({ type: 'eggFarm', id: farm.id, ref: farm, message: 'This Egg will not produce chicks' });
} }
for (const id of MACHINE_FACILITY_IDS) { for (const id of MACHINE_FACILITY_IDS) {
for (const [facilityId, facility] of facilityEntriesForKind(game, id)) { const facility = game.facilities?.[id];
const graph = ensureFactoryGraph(game); if (facility && !facilityHasValidRoute(game, id)) warnings.push({ type: 'facility', id, ref: facility, message: 'This facility cannot receive items' });
if (!graph.facilityPorts.get(facilityId)?.connected) warnings.push({ type: 'facility', id: facilityId, ref: facility, message: 'This facility cannot receive items' });
}
} }
return warnings; return warnings;
} }
@ -654,7 +605,7 @@ export function minimumStartConnectionIssues(game) {
if (outputRouteReachesFacility(game, scanner)) return []; if (outputRouteReachesFacility(game, scanner)) return [];
} }
const farmCount = game.eggFarms?.length || 0; const farmCount = game.eggFarms?.length || 0;
const exitCount = [...graph.facilityPorts.entries()].filter(([id, port]) => port?.connected && ['mixer', 'trash', 'truck'].includes(facilityKind(game.facilities?.[id]))).length; const exitCount = ['mixer', 'trash', 'truck'].filter(id => graph.facilityPorts.get(id)?.connected).length;
if (!farmCount) return ['Build at least one EGG before starting the next day.']; if (!farmCount) return ['Build at least one EGG before starting the next day.'];
if (!graph.metrics.farmOutputs) return ['Connect at least one EGG to a conveyor.']; if (!graph.metrics.farmOutputs) return ['Connect at least one EGG to a conveyor.'];
if (!game.scanners?.length) return ['Build at least one scanner and connect it to an EGG route.']; if (!game.scanners?.length) return ['Build at least one scanner and connect it to an EGG route.'];
@ -673,14 +624,12 @@ export function validateFactoryGraph(game, graph = ensureFactoryGraph(game), opt
const labels = { mixer: 'Mixer', trash: 'Shredder', truck: 'Truck' }; const labels = { mixer: 'Mixer', trash: 'Shredder', truck: 'Truck' };
for (const id of MACHINE_FACILITY_IDS) { for (const id of MACHINE_FACILITY_IDS) {
const facilities = facilityEntriesForKind(game, id); const f = game.facilities[id];
if (!facilities.length) { if (!f) {
issues.push(`${labels[id] || id} is missing`); issues.push(`${labels[id] || id} is missing`);
continue; continue;
} }
for (const [facilityId] of facilities) { if (!graph.facilityPorts.get(id)?.connected) issues.push(`${labels[id] || id} receiver has no conveyor`);
if (!graph.facilityPorts.get(facilityId)?.connected) issues.push(`${labels[id] || id} receiver has no conveyor`);
}
} }
for (const scanner of game.scanners) { for (const scanner of game.scanners) {

View file

@ -1,7 +1,7 @@
import { GRID } from '../core/config.js'; import { GRID } from '../core/config.js';
import { key, parseKey, cellCenter } from '../core/utils.js'; import { key, parseKey, cellCenter } from '../core/utils.js';
import { nearestGridEdge, layoutFacilityOnEdge } from '../core/entities.js'; import { nearestGridEdge, layoutFacilityOnEdge } from '../core/entities.js';
import { farmAt, scannerAt, scannerCenter, scannerFootprintCells, refreshRoutingAfterEdit } from './routing.js'; import { farmAt, scannerAt, scannerCenter, refreshRoutingAfterEdit } from './routing.js';
import { snapshot } from './history.js'; import { snapshot } from './history.js';
import { buildPrice } from './economy.js'; import { buildPrice } from './economy.js';
@ -192,15 +192,12 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
for (const origin of game.groupDrag.origins) { for (const origin of game.groupDrag.origins) {
if (origin.type === 'facility') continue; if (origin.type === 'facility') continue;
const col = origin.col + dcol, row = origin.row + drow; const col = origin.col + dcol, row = origin.row + drow;
const cells = origin.type === 'scanner' ? scannerFootprintCells({ col, row }) : [{ col, row }]; if (!pointInGrid(col, row)) return fail('Selection outside grid');
for (const cell of cells) { if (game.blockedCells?.has?.(key(col, row))) return fail('Selection hits blocked ground');
if (!pointInGrid(cell.col, cell.row)) return fail('Selection outside grid'); const tk = key(col, row);
if (game.blockedCells?.has?.(key(cell.col, cell.row))) return fail('Selection hits blocked ground'); if (targetCells.has(tk)) return fail('Selection overlap');
const tk = key(cell.col, cell.row); if (cellOccupiedByNonSelected(col, row, selectedTokens)) return fail('Cell occupied');
if (targetCells.has(tk)) return fail('Selection overlap'); targetCells.add(tk);
if (cellOccupiedByNonSelected(cell.col, cell.row, selectedTokens)) return fail('Cell occupied');
targetCells.add(tk);
}
} }
if (facilityDragWouldOverlap(dx, dy, selectedTokens)) return fail('Facility overlap'); if (facilityDragWouldOverlap(dx, dy, selectedTokens)) return fail('Facility overlap');
commitGroupDragHistory(); commitGroupDragHistory();

View file

@ -18,9 +18,11 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
if (game.phase !== 'build' || game.cardDraft?.pending || game.cardTargetPick?.pending) return false; if (game.phase !== 'build' || game.cardDraft?.pending || game.cardTargetPick?.pending) return false;
let hasBuildableOption = false; let hasBuildableOption = false;
for (const id of BUILD_TOOL_IDS) { for (const id of BUILD_TOOL_IDS) {
if (MACHINE_FACILITY_IDS.includes(id) && !!game.facilities[id]) continue;
hasBuildableOption = true; hasBuildableOption = true;
if (game.cash >= buildPrice(id, game)) return false; if (game.cash >= buildPrice(id, game)) return false;
} }
if (build?.gridExpansionCost && game.cash >= build.gridExpansionCost()) return false;
return hasBuildableOption; return hasBuildableOption;
} }
@ -42,20 +44,32 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
if (!btn) continue; if (!btn) continue;
const price = buildPrice(id, game); const price = buildPrice(id, game);
const priceSpan = btn.querySelector('span'); const priceSpan = btn.querySelector('span');
if (priceSpan) priceSpan.textContent = (id === 'conveyor' || id === 'boostConveyor') ? `${yen(price)} / tile` : yen(price); if (priceSpan) priceSpan.textContent = id === 'conveyor' ? `${yen(price)} / tile` : yen(price);
const uniqueAlreadyBuilt = MACHINE_FACILITY_IDS.includes(id) && !!game.facilities[id];
const manualLimitReached = id === 'manualScanner' && game.scanners.filter(s => s.kind === 'manual').length >= 8; const manualLimitReached = id === 'manualScanner' && game.scanners.filter(s => s.kind === 'manual').length >= 8;
const unaffordable = game.cash < price; const unaffordable = game.cash < price;
btn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || unaffordable || manualLimitReached; btn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || unaffordable || uniqueAlreadyBuilt || manualLimitReached;
if (btn.disabled && game.buildTool === id) game.buildTool = null; if (btn.disabled && game.buildTool === id) game.buildTool = null;
btn.classList.toggle('unaffordable', unaffordable); btn.classList.toggle('unaffordable', unaffordable);
btn.classList.toggle('already-built', manualLimitReached); btn.classList.toggle('already-built', uniqueAlreadyBuilt || manualLimitReached);
btn.title = unaffordable btn.title = unaffordable
? `Need ${yen(price - game.cash)} more` ? `Need ${yen(price - game.cash)} more`
: manualLimitReached : manualLimitReached
? 'Manual Scanner limit reached (8 max)' ? 'Manual Scanner limit reached (8 max)'
: uniqueAlreadyBuilt
? `${FACILITY_DEFS[id]?.name || id} already exists`
: ''; : '';
} }
if (ui.buttons.erase) ui.buttons.erase.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending; if (ui.buttons.erase) ui.buttons.erase.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending;
if (ui.expandGridButton && build?.gridExpansionCost) {
const cost = build.gridExpansionCost();
const priceSpan = ui.expandGridButton.querySelector('span');
if (priceSpan) priceSpan.textContent = `${yen(cost)} / +10×10`;
const blocked = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || game.cash < cost;
ui.expandGridButton.disabled = blocked;
ui.expandGridButton.classList.toggle('unaffordable', game.cash < cost);
ui.expandGridButton.title = game.cash < cost ? `Need ${yen(cost - game.cash)} more` : `Add the next right 10×10 lot, or click an upper/right lot button on the canvas. Next purchase count: ${(game.gridExpansionPurchases || 0) + 1}`;
}
if (ui.hireRepairmanButton) { if (ui.hireRepairmanButton) {
const hired = !!game.repairman?.hiredForNextDay; const hired = !!game.repairman?.hiredForNextDay;
const cost = repairmanDailyCost(game); const cost = repairmanDailyCost(game);
@ -96,15 +110,15 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
const lastTribute = game.lastBuildFees?.turn === tributeDay ? game.lastBuildFees.fairiesTribute || 0 : null; const lastTribute = game.lastBuildFees?.turn === tributeDay ? game.lastBuildFees.fairiesTribute || 0 : null;
const lastTributeReduction = game.lastBuildFees?.turn === tributeDay ? game.lastBuildFees.reduction || 0 : 0; const lastTributeReduction = game.lastBuildFees?.turn === tributeDay ? game.lastBuildFees.reduction || 0 : 0;
ui.turnSummary.innerHTML = [ ui.turnSummary.innerHTML = [
`Seed: <b>${escapeHtml(game.runSeed || 'none')}</b> | Grid box: <b>${game.gridRows || 10}x${game.gridCols || 20}</b> | Owned: <b>${game.gridOwnedCells || game.ownedCells?.size || ((game.gridRows || 10) * (game.gridCols || 20))}</b> cells | Blocked: <b>${game.blockedCells?.size || 0}</b> cells`, `Seed: <b>${escapeHtml(game.runSeed || 'none')}</b> | Grid box: <b>${game.gridRows || 10}×${game.gridCols || 20}</b> | Owned: <b>${game.gridOwnedCells || game.ownedCells?.size || ((game.gridRows || 10) * (game.gridCols || 20))}</b> cells | Blocked: <b>${game.blockedCells?.size || 0}</b> cells`,
`Truck target: <b>${truckTarget(game).toUpperCase()}</b> | Male fine: <b>-${yen(maleTruckPenalty(game))}</b>`, `Truck target: <b>${truckTarget(game).toUpperCase()}</b> | Male fine: <b>-${yen(maleTruckPenalty(game))}</b>`,
`Poop fine: <b>Mixer -${yen(mixerPoopPenalty(game))}</b> / <b>Shipment -${yen(truckPoopPenalty(game))}</b>`, `Poop fine: <b>Mixer -${yen(mixerPoopPenalty(game))}</b> / <b>Shipment -${yen(truckPoopPenalty(game))}</b>`,
`ZUNDA TAX on Next Day: <b>-${yen(tax.tax)}</b> | basis net ${yen(zundaBasisProfit)} | taxable ${yen(tax.taxable)} x${tax.ratePercent}% | exemption ${yen(tax.exemption)} | 95% at ${yen(tax.maxRateCash)}`, `ZUNDA TAX on Next Day: <b>-${yen(tax.tax)}</b> | basis net ${yen(zundaBasisProfit)} | taxable ${yen(tax.taxable)} × ${tax.ratePercent}% | exemption ${yen(tax.exemption)} | 95% at ${yen(tax.maxRateCash)}`,
lastTribute != null lastTribute != null
? `Fairies tribute paid before Build: <b>-${yen(lastTribute)}</b>${lastTributeReduction ? ` | Flattery -${yen(lastTributeReduction)}` : ''}` ? `Fairies tribute paid before Build: <b>-${yen(lastTribute)}</b>${lastTributeReduction ? ` | Flattery -${yen(lastTributeReduction)}` : ''}`
: `Fairies tribute before Build: <b>-${yen(tribute.amount)}</b>${tribute.reduction ? ` | Flattery -${yen(tribute.reduction)}` : ''}`, : `Fairies tribute before Build: <b>-${yen(tribute.amount)}</b>${tribute.reduction ? ` | Flattery -${yen(tribute.reduction)}` : ''}`,
`Maintenance: <b>worst ${maintenanceSummary(game).worst.percent}% dirty (${maintenanceSummary(game).worst.label})</b> | Repairman: <b>${game.repairman?.hiredForNextDay ? 'hired next day' : game.repairman?.active ? 'working' : 'none'}</b>`, `Maintenance: <b>worst ${maintenanceSummary(game).worst.percent}% dirty (${maintenanceSummary(game).worst.label})</b> | Repairman: <b>${game.repairman?.hiredForNextDay ? 'hired next day' : game.repairman?.active ? 'working' : 'none'}</b>`,
`Manual combo: <b>${game.manualCombo?.count || 0}</b> | Card draft: <b>${game.cardDraft?.pending ? Math.max(1, game.cardDraft.picksRemaining || 1) + ' pick(s) left' : 'ready after each day'}</b> | Belt: <b>${displaySpeed()}px/s</b> | Farms with scanner route: <b>${connected}/${game.eggFarms.length}</b>`, `Manual combo: <b>${game.manualCombo?.count || 0}</b> | Next expansion: <b>${yen(build?.gridExpansionCost ? build.gridExpansionCost() : 0)}</b> | Card draft: <b>${game.cardDraft?.pending ? Math.max(1, game.cardDraft.picksRemaining || 1) + ' pick(s) left' : 'ready after each day'}</b> | Belt: <b>${displaySpeed()}px/s</b> | Farms with scanner route: <b>${connected}/${game.eggFarms.length}</b>`,
game.cardEffects?.usedMachineActive ? '<span class="cash-negative">Procurement: USED MACHINE MODE / no refunds / 60% durability</span>' : '', game.cardEffects?.usedMachineActive ? '<span class="cash-negative">Procurement: USED MACHINE MODE / no refunds / 60% durability</span>' : '',
issues.length ? `<span class="cash-negative">Blocked: ${issues[0]}</span>` : `<span class="cash-positive">${TEXT.status.allPortsConnected}</span>` issues.length ? `<span class="cash-negative">Blocked: ${issues[0]}</span>` : `<span class="cash-positive">${TEXT.status.allPortsConnected}</span>`
].join('<br>'); ].join('<br>');
@ -151,7 +165,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
ui.timeLeft.textContent = game.phase === 'running' && game.timeLeft <= 0 && game.shutdownTimeLeft > 0 ui.timeLeft.textContent = game.phase === 'running' && game.timeLeft <= 0 && game.shutdownTimeLeft > 0
? `+${game.shutdownTimeLeft.toFixed(1)}s` ? `+${game.shutdownTimeLeft.toFixed(1)}s`
: `${Math.max(0, game.timeLeft).toFixed(1)}s`; : `${Math.max(0, game.timeLeft).toFixed(1)}s`;
ui.timeLeft.closest?.('.hud-card')?.classList.toggle('time-critical', game.phase === 'running' && (game.timeLeft <= 10 || (game.timeLeft <= 0 && game.shutdownTimeLeft > 0))); ui.timeLeft.closest?.('.hud-card')?.classList.toggle('time-critical', game.phase === 'running' && (game.timeLeft <= 10 || (game.timeLeft <= 0 && game.shutdownTimeLeft > 0)));
ui.phase.textContent = phaseLabel(); ui.phase.textContent = phaseLabel();
if (ui.comboCount) ui.comboCount.textContent = game.manualCombo?.count || 0; if (ui.comboCount) ui.comboCount.textContent = game.manualCombo?.count || 0;
updatePriorityStrip(); updatePriorityStrip();
@ -233,8 +247,6 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
<div><strong>Chick shipment income</strong>${positive(r.chickShipmentIncome || 0)}</div> <div><strong>Chick shipment income</strong>${positive(r.chickShipmentIncome || 0)}</div>
<div><strong>Poop shipment income</strong>${positive(r.poopShipmentIncome || 0)}</div> <div><strong>Poop shipment income</strong>${positive(r.poopShipmentIncome || 0)}</div>
<div><strong>Mixer income</strong>${positive(r.mixerRevenue || 0)}</div> <div><strong>Mixer income</strong>${positive(r.mixerRevenue || 0)}</div>
<div><strong>Composter income</strong>${positive(r.composterIncome || 0)}</div>
<div><strong>DUD refund income</strong>${positive(r.dudRefundIncome || 0)}</div>
<div><strong>Manual combo bonus</strong>${positive(r.manualComboBonus || 0)}</div> <div><strong>Manual combo bonus</strong>${positive(r.manualComboBonus || 0)}</div>
<div><strong>Chemical subsidy</strong>${positive(r.chemicalWeaponSubsidy || 0)}</div> <div><strong>Chemical subsidy</strong>${positive(r.chemicalWeaponSubsidy || 0)}</div>
<div><strong>Contract bonus</strong>${positive(r.contractBonus || 0)}</div> <div><strong>Contract bonus</strong>${positive(r.contractBonus || 0)}</div>
@ -277,7 +289,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
ui.modalBody.innerHTML = `<p class="gameover-reason">Your cash went negative.</p> ui.modalBody.innerHTML = `<p class="gameover-reason">Your cash went negative.</p>
<div class="result-grid gameover-summary"><div><strong>Final Score</strong><span>${fs.score}</span></div><div><strong>Survival</strong><span>${game.totals.turnsCompleted} days</span></div><div><strong>Final Cash</strong><span>${yen(game.cash)}</span></div><div><strong>Accuracy</strong><span>${accuracy}%</span></div></div> <div class="result-grid gameover-summary"><div><strong>Final Score</strong><span>${fs.score}</span></div><div><strong>Survival</strong><span>${game.totals.turnsCompleted} days</span></div><div><strong>Final Cash</strong><span>${yen(game.cash)}</span></div><div><strong>Accuracy</strong><span>${accuracy}%</span></div></div>
<details class="gameover-details"><summary>Score breakdown</summary> <details class="gameover-details"><summary>Score breakdown</summary>
<div class="formula-box compact"><strong>Formula</strong><br>Base = Cash + Total revenue + Factory value 繝サ繝サ繝サ0.5 + Contract bonus 繝サ繝サ繝サ0.5 = ${yen(fs.base)}<br>Daily earned = ceil(Base 繝サ繝サ・ス・キ Days) = ${yen(fs.dailyEarned)}<br>Score = max(0, floor(Base + Daily earned + Correct繝サ繝サ繝サ - Total outflow - Day penalty)) = ${fs.score}</div> <div class="formula-box compact"><strong>Formula</strong><br>Base = Cash + Total revenue + Factory value × 0.5 + Contract bonus × 0.5 = ${yen(fs.base)}<br>Daily earned = ceil(Base ÷ Days) = ${yen(fs.dailyEarned)}<br>Score = max(0, floor(Base + Daily earned + Correct×5 - Total outflow - Day penalty)) = ${fs.score}</div>
<div class="result-grid gameover-breakdown"><div><strong>Factory Value</strong><span>${yen(fs.factoryValue)}</span></div><div><strong>Total Revenue</strong><span>${yen(game.totals.revenue)}</span></div><div><strong>Daily Earned</strong><span>${yen(fs.dailyEarned)} / day</span></div><div><strong>Total Outflow</strong><span>${yen(game.totals.penalty)}</span></div><div><strong>ZUNDA TAX</strong><span>${yen(game.totals.zundaTax)}</span></div><div><strong>Fairies Tribute</strong><span>${yen(game.totals.fairiesTribute || 0)}</span></div><div><strong>Correct Sorts</strong><span>${game.totals.correct}</span></div><div><strong>Auto Sorted</strong><span>${game.totals.autoSorted}</span></div><div><strong>Poop Control</strong><span>${game.totals.poopTrash} trashed / ${game.totals.poopTruck} shipped / ${game.totals.poopMixer} mixer</span></div><div><strong>Explosion Penalty</strong><span>${yen(game.totals.explosionDamage)}</span></div><div><strong>Contract Bonus</strong><span>${yen(game.totals.contractBonus)}</span></div><div><strong>Day Penalty</strong><span>-${fs.dayPenalty}</span></div><div><strong>Contracts</strong><span>${game.totals.contractSuccess} success / ${game.totals.contractFailed} failed</span></div></div> <div class="result-grid gameover-breakdown"><div><strong>Factory Value</strong><span>${yen(fs.factoryValue)}</span></div><div><strong>Total Revenue</strong><span>${yen(game.totals.revenue)}</span></div><div><strong>Daily Earned</strong><span>${yen(fs.dailyEarned)} / day</span></div><div><strong>Total Outflow</strong><span>${yen(game.totals.penalty)}</span></div><div><strong>ZUNDA TAX</strong><span>${yen(game.totals.zundaTax)}</span></div><div><strong>Fairies Tribute</strong><span>${yen(game.totals.fairiesTribute || 0)}</span></div><div><strong>Correct Sorts</strong><span>${game.totals.correct}</span></div><div><strong>Auto Sorted</strong><span>${game.totals.autoSorted}</span></div><div><strong>Poop Control</strong><span>${game.totals.poopTrash} trashed / ${game.totals.poopTruck} shipped / ${game.totals.poopMixer} mixer</span></div><div><strong>Explosion Penalty</strong><span>${yen(game.totals.explosionDamage)}</span></div><div><strong>Contract Bonus</strong><span>${yen(game.totals.contractBonus)}</span></div><div><strong>Day Penalty</strong><span>-${fs.dayPenalty}</span></div><div><strong>Contracts</strong><span>${game.totals.contractSuccess} success / ${game.totals.contractFailed} failed</span></div></div>
</details>`; </details>`;
ui.modalActions.innerHTML = ''; ui.modalActions.innerHTML = '';

View file

@ -588,52 +588,6 @@ body { font-size: 20px; }
.modal.equipment-popover .modal-actions button { font-size: 11px; } .modal.equipment-popover .modal-actions button { font-size: 11px; }
.equipment-menu-lines.compact, .formula-box.compact { font-size: 11px; } .equipment-menu-lines.compact, .formula-box.compact { font-size: 11px; }
.hover-tooltip strong { font-size: 18px; } .hover-tooltip strong { font-size: 18px; }
/* Keep construction flavor text readable as a hover bubble, not button content. */
.large-tools .tool-button {
min-height: 62px;
position: relative;
overflow: visible;
}
.large-tools .tool-button:hover {
min-height: 62px;
}
.large-tools .tool-button .tool-flavor {
display: none;
position: absolute;
z-index: 90;
right: 0;
top: calc(100% + 8px);
width: min(320px, calc(100vw - 40px));
max-height: none;
opacity: 1;
overflow: visible;
margin: 0;
padding: 10px 12px;
border: 3px solid var(--line);
background: rgba(255,255,255,.98);
color: var(--ink);
box-shadow: 5px 5px 0 rgba(16,32,21,.18);
font-size: 14px;
line-height: 1.35;
pointer-events: none;
overflow-wrap: anywhere;
}
.large-tools .tool-button:hover .tool-flavor {
display: block;
}
.large-tools .tool-button .tool-flavor::before {
content: '';
position: absolute;
right: 18px;
top: -10px;
width: 16px;
height: 16px;
border-left: 3px solid var(--line);
border-top: 3px solid var(--line);
background: rgba(255,255,255,.98);
transform: rotate(45deg);
}
.hover-tooltip span { font-size: 15px; } .hover-tooltip span { font-size: 15px; }
.sort-button { font-size: 17px; } .sort-button { font-size: 17px; }