This commit is contained in:
33333-33333 2026-06-11 14:34:37 +09:00
commit 368de90c6c
68 changed files with 5418 additions and 401 deletions

View file

@ -76,3 +76,39 @@ Implemented events:
- Fixed duplicate Emergency Loan button in the bankruptcy decision modal. - Fixed duplicate Emergency Loan button in the bankruptcy decision modal.
- Fixed stale debug-removal CSS fragments left from the previous audit pass. - Fixed stale debug-removal CSS fragments left from the previous audit pass.
- Fixed corrupted score-breakdown formula text. - Fixed corrupted score-breakdown formula text.
## v27.15 dark factory visual patch
- Wired `assets/dark_factory/facilities/` sprites into canvas rendering.
- Added pixelated rendering for facility sprites and dark factory grid/floor colors.
- Changed CSS theme from green/white panels to dark industrial panels with amber/red/blue accents.
- Moved UI emphasis: status HUD across the top, event/status strip down the left, build/equipment panel on the right, sorting controls at the bottom.
- Kept user-supplied chick/poop slots unmodified. Chick art is not auto-requested; existing poop compatibility path remains.
- Debug UI remains intentionally present for balancing builds.
## v27.17 verified visual patch
- Versioned CSS and JavaScript launch filenames prevent the old visual bundle from being reused from cache.
- Removed all player-facing remnants of wage-based Repairman hiring.
- Maintenance Room remains a 2×2 buildable object and dispatches one Repairman per room.
- Conveyor slats now animate perpendicular to travel, with blue-green rubber on Boost Conveyors.
- DEBUG collapsed width reduced to 58 px.
- Facility sprite scale aligned to grid-object pixel density.
- Scanner ducts, blocked-cell coverage, chick sex colors/symbols, metal/grime decoration, and card texture icons verified in browser.
## v27.18 UI and routing patch
- Moved event, belt speed, combo, and truck target indicators to a non-overlapping lower-left group.
- Reworked T-junction branch mode as explicit output selection: random or a valid fixed exit.
- Lightened floor tiles, added deterministic green-fluid stains, increased conveyor arrow contrast, and centered blocked-cell X marks.
- Converted card icons into translucent background watermarks and normalized modal button styling.
- Replaced Maintenance Room art with storage/break-room pixel art.
## v27.19 land/UI/dynamite patch
- Starting blocked ratio: 3%.
- Purchased-lot blocked ratio: base 3% + 0.5 percentage points per purchase, max 10%.
- Dynamite target selection is map-driven and consumes the card correctly after any blast.
- Duplicate event/status information removed.

20
PATCH_VERIFICATION.md Normal file
View file

@ -0,0 +1,20 @@
# v27.19 verification
## Automated checks
- All source JavaScript files pass `node --check`.
- Production bundle `dist/game-v27.19.js` builds successfully.
- Initial blocked-cell generation: 6 / 200 cells = 3%.
- Purchased-lot ratios: purchase 1 = 3.5%, purchase 2 = 4.0%, purchase 14+ = 10% cap.
- A 10x10 lot at the cap generates 10 blocked cells.
- Dynamite empty-space click leaves target mode active.
- Clicking a chosen blocked cell removes that exact cell and consumes one charge.
- Escape after at least one blast finishes and consumes the card, preventing reuse.
- Browser check: no console/page errors, duplicate right-side event panel absent, Status contains no event/belt/combo/truck-target duplicates.
- Browser check: NEXT DAY Fairies fee remains inside the button.
## Visual checks
- Green floor residue is sparse and splattered.
- Blocked cells use subdued black/yellow hazard striping.
- Initial Status reports 6 blocked cells.

View file

@ -1,46 +1,25 @@
# Chick Sorter v27.14 Balancing Build # Chick Sorter v27.19 — Land and UI Patch
Open `index.html` in a browser. Serve this folder with a static HTTP server and open `index.html`.
Production uses the committed `dist/game.js` bundle. Edit source files under The launch page uses cache-busted files:
`src/`, then run `npm install` once and `npm run build` after JavaScript
changes to regenerate the bundle.
## Static server cache setup - `styles-v27.19.css?build=2719`
- `dist/game-v27.19.js?build=2719`
Serve `index.html`, the bundled JavaScript, and CSS with cache validation A `v27.19 VERIFIED` badge appears near the upper-right when the current build is loaded.
instead of import query strings. This prevents stale files while keeping reloads
fast.
Use the example in `deploy/` for your HTTP server: ## Build
- nginx: include `deploy/nginx-cache.conf` inside the server block. Run `npm install` once, then `npm run build` after editing JavaScript or `styles.css`.
- 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. ## v27.19 changes
## Current baseline - Initial no-build tiles are 3% of the starting 20x10 land.
- Each purchased 10x10 lot increases its own no-build ratio by 0.5 percentage points, capped at 10%.
- Build, move, box-select, sell, and repair factory equipment during Build phase. - Floor slime is sparse, deterministic splatter instead of frequent puddles.
- Start the next day only when at least one EGG route reaches a scanner and then any valid exit. - No-build tiles use subdued black/yellow hazard striping.
- Fairies tribute is paid when pressing Next Day, using the previous day's net profit as part of the calculation. - The duplicate right-side event panel was removed.
- Bankruptcy is checked at day-end settlement after shipment/event/subsidy revenue. Temporary negative cash during production does not immediately end the game. - The Status panel no longer repeats event, belt speed, combo, or truck target data.
- Cards use English names and descriptions with numeric effect summaries. - NEXT DAY displays the Fairies fee on a contained second line.
- `poop.png` is the only bundled image asset currently used by default; other art falls back to canvas drawing. - Dynamite lets the player select any highlighted no-build tiles, up to three. Empty clicks do not cancel selection.
## Controls
- S1 manual scanner: `A` = left / `D` = right.
- S2 manual scanner: `←` = left / `→` = right.
- Build phase: left-drag empty map for box selection; drag selected equipment to move it.
- Right-drag: pan.
- `Ctrl+Z` / `Ctrl+Y`: undo / redo.
- `Esc`: cancel upgrade-card target selection.
## Cleanup in this build
- Removed duplicate `src/index.html` and `src/styles.css`; root `index.html` and `styles.css` are the single launch surface.
- Debug panel is intentionally present for balancing builds. Do not remove it during cleanup unless explicitly requested. Its card menu is grouped and target-consuming cards can be applied to selected targets.
- Removed the obsolete right-side selected-facility panel code left behind after the UI removal.
- Removed stale historical README files and old changelog text that no longer matched the current specification.

View file

@ -0,0 +1,19 @@
# Assets organization (prepared, not yet wired into the game)
This patch adds a **dark factory** art set without changing runtime references yet.
## Folder guide
- `assets/dark_factory/facilities/` : new factory equipment sprites
- `assets/dark_factory/previews/` : visual overview sheet
- `assets/ui/` : future UI asset buckets (buttons / panels / icons / debug)
- `assets/chicks/` : reserved for user-supplied chick sprites
- `assets/poop/` : reserved for user-supplied poop sprites
- `assets/_legacy/` : copied backup of older loose files
- `assets/images/` : left untouched for compatibility with the current build
## Notes
- Grid base unit: **16x16**
- Non-grid UI object base unit: **32x32**
- Color count intentionally limited for a coarse pixel-art look
- Sprites are supplied as individual PNG files with transparency
- This patch **does not yet hook the new art into the live game**

View file

@ -0,0 +1,3 @@
# Image assets
`poop.png` is bundled and loaded by default. Other equipment/chick art is optional and currently falls back to canvas drawing unless external art loading is enabled in `src/render/draw.js`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

1
assets/chicks/README.md Normal file
View file

@ -0,0 +1 @@
Reserved for user-supplied chick sprites. Recommended base: 16x16 world sprites / 32x32 UI icons.\n

View file

@ -0,0 +1 @@
Dark factory facility sprites. See ../manifest.json for file roles and dimensions.\n

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 850 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 557 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 843 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 B

View file

@ -0,0 +1,103 @@
{
"theme": "dark_factory",
"grid_unit_px": 16,
"ui_unit_px": 32,
"palette_note": "restricted palette / coarse pixel art",
"facilities": [
{
"id": "conveyor",
"files": [
"conveyor_straight_h_16.png",
"conveyor_straight_v_16.png",
"conveyor_turn_ne_16.png",
"conveyor_turn_nw_16.png",
"conveyor_turn_se_16.png",
"conveyor_turn_sw_16.png"
],
"size": "16x16",
"folder": "facilities/conveyor"
},
{
"id": "boost_conveyor",
"files": [
"boost_conveyor_straight_h_16.png",
"boost_conveyor_straight_v_16.png",
"boost_conveyor_turn_ne_16.png",
"boost_conveyor_turn_nw_16.png",
"boost_conveyor_turn_se_16.png",
"boost_conveyor_turn_sw_16.png"
],
"size": "16x16",
"folder": "facilities/conveyor"
},
{
"id": "egg_farm",
"files": [
"egg_farm_16.png"
],
"size": "16x16",
"folder": "facilities/machines"
},
{
"id": "manual_scanner",
"files": [
"manual_scanner_32x48.png"
],
"size": "32x48",
"footprint": "2x3 tiles",
"folder": "facilities/machines"
},
{
"id": "auto_scanner",
"files": [
"auto_scanner_32x48.png"
],
"size": "32x48",
"footprint": "2x3 tiles",
"folder": "facilities/machines"
},
{
"id": "mixer",
"files": [
"mixer_32.png",
"mixer_compact_16.png"
],
"size": "32x32 + 16x16 compact",
"folder": "facilities/machines"
},
{
"id": "shredder",
"files": [
"shredder_32.png",
"shredder_compact_16.png"
],
"size": "32x32 + 16x16 compact",
"folder": "facilities/machines"
},
{
"id": "truck",
"files": [
"truck_32.png",
"truck_compact_16.png"
],
"size": "32x32 + 16x16 compact",
"folder": "facilities/vehicles"
},
{
"id": "repairman",
"files": [
"repairman_16.png"
],
"size": "16x16",
"folder": "facilities/utility"
},
{
"id": "obstacle_crate",
"files": [
"obstacle_crate_16.png"
],
"size": "16x16",
"folder": "facilities/utility"
}
]
}

View file

@ -0,0 +1 @@
Preview sheet(s) for quick inspection. These are reference only, not runtime assets.\n

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 378 B

Before After
Before After

1
assets/poop/README.md Normal file
View file

@ -0,0 +1 @@
Reserved for user-supplied poop sprites. Recommended base: 16x16 world sprites / 32x32 UI icons.\n

BIN
assets/poop/poop.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

1
assets/ui/README.md Normal file
View file

@ -0,0 +1 @@
Future UI asset buckets. Nothing is wired yet. Use 32x32 as the base size for non-grid UI objects.\n

59
dist/game-v27.17.js vendored Normal file

File diff suppressed because one or more lines are too long

59
dist/game-v27.18.js vendored Normal file

File diff suppressed because one or more lines are too long

51
dist/game-v27.19.js vendored Normal file

File diff suppressed because one or more lines are too long

74
dist/game.js vendored

File diff suppressed because one or more lines are too long

View file

@ -3,12 +3,16 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Chick Sorter v27.14</title> <meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate" />
<link rel="stylesheet" href="./styles.css" /> <meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<title>Chick Sorter v27.19 — Land and UI Patch</title>
<link rel="stylesheet" href="./styles-v27.19.css?build=2719" />
</head> </head>
<body> <body>
<main id="app"> <main id="app">
<section id="gameShell" class="game-shell" aria-label="Game screen"> <section id="gameShell" class="game-shell" data-build="v27.19" aria-label="Game screen">
<div class="build-stamp" aria-label="loaded build">v27.19 VERIFIED</div>
<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">
@ -17,7 +21,6 @@
<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> </div>
<div class="hud hud-top-right" aria-label="Day result details"> <div class="hud hud-top-right" aria-label="Day result details">
@ -27,16 +30,17 @@
<div class="hud-card"><span>POOP</span><strong id="poopCount">0</strong></div> <div class="hud-card"><span>POOP</span><strong id="poopCount">0</strong></div>
</div> </div>
<div class="priority-strip" aria-label="Important day info"> <div class="left-bottom-status" aria-label="Important day info">
<div id="eventBrief" class="event-brief">NO ACTIVE EVENT</div> <div id="eventBrief" class="event-brief status-chip">NO ACTIVE EVENT</div>
<div id="targetBrief" class="target-brief">TRUCK TARGET: FEMALE</div> <div id="speedBrief" class="speed-brief status-chip">BELT: 52 px/s</div>
<div id="speedBrief" class="speed-brief">BELT: 52 px/s</div> <div class="combo-brief status-chip"><span>COMBO</span><strong id="comboCount">0</strong></div>
<div id="targetBrief" class="target-brief status-chip">TRUCK TARGET: FEMALE</div>
</div> </div>
<div id="buildPanel" class="build-panel" aria-label="Build and facility upgrades"> <div id="buildPanel" class="build-panel" aria-label="Build and facility upgrades">
<div class="panel-head"> <div class="panel-head">
<div> <div>
<h1>CHICK SORTER <span>v27.14</span></h1> <h1>CHICK SORTER <span>v27.19</span></h1>
<p>Build, expand, connect at least one valid EGG route, then start the next day.</p> <p>Build, expand, connect at least one valid EGG route, then start the next day.</p>
</div> </div>
<button id="nextTurnButton" class="primary-button" type="button">Next Day</button> <button id="nextTurnButton" class="primary-button" type="button">Next Day</button>
@ -55,7 +59,7 @@
<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="buildMaintenanceRoomButton" class="tool-button repair" type="button"><strong>Maintenance Room</strong><span>¥220 / 2×2</span></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>
@ -64,10 +68,6 @@
<h2>Status</h2> <h2>Status</h2>
<div id="turnSummary" class="mini-box"></div> <div id="turnSummary" class="mini-box"></div>
</section> </section>
<section class="rules-section compact-rules">
<h2>Next Event</h2>
<div id="eventPanel" class="event-card empty"></div>
</section>
</div> </div>
</div> </div>
@ -110,6 +110,6 @@
</div> </div>
</div> </div>
<script type="module" src="./dist/game.js"></script> <script type="module" src="./dist/game-v27.19.js?build=2719"></script>
</body> </body>
</html> </html>

58
package-lock.json generated
View file

@ -1,19 +1,19 @@
{ {
"name": "zunda-shiwake", "name": "zunda-shiwake",
"version": "27.13.0", "version": "27.19.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "zunda-shiwake", "name": "zunda-shiwake",
"version": "27.13.0", "version": "27.19.0",
"devDependencies": { "devDependencies": {
"esbuild": "^0.25.0" "esbuild": "^0.25.0"
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12", "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", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
@ -30,7 +30,7 @@
}, },
"node_modules/@esbuild/android-arm": { "node_modules/@esbuild/android-arm": {
"version": "0.25.12", "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", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [ "cpu": [
"arm" "arm"
@ -47,7 +47,7 @@
}, },
"node_modules/@esbuild/android-arm64": { "node_modules/@esbuild/android-arm64": {
"version": "0.25.12", "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", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [ "cpu": [
"arm64" "arm64"
@ -64,7 +64,7 @@
}, },
"node_modules/@esbuild/android-x64": { "node_modules/@esbuild/android-x64": {
"version": "0.25.12", "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", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [ "cpu": [
"x64" "x64"
@ -81,7 +81,7 @@
}, },
"node_modules/@esbuild/darwin-arm64": { "node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12", "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", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [ "cpu": [
"arm64" "arm64"
@ -98,7 +98,7 @@
}, },
"node_modules/@esbuild/darwin-x64": { "node_modules/@esbuild/darwin-x64": {
"version": "0.25.12", "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", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [ "cpu": [
"x64" "x64"
@ -114,7 +114,7 @@
}, },
"node_modules/@esbuild/freebsd-arm64": { "node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12", "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", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [ "cpu": [
"arm64" "arm64"
@ -131,7 +131,7 @@
}, },
"node_modules/@esbuild/freebsd-x64": { "node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12", "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", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [ "cpu": [
"x64" "x64"
@ -148,7 +148,7 @@
}, },
"node_modules/@esbuild/linux-arm": { "node_modules/@esbuild/linux-arm": {
"version": "0.25.12", "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", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [ "cpu": [
"arm" "arm"
@ -165,7 +165,7 @@
}, },
"node_modules/@esbuild/linux-arm64": { "node_modules/@esbuild/linux-arm64": {
"version": "0.25.12", "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", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
@ -182,7 +182,7 @@
}, },
"node_modules/@esbuild/linux-ia32": { "node_modules/@esbuild/linux-ia32": {
"version": "0.25.12", "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", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [ "cpu": [
"ia32" "ia32"
@ -199,7 +199,7 @@
}, },
"node_modules/@esbuild/linux-loong64": { "node_modules/@esbuild/linux-loong64": {
"version": "0.25.12", "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", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [ "cpu": [
"loong64" "loong64"
@ -216,7 +216,7 @@
}, },
"node_modules/@esbuild/linux-mips64el": { "node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12", "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", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [ "cpu": [
"mips64el" "mips64el"
@ -233,7 +233,7 @@
}, },
"node_modules/@esbuild/linux-ppc64": { "node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12", "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", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
@ -250,7 +250,7 @@
}, },
"node_modules/@esbuild/linux-riscv64": { "node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12", "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", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
@ -267,7 +267,7 @@
}, },
"node_modules/@esbuild/linux-s390x": { "node_modules/@esbuild/linux-s390x": {
"version": "0.25.12", "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", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [ "cpu": [
"s390x" "s390x"
@ -284,7 +284,7 @@
}, },
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.25.12", "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", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [ "cpu": [
"x64" "x64"
@ -301,7 +301,7 @@
}, },
"node_modules/@esbuild/netbsd-arm64": { "node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12", "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", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [ "cpu": [
"arm64" "arm64"
@ -318,7 +318,7 @@
}, },
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12", "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", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [ "cpu": [
"x64" "x64"
@ -335,7 +335,7 @@
}, },
"node_modules/@esbuild/openbsd-arm64": { "node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12", "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", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [ "cpu": [
"arm64" "arm64"
@ -352,7 +352,7 @@
}, },
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12", "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", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [ "cpu": [
"x64" "x64"
@ -369,7 +369,7 @@
}, },
"node_modules/@esbuild/openharmony-arm64": { "node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12", "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", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [ "cpu": [
"arm64" "arm64"
@ -386,7 +386,7 @@
}, },
"node_modules/@esbuild/sunos-x64": { "node_modules/@esbuild/sunos-x64": {
"version": "0.25.12", "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", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [ "cpu": [
"x64" "x64"
@ -403,7 +403,7 @@
}, },
"node_modules/@esbuild/win32-arm64": { "node_modules/@esbuild/win32-arm64": {
"version": "0.25.12", "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", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [ "cpu": [
"arm64" "arm64"
@ -420,7 +420,7 @@
}, },
"node_modules/@esbuild/win32-ia32": { "node_modules/@esbuild/win32-ia32": {
"version": "0.25.12", "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", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [ "cpu": [
"ia32" "ia32"
@ -437,7 +437,7 @@
}, },
"node_modules/@esbuild/win32-x64": { "node_modules/@esbuild/win32-x64": {
"version": "0.25.12", "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", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [ "cpu": [
"x64" "x64"
@ -453,7 +453,7 @@
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.12", "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", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,

View file

@ -1,11 +1,11 @@
{ {
"name": "zunda-shiwake", "name": "zunda-shiwake",
"version": "27.14.0", "version": "27.19.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"", "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" "build": "npm run clean && node -e \"require('fs').copyFileSync('styles.css', 'styles-v27.19.css')\" && esbuild src/game.js --bundle --format=esm --target=es2020 --minify --outfile=dist/game-v27.19.js"
}, },
"devDependencies": { "devDependencies": {
"esbuild": "^0.25.0" "esbuild": "^0.25.0"

View file

@ -1,7 +1,7 @@
// Central balance sheet for tuning gameplay. // Central balance sheet for tuning gameplay.
// Edit this file first when adjusting prices, income, penalties, timing, card rates, or caps. // Edit this file first when adjusting prices, income, penalties, timing, card rates, or caps.
export const BALANCE = { export const BALANCE = {
version: 'v27.14 debug restore consistency patch', version: 'v27.19 LAND + UI PATCH',
time: { time: {
daySeconds: 60, daySeconds: 60,
farmShutdownGraceSeconds: 10 farmShutdownGraceSeconds: 10
@ -24,7 +24,9 @@ export const BALANCE = {
} }
}, },
map: { map: {
blockedRatio: 0.075 blockedRatio: 0.03,
blockedExpansionIncrement: 0.005,
blockedRatioMax: 0.10
}, },
economy: { economy: {
income: { income: {
@ -251,7 +253,7 @@ export const BALANCE = {
rarity: 'rare', rarity: 'rare',
type: 'cellAction', type: 'cellAction',
target: 'blockedCell', target: 'blockedCell',
description: 'Remove any 3 unbuildable cells', description: 'Choose and remove up to 3 unbuildable cells.',
tags: ['RISK', 'RARE', 'ONE-SHOT'] tags: ['RISK', 'RARE', 'ONE-SHOT']
}, },
{ {
@ -307,7 +309,7 @@ export const BALANCE = {
title: 'Motivational Exploitation', title: 'Motivational Exploitation',
rarity: 'ultraRare', rarity: 'ultraRare',
type: 'instant', type: 'instant',
description: 'Repairman labor cost -30%. The repairman smiles with bloodshot eyes. One time only.', description: 'Repair room work time -30%. The repairman smiles with bloodshot eyes. One time only.',
tags: ['MAINTENANCE', 'ULTRA RARE', 'PASSIVE'] tags: ['MAINTENANCE', 'ULTRA RARE', 'PASSIVE']
}, },
{ {
@ -374,7 +376,6 @@ export const BALANCE = {
trash: 1.00 trash: 1.00
}, },
repairman: { repairman: {
dailyCost: 100,
secondsPerOnePercent: 0.5, secondsPerOnePercent: 0.5,
walkSpeed: 90 walkSpeed: 90
} }
@ -411,6 +412,10 @@ export const BALANCE = {
truck: { truck: {
id: 'truck', type: 'facility', name: 'Truck', shortName: 'TRUCK', price: 450, id: 'truck', type: 'facility', name: 'Truck', shortName: 'TRUCK', price: 450,
buildable: true, upgradeable: true, incomeKey: 'truck', body: { w: 176, h: 138 } buildable: true, upgradeable: true, incomeKey: 'truck', body: { w: 176, h: 138 }
},
maintenanceRoom: {
id: 'maintenanceRoom', type: 'maintenanceRoom', name: 'Maintenance Room', shortName: 'REPAIR ROOM', price: 220,
buildable: true, upgradeable: false, footprint: { cols: 2, rows: 2 }
} }
} }
}; };

View file

@ -26,7 +26,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', 'boostConveyor', 'eggFarm', 'autoScanner', 'manualScanner', 'mixer', 'trash', 'truck', 'maintenanceRoom'];
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'];
@ -40,20 +40,20 @@ export const DIRS = [
]; ];
export const THEME = { export const THEME = {
bg: '#eefbea', bg: '#111318',
ink: '#102015', ink: '#090a0d',
muted: '#526456', muted: '#8f9bab',
green: '#22b94f', green: '#7bd84f',
greenSoft: '#b7f5c6', greenSoft: '#273c24',
mixerBlue: '#2477ff', mixerBlue: '#4aa3ff',
truckPink: '#ff6aa8', truckPink: '#ffb33e',
wasteGreen: '#22b94f', wasteGreen: '#88d35a',
danger: '#c42424', danger: '#e34a36',
warn: '#f0a020', warn: '#ffb827',
white: '#ffffff', white: '#e6edf2',
poop: '#7b4a23', poop: '#7b4a23',
male: '#62a9ff', male: '#4aa3ff',
female: '#ff8bc6' female: '#ff6fae'
}; };
export const EFFECT_PRIORITY = { export const EFFECT_PRIORITY = {

View file

@ -94,6 +94,26 @@ export function createFacility(game, id, p, price) {
return layoutFacilityOnEdge(f, entry, side); return layoutFacilityOnEdge(f, entry, side);
} }
export function maintenanceRoomFootprint(room) {
if (!room) return [];
return [
{ col: room.col, row: room.row },
{ col: room.col + 1, row: room.row },
{ col: room.col, row: room.row + 1 },
{ col: room.col + 1, row: room.row + 1 }
];
}
export function createMaintenanceRoom(game, col, row) {
return {
type: 'maintenanceRoom', id: game.nextId++, col, row,
price: BUILD_COSTS.maintenanceRoom,
builtSession: game.buildSession,
wCells: 2, hCells: 2
};
}
export function rollChickSex(game) { export function rollChickSex(game) {
return Math.random() < currentPoopRate(game) ? 'poop' : (Math.random() < 0.5 ? 'male' : 'female'); return Math.random() < currentPoopRate(game) ? 'poop' : (Math.random() < 0.5 ? 'male' : 'female');
} }

View file

@ -20,6 +20,12 @@ function mulberry32(seed) {
}; };
} }
export function blockedRatioForExpansionPurchase(purchaseCount, { base = 0.03, increment = 0.005, max = 0.10 } = {}) {
const count = Math.max(0, Math.floor(Number(purchaseCount) || 0));
return Number(Math.min(max, base + count * increment).toFixed(6));
}
export function createRunSeed() { export function createRunSeed() {
return `RUN-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`.toUpperCase(); return `RUN-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`.toUpperCase();
} }
@ -38,7 +44,7 @@ export function generateBlockedCells({ seed, targetRatio = 0.20, reserved = [] }
const j = Math.floor(rng() * (i + 1)); const j = Math.floor(rng() * (i + 1));
[candidates[i], candidates[j]] = [candidates[j], candidates[i]]; [candidates[i], candidates[j]] = [candidates[j], candidates[i]];
} }
const target = Math.round(GRID.cols * GRID.rows * targetRatio); const target = Math.round(GRID.cols * GRID.rows * targetRatio + 1e-9);
return new Set(candidates.slice(0, Math.min(target, candidates.length)).map(c => c.k)); return new Set(candidates.slice(0, Math.min(target, candidates.length)).map(c => c.k));
} }
@ -60,6 +66,6 @@ export function generateBlockedCellsInRect({ seed, colStart = 0, colEnd = GRID.c
const j = Math.floor(rng() * (i + 1)); const j = Math.floor(rng() * (i + 1));
[candidates[i], candidates[j]] = [candidates[j], candidates[i]]; [candidates[i], candidates[j]] = [candidates[j], candidates[i]];
} }
const target = Math.round((c1 - c0) * (r1 - r0) * targetRatio); const target = Math.round((c1 - c0) * (r1 - r0) * targetRatio + 1e-9);
return new Set(candidates.slice(0, Math.min(target, candidates.length)).map(c => c.k)); return new Set(candidates.slice(0, Math.min(target, candidates.length)).map(c => c.k));
} }

View file

@ -26,7 +26,6 @@ export function newTurnStats() {
manualComboBonus: 0, manualComboBonus: 0,
manualComboSuccess: 0, manualComboSuccess: 0,
manualComboFailure: 0, manualComboFailure: 0,
repairWorkerWages: 0,
fairiesTribute: 0, fairiesTribute: 0,
chemicalWeaponSubsidy: 0, chemicalWeaponSubsidy: 0,
rescueLoan: 0, rescueLoan: 0,
@ -73,7 +72,6 @@ export function newTotalStats() {
manualComboBonus: 0, manualComboBonus: 0,
manualComboSuccess: 0, manualComboSuccess: 0,
manualComboFailure: 0, manualComboFailure: 0,
repairWorkerWages: 0,
cardRerollCost: 0, cardRerollCost: 0,
dudRefundIncome: 0, dudRefundIncome: 0,
composterIncome: 0, composterIncome: 0,
@ -129,6 +127,8 @@ export function createGame() {
eggFarms: [], eggFarms: [],
scanners: [], scanners: [],
facilities: {}, facilities: {},
maintenanceRooms: [],
repairmen: [],
branchCounters: new Map(), branchCounters: new Map(),
routingVersion: 0, routingVersion: 0,
factoryGraph: null, factoryGraph: null,
@ -144,8 +144,7 @@ export function createGame() {
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 },
pendingRepairWorkerWages: 0, repairman: { active: false, x: GRID.x + GRID.cell * 0.5, y: GRID.y + BALANCE.grid.rows * GRID.cell + 84, target: null, repairedToday: 0 },
repairman: { hiredForNextDay: false, active: false, x: GRID.x + GRID.cell * 0.5, y: GRID.y + BALANCE.grid.rows * GRID.cell + 84, target: null, repairedToday: 0 },
stats: newTurnStats(), stats: newTurnStats(),
lastResult: null, lastResult: null,
totals: newTotalStats() totals: newTotalStats()
@ -319,10 +318,11 @@ export function resetLayout(game) {
game.repairman.y = GRID.y + GRID.rows * GRID.cell + 84; game.repairman.y = GRID.y + GRID.rows * GRID.cell + 84;
game.repairman.target = null; game.repairman.target = null;
game.repairman.active = false; game.repairman.active = false;
game.repairman.hiredForNextDay = false;
} }
game.conveyorTiles.clear(); game.conveyorTiles.clear();
game.conveyorMeta.clear(); game.conveyorMeta.clear();
game.maintenanceRooms = [];
game.repairmen = [];
game.blockedCells = new Set(); game.blockedCells = new Set();
installInitialConveyorBackbone(game); installInitialConveyorBackbone(game);
game.facilities = defaultFacilities(); game.facilities = defaultFacilities();
@ -336,7 +336,7 @@ export function resetLayout(game) {
// installed. The protected footprint includes every default conveyor segment, // installed. The protected footprint includes every default conveyor segment,
// scanner body/ports, farm output, and machine receiver. This guarantees that // scanner body/ports, farm output, and machine receiver. This guarantees that
// the starter line is always continuous from Egg Farm -> S1 -> S2 -> exits even // the starter line is always continuous from Egg Farm -> S1 -> S2 -> exits even
// when roughly 20% of the map is blocked. // when roughly 3% of the initial map is blocked.
const reserved = protectedInitialCells(game); const reserved = protectedInitialCells(game);
game.blockedCells = generateBlockedCells({ seed: game.runSeed, targetRatio: BALANCE.map.blockedRatio, reserved: [...reserved] }); game.blockedCells = generateBlockedCells({ seed: game.runSeed, targetRatio: BALANCE.map.blockedRatio, reserved: [...reserved] });
scrubBlockedCellsFromInitialBackbone(game); scrubBlockedCellsFromInitialBackbone(game);

View file

@ -28,7 +28,6 @@ export const TEXT = {
sorting: 'Sorting: use scanner keys. Build after clearing the day.', sorting: 'Sorting: use scanner keys. Build after clearing the day.',
allPortsConnected: 'Start condition met: one EGG route reaches an exit.', allPortsConnected: 'Start condition met: one EGG route reaches an exit.',
equipmentPanelEmpty: 'Hover equipment in Build phase.', equipmentPanelEmpty: 'Hover equipment in Build phase.',
eventPanelRunning: 'Forced events appear in Build phase.'
}, },
routeLabels: { routeLabels: {
mixer: 'MIXER', mixer: 'MIXER',
@ -71,7 +70,8 @@ export function buildToolFlavorText(id) {
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.',
truck: 'Ships the target cargo. Wrong cargo still leaves a paper trail.' truck: 'Ships the target cargo. Wrong cargo still leaves a paper trail.',
maintenanceRoom: '2×2 repair bay. Each room dispatches one repairman during the day.'
}; };
return flavors[id] || ''; return flavors[id] || '';
} }

View file

@ -13,7 +13,7 @@ import { createChickSystem } from './systems/chickSystem.js';
import { CARD_DEFS, cardById, conveyorSpeedForGame, createCardSystem, debugCardTargets, debugGrantCard } from './systems/cards.js'; import { CARD_DEFS, cardById, conveyorSpeedForGame, createCardSystem, debugCardTargets, debugGrantCard } from './systems/cards.js';
import { floating, shockwave, sparkBurst, updateEffects } from './systems/effects.js'; import { floating, shockwave, sparkBurst, updateEffects } from './systems/effects.js';
import { rollEventOffer, activateEventOffer, clearActiveEvent, resolveEvent, eventDaySeconds, fairiesTributeMultiplierFromEvent } from './systems/events.js'; import { rollEventOffer, activateEventOffer, clearActiveEvent, resolveEvent, eventDaySeconds, fairiesTributeMultiplierFromEvent } from './systems/events.js';
import { ensureMaintenanceState, hireRepairmanForNextDay, activateRepairmanForDay, deactivateRepairman, conveyorSpeedFactorForKey } from './systems/maintenance.js'; import { ensureMaintenanceState, activateRepairmanForDay, deactivateRepairman, conveyorSpeedFactorForKey } from './systems/maintenance.js';
const canvas = document.getElementById('gameCanvas'); const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
@ -23,11 +23,10 @@ const ui = {
money: document.getElementById('money'), turn: document.getElementById('turn'), timeLeft: document.getElementById('timeLeft'), phase: document.getElementById('phaseLabel'), comboCount: document.getElementById('comboCount'), money: document.getElementById('money'), turn: document.getElementById('turn'), timeLeft: document.getElementById('timeLeft'), phase: document.getElementById('phaseLabel'), comboCount: document.getElementById('comboCount'),
eventBrief: document.getElementById('eventBrief'), targetBrief: document.getElementById('targetBrief'), speedBrief: document.getElementById('speedBrief'), eventBrief: document.getElementById('eventBrief'), targetBrief: document.getElementById('targetBrief'), speedBrief: document.getElementById('speedBrief'),
turnProfit: document.getElementById('turnProfit'), mixerCount: document.getElementById('mixerCount'), truckFemaleCount: document.getElementById('truckFemaleCount'), truckMaleCount: document.getElementById('truckMaleCount'), poopCount: document.getElementById('poopCount'), turnProfit: document.getElementById('turnProfit'), mixerCount: document.getElementById('mixerCount'), truckFemaleCount: document.getElementById('truckFemaleCount'), truckMaleCount: document.getElementById('truckMaleCount'), poopCount: document.getElementById('poopCount'),
buildStatus: document.getElementById('buildStatus'), turnSummary: document.getElementById('turnSummary'), eventPanel: document.getElementById('eventPanel'), buildStatus: document.getElementById('buildStatus'), turnSummary: document.getElementById('turnSummary'),
modal: document.getElementById('modal'), modalTitle: document.getElementById('modalTitle'), modalBody: document.getElementById('modalBody'), modalActions: document.getElementById('modalActions'), modal: document.getElementById('modal'), modalTitle: document.getElementById('modalTitle'), modalBody: document.getElementById('modalBody'), modalActions: document.getElementById('modalActions'),
hoverTooltip: document.getElementById('hoverTooltip'), hoverTooltip: document.getElementById('hoverTooltip'),
manualScannerMonitor: document.getElementById('manualScannerMonitor'), manualScannerMonitor: document.getElementById('manualScannerMonitor'),
hireRepairmanButton: document.getElementById('hireRepairmanButton'),
debug: { debug: {
panel: document.getElementById('debugPanel'), panel: document.getElementById('debugPanel'),
infiniteCash: document.getElementById('debugInfiniteCash'), infiniteCash: document.getElementById('debugInfiniteCash'),
@ -42,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'), 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'), maintenanceRoom: document.getElementById('buildMaintenanceRoomButton'), erase: document.getElementById('eraseButton'), undo: document.getElementById('undoButton'), redo: document.getElementById('redoButton')
} }
}; };
@ -56,7 +55,8 @@ function initializeStaticText() {
manualScanner: 'manualScanner', manualScanner: 'manualScanner',
mixer: 'mixer', mixer: 'mixer',
trash: 'trash', trash: 'trash',
truck: 'truck' truck: 'truck',
maintenanceRoom: 'maintenanceRoom'
}; };
for (const [buttonKey, toolId] of Object.entries(buttonToolMap)) { for (const [buttonKey, toolId] of Object.entries(buttonToolMap)) {
if (ui.buttons[buttonKey]) ui.buttons[buttonKey].innerHTML = buildToolButtonHtml(toolId); if (ui.buttons[buttonKey]) ui.buttons[buttonKey].innerHTML = buildToolButtonHtml(toolId);
@ -259,13 +259,10 @@ function startNextTurn() {
resetCameraToFactoryStart(); resetCameraToFactoryStart();
game.stats = newTurnStats(); game.stats = newTurnStats();
game.stats.fairiesTribute = game.lastStartFees?.fairiesTribute || 0; game.stats.fairiesTribute = game.lastStartFees?.fairiesTribute || 0;
game.stats.repairWorkerWages = game.pendingRepairWorkerWages || 0; if (game.stats.fairiesTribute > 0) {
const carriedStartCosts = game.stats.fairiesTribute + game.stats.repairWorkerWages; game.stats.penalty += game.stats.fairiesTribute;
if (carriedStartCosts > 0) { game.stats.profit -= game.stats.fairiesTribute;
game.stats.penalty += carriedStartCosts;
game.stats.profit -= carriedStartCosts;
} }
game.pendingRepairWorkerWages = 0;
for (const scanner of game.scanners) { scanner.queue = []; scanner.cooldown = 0; } for (const scanner of game.scanners) { scanner.queue = []; scanner.cooldown = 0; }
for (const farm of game.eggFarms) { farm.nextSpawn = chicks.currentSpawnDelay(farm); farm.lastInterval = farm.nextSpawn; farm.shutterProgress = 0; farm.shutterSparked = false; } for (const farm of game.eggFarms) { farm.nextSpawn = chicks.currentSpawnDelay(farm); farm.lastInterval = farm.nextSpawn; farm.shutterProgress = 0; farm.shutterSparked = false; }
game.buildTool = null; game.buildTool = null;
@ -279,19 +276,6 @@ function startNextTurn() {
} }
function hireRepairman() {
ensureMaintenanceState(game);
const result = hireRepairmanForNextDay(game);
if (!result.ok) {
floating(game, canvas.width / 2 - game.view.x, 96 - game.view.y, result.reason || 'Cannot hire repairman.', THEME.danger);
build?.fail?.(result.reason || 'Cannot hire repairman.');
} else {
floating(game, canvas.width / 2 - game.view.x, 96 - game.view.y, `Repairman hired -${yen(result.cost)}`, THEME.green);
}
uiSystem.updatePanels();
uiSystem.updateUI();
}
function completeTurn() { function completeTurn() {
if (game.phase !== 'running') return; if (game.phase !== 'running') return;
const ship = settleTruckRevenue(game); const ship = settleTruckRevenue(game);
@ -713,8 +697,7 @@ document.addEventListener('pointerdown', event => {
}, 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.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.maintenanceRoom.addEventListener('click', () => build.setBuildTool('maintenanceRoom')); ui.buttons.erase.addEventListener('click', () => build.setBuildTool('erase'));
ui.hireRepairmanButton?.addEventListener('click', hireRepairman);
ui.debug.setDay?.addEventListener('click', debugSetDay); ui.debug.setDay?.addEventListener('click', debugSetDay);
ui.debug.zeroTimer?.addEventListener('click', debugZeroTimer); ui.debug.zeroTimer?.addEventListener('click', debugZeroTimer);
ui.debug.grantCard?.addEventListener('click', debugGrantSelectedCard); ui.debug.grantCard?.addEventListener('click', debugGrantSelectedCard);

View file

@ -9,18 +9,36 @@ import { wearRatio } from '../systems/maintenance.js';
// Optional art overlay hook. // Optional art overlay hook.
// Keep external art probing disabled by default so a clean checkout does not emit 404s for missing PNGs. // Keep external art probing disabled by default so a clean checkout does not emit 404s for missing PNGs.
// To use replacement art, set ENABLE_EXTERNAL_ART_ASSETS to true and place files under assets/images/. // To use replacement art, set ENABLE_EXTERNAL_ART_ASSETS to true and place files under assets/images/.
const ENABLE_EXTERNAL_ART_ASSETS = false; const ENABLE_EXTERNAL_ART_ASSETS = true;
const ASSET_PATHS = { const ASSET_PATHS = {
// User-supplied chick/poop assets remain optional. Facility/UI art is wired below.
chickMale: './assets/images/chick_male.png', chickMale: './assets/images/chick_male.png',
chickFemale: './assets/images/chick_female.png', chickFemale: './assets/images/chick_female.png',
poop: new URL('../../assets/images/poop.png', import.meta.url).href, poop: './assets/images/poop.png',
conveyor: './assets/images/conveyor.png', conveyorStraightH: './assets/dark_factory/facilities/conveyor/conveyor_straight_h_16.png',
scannerManual: './assets/images/scanner_manual.png', conveyorStraightV: './assets/dark_factory/facilities/conveyor/conveyor_straight_v_16.png',
scannerAuto: './assets/images/scanner_auto.png', conveyorTurnNe: './assets/dark_factory/facilities/conveyor/conveyor_turn_ne_16.png',
eggFarm: './assets/images/egg_farm.png', conveyorTurnNw: './assets/dark_factory/facilities/conveyor/conveyor_turn_nw_16.png',
mixer: './assets/images/mixer.png', conveyorTurnSe: './assets/dark_factory/facilities/conveyor/conveyor_turn_se_16.png',
shredder: './assets/images/shredder.png', conveyorTurnSw: './assets/dark_factory/facilities/conveyor/conveyor_turn_sw_16.png',
truck: './assets/images/truck.png' boostConveyorStraightH: './assets/dark_factory/facilities/conveyor/boost_conveyor_straight_h_16.png',
boostConveyorStraightV: './assets/dark_factory/facilities/conveyor/boost_conveyor_straight_v_16.png',
boostConveyorTurnNe: './assets/dark_factory/facilities/conveyor/boost_conveyor_turn_ne_16.png',
boostConveyorTurnNw: './assets/dark_factory/facilities/conveyor/boost_conveyor_turn_nw_16.png',
boostConveyorTurnSe: './assets/dark_factory/facilities/conveyor/boost_conveyor_turn_se_16.png',
boostConveyorTurnSw: './assets/dark_factory/facilities/conveyor/boost_conveyor_turn_sw_16.png',
scannerManual: './assets/dark_factory/facilities/machines/manual_scanner_32x48.png',
scannerAuto: './assets/dark_factory/facilities/machines/auto_scanner_32x48.png',
eggFarm: './assets/dark_factory/facilities/machines/egg_farm_16.png',
mixer: './assets/dark_factory/facilities/machines/mixer_32.png',
shredder: './assets/dark_factory/facilities/machines/shredder_32.png',
truck: './assets/dark_factory/facilities/vehicles/truck_32.png',
mixerCompact: './assets/dark_factory/facilities/machines/mixer_compact_16.png',
shredderCompact: './assets/dark_factory/facilities/machines/shredder_compact_16.png',
truckCompact: './assets/dark_factory/facilities/vehicles/truck_compact_16.png',
maintenanceRoom: './assets/dark_factory/facilities/utility/maintenance_room_32.png',
repairman: './assets/dark_factory/facilities/utility/repairman_16.png',
obstacleCrate: './assets/dark_factory/facilities/utility/obstacle_crate_16.png'
}; };
export const assets = {}; export const assets = {};
@ -28,7 +46,7 @@ for (const [name, src] of Object.entries(ASSET_PATHS)) {
const img = new Image(); const img = new Image();
img.loaded = false; img.loaded = false;
img.onerror = () => { img.loaded = false; }; img.onerror = () => { img.loaded = false; };
if ((ENABLE_EXTERNAL_ART_ASSETS || name === 'poop') && src) { if (ENABLE_EXTERNAL_ART_ASSETS && src) {
img.onload = () => { img.loaded = true; }; img.onload = () => { img.loaded = true; };
img.src = src; img.src = src;
} }
@ -38,6 +56,7 @@ for (const [name, src] of Object.entries(ASSET_PATHS)) {
// Each draw* function first checks whether an image loaded; if not, it falls back to simple canvas shapes. // Each draw* function first checks whether an image loaded; if not, it falls back to simple canvas shapes.
export function drawAll(ctx, canvas, game, helpers) { export function drawAll(ctx, canvas, game, helpers) {
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
drawBackground(ctx, canvas); drawBackground(ctx, canvas);
ctx.save(); ctx.save();
@ -50,6 +69,7 @@ export function drawAll(ctx, canvas, game, helpers) {
drawExpansionOffers(ctx, game); drawExpansionOffers(ctx, game);
drawConveyors(ctx, game); drawConveyors(ctx, game);
drawScannerConnectors(ctx, game); drawScannerConnectors(ctx, game);
drawMaintenanceRooms(ctx, game);
drawFacilities(ctx, game); drawFacilities(ctx, game);
drawEggFarms(ctx, game); drawEggFarms(ctx, game);
drawScanners(ctx, game); drawScanners(ctx, game);
@ -108,17 +128,84 @@ function label(ctx, x, y, text, color = THEME.ink) {
} }
function drawImageIfLoaded(ctx, image, x, y, w, h) { function drawImageIfLoaded(ctx, image, x, y, w, h) {
if (!image?.loaded) return false; if (!image?.loaded) return false;
ctx.drawImage(image, x, y, w, h); const previous = ctx.imageSmoothingEnabled;
ctx.imageSmoothingEnabled = false;
ctx.drawImage(image, Math.round(x), Math.round(y), Math.round(w), Math.round(h));
ctx.imageSmoothingEnabled = previous;
return true; return true;
} }
function pixelRect(ctx, x, y, w, h, fill, stroke = null, lineWidth = 1) {
ctx.fillStyle = fill;
ctx.fillRect(Math.round(x), Math.round(y), Math.round(w), Math.round(h));
if (stroke) {
ctx.strokeStyle = stroke;
ctx.lineWidth = lineWidth;
ctx.strokeRect(Math.round(x) + 0.5, Math.round(y) + 0.5, Math.round(w) - 1, Math.round(h) - 1);
}
}
function floorCellHash(col, row) {
let n = (Math.imul(col + 37, 73856093) ^ Math.imul(row + 71, 19349663)) >>> 0;
n ^= n >>> 13;
return n >>> 0;
}
function drawFloorSlime(ctx, x, y, col, row) {
const h = floorCellHash(col, row);
// Sparse, deterministic splashes: about 3% of owned floor cells.
if (h % 31 !== 0) return;
const ox = 10 + ((h >>> 4) % Math.max(1, GRID.cell - 22));
const oy = 10 + ((h >>> 9) % Math.max(1, GRID.cell - 22));
const angle = ((h >>> 14) % 16) * Math.PI / 16;
ctx.save();
ctx.globalAlpha = 0.46;
ctx.fillStyle = '#4d9638';
ctx.strokeStyle = 'rgba(30,66,27,.48)';
ctx.lineWidth = 1;
// Small irregular core rather than a large puddle.
ctx.beginPath();
for (let i = 0; i < 8; i += 1) {
const a = angle + i * Math.PI / 4;
const r = 3.5 + ((h >>> (i + 2)) & 3);
const px = x + ox + Math.cos(a) * r * 1.35;
const py = y + oy + Math.sin(a) * r * .75;
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.fill();
ctx.stroke();
// Several separated droplets produce a splattered silhouette.
const droplets = 3 + ((h >>> 24) % 4);
for (let i = 0; i < droplets; i += 1) {
const a = angle + i * 2.17 + ((h >>> (i + 8)) & 3) * .18;
const dist = 8 + ((h >>> (i + 11)) & 7);
const radius = 1.1 + ((h >>> (i + 17)) & 3) * .45;
ctx.beginPath();
ctx.arc(x + ox + Math.cos(a) * dist, y + oy + Math.sin(a) * dist * .72, radius, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = 'rgba(151,218,101,.40)';
ctx.beginPath();
ctx.arc(x + ox - 2, y + oy - 2, 1.7, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function drawBackground(ctx, canvas) { function drawBackground(ctx, canvas) {
ctx.fillStyle = THEME.bg; ctx.fillStyle = THEME.bg;
ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.save(); ctx.save();
ctx.strokeStyle = 'rgba(16, 32, 21, 0.035)'; ctx.globalAlpha = 0.85;
for (let y = 0; y < canvas.height; y += 16) {
for (let x = 0; x < canvas.width; x += 16) {
const even = ((x / 16 + y / 16) | 0) % 2 === 0;
ctx.fillStyle = even ? '#181b20' : '#20242b';
ctx.fillRect(x, y, 16, 16);
}
}
ctx.strokeStyle = 'rgba(255,184,39,.08)';
ctx.lineWidth = 1; ctx.lineWidth = 1;
for (let x = 0; x < canvas.width; x += 32) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke(); } for (let x = 0; x < canvas.width; x += 64) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke(); }
for (let y = 0; y < canvas.height; y += 32) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke(); } for (let y = 0; y < canvas.height; y += 64) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke(); }
ctx.restore(); ctx.restore();
} }
function ownedCellKeys(game) { function ownedCellKeys(game) {
@ -131,15 +218,20 @@ function ownedCellKeys(game) {
function drawGrid(ctx, game) { function drawGrid(ctx, game) {
ctx.save(); ctx.save();
const cells = ownedCellKeys(game); const cells = ownedCellKeys(game);
ctx.fillStyle = 'rgba(255,255,255,.46)';
ctx.strokeStyle = 'rgba(16,32,21,.055)';
ctx.lineWidth = 1;
for (const k of cells) { for (const k of cells) {
const p = parseKey(k); const p = parseKey(k);
const c = cellCenter(p.col, p.row); const c = cellCenter(p.col, p.row);
rect(ctx, c.x - GRID.cell / 2, c.y - GRID.cell / 2, GRID.cell, GRID.cell, true, true); const x = c.x - GRID.cell / 2;
const y = c.y - GRID.cell / 2;
const tone = ((p.col + p.row) % 2 === 0) ? '#59616d' : '#505965';
pixelRect(ctx, x, y, GRID.cell, GRID.cell, tone, 'rgba(190,199,210,.28)', 1);
ctx.fillStyle = 'rgba(255,255,255,.055)';
ctx.fillRect(Math.round(x + 2), Math.round(y + 2), Math.round(GRID.cell - 4), 2);
ctx.fillStyle = 'rgba(0,0,0,.18)';
ctx.fillRect(Math.round(x + 1), Math.round(y + GRID.cell - 3), Math.round(GRID.cell - 2), 2);
drawFloorSlime(ctx, x, y, p.col, p.row);
} }
ctx.strokeStyle = 'rgba(16,32,21,.16)'; ctx.strokeStyle = 'rgba(255,184,39,.32)';
ctx.lineWidth = 3; ctx.lineWidth = 3;
for (const k of cells) { for (const k of cells) {
const p = parseKey(k); const p = parseKey(k);
@ -154,14 +246,41 @@ function drawGrid(ctx, game) {
const p = parseKey(k); const p = parseKey(k);
if (!isOwnedCell(game, p.col, p.row)) continue; if (!isOwnedCell(game, p.col, p.row)) continue;
const c = cellCenter(p.col, p.row); const c = cellCenter(p.col, p.row);
ctx.fillStyle = 'rgba(16,32,21,.30)'; const x = c.x - GRID.cell / 2;
ctx.strokeStyle = 'rgba(16,32,21,.60)'; const y = c.y - GRID.cell / 2;
// Muted floor-level hazard striping. It remains readable without dominating machines.
ctx.save();
ctx.beginPath();
ctx.rect(Math.round(x + 1), Math.round(y + 1), GRID.cell - 2, GRID.cell - 2);
ctx.clip();
ctx.fillStyle = 'rgba(42,45,48,.42)';
ctx.fillRect(Math.round(x), Math.round(y), GRID.cell, GRID.cell);
ctx.lineWidth = 8;
ctx.strokeStyle = 'rgba(196,163,79,.38)';
for (let stripe = -GRID.cell; stripe < GRID.cell * 2; stripe += 16) {
ctx.beginPath();
ctx.moveTo(x + stripe, y + GRID.cell);
ctx.lineTo(x + stripe + GRID.cell, y);
ctx.stroke();
}
ctx.lineWidth = 8;
ctx.strokeStyle = 'rgba(20,23,26,.32)';
for (let stripe = -GRID.cell + 8; stripe < GRID.cell * 2; stripe += 16) {
ctx.beginPath();
ctx.moveTo(x + stripe, y + GRID.cell);
ctx.lineTo(x + stripe + GRID.cell, y);
ctx.stroke();
}
ctx.restore();
ctx.strokeStyle = 'rgba(17,19,22,.72)';
ctx.lineWidth = 2; ctx.lineWidth = 2;
rect(ctx, c.x - GRID.cell / 2 + 4, c.y - GRID.cell / 2 + 4, GRID.cell - 8, GRID.cell - 8, true, true); ctx.strokeRect(Math.round(x) + 1, Math.round(y) + 1, GRID.cell - 2, GRID.cell - 2);
ctx.strokeStyle = 'rgba(224,194,112,.24)';
ctx.lineWidth = 1;
ctx.strokeRect(Math.round(x) + 4, Math.round(y) + 4, GRID.cell - 8, GRID.cell - 8);
} }
ctx.restore(); ctx.restore();
} }
function drawExpansionOffers(ctx, game) { function drawExpansionOffers(ctx, game) {
if (game.phase !== 'build' || game.cardDraft?.pending || game.cardTargetPick?.pending) return; if (game.phase !== 'build' || game.cardDraft?.pending || game.cardTargetPick?.pending) return;
const cost = expansionCost(game); const cost = expansionCost(game);
@ -220,6 +339,22 @@ function componentRatio(game, k) {
const data = id ? game.congestion.get(id) : null; const data = id ? game.congestion.get(id) : null;
return data?.ratio || 0; return data?.ratio || 0;
} }
function conveyorAssetForTile(game, k, meta = {}) {
const dirs = connectedConveyorDirs(game, k).sort();
const boost = meta.kind === 'boostConveyor';
const prefix = boost ? 'boostConveyor' : 'conveyor';
const has = name => dirs.includes(name);
if (dirs.length === 2) {
if (has('left') && has('right')) return assets[`${prefix}StraightH`];
if (has('up') && has('down')) return assets[`${prefix}StraightV`];
if (has('up') && has('right')) return assets[`${prefix}TurnNe`];
if (has('up') && has('left')) return assets[`${prefix}TurnNw`];
if (has('down') && has('right')) return assets[`${prefix}TurnSe`];
if (has('down') && has('left')) return assets[`${prefix}TurnSw`];
}
if (dirs.length === 1) return has('up') || has('down') ? assets[`${prefix}StraightV`] : assets[`${prefix}StraightH`];
return has('up') || has('down') ? assets[`${prefix}StraightV`] : assets[`${prefix}StraightH`];
}
function drawConveyors(ctx, game) { function drawConveyors(ctx, game) {
const edges = []; const edges = [];
const seen = new Set(); const seen = new Set();
@ -239,9 +374,9 @@ function drawConveyors(ctx, game) {
ctx.save(); ctx.save();
ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
for (const layer of [ for (const layer of [
{ width: 33, color: THEME.ink, alpha: 1 }, { width: 31, color: '#0b0c0f', alpha: 1 },
{ width: 25, color: THEME.white, alpha: 1 }, { width: 23, color: '#444b56', alpha: 1 },
{ width: 17, color: THEME.greenSoft, alpha: 1 } { width: 15, color: '#171a1f', alpha: 1 }
]) { ]) {
ctx.globalAlpha = layer.alpha; ctx.strokeStyle = layer.color; ctx.lineWidth = layer.width; ctx.globalAlpha = layer.alpha; ctx.strokeStyle = layer.color; ctx.lineWidth = layer.width;
for (const e of edges) { ctx.beginPath(); ctx.moveTo(e.a.x, e.a.y); ctx.lineTo(e.b.x, e.b.y); ctx.stroke(); } for (const e of edges) { ctx.beginPath(); ctx.moveTo(e.a.x, e.a.y); ctx.lineTo(e.b.x, e.b.y); ctx.stroke(); }
@ -258,27 +393,80 @@ function drawConveyors(ctx, game) {
const c = cellCenter(...Object.values(parseKey(k))); const c = cellCenter(...Object.values(parseKey(k)));
const meta = game.conveyorMeta.get(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; const sprite = conveyorAssetForTile(game, k, meta);
ctx.fillStyle = ratio > 0.5 ? mixHex(baseFill, '#ffd6d6', Math.max(0, (ratio - .5) / .5)) : baseFill; const congested = ratio > 0.5;
ctx.strokeStyle = THEME.ink; ctx.lineWidth = 2; ctx.fillStyle = congested ? 'rgba(196,36,36,.28)' : 'rgba(0,0,0,.35)';
rect(ctx, c.x - 8, c.y - 8, 16, 16, true, true); ctx.strokeStyle = congested ? THEME.danger : 'rgba(255,184,39,.34)';
if (meta.kind === 'boostConveyor') { ctx.lineWidth = congested ? 3 : 1;
ctx.save(); rect(ctx, c.x - 20, c.y - 20, 40, 40, true, true);
ctx.strokeStyle = THEME.warn; if (!drawImageIfLoaded(ctx, sprite, c.x - 19, c.y - 19, 38, 38)) {
ctx.lineWidth = 3; const baseFill = meta.kind === 'boostConveyor' ? '#315e92' : '#454b55';
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.fillStyle = ratio > 0.5 ? mixHex(baseFill, '#a12c2c', Math.max(0, (ratio - .5) / .5)) : baseFill;
ctx.restore(); ctx.strokeStyle = THEME.ink; ctx.lineWidth = 2;
rect(ctx, c.x - 18, c.y - 18, 36, 36, true, true);
} }
drawDirtOverlay(ctx, c.x - 14, c.y - 14, 28, 28, { meta }); drawAnimatedConveyorStripes(ctx, c, k, meta, ratio);
drawDirtOverlay(ctx, c.x - 18, c.y - 18, 36, 36, { 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); const connectedDirs = connectedConveyorDirs(game, k);
drawBranchModeIcon(ctx, c, meta, branchSelectableEntranceDirs(game, k, connectedDirs), connectedDirs); drawBranchModeIcon(ctx, c, meta, branchSelectableExitDirs(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 drawAnimatedConveyorStripes(ctx, center, tileKey, meta = {}, ratio = 0) {
const names = [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))];
const primary = names[0] || 'right';
const verticalTravel = primary === 'up' || primary === 'down';
const reverse = primary === 'left' || primary === 'up';
const boost = meta.kind === 'boostConveyor';
const t = typeof performance !== 'undefined' ? performance.now() / (boost ? 70 : 105) : 0;
const phase = ((reverse ? -t : t) % 10 + 10) % 10;
ctx.save();
ctx.beginPath();
ctx.rect(center.x - 18, center.y - 18, 36, 36);
ctx.clip();
// Blue-green rubber for boost belts; dark steel/rubber for standard belts.
const rubber = boost ? 'rgba(20,126,124,.62)' : 'rgba(42,47,54,.72)';
ctx.fillStyle = rubber;
ctx.fillRect(center.x - 18, center.y - 18, 36, 36);
// Slats are always perpendicular to travel and move in the travel direction.
ctx.strokeStyle = ratio > 0.5 ? 'rgba(238,83,61,.90)' : 'rgba(184,190,198,.92)';
ctx.lineWidth = 4;
if (verticalTravel) {
for (let yy = center.y - 28 + phase; yy < center.y + 28; yy += 10) {
ctx.beginPath(); ctx.moveTo(center.x - 17, yy); ctx.lineTo(center.x + 17, yy); ctx.stroke();
}
} else {
for (let xx = center.x - 28 + phase; xx < center.x + 28; xx += 10) {
ctx.beginPath(); ctx.moveTo(xx, center.y - 17); ctx.lineTo(xx, center.y + 17); ctx.stroke();
}
}
ctx.globalAlpha = 0.55;
ctx.strokeStyle = '#050609';
ctx.lineWidth = 1;
if (verticalTravel) {
for (let yy = center.y - 23 + phase; yy < center.y + 28; yy += 10) {
ctx.beginPath(); ctx.moveTo(center.x - 17, yy); ctx.lineTo(center.x + 17, yy); ctx.stroke();
}
} else {
for (let xx = center.x - 23 + phase; xx < center.x + 28; xx += 10) {
ctx.beginPath(); ctx.moveTo(xx, center.y - 17); ctx.lineTo(xx, center.y + 17); ctx.stroke();
}
}
ctx.globalAlpha = 1;
ctx.strokeStyle = 'rgba(8,9,12,.92)';
ctx.lineWidth = 2;
rect(ctx, center.x - 18, center.y - 18, 36, 36, false, true);
ctx.restore();
}
function reachableConveyorDirs(game, k, meta) { function reachableConveyorDirs(game, k, meta) {
const p = parseKey(k); const p = parseKey(k);
const neighborNames = DIRS const neighborNames = DIRS
@ -306,30 +494,27 @@ function neighborPointsIntoCell(game, p, dirName) {
return names.includes(d.opposite); return names.includes(d.opposite);
} }
function branchExitDirs(game, k, dirs, connectedDirs = connectedConveyorDirs(game, k), branchMode = 'random') { function branchExitCandidates(game, k, dirs, connectedDirs = connectedConveyorDirs(game, k)) {
if (connectedDirs.length < 3) return dirs; if (connectedDirs.length < 3) return dirs;
const p = parseKey(k); const p = parseKey(k);
const entrances = new Set(connectedDirs.filter(dir => neighborPointsIntoCell(game, p, dir))); const physicalEntrances = new Set(connectedDirs.filter(dir => neighborPointsIntoCell(game, p, dir)));
if (branchMode && branchMode !== 'random' && connectedDirs.includes(branchMode)) { const physicalExits = connectedDirs.filter(dir => !physicalEntrances.has(dir));
const selectedEntrances = new Set(entrances); if (physicalEntrances.size && physicalExits.length >= 2) return physicalExits;
selectedEntrances.add(branchMode); const explicit = dirs.filter(dir => connectedDirs.includes(dir));
if (connectedDirs.filter(dir => !selectedEntrances.has(dir)).length >= 2) entrances.add(branchMode); if (explicit.length >= 2) return [...new Set(explicit)];
} return connectedDirs;
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 branchSelectableEntranceDirs(game, k, connectedDirs = connectedConveyorDirs(game, k)) { function branchExitDirs(game, k, dirs, connectedDirs = connectedConveyorDirs(game, k), branchMode = 'random') {
if (connectedDirs.length < 3) return []; const candidates = branchExitCandidates(game, k, dirs, connectedDirs);
const p = parseKey(k); if (connectedDirs.length >= 3 && branchMode && branchMode !== 'random' && candidates.includes(branchMode)) return [branchMode];
const physicalEntrances = new Set(connectedDirs.filter(dir => neighborPointsIntoCell(game, p, dir))); return candidates;
return connectedDirs.filter(dir => { }
const entrances = new Set(physicalEntrances);
entrances.add(dir); function branchSelectableExitDirs(game, k, connectedDirs = connectedConveyorDirs(game, k)) {
return connectedDirs.filter(item => !entrances.has(item)).length >= 2; const meta = game.conveyorMeta?.get?.(k) || {};
}); const explicit = [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))];
return connectedDirs.length < 3 ? [] : branchExitCandidates(game, k, explicit, connectedDirs);
} }
function effectiveConveyorDirs(game, k, meta) { function effectiveConveyorDirs(game, k, meta) {
@ -351,12 +536,13 @@ function drawBranchModeIcon(ctx, center, meta, dirs, connectedDirs = dirs) {
ctx.lineJoin = 'round'; ctx.lineJoin = 'round';
ctx.lineCap = 'round'; ctx.lineCap = 'round';
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.fillStyle = 'rgba(255,255,255,.9)'; ctx.fillStyle = 'rgba(18,21,26,.94)';
ctx.strokeStyle = isFixed ? THEME.warn : THEME.green; ctx.strokeStyle = isFixed ? THEME.warn : THEME.green;
rect(ctx, center.x - 17, center.y - 17, 34, 34, true, true); ctx.lineWidth = 4;
ctx.strokeStyle = THEME.ink; rect(ctx, center.x - 18, center.y - 18, 36, 36, true, true);
ctx.lineWidth = 2; ctx.strokeStyle = '#ffd36a';
ctx.fillStyle = THEME.ink; ctx.lineWidth = 3;
ctx.fillStyle = '#020304';
if (mode !== 'random') { if (mode !== 'random') {
const d = DIRS.find(item => item.name === mode); const d = DIRS.find(item => item.name === mode);
drawDirectionTriangle(ctx, center.x, center.y, d?.angle || 0, 9, 6); drawDirectionTriangle(ctx, center.x, center.y, d?.angle || 0, 9, 6);
@ -400,10 +586,14 @@ function drawConveyorDirectionMarkers(ctx, center, dirs) {
const limited = dirs.slice(0, 4); const limited = dirs.slice(0, 4);
if (!limited.length) return; if (!limited.length) return;
ctx.save(); ctx.save();
ctx.fillStyle = THEME.ink; ctx.globalAlpha = 1;
ctx.globalAlpha = 0.86;
limited.forEach(d => { limited.forEach(d => {
drawDirectionTriangle(ctx, center.x + d.dc * 12, center.y + d.dr * 12, d.angle, 6, 5); const x = center.x + d.dc * 11;
const y = center.y + d.dr * 11;
ctx.fillStyle = 'rgba(255,205,83,.96)';
drawDirectionTriangle(ctx, x, y, d.angle, 9, 7);
ctx.fillStyle = '#020304';
drawDirectionTriangle(ctx, x, y, d.angle, 6.5, 4.6);
}); });
ctx.restore(); ctx.restore();
} }
@ -413,6 +603,36 @@ function drawDirectionTriangle(ctx, x, y, angle, length = 11, halfWidth = 8) {
ctx.beginPath(); ctx.moveTo(length, 0); ctx.lineTo(-length * 0.72, -halfWidth); ctx.lineTo(-length * 0.72, halfWidth); ctx.closePath(); ctx.fill(); ctx.beginPath(); ctx.moveTo(length, 0); ctx.lineTo(-length * 0.72, -halfWidth); ctx.lineTo(-length * 0.72, halfWidth); ctx.closePath(); ctx.fill();
ctx.restore(); ctx.restore();
} }
function drawScannerPortDuct(ctx, scanner, type, p, color) {
const c = scannerCenter(scanner);
let target = null;
if (type === 'inputA') target = { x: c.x, y: c.y - 67 };
if (type === 'left') target = { x: c.x - 49, y: c.y + 14 };
if (type === 'right') target = { x: c.x + 49, y: c.y + 14 };
if (!target) return;
const elbow = type === 'inputA'
? { x: p.x, y: target.y }
: { x: target.x, y: p.y };
ctx.save();
ctx.lineCap = 'square';
ctx.lineJoin = 'bevel';
ctx.strokeStyle = '#050609';
ctx.lineWidth = 15;
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(elbow.x, elbow.y); ctx.lineTo(target.x, target.y); ctx.stroke();
ctx.strokeStyle = '#596473';
ctx.lineWidth = 9;
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(elbow.x, elbow.y); ctx.lineTo(target.x, target.y); ctx.stroke();
ctx.strokeStyle = color || '#7b8795';
ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(elbow.x, elbow.y); ctx.lineTo(target.x, target.y); ctx.stroke();
ctx.fillStyle = color || '#7b8795';
ctx.strokeStyle = '#050609';
ctx.lineWidth = 3;
rect(ctx, target.x - 9, target.y - 7, 18, 14, true, true);
ctx.restore();
}
function drawScannerConnectors(ctx, game) { function drawScannerConnectors(ctx, game) {
ctx.save(); ctx.save();
ctx.lineWidth = 2; ctx.lineWidth = 2;
@ -428,11 +648,12 @@ function drawScannerConnectors(ctx, game) {
for (const item of items) { for (const item of items) {
if (!item.p) continue; if (!item.p) continue;
const p = cellCenter(item.p.col, item.p.row); const p = cellCenter(item.p.col, item.p.row);
drawScannerPortDuct(ctx, scanner, item.type, p, item.color);
const connected = scannerPortHasConveyor(game, scanner, item.type || (item.label === 'IN' ? 'inputA' : null)) || game.conveyorTiles.has(key(item.p.col, item.p.row)); const connected = scannerPortHasConveyor(game, scanner, item.type || (item.label === 'IN' ? 'inputA' : null)) || game.conveyorTiles.has(key(item.p.col, item.p.row));
ctx.fillStyle = connected ? item.color : '#f4c2c2'; ctx.fillStyle = connected ? mixHex('#1a1d23', item.color, 0.24) : '#3a1e22';
ctx.strokeStyle = THEME.ink; ctx.strokeStyle = connected ? item.color : THEME.danger;
rect(ctx, p.x - 18, p.y - 12, 36, 24, true, true); rect(ctx, p.x - 18, p.y - 12, 36, 24, true, true);
ctx.fillStyle = connected && item.color === THEME.white ? THEME.ink : THEME.white; ctx.fillStyle = THEME.white;
ctx.fillText(item.label, p.x, p.y + 4); ctx.fillText(item.label, p.x, p.y + 4);
} }
} }
@ -598,7 +819,7 @@ function drawFacilityReceiver(ctx, game, facility) {
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();
ctx.fillStyle = THEME.white; ctx.fillStyle = '#191c22';
ctx.strokeStyle = info.color; ctx.strokeStyle = info.color;
ctx.lineWidth = 5; ctx.lineWidth = 5;
rect(ctx, c.x - 22, c.y - 22, 44, 44, true, true); rect(ctx, c.x - 22, c.y - 22, 44, 44, true, true);
@ -612,6 +833,90 @@ function drawFacilityReceiver(ctx, game, facility) {
ctx.fillText(info.title, c.x, c.y + 20); ctx.fillText(info.title, c.x, c.y + 20);
ctx.restore(); ctx.restore();
} }
function drawMetalPanel(ctx, x, y, w, h, fill = '#252b34', stroke = '#08090c', accent = '#606a78') {
ctx.save();
const g = ctx.createLinearGradient(x, y, x, y + h);
g.addColorStop(0, mixHex(fill, '#ffffff', 0.12));
g.addColorStop(0.55, fill);
g.addColorStop(1, mixHex(fill, '#000000', 0.28));
ctx.fillStyle = g;
ctx.strokeStyle = stroke;
ctx.lineWidth = 4;
rect(ctx, x, y, w, h, true, true);
ctx.fillStyle = 'rgba(255,255,255,.08)';
ctx.fillRect(x + 5, y + 5, Math.max(0, w - 10), 3);
ctx.strokeStyle = 'rgba(0,0,0,.34)';
ctx.lineWidth = 1;
for (let yy = y + 11; yy < y + h - 8; yy += 13) { ctx.beginPath(); ctx.moveTo(x + 6, yy); ctx.lineTo(x + w - 6, yy); ctx.stroke(); }
ctx.fillStyle = accent;
for (const [rx, ry] of [[x + 8, y + 8], [x + w - 8, y + 8], [x + 8, y + h - 8], [x + w - 8, y + h - 8]]) {
ctx.beginPath(); ctx.arc(rx, ry, 2.2, 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
}
function drawFacilityDetails(ctx, f, accent = THEME.green) {
ctx.save();
ctx.strokeStyle = '#06070a'; ctx.lineWidth = 4;
ctx.beginPath(); ctx.moveTo(f.x + 12, f.y + f.h - 18); ctx.lineTo(f.x + f.w - 12, f.y + f.h - 18); ctx.stroke();
ctx.strokeStyle = accent; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(f.x + 16, f.y + 22); ctx.lineTo(f.x + f.w - 18, f.y + 22); ctx.stroke();
ctx.fillStyle = '#20242b'; ctx.strokeStyle = '#07080a'; ctx.lineWidth = 2;
rect(ctx, f.x + f.w - 44, f.y + 12, 30, 22, true, true);
ctx.fillStyle = accent; ctx.fillRect(f.x + f.w - 38, f.y + 18, 6, 6);
ctx.fillStyle = THEME.danger; ctx.fillRect(f.x + f.w - 27, f.y + 18, 6, 6);
ctx.strokeStyle = '#47515f'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(f.x + 12, f.y + f.h - 35); ctx.bezierCurveTo(f.x + 42, f.y + f.h - 52, f.x + 60, f.y + f.h - 20, f.x + 88, f.y + f.h - 38); ctx.stroke();
ctx.restore();
}
function drawFacilityTexture(ctx, image, f, maxCells = 1.45) {
if (!image?.loaded) return false;
// Keep external-machine sprites at roughly the same pixel density as a grid object.
const maxSize = GRID.cell * Math.min(1.45, maxCells);
const size = Math.max(42, Math.min(maxSize, f.w - 34, f.h - 42));
const x = f.x + Math.round((f.w - size) / 2);
const y = f.y + Math.round((f.h - size) / 2) + 2;
return drawImageIfLoaded(ctx, image, x, y, size, size);
}
function roomBounds(room) {
const first = cellCenter(room.col, room.row);
return { x: first.x - GRID.cell / 2, y: first.y - GRID.cell / 2, w: GRID.cell * 2, h: GRID.cell * 2, cx: first.x + GRID.cell / 2, cy: first.y + GRID.cell / 2 };
}
function drawMaintenanceRooms(ctx, game) {
for (const room of game.maintenanceRooms || []) {
const b = roomBounds(room);
ctx.save();
drawMetalPanel(ctx, b.x + 2, b.y + 2, b.w - 4, b.h - 4, '#343b45', '#0a0b0e', '#7a6750');
if (!drawImageIfLoaded(ctx, assets.maintenanceRoom, b.x + 5, b.y + 5, b.w - 10, b.h - 10)) {
ctx.fillStyle = '#414a55'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3;
rect(ctx, b.x + 8, b.y + 8, b.w - 16, b.h - 16, true, true);
// Storage shelves, spare-parts crates, a workbench, lockers and a small break stool.
ctx.fillStyle = '#6e573e'; ctx.strokeStyle = '#111318'; ctx.lineWidth = 2;
rect(ctx, b.x + 13, b.y + 14, 24, 51, true, true);
ctx.strokeStyle = '#b58a52';
for (const yy of [b.y + 29, b.y + 44, b.y + 59]) { ctx.beginPath(); ctx.moveTo(b.x + 14, yy); ctx.lineTo(b.x + 36, yy); ctx.stroke(); }
ctx.fillStyle = '#515b68'; ctx.strokeStyle = '#111318';
rect(ctx, b.x + b.w - 34, b.y + 13, 20, 43, true, true);
ctx.fillStyle = '#d49d28'; ctx.fillRect(b.x + b.w - 21, b.y + 32, 3, 3);
ctx.fillStyle = '#735431'; ctx.strokeStyle = '#111318';
rect(ctx, b.x + 41, b.y + b.h - 31, 34, 14, true, true);
ctx.fillStyle = '#1b2027'; ctx.fillRect(b.x + 46, b.y + b.h - 39, 24, 8);
ctx.fillStyle = '#7b8795'; ctx.fillRect(b.x + 51, b.y + b.h - 37, 4, 5);
ctx.fillStyle = '#805b35'; ctx.strokeStyle = '#111318';
rect(ctx, b.x + b.w - 32, b.y + b.h - 28, 16, 12, true, true);
ctx.fillStyle = '#222831'; ctx.beginPath(); ctx.arc(b.x + b.w - 46, b.y + b.h - 20, 7, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#ddd0ad'; ctx.font = '900 8px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText('STORE / REST', b.cx, b.y + 11);
}
drawDirtOverlay(ctx, b.x + 5, b.y + 5, b.w - 10, b.h - 10, room);
drawSelection(ctx, game, 'maintenanceRoom', room.id, b.cx, b.cy, b.w + 6, b.h + 6);
ctx.restore();
}
}
function drawFacilities(ctx, game) { function drawFacilities(ctx, game) {
for (const f of Object.values(game.facilities || {})) { for (const f of Object.values(game.facilities || {})) {
const kind = facilityKind(f); const kind = facilityKind(f);
@ -630,24 +935,29 @@ function drawExternalDuct(ctx, f) {
else if (f.side === 'top') { bx = c.x; by = f.y + f.h; } else if (f.side === 'top') { bx = c.x; by = f.y + f.h; }
else if (f.side === 'bottom') { bx = c.x; by = f.y; } else if (f.side === 'bottom') { bx = c.x; by = f.y; }
ctx.save(); ctx.save();
ctx.strokeStyle = THEME.ink; ctx.strokeStyle = '#07080a';
ctx.lineWidth = 9; ctx.lineWidth = 11;
ctx.lineCap = 'round'; ctx.lineCap = 'round';
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.strokeStyle = THEME.white; ctx.lineWidth = 5; ctx.strokeStyle = '#555f70'; ctx.lineWidth = 5;
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, 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; } drawMetalPanel(ctx, m.x, m.y, m.w, m.h, '#2b333d', THEME.ink, THEME.mixerBlue);
ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, m.x, m.y, m.w, m.h, true, true); drawFacilityDetails(ctx, m, THEME.mixerBlue);
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.white; ctx.font = '900 14px ui-monospace, monospace'; ctx.textAlign = 'left';
ctx.font = '900 12px ui-monospace, monospace'; ctx.fillText(`PAY ${yen(upgradedMixerPrice(game))}`, m.x + m.w / 2, m.y + 48); ctx.fillText(`MIXER L${m.level}`, m.x + 14, m.y + 18);
ctx.fillStyle = THEME.muted; ctx.font = '900 10px ui-monospace, monospace';
ctx.fillText(`PAY ${yen(upgradedMixerPrice(game))}`, m.x + 14, m.y + 34);
const drew = drawFacilityTexture(ctx, assets.mixerCompact.loaded ? assets.mixerCompact : assets.mixer, m, 2.05);
if (!drew) {
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 + m.h / 2 + 6, 11 + i * 8, 0, Math.PI * 1.5); ctx.stroke(); }
}
drawTargetBadge(ctx, m.x + 14, m.y + m.h - 40, m.w - 28, 'SEND MALE', 'male', 'safe meat route'); drawTargetBadge(ctx, m.x + 14, m.y + m.h - 40, m.w - 28, 'SEND MALE', 'male', 'safe meat route');
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(); }
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', m.id, m.x + m.w / 2, m.y + m.h / 2, m.w + 12, m.h + 12);
ctx.restore(); ctx.restore();
@ -655,10 +965,15 @@ function drawMixer(ctx, game, m = game.facilities.mixer) {
function drawTrash(ctx, game, t = game.facilities.trash) { function drawTrash(ctx, game, 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; } drawMetalPanel(ctx, t.x, t.y, t.w, t.h, '#29342d', THEME.ink, THEME.wasteGreen);
ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, t.x, t.y, t.w, t.h, true, true); drawFacilityDetails(ctx, t, THEME.wasteGreen);
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.white; ctx.font = '900 14px ui-monospace, monospace'; ctx.textAlign = 'left';
for (let i = 0; i < 7; i += 1) { ctx.fillRect(t.x + 34 + i * 16, t.y + 48, 7, t.h - 66); } ctx.fillText(`SHREDDER L${t.level}`, t.x + 14, t.y + 18);
const drew = drawFacilityTexture(ctx, assets.shredderCompact.loaded ? assets.shredderCompact : assets.shredder, t, 1.85);
if (!drew) {
ctx.fillStyle = '#0a0b0e';
for (let i = 0; i < 7; i += 1) ctx.fillRect(t.x + 40 + i * 12, t.y + 42, 6, Math.max(20, t.h - 72));
}
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', t.id, t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12);
@ -667,14 +982,18 @@ function drawTrash(ctx, game, t = game.facilities.trash) {
function drawTruck(ctx, game, t = game.facilities.truck) { function drawTruck(ctx, game, 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; } drawMetalPanel(ctx, t.x, t.y, t.w, t.h, '#343128', THEME.ink, THEME.truckPink);
ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, t.x, t.y, t.w, t.h, true, true); drawFacilityDetails(ctx, t, THEME.truckPink);
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.white; ctx.font = '900 14px ui-monospace, monospace'; ctx.textAlign = 'left';
ctx.font = '900 11px ui-monospace, monospace'; ctx.fillText(`UNIT ${yen(upgradedTruckPrice(game))}`, t.x + t.w / 2, t.y + 42); ctx.fillText(`TRUCK L${t.level}`, t.x + 14, t.y + 18);
ctx.fillStyle = THEME.muted; ctx.font = '900 10px ui-monospace, monospace';
ctx.fillText(`UNIT ${yen(upgradedTruckPrice(game))}`, t.x + 14, t.y + 34);
drawFacilityTexture(ctx, assets.truckCompact.loaded ? assets.truckCompact : assets.truck, t, 2.0);
const truckTargetType = targetForTruck(game); const truckTargetType = targetForTruck(game);
drawTargetBadge(ctx, t.x + 15, t.y + 50, t.w - 30, `SEND ${truckTargetType.toUpperCase()}`, truckTargetType, game.eventOffer && !game.eventActive ? 'next event' : 'truck cargo'); drawTargetBadge(ctx, t.x + 15, t.y + 50, t.w - 30, `SEND ${truckTargetType.toUpperCase()}`, truckTargetType, game.eventOffer && !game.eventActive ? '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 = 'rgba(39,60,36,.9)'; rect(ctx, t.x + 13, t.y + 88, t.w - 26, Math.max(18, 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');
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', t.id, t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12);
ctx.restore(); ctx.restore();
} }
@ -695,13 +1014,32 @@ function drawChick(ctx, chick, active, game) {
if (chick.sex === 'poop') drawPoop(ctx, chick.x, y, chick.radius); if (chick.sex === 'poop') drawPoop(ctx, chick.x, y, chick.radius);
else { else {
const img = chick.sex === 'male' ? assets.chickMale : assets.chickFemale; const img = chick.sex === 'male' ? assets.chickMale : assets.chickFemale;
if (!drawImageIfLoaded(ctx, img, chick.x - chick.radius, y - chick.radius, chick.radius * 2, chick.radius * 2)) { const spriteDrawn = drawImageIfLoaded(ctx, img, chick.x - chick.radius, y - chick.radius, chick.radius * 2, chick.radius * 2);
ctx.fillStyle = chick.sex === 'male' ? THEME.male : THEME.female; if (!spriteDrawn) {
const bodyColor = chick.sex === 'male' ? THEME.male : THEME.female;
const accent = chick.sex === 'male' ? '#164f8f' : '#8b2252';
ctx.fillStyle = bodyColor;
ctx.strokeStyle = THEME.ink; ctx.lineWidth = 5; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 5;
ctx.beginPath(); ctx.arc(chick.x, y, chick.radius, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.beginPath(); ctx.arc(chick.x, y, chick.radius, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,.24)'; ctx.beginPath(); ctx.arc(chick.x - 5, y - 7, chick.radius * .36, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = THEME.ink; ctx.beginPath(); ctx.arc(chick.x - 6, y - 5, 2.5, 0, Math.PI * 2); ctx.arc(chick.x + 6, y - 5, 2.5, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = THEME.ink; ctx.beginPath(); ctx.arc(chick.x - 6, y - 5, 2.5, 0, Math.PI * 2); ctx.arc(chick.x + 6, y - 5, 2.5, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#ffaa2e'; ctx.beginPath(); ctx.moveTo(chick.x, y + 2); ctx.lineTo(chick.x + 9, y + 6); ctx.lineTo(chick.x, y + 10); ctx.closePath(); ctx.fill(); ctx.fillStyle = '#ffaa2e'; ctx.beginPath(); ctx.moveTo(chick.x, y + 2); ctx.lineTo(chick.x + 9, y + 6); ctx.lineTo(chick.x, y + 10); ctx.closePath(); ctx.fill();
ctx.strokeStyle = accent; ctx.lineWidth = 3;
if (chick.sex === 'male') {
ctx.beginPath(); ctx.moveTo(chick.x - 8, y - chick.radius - 2); ctx.lineTo(chick.x + 8, y - chick.radius - 2); ctx.stroke();
} else {
ctx.beginPath(); ctx.moveTo(chick.x - 9, y - chick.radius + 2); ctx.lineTo(chick.x, y - chick.radius - 6); ctx.lineTo(chick.x + 9, y - chick.radius + 2); ctx.stroke();
}
} }
// Symbols remain legible even when custom sprites are replaced.
ctx.fillStyle = chick.sex === 'male' ? '#dff1ff' : '#fff0f7';
ctx.strokeStyle = '#050609';
ctx.lineWidth = 3;
ctx.font = `900 ${Math.max(10, Math.round(chick.radius * .72))}px ui-monospace, monospace`;
ctx.textAlign = 'center';
const sexMark = chick.sex === 'male' ? '♂' : '♀';
ctx.strokeText(sexMark, chick.x + chick.radius * .54, y + chick.radius * .70);
ctx.fillText(sexMark, chick.x + chick.radius * .54, y + chick.radius * .70);
} }
ctx.restore(); ctx.restore();
} }
@ -721,8 +1059,14 @@ function drawPoop(ctx, x, y, radius) {
} }
function drawTinyPoop(ctx, x, y) { drawPoop(ctx, x, y, 7); } function drawTinyPoop(ctx, x, y) { drawPoop(ctx, x, y, 7); }
function drawTinyChick(ctx, x, y, sex, warning = false) { function drawTinyChick(ctx, x, y, sex, warning = false) {
ctx.save(); ctx.fillStyle = sex === 'male' ? THEME.male : THEME.female; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 2; ctx.save();
const bodyColor = sex === 'male' ? THEME.male : THEME.female;
const accent = sex === 'male' ? '#164f8f' : '#8b2252';
ctx.fillStyle = bodyColor; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.strokeStyle = accent; ctx.lineWidth = 2;
if (sex === 'male') { ctx.beginPath(); ctx.moveTo(x - 4, y - 8); ctx.lineTo(x + 4, y - 8); ctx.stroke(); }
else { ctx.beginPath(); ctx.moveTo(x - 5, y - 7); ctx.lineTo(x, y - 11); ctx.lineTo(x + 5, y - 7); ctx.stroke(); }
if (warning) { ctx.fillStyle = THEME.danger; ctx.font = '900 12px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText('!', x, y + 15); } if (warning) { ctx.fillStyle = THEME.danger; ctx.font = '900 12px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText('!', x, y + 15); }
ctx.restore(); ctx.restore();
} }
@ -784,9 +1128,12 @@ 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 dynamiteUsed = Math.max(0, game.cardTargetPick?.used || 0);
const msg = game.cardTargetPick?.mode === 'autoScannerMenu' const msg = game.cardTargetPick?.mode === 'autoScannerMenu'
? 'AUTO SCANNER ROUTE SETTINGS. CLICK EMPTY AREA TO CLOSE.' ? '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.'; : isBlockedCellMode
? `DYNAMITE: CLICK ANY BLOCKED TILE (${remaining} LEFT). ESC ${dynamiteUsed > 0 ? 'FINISHES' : 'CANCELS'}.`
: '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';
@ -835,10 +1182,19 @@ function drawFloatingTexts(ctx, game) {
} }
function drawRepairman(ctx, game) { function drawRepairman(ctx, game) {
const r = game.repairman; const list = (game.repairmen && game.repairmen.length) ? game.repairmen : (game.repairman ? [game.repairman] : []);
const visible = !!r?.active || (game.phase === 'build' && !!r?.hiredForNextDay); for (const r of list) drawSingleRepairman(ctx, game, r);
}
function drawSingleRepairman(ctx, game, r) {
const visible = !!r?.active;
if (!visible) return; if (!visible) return;
ctx.save(); ctx.save();
if (drawImageIfLoaded(ctx, assets.repairman, r.x - 18, r.y - 18, 36, 36)) {
if (r.target?.label) label(ctx, r.x, r.y - 24, r.target.label, THEME.green);
ctx.restore();
return;
}
ctx.lineWidth = 3; ctx.lineWidth = 3;
ctx.fillStyle = '#fff0d1'; ctx.fillStyle = '#fff0d1';
ctx.strokeStyle = THEME.ink; ctx.strokeStyle = THEME.ink;
@ -862,19 +1218,15 @@ function drawRepairman(ctx, game) {
ctx.fillStyle = THEME.ink; ctx.fillStyle = THEME.ink;
ctx.font = '900 11px ui-monospace, monospace'; ctx.font = '900 11px ui-monospace, monospace';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText(r.active ? 'FIX' : 'HIRED', r.x, r.y + 33); ctx.fillText('FIX', r.x, r.y + 33);
if (r.target?.label) label(ctx, r.x, r.y - 24, r.target.label, THEME.green); if (r.target?.label) label(ctx, r.x, r.y - 24, r.target.label, THEME.green);
else if (!r.active && r.hiredForNextDay) label(ctx, r.x, r.y - 24, 'NEXT DAY', THEME.green);
ctx.restore(); ctx.restore();
} }
function drawCanvasHints(ctx, game, canvas) { function drawCanvasHints(ctx, game, canvas) {
ctx.save(); ctx.save();
ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'left'; ctx.fillStyle = THEME.ink; ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'left'; ctx.fillStyle = THEME.ink;
// Visible version marker helps avoid browser/file-cache confusion when testing zips.
ctx.fillStyle = 'rgba(255,255,255,.92)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3;
rect(ctx, canvas.width - 284, 18, 260, 34, true, true);
ctx.fillStyle = THEME.green; ctx.font = '900 12px ui-monospace, monospace'; ctx.fillText(VERSION, canvas.width - 270, 40);
ctx.font = '900 19px ui-monospace, monospace'; ctx.fillStyle = THEME.ink; ctx.font = '900 19px ui-monospace, monospace'; ctx.fillStyle = THEME.ink;
const lines = []; const lines = [];
if (game.phase === 'running') lines.push(game.timeLeft <= 0 ? 'TIME UP: FARMS SHUT. CLEAR LINE.' : 'A/D: S1 ←/→: S2'); if (game.phase === 'running') lines.push(game.timeLeft <= 0 ? 'TIME UP: FARMS SHUT. CLEAR LINE.' : 'A/D: S1 ←/→: S2');

View file

@ -1,8 +1,8 @@
import { BALANCE } from '../core/balance.js'; import { BALANCE } from '../core/balance.js';
import { DIRS, MACHINE_FACILITY_IDS, GRID, THEME } from '../core/config.js'; import { DIRS, 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, createMaintenanceRoom, maintenanceRoomFootprint } from '../core/entities.js';
import { generateBlockedCellsInRect } from '../core/mapGen.js'; import { generateBlockedCellsInRect, blockedRatioForExpansionPurchase } 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';
@ -92,13 +92,18 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
game.gridExpansionPurchases = (game.gridExpansionPurchases || 0) + 1; game.gridExpansionPurchases = (game.gridExpansionPurchases || 0) + 1;
game.gridExpansionPurchasesByDirection[direction] = (game.gridExpansionPurchasesByDirection[direction] || 0) + 1; game.gridExpansionPurchasesByDirection[direction] = (game.gridExpansionPurchasesByDirection[direction] || 0) + 1;
syncGridSizeToGame(); syncGridSizeToGame();
const blockedRatio = blockedRatioForExpansionPurchase(game.gridExpansionPurchases, {
base: BALANCE.map.blockedRatio ?? 0.03,
increment: BALANCE.map.blockedExpansionIncrement ?? 0.005,
max: BALANCE.map.blockedRatioMax ?? 0.10
});
const blocked = generateBlockedCellsInRect({ const blocked = generateBlockedCellsInRect({
seed: `${game.runSeed}:grid-expansion:${lot.chunkX},${lot.chunkY}:${game.gridExpansionPurchases}`, seed: `${game.runSeed}:grid-expansion:${lot.chunkX},${lot.chunkY}:${game.gridExpansionPurchases}`,
colStart: lot.colStart, colStart: lot.colStart,
colEnd: lot.colStart + lot.cols, colEnd: lot.colStart + lot.cols,
rowStart: lot.rowStart, rowStart: lot.rowStart,
rowEnd: lot.rowStart + lot.rows, rowEnd: lot.rowStart + lot.rows,
targetRatio: BALANCE.map.blockedRatio targetRatio: blockedRatio
}); });
for (const k of blocked) game.blockedCells.add(k); for (const k of blocked) game.blockedCells.add(k);
refreshRoutingAfterEdit(game); refreshRoutingAfterEdit(game);
@ -106,7 +111,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
const b = lotBounds(lot); const b = lotBounds(lot);
floating(game, b.x + b.w / 2, b.y + b.h / 2, `${direction.toUpperCase()} +${addedCells} CELLS -${yen(cost)}`, THEME.green); floating(game, b.x + b.w / 2, b.y + b.h / 2, `${direction.toUpperCase()} +${addedCells} CELLS -${yen(cost)}`, THEME.green);
updatePanels(); updatePanels();
return { ok: true, direction, cost, addedCells, blocked: blocked.size, chunkX: lot.chunkX, chunkY: lot.chunkY }; return { ok: true, direction, cost, addedCells, blocked: blocked.size, blockedRatio, chunkX: lot.chunkX, chunkY: lot.chunkY };
} }
function pointInGrid(col, row) { return col >= 0 && col < GRID.cols && row >= 0 && row < GRID.rows && isOwnedCell(game, col, row); } function pointInGrid(col, row) { return col >= 0 && col < GRID.cols && row >= 0 && row < GRID.rows && isOwnedCell(game, col, row); }
@ -129,10 +134,32 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
if (existingFarm && !(moving?.type === 'eggFarm' && moving.ref?.id === existingFarm.id)) return true; if (existingFarm && !(moving?.type === 'eggFarm' && moving.ref?.id === existingFarm.id)) return true;
const existingScanner = scannerAt(game, col, row); const existingScanner = scannerAt(game, col, row);
if (existingScanner && !(moving?.type === 'scanner' && moving.ref?.id === existingScanner.id)) return true; if (existingScanner && !(moving?.type === 'scanner' && moving.ref?.id === existingScanner.id)) return true;
const existingRoom = maintenanceRoomAt(game, col, row);
if (existingRoom && !(moving?.type === 'maintenanceRoom' && moving.ref?.id === existingRoom.id)) return true;
if (game.conveyorTiles.has(k) && !(moving?.type === 'conveyor' && moving.oldKey === k)) return true; if (game.conveyorTiles.has(k) && !(moving?.type === 'conveyor' && moving.oldKey === k)) return true;
return false; return false;
} }
function maintenanceRoomAt(gameRef, col, row) {
return (gameRef.maintenanceRooms || []).find(room => maintenanceRoomFootprint(room).some(cell => cell.col === col && cell.row === row)) || null;
}
function maintenanceRoomPlacementBlocked(col, row, moving = null) {
const draft = { col, row };
for (const cell of maintenanceRoomFootprint(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) return true;
const existingRoom = maintenanceRoomAt(game, cell.col, cell.row);
if (existingRoom && !(moving?.type === 'maintenanceRoom' && moving.ref?.id === existingRoom.id)) return true;
if (game.conveyorTiles.has(k)) return true;
}
return false;
}
function scannerPlacementBlocked(col, row, moving = null) { function scannerPlacementBlocked(col, row, moving = null) {
const draft = { col, row }; const draft = { col, row };
for (const cell of scannerFootprintCells(draft)) { for (const cell of scannerFootprintCells(draft)) {
@ -153,6 +180,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
if (game.selected.type === 'scanner') return game.scanners.find(s => s.id === game.selected.id) || null; if (game.selected.type === 'scanner') return game.scanners.find(s => s.id === game.selected.id) || null;
if (game.selected.type === 'conveyor') return game.conveyorTiles.has(game.selected.id) ? { type: 'conveyor', id: game.selected.id } : null; if (game.selected.type === 'conveyor') return game.conveyorTiles.has(game.selected.id) ? { type: 'conveyor', id: game.selected.id } : null;
if (game.selected.type === 'facility') return game.facilities[game.selected.id] || null; if (game.selected.type === 'facility') return game.facilities[game.selected.id] || null;
if (game.selected.type === 'maintenanceRoom') return (game.maintenanceRooms || []).find(r => r.id === game.selected.id) || null;
return null; return null;
} }
@ -162,6 +190,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
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 game.conveyorMeta.get(obj.id)?.kind === 'boostConveyor' ? equipmentName('boostConveyor') : equipmentName('conveyor');
if (obj.type === 'facility') return obj.name; if (obj.type === 'facility') return obj.name;
if (obj.type === 'maintenanceRoom') return `${equipmentName('maintenanceRoom')} #${obj.id}`;
return 'Equipment'; return 'Equipment';
} }
@ -266,16 +295,18 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
return connected; return connected;
} }
function branchSelectableEntranceDirsForKey(k) { function branchSelectableExitDirsForKey(k) {
const cell = parseKey(k); const cell = parseKey(k);
const connected = branchExitDirsForKey(k); const connected = branchExitDirsForKey(k);
if (connected.length < 3) return []; if (connected.length < 3) return [];
const meta = game.conveyorMeta.get(k) || {};
const physicalEntrances = new Set(connected.filter(dir => neighborPointsIntoCell(cell, dir))); const physicalEntrances = new Set(connected.filter(dir => neighborPointsIntoCell(cell, dir)));
return connected.filter(dir => { const physicalExits = connected.filter(dir => !physicalEntrances.has(dir));
const entrances = new Set(physicalEntrances); if (physicalEntrances.size && physicalExits.length >= 2) return physicalExits;
entrances.add(dir); const explicit = [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))]
return connected.filter(item => !entrances.has(item)).length >= 2; .filter(dir => connected.includes(dir));
}); if (explicit.length >= 2) return explicit;
return connected;
} }
function markConveyorDirection(from, to) { function markConveyorDirection(from, to) {
@ -502,6 +533,10 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
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 === 'maintenanceRoom') {
if (maintenanceRoomPlacementBlocked(col, row)) return fail(TEXT.fail.cellOccupied);
return buildMaintenanceRoom(col, row);
}
if ((game.buildTool === 'manualScanner' || game.buildTool === 'autoScanner') ? scannerPlacementBlocked(col, row) : isEquipmentCell(col, row)) return fail(TEXT.fail.cellOccupied); if ((game.buildTool === 'manualScanner' || game.buildTool === 'autoScanner') ? scannerPlacementBlocked(col, row) : 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');
@ -510,7 +545,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', 'boostConveyor', 'eggFarm', 'manualScanner', 'autoScanner', 'maintenanceRoom'].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);
} }
@ -542,6 +577,26 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(cost)}`, THEME.ink); floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(cost)}`, THEME.ink);
} }
function buildMaintenanceRoom(col, row) {
const cost = buildPrice('maintenanceRoom', game);
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
if (maintenanceRoomPlacementBlocked(col, row)) return fail(TEXT.fail.cellOccupied);
record(game);
spendCash(game, cost);
const room = applyBuildQuality(createMaintenanceRoom(game, col, row));
room.price = cost;
game.maintenanceRooms = game.maintenanceRooms || [];
game.maintenanceRooms.push(room);
game.selected = { type: 'maintenanceRoom', id: room.id };
const center = roomCenter(room);
floating(game, center.x, center.y, `-${yen(cost)}`, THEME.ink);
}
function roomCenter(room) {
const first = cellCenter(room.col, room.row);
return { x: first.x + GRID.cell / 2, y: first.y + GRID.cell / 2 };
}
function buildFacility(p, id) { function buildFacility(p, id) {
const cost = buildPrice(id, game); const cost = buildPrice(id, game);
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash); if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
@ -603,6 +658,13 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
eraseEffect(game, f.x + f.w / 2, f.y + f.h / 2); eraseEffect(game, f.x + f.w / 2, f.y + f.h / 2);
refreshRoutingAfterEdit(game); refreshRoutingAfterEdit(game);
} }
if (hit.type === 'maintenanceRoom') {
const room = hit.ref;
const center = roomCenter(room);
refundOrSell(hit, center.x, center.y);
game.maintenanceRooms = (game.maintenanceRooms || []).filter(r => r.id !== room.id);
eraseEffect(game, center.x, center.y);
}
game.selected = null; game.selected = null;
game.multiSelected = []; game.multiSelected = [];
updatePanels(); updatePanels();
@ -623,6 +685,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
if (farm) return { type: 'eggFarm', ref: farm }; if (farm) return { type: 'eggFarm', ref: farm };
const scanner = scannerAt(game, cell.col, cell.row); const scanner = scannerAt(game, cell.col, cell.row);
if (scanner) return { type: 'scanner', ref: scanner }; if (scanner) return { type: 'scanner', ref: scanner };
const room = maintenanceRoomAt(game, cell.col, cell.row);
if (room) return { type: 'maintenanceRoom', ref: room };
const k = key(cell.col, cell.row); const k = key(cell.col, cell.row);
if (game.conveyorTiles.has(k)) return { type: 'conveyor', oldKey: k, ref: { type: 'conveyor', id: k } }; if (game.conveyorTiles.has(k)) return { type: 'conveyor', oldKey: k, ref: { type: 'conveyor', id: k } };
return null; return null;
@ -641,6 +705,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
else if (obj.type === 'eggFarm') eraseAtPoint(cellCenter(obj.col, obj.row)); else if (obj.type === 'eggFarm') eraseAtPoint(cellCenter(obj.col, obj.row));
else if (obj.type === 'scanner') eraseAtPoint(scannerCenter(obj)); else if (obj.type === 'scanner') eraseAtPoint(scannerCenter(obj));
else if (obj.type === 'facility') eraseAtPoint({ x: obj.x + obj.w / 2, y: obj.y + obj.h / 2 }); else if (obj.type === 'facility') eraseAtPoint({ x: obj.x + obj.w / 2, y: obj.y + obj.h / 2 });
else if (obj.type === 'maintenanceRoom') eraseAtPoint(roomCenter(obj));
} }
function modalButton(text, onClick, className = 'primary-button') { function modalButton(text, onClick, className = 'primary-button') {
@ -798,6 +863,14 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
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'));
if (obj.type === 'conveyor' && isBranchKey(obj.id)) {
const mode = game.conveyorMeta.get(obj.id)?.branchMode || 'random';
ui.modalActions.appendChild(modalButton(`Branch: ${branchModeLabel(mode)}`, () => {
cycleBranchModeForKey(obj.id);
game.selected = { type: 'conveyor', id: obj.id };
showSelectedMenu();
}, 'facility-action warn branch-action'));
}
const hit = obj.type === 'conveyor' ? { type: 'conveyor', oldKey: obj.id, ref: obj } : { type: obj.type, ref: obj }; const hit = obj.type === 'conveyor' ? { type: 'conveyor', oldKey: obj.id, ref: obj } : { type: obj.type, ref: obj };
const saleReason = lastProtectedSaleReason(hit); const saleReason = lastProtectedSaleReason(hit);
if (saleReason) { if (saleReason) {
@ -829,6 +902,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
boxes.push({ id: `facility:${id}`, price: equipmentPrice({ type: 'facility', ref: f }), x: f.x, y: f.y, w: f.w, h: f.h }); boxes.push({ id: `facility:${id}`, price: equipmentPrice({ type: 'facility', ref: f }), x: f.x, y: f.y, w: f.w, h: f.h });
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 room of game.maintenanceRooms || []) { const c = roomCenter(room); boxes.push({ id: `maintenanceRoom:${room.id}`, price: equipmentPrice({ type: 'maintenanceRoom', ref: room }), x: c.x - GRID.cell, y: c.y - GRID.cell, w: GRID.cell * 2, h: GRID.cell * 2 }); }
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 - 50, y: c.y - 73, w: 100, h: 146 }); }
for (const k of game.conveyorTiles) { for (const k of game.conveyorTiles) {
@ -869,19 +943,19 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function cycleBranchModeAtPoint(p) { function cycleBranchModeAtPoint(p) {
const k = branchSwitcherAtPoint(p); const k = branchSwitcherAtPoint(p);
if (!k) return false; if (!k) return false;
cycleBranchModeForKey(k); return cycleBranchModeForKey(k);
return true;
} }
function cycleBranchModeForKey(k) { function cycleBranchModeForKey(k) {
const meta = game.conveyorMeta.get(k); const meta = game.conveyorMeta.get(k);
if (!meta) return false; if (!meta) return false;
const reachable = branchSelectableEntranceDirsForKey(k); const reachable = branchSelectableExitDirsForKey(k);
if (reachable.length < 2) return false; if (reachable.length < 2) return false;
record(game); record(game);
const current = BRANCH_MODES.includes(meta.branchMode) ? meta.branchMode : 'random'; const current = BRANCH_MODES.includes(meta.branchMode) ? meta.branchMode : 'random';
const modes = ['random', ...DIRS.map(d => d.name).filter(mode => reachable.includes(mode))]; 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]; meta.branchMode = modes[(Math.max(0, modes.indexOf(current)) + 1) % modes.length];
refreshRoutingAfterEdit(game);
game.selected = null; game.selected = null;
game.multiSelected = []; game.multiSelected = [];
updatePanels(); updatePanels();
@ -920,11 +994,16 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
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)) { if (isBranchKey(obj.id)) {
lines.push(`Branch mode: ${branchModeLabel(meta?.branchMode || 'random')}`); lines.push(`Branch mode: ${branchModeLabel(meta?.branchMode || 'random')}`);
lines.push('Use the Branch button below or the on-belt branch badge.'); lines.push('Click the Branch button or the large on-belt badge to cycle RANDOM / fixed exit.');
} else { } else {
lines.push('Branch mode: needs 3+ conveyor connections.'); 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 === 'maintenanceRoom') {
lines.push(`Price: ${yen(equipmentPrice({ type: 'maintenanceRoom', ref: obj }))}`);
lines.push(`Sale value: ${yen(resaleValueFor({ type: 'maintenanceRoom', ref: obj }, game).amount)}`);
lines.push('Size: 2×2 grid cells');
lines.push('Dispatch: 1 repairman during each production day');
} 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)}`); }
@ -969,6 +1048,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
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.baseId || 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.baseId || 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.baseId || obj.id) === 'truck') return 'The shipping endpoint. Correct cargo pays; wrong cargo complains.';
if (obj.type === 'maintenanceRoom') return 'A cramped 2×2 storage and break room with lockers, spare parts, a bench and one dispatched repairman.';
return 'Factory equipment.'; return 'Factory equipment.';
} }

View file

@ -213,8 +213,7 @@ function cardAtCap(game, card) {
} }
function repairmanCardVisible(game) { function repairmanCardVisible(game) {
const r = game.repairman || {}; return !!((game.maintenanceRooms || []).length || (game.repairmen || []).length || game.repairman?.active);
return !!(r.active || r.hiredForNextDay || game.pendingRepairWorkerWages > 0);
} }
function availableCards(game) { function availableCards(game) {
@ -506,8 +505,9 @@ function cardDescription(game, card) {
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.';
if (card.id === 'laborExploitation') return 'Repairman labor cost -30% permanently. One time only.'; if (card.id === 'laborExploitation') return 'Repairman dispatch paperwork is streamlined. Repair rooms keep operating; one time only.';
if (card.id === 'rescueLoan') return `At day-end bankruptcy check, gain ${yen(500)} if cash is below ${yen(0)}. Held: ${e.rescueLoanCharges || 0}.`; if (card.id === 'rescueLoan') return `At day-end bankruptcy check, gain ${yen(500)} if cash is below ${yen(0)}. Held: ${e.rescueLoanCharges || 0}.`;
if (card.id === 'dynamite') return 'Choose any blocked tiles on the map and destroy up to 3. Empty-space clicks are ignored; press Esc to finish after at least one blast.';
return card.description; return card.description;
} }
@ -610,7 +610,8 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
cardId: card.id, cardId: card.id,
mode: card.target === 'blockedCell' ? 'blockedCell' : 'equipment', mode: card.target === 'blockedCell' ? 'blockedCell' : 'equipment',
targetKeys: targets.map(targetKey), targetKeys: targets.map(targetKey),
remaining: card.id === 'dynamite' ? Math.min(3, targets.length) : 1 remaining: card.id === 'dynamite' ? Math.min(3, targets.length) : 1,
used: 0
}; };
ui.modal.classList.remove('visible', 'equipment-popover'); ui.modal.classList.remove('visible', 'equipment-popover');
updatePanels(); updatePanels();
@ -630,6 +631,7 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
floating(game, c.x, c.y - 18, 'BOOM', THEME.danger); floating(game, c.x, c.y - 18, 'BOOM', THEME.danger);
refreshRoutingAfterEdit(game); refreshRoutingAfterEdit(game);
game.cardTargetPick.remaining = Math.max(0, (game.cardTargetPick.remaining || 1) - 1); game.cardTargetPick.remaining = Math.max(0, (game.cardTargetPick.remaining || 1) - 1);
game.cardTargetPick.used = Math.max(0, game.cardTargetPick.used || 0) + 1;
game.cardTargetPick.targetKeys = targetsForCard(game, 'dynamite').map(targetKey); game.cardTargetPick.targetKeys = targetsForCard(game, 'dynamite').map(targetKey);
updatePanels(); updatePanels();
return true; return true;
@ -639,15 +641,21 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
if (!game.cardTargetPick?.pending) return false; if (!game.cardTargetPick?.pending) return false;
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) {
cancelTargetPick(); cancelTargetPick();
return true; return true;
} }
if (card.id === 'dynamite') { if (card.id === 'dynamite') {
// Empty-space clicks do nothing: the player may freely choose any highlighted tile.
if (!target) return true;
if (!applyDynamiteTarget(target)) return true; if (!applyDynamiteTarget(target)) return true;
if ((game.cardTargetPick.remaining || 0) <= 0 || !targetsForCard(game, 'dynamite').length) completePickedCard(card); if ((game.cardTargetPick.remaining || 0) <= 0 || !targetsForCard(game, 'dynamite').length) completePickedCard(card);
return true; return true;
} }
if (!target) {
cancelTargetPick();
return true;
}
applyEquipmentUpgrade(game, target); applyEquipmentUpgrade(game, target);
completePickedCard(card); completePickedCard(card);
return true; return true;
@ -655,6 +663,15 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
function cancelTargetPick() { function cancelTargetPick() {
if (!game.cardTargetPick?.pending) return; if (!game.cardTargetPick?.pending) return;
const activePick = game.cardTargetPick;
const card = cardById(activePick.cardId);
// Once a dynamite charge has been used, Escape finishes the card instead of
// returning a partially consumed card to the draft. Before the first blast,
// Escape still cancels normally.
if (card?.id === 'dynamite' && (activePick.used || 0) > 0) {
completePickedCard(card);
return;
}
game.cardTargetPick = null; game.cardTargetPick = null;
showDraft(); showDraft();
} }
@ -678,6 +695,36 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
showDraft(); showDraft();
} }
function cardIconFor(card) {
const tags = new Set(card.tags || []);
const byTarget = {
eggFarm: { type: 'image', src: './assets/dark_factory/facilities/machines/egg_farm_16.png', alt: 'EGG' },
eggOutlet: { type: 'image', src: './assets/dark_factory/facilities/machines/egg_farm_16.png', alt: 'EGG' },
autoScanner: { type: 'image', src: './assets/dark_factory/facilities/machines/auto_scanner_32x48.png', alt: 'SCAN' },
mixer: { type: 'image', src: './assets/dark_factory/facilities/machines/mixer_compact_16.png', alt: 'MIX' },
truck: { type: 'image', src: './assets/dark_factory/facilities/vehicles/truck_compact_16.png', alt: 'TRUCK' },
trash: { type: 'image', src: './assets/dark_factory/facilities/machines/shredder_compact_16.png', alt: 'SHR' },
blockedCell: { type: 'image', src: './assets/dark_factory/facilities/utility/obstacle_crate_16.png', alt: 'BLOCK' }
};
if (byTarget[card.target]) return byTarget[card.target];
if (card.id === 'bearing' || tags.has('CONVEYOR')) return { type: 'image', src: './assets/dark_factory/facilities/conveyor/conveyor_straight_h_16.png', alt: 'BELT' };
if (tags.has('SCANNER')) return { type: 'image', src: './assets/dark_factory/facilities/machines/auto_scanner_32x48.png', alt: 'SCAN' };
if (tags.has('EGG')) return { type: 'image', src: './assets/dark_factory/facilities/machines/egg_farm_16.png', alt: 'EGG' };
if (tags.has('POOP')) return { type: 'glyph', text: '💩' };
if (tags.has('MAINTENANCE')) return { type: 'image', src: './assets/dark_factory/facilities/utility/repairman_16.png', alt: 'FIX' };
if (tags.has('CARD')) return { type: 'glyph', text: '▣' };
if (tags.has('ECONOMY')) return { type: 'glyph', text: '¥' };
if (tags.has('RISK')) return { type: 'glyph', text: '!' };
return { type: 'glyph', text: '◆' };
}
function cardIconHtml(card) {
const icon = cardIconFor(card);
if (icon.type === 'image') return `<div class="card-icon image"><img src="${icon.src}" alt="${icon.alt || ''}" /></div>`;
return `<div class="card-icon glyph" aria-hidden="true">${icon.text || '◆'}</div>`;
}
function cardButton(card, index) { function cardButton(card, index) {
const b = document.createElement('button'); const b = document.createElement('button');
b.type = 'button'; b.type = 'button';
@ -685,7 +732,7 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
b.className = `card-choice ${kind}`; b.className = `card-choice ${kind}`;
const rarity = card.type === 'dud' ? 'MISS' : (card.rarity === 'ultraRare' ? 'ULTRA RARE' : (card.rarity === 'rare' ? 'RARE' : 'COMMON')); const rarity = card.type === 'dud' ? 'MISS' : (card.rarity === 'ultraRare' ? 'ULTRA RARE' : (card.rarity === 'rare' ? 'RARE' : 'COMMON'));
const tags = [rarity, ...(card.tags || []).filter(tag => tag !== rarity)].slice(0, 5); const tags = [rarity, ...(card.tags || []).filter(tag => tag !== rarity)].slice(0, 5);
b.innerHTML = `<strong>${card.title}</strong><div class="card-tags">${tags.map(tag => `<span>${tag}</span>`).join('')}</div><small>${cardDescription(game, card)}</small>`; b.innerHTML = `${cardIconHtml(card)}<div class="card-copy"><strong>${card.title}</strong><div class="card-tags">${tags.map(tag => `<span>${tag}</span>`).join('')}</div><small>${cardDescription(game, card)}</small></div>`;
b.addEventListener('click', () => chooseCard(card, b)); b.addEventListener('click', () => chooseCard(card, b));
b.dataset.index = String(index); b.dataset.index = String(index);
return b; return b;

View file

@ -249,15 +249,13 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
const connected = conveyorNeighborDirNames(cell); const connected = conveyorNeighborDirNames(cell);
if (connected.length < 3) return dirs; if (connected.length < 3) return dirs;
const entrances = new Set(connected.filter(dir => neighborPointsIntoCell(cell, dir))); const entrances = new Set(connected.filter(dir => neighborPointsIntoCell(cell, dir)));
if (branchMode && branchMode !== 'random' && branchMode !== 'pass' && connected.includes(branchMode)) { const physicalExits = connected.filter(dir => !entrances.has(dir));
const selectedEntrances = new Set(entrances); let candidates = entrances.size && physicalExits.length >= 2
selectedEntrances.add(branchMode); ? physicalExits
if (connected.filter(dir => !selectedEntrances.has(dir)).length >= 2) entrances.add(branchMode); : [...new Set(dirs.filter(dir => connected.includes(dir)))];
} if (candidates.length < 2) candidates = connected;
const exits = connected.filter(dir => !entrances.has(dir)); if (branchMode && branchMode !== 'random' && branchMode !== 'pass' && candidates.includes(branchMode)) return [branchMode];
if (exits.length) return exits; return candidates;
const fallback = dirs.filter(dir => !entrances.has(dir));
return fallback.length ? fallback : dirs;
} }
function optionAvailableForBranch(chick, opt) { function optionAvailableForBranch(chick, opt) {

View file

@ -25,6 +25,7 @@ export function equipmentBasePrice(objOrHit) {
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;
if (type === 'maintenanceRoom') return obj.price || FACILITY_DEFS.maintenanceRoom.price;
return FACILITY_DEFS[obj.id]?.price || 0; return FACILITY_DEFS[obj.id]?.price || 0;
} }

View file

@ -1,7 +1,6 @@
import { BALANCE } from '../core/balance.js'; import { BALANCE } from '../core/balance.js';
import { GRID, THEME } from '../core/config.js'; import { GRID, THEME } from '../core/config.js';
import { cellCenter, parseKey, key, yen } from '../core/utils.js'; import { cellCenter, parseKey, key } from '../core/utils.js';
import { spendCash } from './economy.js';
import { floating } from './effects.js'; import { floating } from './effects.js';
const RULES = BALANCE.maintenance; const RULES = BALANCE.maintenance;
@ -25,19 +24,25 @@ export function durabilityCapFor(game, type, qualityMultiplier = 1) {
return Math.max(1, Math.ceil(base * Math.max(0.1, qualityMultiplier || 1) * durabilityMultiplier(game))); return Math.max(1, Math.ceil(base * Math.max(0.1, qualityMultiplier || 1) * durabilityMultiplier(game)));
} }
export function repairmanDailyCost(game) {
const base = RULES.repairman.dailyCost;
return game?.cardEffects?.laborExploitation ? Math.ceil(base * 0.7) : base;
}
function qualityMultiplierOf(objOrMeta) { function qualityMultiplierOf(objOrMeta) {
return Math.max(0.1, Number(objOrMeta?.durabilityBaseMultiplier) || 1); return Math.max(0.1, Number(objOrMeta?.durabilityBaseMultiplier) || 1);
} }
function maintenanceRoomCenter(room, index = 0) {
if (!room) return { x: GRID.x + GRID.cell * (0.5 + index * 0.55), y: GRID.y + GRID.rows * GRID.cell + 84 };
const x = GRID.x + room.col * GRID.cell + GRID.cell;
const y = GRID.y + room.row * GRID.cell + GRID.cell;
return { x, y };
}
function repairmanHome(game, index = 0) {
const room = (game.maintenanceRooms || [])[index];
return maintenanceRoomCenter(room, index);
}
export function ensureMaintenanceState(game) { export function ensureMaintenanceState(game) {
if (!game.repairman) { if (!game.repairman) {
game.repairman = { game.repairman = {
hiredForNextDay: false,
active: false, active: false,
x: GRID.x + GRID.cell * 0.5, x: GRID.x + GRID.cell * 0.5,
y: GRID.y + GRID.rows * GRID.cell + 84, y: GRID.y + GRID.rows * GRID.cell + 84,
@ -45,6 +50,8 @@ export function ensureMaintenanceState(game) {
repairedToday: 0 repairedToday: 0
}; };
} }
if (!Array.isArray(game.maintenanceRooms)) game.maintenanceRooms = [];
if (!Array.isArray(game.repairmen)) game.repairmen = [];
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';
@ -240,28 +247,32 @@ export function repairAllEquipment(game, percent = 0.02) {
return repaired; return repaired;
} }
export function hireRepairmanForNextDay(game) {
ensureMaintenanceState(game);
if (game.repairman.hiredForNextDay) return { ok: false, reason: 'Repairman already hired.' };
const cost = repairmanDailyCost(game);
if (game.cash < cost) return { ok: false, reason: `Need ${yen(cost - game.cash)} more.` };
spendCash(game, cost);
game.pendingRepairWorkerWages = (game.pendingRepairWorkerWages || 0) + cost;
game.totals.repairWorkerWages = (game.totals.repairWorkerWages || 0) + cost;
game.repairman.hiredForNextDay = true;
return { ok: true, cost };
}
export function activateRepairmanForDay(game) { export function activateRepairmanForDay(game) {
ensureMaintenanceState(game); ensureMaintenanceState(game);
game.repairman.active = !!game.repairman.hiredForNextDay; const rooms = game.maintenanceRooms || [];
game.repairman.hiredForNextDay = false; game.repairmen = rooms.map((room, index) => {
game.repairman.target = null; const home = repairmanHome(game, index);
return {
id: `room-${room.id}`,
roomId: room.id,
active: true,
x: home.x,
y: home.y,
target: null,
repairedToday: 0
};
});
const first = game.repairmen[0];
game.repairman.active = !!first;
game.repairman.target = first?.target || null;
game.repairman.repairedToday = 0; game.repairman.repairedToday = 0;
if (first) { game.repairman.x = first.x; game.repairman.y = first.y; }
} }
export function deactivateRepairman(game) { export function deactivateRepairman(game) {
ensureMaintenanceState(game); ensureMaintenanceState(game);
for (const r of game.repairmen || []) { r.active = false; r.target = null; }
game.repairmen = [];
game.repairman.active = false; game.repairman.active = false;
game.repairman.target = null; game.repairman.target = null;
} }
@ -284,14 +295,34 @@ function chooseRepairTarget(game) {
export function updateRepairman(game, dt) { export function updateRepairman(game, dt) {
ensureMaintenanceState(game); ensureMaintenanceState(game);
const r = game.repairman; const repairmen = game.repairmen?.length ? game.repairmen : (game.repairman?.active ? [game.repairman] : []);
if (!r.active || game.phase !== 'running') return; if (!repairmen.length || game.phase !== 'running') return;
let target = resolveStoredTarget(game, r.target) || chooseRepairTarget(game); const assigned = new Set(repairmen.map(r => r.target?.key).filter(Boolean));
for (const r of repairmen) updateSingleRepairman(game, r, dt, assigned);
const first = repairmen[0];
if (first && game.repairman !== first) {
game.repairman.active = true;
game.repairman.x = first.x;
game.repairman.y = first.y;
game.repairman.target = first.target;
game.repairman.repairedToday = first.repairedToday || 0;
}
}
function updateSingleRepairman(game, r, dt, assigned) {
if (!r.active) return;
let target = resolveStoredTarget(game, r.target);
if (!target || targetWear(target) <= 0.001) {
if (r.target?.key) assigned.delete(r.target.key);
target = chooseRepairTargetAvoiding(game, assigned);
}
if (!target) { if (!target) {
r.target = null; r.target = null;
return; return;
} }
r.target = { key: targetKey(target), label: target.label }; const tKey = targetKey(target);
assigned.add(tKey);
r.target = { key: tKey, label: target.label };
const speed = RULES.repairman.walkSpeed; const speed = RULES.repairman.walkSpeed;
const dx = target.center.x - r.x; const dx = target.center.x - r.x;
const dy = target.center.y - r.y; const dy = target.center.y - r.y;
@ -302,16 +333,32 @@ export function updateRepairman(game, dt) {
r.y += dy / dist * step; r.y += dy / dist * step;
return; return;
} }
const repairWear = dt / (RULES.repairman.secondsPerOnePercent * 100); const workTimeMultiplier = game?.cardEffects?.laborExploitation ? 0.7 : 1;
const repairWear = dt / (RULES.repairman.secondsPerOnePercent * workTimeMultiplier * 100);
const usesBefore = targetUses(target); const usesBefore = targetUses(target);
const usesDelta = repairWear * targetDurability(target); const usesDelta = repairWear * targetDurability(target);
reduceTargetUses(target, usesDelta); reduceTargetUses(target, usesDelta);
const repairedPct = Math.max(0, (usesBefore - targetUses(target)) / targetDurability(target) * 100); const repairedPct = Math.max(0, (usesBefore - targetUses(target)) / targetDurability(target) * 100);
r.repairedToday = (r.repairedToday || 0) + repairedPct; r.repairedToday = (r.repairedToday || 0) + repairedPct;
if (repairedPct > 0 && Math.random() < 0.02) floating(game, r.x, r.y - 28, 'REPAIR', THEME.green); if (repairedPct > 0 && Math.random() < 0.02) floating(game, r.x, r.y - 28, 'REPAIR', THEME.green);
if (targetWear(target) <= 0.001) r.target = null; if (targetWear(target) <= 0.001) {
assigned.delete(tKey);
r.target = null;
}
} }
function chooseRepairTargetAvoiding(game, assigned = new Set()) {
let best = null;
for (const t of equipmentMaintenanceTargets(game)) {
if (assigned.has(targetKey(t))) continue;
const w = targetWear(t);
if (w <= 0.001) continue;
if (!best || w > targetWear(best)) best = t;
}
return best || chooseRepairTarget(game);
}
function worstDegradation(game) { function worstDegradation(game) {
let worst = 0; let worst = 0;
let label = 'none'; let label = 'none';
@ -325,5 +372,5 @@ function worstDegradation(game) {
export function maintenanceSummary(game) { export function maintenanceSummary(game) {
const worst = worstDegradation(game); const worst = worstDegradation(game);
const avgBeltPerformance = averageConveyorPerformance(game); const avgBeltPerformance = averageConveyorPerformance(game);
return { worst, avgBeltPerformance, repairman: game.repairman || null }; return { worst, avgBeltPerformance, repairman: game.repairman || null, repairmen: game.repairmen || [], rooms: game.maintenanceRooms || [] };
} }

View file

@ -138,19 +138,14 @@ function inferredConveyorOutDirNames(game, cellKey) {
.filter(d => game.conveyorTiles.has(graphNodeKey(p.col + d.dc, p.row + d.dr))) .filter(d => game.conveyorTiles.has(graphNodeKey(p.col + d.dc, p.row + d.dr)))
.map(d => d.name); .map(d => d.name);
const explicit = conveyorOutDirNames(game, cellKey).filter(dir => neighbors.includes(dir)); const explicit = conveyorOutDirNames(game, cellKey).filter(dir => neighbors.includes(dir));
const inferred = neighbors; const dirs = explicit.length ? explicit : neighbors;
const dirs = explicit.length ? explicit : inferred;
if (neighbors.length < 3) return dirs; if (neighbors.length < 3) return dirs;
const entrances = new Set(neighbors.filter(dir => neighborPointsIntoCell(game, p, dir))); const entrances = new Set(neighbors.filter(dir => neighborPointsIntoCell(game, p, dir)));
if (meta.branchMode && meta.branchMode !== 'random' && neighbors.includes(meta.branchMode)) { const physicalExits = neighbors.filter(dir => !entrances.has(dir));
const selectedEntrances = new Set(entrances); let candidates = entrances.size && physicalExits.length >= 2 ? physicalExits : [...new Set(dirs)];
selectedEntrances.add(meta.branchMode); if (candidates.length < 2) candidates = neighbors;
if (neighbors.filter(dir => !selectedEntrances.has(dir)).length >= 2) entrances.add(meta.branchMode); if (meta.branchMode && meta.branchMode !== 'random' && candidates.includes(meta.branchMode)) return [meta.branchMode];
} return candidates;
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) {

View file

@ -1,6 +1,6 @@
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, maintenanceRoomFootprint } from '../core/entities.js';
import { farmAt, scannerAt, scannerCenter, scannerFootprintCells, refreshRoutingAfterEdit } from './routing.js'; import { farmAt, scannerAt, scannerCenter, scannerFootprintCells, refreshRoutingAfterEdit } from './routing.js';
import { snapshot } from './history.js'; import { snapshot } from './history.js';
import { buildPrice } from './economy.js'; import { buildPrice } from './economy.js';
@ -10,6 +10,7 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
if (!item) return ''; if (!item) return '';
if (item.type === 'conveyor') return `conveyor:${item.id || item.oldKey}`; if (item.type === 'conveyor') return `conveyor:${item.id || item.oldKey}`;
if (item.type === 'facility') return `facility:${item.id || item.ref?.id}`; if (item.type === 'facility') return `facility:${item.id || item.ref?.id}`;
if (item.type === 'maintenanceRoom') return `maintenanceRoom:${item.id || item.ref?.id}`;
return `${item.type}:${item.id || item.ref?.id}`; return `${item.type}:${item.id || item.ref?.id}`;
} }
@ -29,6 +30,7 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
if (sel.type === 'eggFarm') return game.eggFarms.find(f => f.id === sel.id) || null; if (sel.type === 'eggFarm') return game.eggFarms.find(f => f.id === sel.id) || null;
if (sel.type === 'scanner') return game.scanners.find(sc => sc.id === sel.id) || null; if (sel.type === 'scanner') return game.scanners.find(sc => sc.id === sel.id) || null;
if (sel.type === 'facility') return game.facilities[sel.id] || null; if (sel.type === 'facility') return game.facilities[sel.id] || null;
if (sel.type === 'maintenanceRoom') return (game.maintenanceRooms || []).find(r => r.id === sel.id) || null;
if (sel.type === 'conveyor') return game.conveyorTiles.has(sel.id) ? { type: 'conveyor', id: sel.id } : null; if (sel.type === 'conveyor') return game.conveyorTiles.has(sel.id) ? { type: 'conveyor', id: sel.id } : null;
return null; return null;
} }
@ -42,6 +44,10 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
} }
for (const farm of game.eggFarms) items.push({ type: 'eggFarm', id: farm.id, ref: farm, center: cellCenter(farm.col, farm.row), cell: { col: farm.col, row: farm.row } }); for (const farm of game.eggFarms) items.push({ type: 'eggFarm', id: farm.id, ref: farm, center: cellCenter(farm.col, farm.row), cell: { col: farm.col, row: farm.row } });
for (const scanner of game.scanners) items.push({ type: 'scanner', id: scanner.id, ref: scanner, center: scannerCenter(scanner), cell: { col: scanner.col, row: scanner.row } }); for (const scanner of game.scanners) items.push({ type: 'scanner', id: scanner.id, ref: scanner, center: scannerCenter(scanner), cell: { col: scanner.col, row: scanner.row } });
for (const room of game.maintenanceRooms || []) {
const c = cellCenter(room.col, room.row);
items.push({ type: 'maintenanceRoom', id: room.id, ref: room, center: { x: c.x + GRID.cell / 2, y: c.y + GRID.cell / 2 }, cell: { col: room.col, row: room.row } });
}
for (const f of Object.values(game.facilities)) { for (const f of Object.values(game.facilities)) {
const bodyCenter = { x: f.x + f.w / 2, y: f.y + f.h / 2 }; const bodyCenter = { x: f.x + f.w / 2, y: f.y + f.h / 2 };
const portCenter = f.entry ? cellCenter(f.entry.col, f.entry.row) : bodyCenter; const portCenter = f.entry ? cellCenter(f.entry.col, f.entry.row) : bodyCenter;
@ -117,7 +123,7 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
const cell = parseKey(sel.id); const cell = parseKey(sel.id);
return { ...sel, obj, oldKey: sel.id, currentKey: sel.id, meta: game.conveyorMeta.get(sel.id) || { price: buildPrice('conveyor'), builtSession: null }, col: cell.col, row: cell.row }; return { ...sel, obj, oldKey: sel.id, currentKey: sel.id, meta: game.conveyorMeta.get(sel.id) || { price: buildPrice('conveyor'), builtSession: null }, col: cell.col, row: cell.row };
} }
if (sel.type === 'eggFarm' || sel.type === 'scanner') return { ...sel, obj, col: obj.col, row: obj.row }; if (sel.type === 'eggFarm' || sel.type === 'scanner' || sel.type === 'maintenanceRoom') return { ...sel, obj, col: obj.col, row: obj.row };
if (sel.type === 'facility') return { ...sel, obj, x: obj.x, y: obj.y, w: obj.w, h: obj.h, entry: obj.entry ? { ...obj.entry } : null, side: obj.side, center: { x: obj.x + obj.w / 2, y: obj.y + obj.h / 2 } }; if (sel.type === 'facility') return { ...sel, obj, x: obj.x, y: obj.y, w: obj.w, h: obj.h, entry: obj.entry ? { ...obj.entry } : null, side: obj.side, center: { x: obj.x + obj.w / 2, y: obj.y + obj.h / 2 } };
return null; return null;
} }
@ -149,6 +155,8 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
if (farm && !selectedTokens.has(`eggFarm:${farm.id}`)) return true; if (farm && !selectedTokens.has(`eggFarm:${farm.id}`)) return true;
const scanner = scannerAt(game, col, row); const scanner = scannerAt(game, col, row);
if (scanner && !selectedTokens.has(`scanner:${scanner.id}`)) return true; if (scanner && !selectedTokens.has(`scanner:${scanner.id}`)) return true;
const room = (game.maintenanceRooms || []).find(item => maintenanceRoomFootprint(item).some(cell => cell.col === col && cell.row === row));
if (room && !selectedTokens.has(`maintenanceRoom:${room.id}`)) return true;
if (game.conveyorTiles.has(k) && !selectedTokens.has(`conveyor:${k}`)) return true; if (game.conveyorTiles.has(k) && !selectedTokens.has(`conveyor:${k}`)) return true;
return false; return false;
} }
@ -192,7 +200,7 @@ 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 }]; const cells = origin.type === 'scanner' ? scannerFootprintCells({ col, row }) : (origin.type === 'maintenanceRoom' ? maintenanceRoomFootprint({ col, row }) : [{ col, row }]);
for (const cell of cells) { for (const cell of cells) {
if (!pointInGrid(cell.col, cell.row)) return fail('Selection outside grid'); if (!pointInGrid(cell.col, cell.row)) return fail('Selection outside grid');
if (game.blockedCells?.has?.(key(cell.col, cell.row))) return fail('Selection hits blocked ground'); if (game.blockedCells?.has?.(key(cell.col, cell.row))) return fail('Selection hits blocked ground');
@ -224,6 +232,9 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
} else if (origin.type === 'scanner') { } else if (origin.type === 'scanner') {
origin.obj.col = origin.col + dcol; origin.obj.col = origin.col + dcol;
origin.obj.row = origin.row + drow; origin.obj.row = origin.row + drow;
} else if (origin.type === 'maintenanceRoom') {
origin.obj.col = origin.col + dcol;
origin.obj.row = origin.row + drow;
} else if (origin.type === 'facility') { } else if (origin.type === 'facility') {
const nextCenter = { x: origin.center.x + dx, y: origin.center.y + dy }; const nextCenter = { x: origin.center.x + dx, y: origin.center.y + dy };
const { entry, side } = nearestGridEdge(nextCenter); const { entry, side } = nearestGridEdge(nextCenter);

View file

@ -5,7 +5,7 @@ import { routeFromFarmToScanner, factoryReady, facilityConnectionIssues } from '
import { buildPrice, fairiesTributeInfo, finalScore, maleTruckPenalty, mixerPoopPenalty, truckPoopPenalty } from './economy.js'; import { buildPrice, fairiesTributeInfo, finalScore, maleTruckPenalty, mixerPoopPenalty, truckPoopPenalty } from './economy.js';
import { truckTarget } from './events.js'; import { truckTarget } from './events.js';
import { conveyorSpeedForGame } from './cards.js'; import { conveyorSpeedForGame } from './cards.js';
import { maintenanceSummary, repairmanDailyCost } from './maintenance.js'; import { maintenanceSummary } from './maintenance.js';
export function createUISystem({ game, ui, build, startGame, beginCardDraft, activeQueuedChick }) { export function createUISystem({ game, ui, build, startGame, beginCardDraft, activeQueuedChick }) {
function escapeHtml(value) { function escapeHtml(value) {
@ -27,7 +27,6 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
function updatePanels() { function updatePanels() {
updateToolButtons(); updateToolButtons();
updateBuildStatus(); updateBuildStatus();
updateEventPanel();
updateTurnSummary(); updateTurnSummary();
updateHistoryButtons(); updateHistoryButtons();
} }
@ -56,15 +55,6 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
: ''; : '';
} }
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.hireRepairmanButton) {
const hired = !!game.repairman?.hiredForNextDay;
const cost = repairmanDailyCost(game);
const blocked = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || hired || game.cash < cost;
ui.hireRepairmanButton.disabled = blocked;
ui.hireRepairmanButton.classList.toggle('active', hired);
ui.hireRepairmanButton.classList.toggle('unaffordable', game.cash < cost);
ui.hireRepairmanButton.title = hired ? 'Repairman is hired for the next production phase.' : `Hire for ${yen(cost)} for one day. Repairs 2% durability per second.`;
}
const cashout = noAffordableBuildTools(); const cashout = noAffordableBuildTools();
ui.buttons.nextTurn?.classList.toggle('cashout-emphasis', cashout); ui.buttons.nextTurn?.classList.toggle('cashout-emphasis', cashout);
ui.buttons.erase?.classList.toggle('cashout-emphasis', cashout); ui.buttons.erase?.classList.toggle('cashout-emphasis', cashout);
@ -89,47 +79,14 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
function updateTurnSummary() { function updateTurnSummary() {
const connected = game.eggFarms.filter(f => routeFromFarmToScanner(game, f)?.scannerId).length; const connected = game.eggFarms.filter(f => routeFromFarmToScanner(game, f)?.scannerId).length;
const issues = facilityConnectionIssues(game); const issues = facilityConnectionIssues(game);
const tributeBasisProfit = Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0));
const tributeDay = Math.max(1, Number(game.turn) || 1);
const tribute = fairiesTributeInfo(game, tributeDay, tributeBasisProfit);
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}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`,
`Truck target: <b>${truckTarget(game).toUpperCase()}</b> | Male fine: <b>-${yen(maleTruckPenalty(game))}</b>`, `Penalties: <b>wrong shipment -${yen(maleTruckPenalty(game))}</b> | <b>Mixer poop -${yen(mixerPoopPenalty(game))}</b> | <b>Shipment poop -${yen(truckPoopPenalty(game))}</b>`,
`Poop fine: <b>Mixer -${yen(mixerPoopPenalty(game))}</b> / <b>Shipment -${yen(truckPoopPenalty(game))}</b>`, `Maintenance: <b>worst ${maintenanceSummary(game).worst.percent}% dirty (${maintenanceSummary(game).worst.label})</b> | Rooms: <b>${(game.maintenanceRooms || []).length}</b> | Repairmen: <b>${(game.repairmen || []).length}</b>`,
`Fairies tribute on Next Day: <b>-${yen(tribute.amount)}</b> | base ${yen(tribute.base)} + profit share ${yen(tribute.profitTribute)} | basis net ${yen(tributeBasisProfit)} | exemption ${yen(tribute.profitExemption)}${tribute.reduction ? ` | reduction -${yen(tribute.reduction)}` : ''}`, `Card draft: <b>${game.cardDraft?.pending ? Math.max(1, game.cardDraft.picksRemaining || 1) + ' pick(s) left' : 'ready after each day'}</b> | Farms with scanner route: <b>${connected}/${game.eggFarms.length}</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>`,
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>'); ].filter(Boolean).join('<br>');
}
function updateEventPanel() {
if (!ui.eventPanel) return;
const offer = game.eventOffer;
if (game.phase !== 'build') {
ui.eventPanel.innerHTML = TEXT.status.eventPanelRunning;
return;
}
if (!offer) {
ui.eventPanel.className = 'event-card empty';
const nextTurn = game.turn + 1;
if (nextTurn < DAILY_EVENT_FIRST_TURN) {
ui.eventPanel.innerHTML = `<strong>Event lock active.</strong><span>No irregular events during Days 1-7. First possible forced event: Day ${DAILY_EVENT_FIRST_TURN}.</span>`;
} else {
ui.eventPanel.innerHTML = '<strong>No event.</strong><span>Random forced events can appear later.</span>';
}
return;
}
ui.eventPanel.className = 'event-card accepted';
ui.eventPanel.innerHTML = `
<div class="event-status">FORCED NEXT DAY</div>
<h3>${offer.title}</h3>
<div class="event-metrics">
<span>Effect: ${offer.route}</span>
<span>${offer.targetAmount ? `Target: ${offer.targetAmount}` : 'Target: none'}</span>
<span>${offer.reward ? `Reward: ${yen(offer.reward)}` : 'Reward: none'}</span>
<span>Time ${offer.daySeconds || 60}s / Output x${Number(offer.productionMultiplier || 1).toFixed(2)} / Poop +${Math.round((offer.poopRateAdd || 0) * 100)}pt</span>
</div>`;
} }
function updateUI() { function updateUI() {
updateToolButtons(); updateToolButtons();
@ -172,7 +129,9 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
} }
const nextTributeBasis = Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0)); const nextTributeBasis = Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0));
const nextTribute = fairiesTributeInfo(game, Math.max(1, Number(game.turn) || 1), nextTributeBasis).amount; const nextTribute = fairiesTributeInfo(game, Math.max(1, Number(game.turn) || 1), nextTributeBasis).amount;
ui.buttons.nextTurn.textContent = game.phase === 'build' && nextTribute > 0 ? `Next Day - Fairies ${yen(nextTribute)}` : TEXT.actions.nextDay; ui.buttons.nextTurn.innerHTML = game.phase === 'build' && nextTribute > 0
? `<span class="next-day-main">NEXT DAY</span><small class="next-day-fee">FAIRIES -${yen(nextTribute)}</small>`
: `<span class="next-day-main">${TEXT.actions.nextDay}</span>`;
ui.buttons.nextTurn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || !factoryReady(game); ui.buttons.nextTurn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || !factoryReady(game);
} }
@ -180,7 +139,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
if (!ui.eventBrief || !ui.targetBrief || !ui.speedBrief) return; if (!ui.eventBrief || !ui.targetBrief || !ui.speedBrief) return;
const target = truckTarget(game).toUpperCase(); const target = truckTarget(game).toUpperCase();
ui.targetBrief.textContent = `TRUCK TARGET: ${target}`; ui.targetBrief.textContent = `TRUCK TARGET: ${target}`;
ui.speedBrief.textContent = `BELT: ${displaySpeed()} px/s | COMBO: ${game.manualCombo?.count || 0}`; ui.speedBrief.textContent = `BELT SPEED: ${displaySpeed()} px/s`;
ui.speedBrief.title = `Conveyor speed does not increase by day. Degraded belts reduce speed. Cap: ${CONVEYOR_SPEED_MAX}px/s.`; ui.speedBrief.title = `Conveyor speed does not increase by day. Degraded belts reduce speed. Cap: ${CONVEYOR_SPEED_MAX}px/s.`;
const active = game.eventActive; const active = game.eventActive;
const offer = game.eventOffer; const offer = game.eventOffer;
@ -235,7 +194,6 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
<div><strong>Event bonus</strong>${positive(r.eventBonus || 0)}</div> <div><strong>Event bonus</strong>${positive(r.eventBonus || 0)}</div>
<div><strong>Penalties</strong>${negative(penalties)}</div> <div><strong>Penalties</strong>${negative(penalties)}</div>
<div><strong>Fairies fee</strong>${negative(r.fairiesTribute?.amount || r.fairiesTribute || 0)}</div> <div><strong>Fairies fee</strong>${negative(r.fairiesTribute?.amount || r.fairiesTribute || 0)}</div>
<div><strong>Repair worker wages</strong>${negative(r.repairWorkerWages || 0)}</div>
</div> </div>
<p class="formula-box compact">Income ${yen(r.revenue)} - losses ${yen(r.penalty)} = <b class="${netClass}">${yen(r.profit)}</b></p>`; <p class="formula-box compact">Income ${yen(r.revenue)} - losses ${yen(r.penalty)} = <b class="${netClass}">${yen(r.profit)}</b></p>`;
ui.modalActions.innerHTML = ''; ui.modalActions.innerHTML = '';

1130
styles-v27.17.css Normal file

File diff suppressed because it is too large Load diff

1253
styles-v27.18.css Normal file

File diff suppressed because it is too large Load diff

1279
styles-v27.19.css Normal file

File diff suppressed because it is too large Load diff

View file

@ -759,3 +759,521 @@ body { font-size: 20px; }
@media (max-width: 1180px) { @media (max-width: 1180px) {
.scanner-monitor { bottom: 132px; width: min(520px, calc(100% - 16px)); left: 8px; } .scanner-monitor { bottom: 132px; width: min(520px, calc(100% - 16px)); left: 8px; }
} }
/* ------------------------------------------------------------------
DARK FACTORY VISUAL PASS
- Facility sprites are now loaded from assets/dark_factory/.
- Debug UI is intentionally retained for balancing builds. Do not remove.
------------------------------------------------------------------ */
:root {
--bg: #0b0c0f;
--screen: #111318;
--panel: rgba(24, 27, 34, 0.96);
--panel-2: #20242b;
--panel-3: #2a303a;
--ink: #e6edf2;
--muted: #8f9bab;
--line: #050609;
--green: #7bd84f;
--green-soft: #273c24;
--white: #e6edf2;
--danger: #e34a36;
--warn: #ffb827;
--cyan: #4aa3ff;
--orange: #c86a2b;
--shadow: 6px 6px 0 rgba(0, 0, 0, 0.62);
}
html, body { background: #050609; color: var(--ink); }
body {
background:
linear-gradient(90deg, rgba(255,184,39,.035) 1px, transparent 1px) 0 0 / 32px 32px,
linear-gradient(rgba(255,184,39,.028) 1px, transparent 1px) 0 0 / 32px 32px,
#050609;
}
#app { width: min(100vw, 1720px); padding: 0; }
.game-shell {
min-height: 100vh;
border: 0;
background: var(--screen);
box-shadow: inset 0 0 0 4px #050609, inset 0 0 0 8px #303744;
}
#gameCanvas {
height: 100vh;
min-height: 720px;
background: var(--screen);
image-rendering: pixelated;
image-rendering: crisp-edges;
}
.hud { gap: 4px; }
.hud-top-left {
top: 8px;
left: 8px;
right: 420px;
grid-template-columns: 1.25fr repeat(5, minmax(92px, .65fr));
}
.hud-top-right {
top: 8px;
right: 8px;
grid-template-columns: repeat(4, minmax(74px, auto));
}
.hud-card {
min-width: 74px;
padding: 7px 9px;
border: 3px solid var(--line);
background: linear-gradient(#242a33, #171a20);
box-shadow: 4px 4px 0 rgba(0,0,0,.7), inset 0 0 0 2px rgba(255,184,39,.10);
color: var(--ink);
}
.hud-card span { color: var(--warn); font-size: 9px; letter-spacing: .12em; }
.hud-card strong { color: var(--ink); text-shadow: 2px 2px 0 #050609; }
.hud-card.cash strong, .hud-card.profit strong, .hud-card.combo strong, .cash-positive { color: var(--green) !important; }
.hud-card.time-critical { background: #3a1f1f; box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(0,0,0,.7); }
.priority-strip {
top: 74px;
left: 10px;
width: clamp(250px, 21vw, 330px);
max-width: none;
grid-template-columns: 1fr;
gap: 6px;
}
.priority-strip > div {
border: 3px solid var(--line);
background: linear-gradient(#222832, #15181e);
color: var(--ink);
box-shadow: 4px 4px 0 rgba(0,0,0,.66), inset 0 0 0 2px rgba(255,184,39,.12);
}
.event-brief.active { background: #38251a; box-shadow: inset 0 0 0 3px var(--orange), 4px 4px 0 rgba(0,0,0,.66); }
.event-brief.next { background: #1c2c20; box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(0,0,0,.66); }
.event-brief.locked { background: #181b20; color: var(--muted); }
.target-brief { color: var(--warn); }
.speed-brief { color: var(--cyan); }
.build-panel {
top: 82px;
right: 8px;
bottom: 8px;
width: clamp(320px, 24vw, 410px);
padding: 9px;
border: 4px solid var(--line);
background: linear-gradient(#222832, #14171d);
box-shadow: 6px 6px 0 rgba(0,0,0,.72), inset 0 0 0 3px rgba(255,184,39,.10);
scrollbar-color: var(--warn) #111318;
}
.panel-head {
display: grid;
grid-template-columns: 1fr 126px;
align-items: stretch;
gap: 8px;
margin-bottom: 8px;
}
.panel-head h1 { color: var(--warn); text-shadow: 2px 2px 0 #050609; font-size: 17px; }
.panel-head h1 span { color: #050609; background: var(--warn); border-color: var(--line); }
.panel-head p { color: var(--muted); }
.panel-grid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.build-panel h2, .result-section-title { color: var(--warn); }
.large-tools {
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.tool-button, .primary-button, .sort-button, .facility-action {
border: 3px solid var(--line);
background: linear-gradient(#2a303a, #171a20);
color: var(--ink);
box-shadow: 4px 4px 0 rgba(0,0,0,.70), inset 0 0 0 2px rgba(255,184,39,.08);
text-shadow: 1px 1px 0 #050609;
}
.tool-button { min-height: 48px; padding: 8px; }
.tool-button strong { color: var(--ink); }
.tool-button span { color: var(--muted); }
.tool-button.active {
background: linear-gradient(#33311d, #1e2117);
box-shadow: inset 0 0 0 3px var(--warn), 4px 4px 0 rgba(0,0,0,.70);
}
.tool-button.danger.active { background: linear-gradient(#4b1f1c, #241014); box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(0,0,0,.70); }
.primary-button { background: linear-gradient(#8b2d23, #4b1714); color: #fff3d2; }
.primary-button:not(:disabled) { box-shadow: inset 0 0 0 3px #ffb827, 5px 5px 0 rgba(0,0,0,.72); }
.primary-button:disabled, .tool-button:disabled, .sort-button:disabled, .facility-action:disabled { opacity: .42; filter: grayscale(1); }
.mini-box, .event-card, .formula-box, .equipment-menu-lines, .route-card, .result-grid div, .muted-title {
border: 3px solid var(--line);
background: #15181e;
color: var(--muted);
box-shadow: inset 0 0 0 2px rgba(255,184,39,.08);
}
.event-card h3, .event-card strong, .event-metrics span { color: var(--ink); }
.event-card.accepted { background: #1c2c20; box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(0,0,0,.66); }
.event-card.empty { background: #15181e; color: var(--muted); }
.event-metrics span { border-color: #3d4654; background: #20242b; }
.event-status { background: #2a303a; color: var(--warn); border-color: var(--line); }
.control-dock {
left: 50%;
right: auto;
bottom: 8px;
transform: translateX(-50%);
grid-template-columns: repeat(4, minmax(135px, 160px));
gap: 6px;
padding: 6px;
border: 4px solid var(--line);
background: rgba(13,15,19,.96);
box-shadow: 5px 5px 0 rgba(0,0,0,.72), inset 0 0 0 2px rgba(255,184,39,.10);
}
.sort-button { min-height: 44px; font-size: 12px; }
.scanner-monitor {
position: absolute;
z-index: 5;
left: 10px;
top: 198px;
width: clamp(250px, 21vw, 330px);
}
.scanner-monitor-card {
border: 3px solid var(--line) !important;
background: linear-gradient(#222832, #15181e) !important;
color: var(--ink) !important;
box-shadow: 4px 4px 0 rgba(0,0,0,.66), inset 0 0 0 2px rgba(255,184,39,.10) !important;
}
.scanner-monitor-card strong { color: var(--warn); }
.debug-panel {
left: auto;
right: 12px;
bottom: 10px;
width: min(420px, calc(100% - 24px));
border-color: var(--line);
background: rgba(16,18,23,.97);
box-shadow: 5px 5px 0 rgba(0,0,0,.72), inset 0 0 0 2px rgba(227,74,54,.18);
color: var(--ink);
}
.debug-panel summary { color: var(--danger); }
.debug-body { border-top-color: var(--line); }
.debug-body button, .debug-body input[type="number"], .debug-body select, .debug-readout {
border-color: var(--line);
background: #20242b;
color: var(--ink);
}
.debug-readout { color: var(--muted); }
.modal { background: rgba(3,4,6,.72); }
.modal-card {
border-color: var(--line);
background: linear-gradient(#242a33, #15181e);
color: var(--ink);
box-shadow: 8px 8px 0 rgba(0,0,0,.72), inset 0 0 0 3px rgba(255,184,39,.12);
}
.modal-card h2 { color: var(--warn); text-shadow: 2px 2px 0 #050609; }
.modal-card ul, .modal-card p { color: var(--muted); }
.hover-tooltip {
border-color: var(--line) !important;
background: rgba(20,23,29,.98) !important;
color: var(--ink) !important;
box-shadow: 5px 5px 0 rgba(0,0,0,.72) !important;
}
::-webkit-scrollbar { width: 14px; height: 14px; }
::-webkit-scrollbar-track { background: #111318; }
::-webkit-scrollbar-thumb { background: #333b48; border: 3px solid #111318; }
::-webkit-scrollbar-thumb:hover { background: #ffb827; }
@media (max-width: 1180px) {
.hud-top-left { right: 8px; grid-template-columns: repeat(3, minmax(80px, 1fr)); }
.hud-top-right { top: 132px; left: 8px; right: auto; grid-template-columns: repeat(2, minmax(82px, auto)); }
.priority-strip { top: 226px; }
.scanner-monitor { top: 348px; }
.build-panel { left: 8px; right: 8px; bottom: 8px; width: auto; max-height: 46vh; }
.control-dock { grid-template-columns: repeat(2, 150px); }
}
/* v27.16: compact collapsed debug panel + icon card layout */
.debug-panel:not([open]) {
width: 104px !important;
min-width: 0;
overflow: hidden;
}
.debug-panel:not([open]) summary {
display: block;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.debug-panel[open] {
width: min(420px, calc(100% - 24px));
}
.card-choice {
position: relative;
display: grid;
grid-template-columns: 54px minmax(0, 1fr);
gap: 10px;
align-items: start;
min-height: 150px;
overflow: hidden;
background:
linear-gradient(135deg, rgba(255,255,255,.08), rgba(0,0,0,.20)),
#20242b;
border-color: var(--line);
}
.card-choice::before {
content: "";
position: absolute;
inset: 5px;
border: 1px solid rgba(255,184,39,.18);
pointer-events: none;
}
.card-icon {
position: relative;
z-index: 1;
width: 46px;
height: 46px;
display: grid;
place-items: center;
border: 3px solid var(--line);
background: #15181e;
box-shadow: inset 0 0 0 2px rgba(255,184,39,.10), 3px 3px 0 rgba(0,0,0,.35);
image-rendering: pixelated;
}
.card-icon img {
width: 36px;
height: 36px;
object-fit: contain;
image-rendering: pixelated;
}
.card-icon.glyph {
color: var(--warn, #ffb827);
font-size: 24px;
font-weight: 900;
text-align: center;
line-height: 1;
}
.card-copy {
position: relative;
z-index: 1;
min-width: 0;
}
.card-choice strong {
color: var(--warn, #ffb827);
text-shadow: 1px 1px 0 #050609;
}
.card-choice small { color: var(--muted); }
.card-tags span {
background: #15181e;
color: var(--white);
border-color: var(--line);
}
.card-choice.common { background: linear-gradient(145deg, #262c35, #171a20); }
.card-choice.rare {
background: linear-gradient(145deg, #3b3020, #191b20);
box-shadow: inset 0 0 0 3px #f0a020, 5px 5px 0 rgba(0,0,0,.50);
}
.card-choice.ultra-rare {
background: linear-gradient(145deg, #401d2a, #171a20);
box-shadow: inset 0 0 0 3px #c42424, 5px 5px 0 rgba(0,0,0,.55);
}
.card-choice.dud {
background: linear-gradient(145deg, #2b2b2b, #191919);
color: #878787;
}
.card-choice.dud .card-tags span { background: #222; color: #aaa; }
@media (max-width: 1180px) {
.debug-panel:not([open]) {
width: 92px !important;
right: 8px;
bottom: 8px;
}
}
/* v27.18 inherited cache-proof identity and final UI overrides */
.build-stamp {
position: absolute;
z-index: 12;
right: 14px;
top: 76px;
padding: 5px 9px;
border: 2px solid #ffb827;
background: rgba(14, 17, 22, .94);
color: #80e067;
box-shadow: 3px 3px 0 rgba(0,0,0,.7);
font: 900 11px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: .04em;
pointer-events: none;
}
.debug-panel:not([open]) {
width: 58px !important;
max-width: 58px !important;
height: 30px;
min-height: 0;
}
.debug-panel:not([open]) summary {
box-sizing: border-box;
width: 58px;
height: 27px;
padding: 5px 7px;
text-align: center;
overflow: hidden;
text-overflow: clip;
}
.debug-panel[open] {
width: min(390px, calc(100% - 24px)) !important;
height: auto;
}
.card-choice {
border-width: 3px;
box-shadow: 4px 4px 0 rgba(0,0,0,.55), inset 0 0 0 1px rgba(255,184,39,.14);
}
.card-choice:hover {
transform: translateY(-2px);
box-shadow: 4px 6px 0 rgba(0,0,0,.65), inset 0 0 0 2px rgba(128,224,103,.24);
}
.card-icon img {
image-rendering: pixelated;
filter: drop-shadow(2px 2px 0 rgba(0,0,0,.7));
}
#gameCanvas { image-rendering: pixelated; }
/* v27.18: status relocation, consistent industrial controls, card watermarks */
.hud-top-left { grid-template-columns: repeat(5, minmax(82px, auto)); }
.left-bottom-status {
position: absolute;
z-index: 6;
left: 10px;
bottom: 10px;
width: min(310px, calc(100% - 20px));
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
pointer-events: none;
}
.left-bottom-status > div {
min-width: 0;
border: 3px solid var(--line);
background: rgba(22, 26, 32, .95);
color: var(--ink);
padding: 8px 10px;
box-shadow: 4px 4px 0 rgba(0,0,0,.68), inset 0 0 0 1px rgba(255,184,39,.10);
font-size: 12px;
line-height: 1.2;
font-weight: 900;
text-transform: uppercase;
letter-spacing: .025em;
overflow: hidden;
text-overflow: ellipsis;
}
.left-bottom-status .event-brief { grid-column: 1 / -1; white-space: normal; min-height: 39px; }
.left-bottom-status .event-brief.active { background: #35221c; box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(0,0,0,.68); }
.left-bottom-status .event-brief.next { background: #22301f; box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(0,0,0,.68); }
.left-bottom-status .event-brief.locked { background: #1c2027; color: var(--muted); }
.left-bottom-status .speed-brief { color: #d9e2e8; }
.left-bottom-status .target-brief { color: #ffcf68; }
.left-bottom-status .combo-brief { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.left-bottom-status .combo-brief span { color: var(--muted); font-size: 10px; }
.left-bottom-status .combo-brief strong { color: var(--green); font-size: 22px; line-height: 1; }
/* Remove the remaining bright green/white control styling from modal actions. */
.modal-actions button,
.facility-action,
.card-target-button {
border: 3px solid #07080a;
background: linear-gradient(#353d48, #1b1f25);
color: #e6edf2;
box-shadow: inset 0 0 0 1px rgba(255,184,39,.14), 4px 4px 0 rgba(0,0,0,.68);
}
.modal-actions button:hover:not(:disabled),
.facility-action:hover:not(:disabled),
.card-target-button:hover:not(:disabled) {
background: linear-gradient(#424b58, #242930);
color: #fff0c9;
box-shadow: inset 0 0 0 2px rgba(255,184,39,.42), 4px 4px 0 rgba(0,0,0,.72);
}
.facility-action.warn,
.modal-actions .reroll-button {
background: linear-gradient(#554221, #2b2419);
color: #ffd36a;
box-shadow: inset 0 0 0 3px #b97810, 5px 5px 0 rgba(0,0,0,.72);
}
.modal-actions .reroll-button:hover:not(:disabled) {
background: linear-gradient(#6b5227, #342919);
color: #fff0bd;
box-shadow: inset 0 0 0 3px #ffb827, 5px 5px 0 rgba(0,0,0,.76);
}
.facility-action.good { background: linear-gradient(#29422a, #19271a); color: #a9ed91; }
.facility-action.danger { background: linear-gradient(#57251f, #2d1715); color: #ffb1a3; }
.modal-actions button:disabled,
.facility-action:disabled,
.card-target-button:disabled { opacity: .42; filter: grayscale(.85); box-shadow: 3px 3px 0 rgba(0,0,0,.45); }
/* Icons are now translucent background art rather than separate foreground badges. */
.card-choice {
display: block;
min-height: 170px;
padding: 16px;
isolation: isolate;
}
.card-choice::before { z-index: 1; }
.card-copy { position: relative; z-index: 3; padding-right: 6px; }
.card-icon {
position: absolute;
z-index: 0;
right: -9px;
bottom: -12px;
width: 124px;
height: 124px;
border: 0;
background: transparent;
box-shadow: none;
opacity: .14;
transform: rotate(-7deg);
pointer-events: none;
overflow: visible;
}
.card-icon img {
width: 100%;
height: 100%;
object-fit: contain;
filter: grayscale(.12) contrast(1.2) drop-shadow(3px 3px 0 rgba(0,0,0,.55));
}
.card-icon.glyph {
display: grid;
place-items: center;
color: #f0b437;
font-size: 88px;
line-height: 1;
text-shadow: 4px 4px 0 rgba(0,0,0,.55);
}
.card-choice.rare .card-icon { opacity: .16; }
.card-choice.ultra-rare .card-icon { opacity: .17; }
.card-choice.dud .card-icon { opacity: .08; filter: grayscale(1); }
@media (max-width: 1180px) {
.hud-top-left { grid-template-columns: repeat(3, minmax(80px, 1fr)); }
.left-bottom-status { width: 290px; left: 8px; bottom: 8px; }
}
@media (max-width: 760px) {
.left-bottom-status { width: min(270px, calc(100% - 16px)); grid-template-columns: 1fr; }
.left-bottom-status .event-brief { grid-column: auto; }
}
/* v27.19: compact Next Day fee label and deduplicated side panel */
.panel-head { grid-template-columns: minmax(0, 1fr) minmax(112px, 126px); }
.primary-button#nextTurnButton {
min-width: 0;
max-width: 126px;
padding: 8px 7px;
white-space: normal;
overflow: hidden;
line-height: 1.08;
display: grid;
place-content: center;
gap: 4px;
text-align: center;
}
#nextTurnButton .next-day-main { display: block; font-size: 15px; letter-spacing: .035em; }
#nextTurnButton .next-day-fee {
display: block;
max-width: 100%;
color: #ffd36a;
font-size: 9px;
line-height: 1.15;
letter-spacing: 0;
overflow-wrap: anywhere;
}