aa
This commit is contained in:
parent
22e6366dc2
commit
6097bac9d5
27 changed files with 1831 additions and 109 deletions
12
FUTURE_IDEAS.md
Normal file
12
FUTURE_IDEAS.md
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Future Ideas
|
||||||
|
|
||||||
|
These items are intentionally not implemented in v29.00.
|
||||||
|
|
||||||
|
- Capacitor or battery storage for smoothing biomass and coal voltage pulses.
|
||||||
|
- Transformer or power-combiner station for a late-game managed bus where generators can be combined.
|
||||||
|
- Editable machine power priorities.
|
||||||
|
- Advanced power statistics such as average voltage, brownout count, utilization, and loss heat maps.
|
||||||
|
- Additional wire tiers beyond regular wire at 0.5V per tile and insulated wire at 0.15V per tile.
|
||||||
|
- Zunda balancing controls such as shorter duration, consumption cooldown, reduced zunda-only quality conversion, or farm appetite limits.
|
||||||
|
- Repair-access visualization for reachable cells and suggested footbridge locations.
|
||||||
|
- Power-aware card ideas after the base power economy is measured.
|
||||||
26
README.md
26
README.md
|
|
@ -1,4 +1,24 @@
|
||||||
# Chick Sorter
|
# Chick Sorter v29.00
|
||||||
|
|
||||||
|
## v29.00 systems
|
||||||
|
|
||||||
|
Power is a simplified deterministic voltage network. Manual Scanners, Egg Farms, regular Conveyors, Boost Conveyors, Power Wires, Insulated Power Wires, Footbridges, the Starter Generator, Biomass Generators, and Coal Generators do not require electricity. Mixers need 10V and 4V Voltage Load, Shredders need 8V and 3V Voltage Load, Shipping Trucks need 6V and 2V Voltage Load, Auto Scanners need 8V and 2V Voltage Load, Sausage Machines need 12V and 5V Voltage Load, Maintenance Rooms need 6V and 1V Voltage Load, and Inbound Trucks need 5V and 1V Voltage Load.
|
||||||
|
|
||||||
|
Regular Power Wire costs 10 yen per tile and loses 0.5V per traversed tile. Insulated Power Wire costs 22 yen per tile and loses 0.15V per traversed tile. Wire branches do not divide voltage, loops do not increase voltage, and separate generators are not summed. Each generator admits machine loads deterministically; its bus voltage is generator output minus admitted Voltage Load.
|
||||||
|
|
||||||
|
Every new game includes a free 2x2 Starter Generator with 25V output and free initial regular wiring to the initial Mixer, Shredder, and Shipping Truck. The starter generator cannot be sold and has no build button.
|
||||||
|
|
||||||
|
Biomass Generators are 3x3, cost 520 yen, and accept male chicks, female chicks, and poop. Each accepted fuel item adds +5V for 2 seconds. Coal Generators are 2x3, cost 680 yen, and accept coal only. Each coal adds +15V for 3 seconds. Fuel pulses in one generator stack additively and are cleared by day end.
|
||||||
|
|
||||||
|
Inbound Trucks cost 350 yen and can import coal and zunda mochi. Coal costs 2 yen each, zunda mochi costs 4 yen each, and each truck can order up to 80 combined items per day. Imports are charged at day start, distributed over the 60-second day with deterministic jitter, retained while the output is blocked, and not refunded if undelivered by day end.
|
||||||
|
|
||||||
|
Egg Farm production is capped by the theoretical capacity of one Boost Conveyor, derived from live conveyor speed divided by minimum item spacing. Production above the cap becomes quality overflow using `1 + 0.45 * overflowRatio^0.85`, clamped from 1.0x to 2.5x. Female shipments and sausage shipments use continuous quality multipliers; meat inherits chick quality and sausages use the arithmetic mean of two meat inputs. Mixer blocked-output fallback remains exactly +5 yen and ignores quality.
|
||||||
|
|
||||||
|
Zunda mochi on a conveyor cell orthogonally adjacent to an Egg Farm is consumed by that farm. Each item adds 8 seconds of boost up to 30 seconds. While boosted, the farm production interval is multiplied by 0.55, with the existing 0.25 second safe minimum.
|
||||||
|
|
||||||
|
Repair workers use four-directional grid pathing. They can walk on owned, unobstructed ground; they cannot walk through machines or uncovered conveyor cells, and power wires do not block them. Workers exit maintenance rooms from a reachable perimeter cell and repair from a reachable orthogonally adjacent work cell.
|
||||||
|
|
||||||
|
Footbridges cost 90 yen and live on an overlay layer with wires. They cover 1x3 or 3x1 cells; press R while the Footbridge tool is active to rotate placement. They allow repair-worker pathing over conveyor cells without changing conveyor transport, direction editing, or branch behavior.
|
||||||
|
|
||||||
Serve this folder with a static HTTP server and open `index.html`.
|
Serve this folder with a static HTTP server and open `index.html`.
|
||||||
|
|
||||||
|
|
@ -11,9 +31,9 @@ The launch page uses:
|
||||||
|
|
||||||
Run `npm install` once, then `npm run build` after editing JavaScript.
|
Run `npm install` once, then `npm run build` after editing JavaScript.
|
||||||
|
|
||||||
## Current gameplay specification (v28.21)
|
## Current gameplay specification (v29.00)
|
||||||
|
|
||||||
### Boost conveyor rendering hotfix (v28.21)
|
### Preserved boost conveyor rendering behavior
|
||||||
|
|
||||||
- Fixed a runtime `ReferenceError` caused by an undeclared `boost` variable in the animated conveyor renderer.
|
- Fixed a runtime `ReferenceError` caused by an undeclared `boost` variable in the animated conveyor renderer.
|
||||||
- Added a runtime smoke test for both normal and boost conveyor drawing.
|
- Added a runtime smoke test for both normal and boost conveyor drawing.
|
||||||
|
|
|
||||||
BIN
deploy/chick-sorter-v29.00.zip
Normal file
BIN
deploy/chick-sorter-v29.00.zip
Normal file
Binary file not shown.
84
dist/game.js
vendored
84
dist/game.js
vendored
File diff suppressed because one or more lines are too long
19
index.html
19
index.html
|
|
@ -6,13 +6,13 @@
|
||||||
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate" />
|
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate" />
|
||||||
<meta http-equiv="Pragma" content="no-cache" />
|
<meta http-equiv="Pragma" content="no-cache" />
|
||||||
<meta http-equiv="Expires" content="0" />
|
<meta http-equiv="Expires" content="0" />
|
||||||
<title>Chick Sorter v28.21 — Boost Draw Hotfix</title>
|
<title>Chick Sorter v29.00</title>
|
||||||
<link rel="stylesheet" href="./styles.css" />
|
<link rel="stylesheet" href="./styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main id="app">
|
<main id="app">
|
||||||
<section id="gameShell" class="game-shell" data-build="v28.21" aria-label="Game screen">
|
<section id="gameShell" class="game-shell" data-build="v29.00" aria-label="Game screen">
|
||||||
<div class="build-stamp" aria-label="loaded build">v28.21 VERIFIED</div>
|
<div class="build-stamp" aria-label="loaded build">v29.00</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">
|
||||||
|
|
@ -39,7 +39,7 @@
|
||||||
<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>v28.21</span></h1>
|
<h1>CHICK SORTER <span>v29.00</span></h1>
|
||||||
</div>
|
</div>
|
||||||
<button id="nextTurnButton" class="primary-button" type="button">Next Day</button>
|
<button id="nextTurnButton" class="primary-button" type="button">Next Day</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -50,13 +50,20 @@
|
||||||
<div class="large-tools">
|
<div class="large-tools">
|
||||||
<button id="buildConveyorButton" class="tool-button" type="button"><strong>Conveyor</strong><span>¥30 / tile</span></button>
|
<button id="buildConveyorButton" class="tool-button" type="button"><strong>Conveyor</strong><span>¥30 / tile</span></button>
|
||||||
<button id="buildBoostConveyorButton" class="tool-button" type="button"><strong>Boost Conveyor</strong><span>¥75 / tile</span></button>
|
<button id="buildBoostConveyorButton" class="tool-button" type="button"><strong>Boost Conveyor</strong><span>¥75 / tile</span></button>
|
||||||
|
<button id="buildPowerWireButton" class="tool-button" type="button"><strong>Power Wire</strong><span>¥10 / tile</span></button>
|
||||||
|
<button id="buildInsulatedPowerWireButton" class="tool-button" type="button"><strong>Insulated Wire</strong><span>¥22 / tile</span></button>
|
||||||
|
<button id="powerOverlayButton" class="tool-button small" type="button"><strong>Power Overlay</strong><span>Show wires</span></button>
|
||||||
|
<button id="buildFootbridgeButton" class="tool-button" type="button"><strong>Footbridge</strong><span>¥90</span></button>
|
||||||
<button id="buildEggFarmButton" class="tool-button" type="button"><strong>Egg Farm</strong><span>¥250</span></button>
|
<button id="buildEggFarmButton" class="tool-button" type="button"><strong>Egg Farm</strong><span>¥250</span></button>
|
||||||
<button id="buildAutoScannerButton" class="tool-button" type="button"><strong>Auto Scanner</strong><span>¥180 / 1.5s cooldown</span></button>
|
<button id="buildAutoScannerButton" class="tool-button" type="button"><strong>Auto Scanner</strong><span>¥180 / 1.5s cooldown</span></button>
|
||||||
<button id="buildManualScannerButton" class="tool-button" type="button"><strong>Manual Scanner</strong><span>¥260</span></button>
|
<button id="buildManualScannerButton" class="tool-button" type="button"><strong>Manual Scanner</strong><span>¥260</span></button>
|
||||||
<button id="buildMixerButton" class="tool-button" type="button"><strong>Mixer</strong><span>¥450</span></button>
|
<button id="buildMixerButton" class="tool-button" type="button"><strong>Mixer</strong><span>¥450</span></button>
|
||||||
<button id="buildTrashButton" class="tool-button" type="button"><strong>Waste Shredder</strong><span>¥240</span></button>
|
<button id="buildTrashButton" class="tool-button" type="button"><strong>Waste Shredder</strong><span>¥240</span></button>
|
||||||
<button id="buildTruckButton" class="tool-button" type="button"><strong>Truck</strong><span>¥450</span></button>
|
<button id="buildTruckButton" class="tool-button" type="button"><strong>Shipping Truck</strong><span>¥450</span></button>
|
||||||
|
<button id="buildInboundTruckButton" class="tool-button" type="button"><strong>Inbound Truck</strong><span>¥350</span></button>
|
||||||
<button id="buildSausageMakerButton" class="tool-button" type="button"><strong>Sausage Machine</strong><span>¥380 / 3×3</span></button>
|
<button id="buildSausageMakerButton" class="tool-button" type="button"><strong>Sausage Machine</strong><span>¥380 / 3×3</span></button>
|
||||||
|
<button id="buildBiomassGeneratorButton" class="tool-button" type="button"><strong>Biomass Gen</strong><span>¥520 / 3×3</span></button>
|
||||||
|
<button id="buildCoalGeneratorButton" class="tool-button" type="button"><strong>Coal Gen</strong><span>¥680 / 2×3</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="buildMaintenanceRoomButton" class="tool-button repair" type="button"><strong>Maintenance Room</strong><span>¥220 / 2×2</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>
|
||||||
|
|
@ -110,6 +117,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module" src="./dist/game.js?v=28.21"></script>
|
<script type="module" src="./dist/game.js?v=29.00"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "zunda-shiwake",
|
"name": "zunda-shiwake",
|
||||||
"version": "28.2.1",
|
"version": "29.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "zunda-shiwake",
|
"name": "zunda-shiwake",
|
||||||
"version": "28.2.1",
|
"version": "29.0.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"esbuild": "^0.25.0"
|
"esbuild": "^0.25.0"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "zunda-shiwake",
|
"name": "zunda-shiwake",
|
||||||
"version": "28.2.1",
|
"version": "29.0.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 scripts/build.mjs",
|
||||||
"test": "node test_v28_21.mjs"
|
"test": "node -e \"const fs=require('fs'); const cp=require('child_process'); if (fs.existsSync('test_v28_21.mjs')) cp.execFileSync(process.execPath, ['test_v28_21.mjs'], { stdio: 'inherit' }); cp.execFileSync(process.execPath, ['test_v29_00.mjs'], { stdio: 'inherit' });\""
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"esbuild": "^0.25.0"
|
"esbuild": "^0.25.0"
|
||||||
|
|
|
||||||
15
scripts/build.mjs
Normal file
15
scripts/build.mjs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { build } from 'esbuild';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
await build({
|
||||||
|
absWorkingDir: root,
|
||||||
|
entryPoints: [resolve(root, 'src/game.js')],
|
||||||
|
bundle: true,
|
||||||
|
format: 'esm',
|
||||||
|
target: 'es2020',
|
||||||
|
minify: true,
|
||||||
|
outfile: resolve(root, 'dist/game.js')
|
||||||
|
});
|
||||||
|
|
@ -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: 'v28.21 BOOST DRAW HOTFIX',
|
version: 'v29.00',
|
||||||
time: {
|
time: {
|
||||||
daySeconds: 60,
|
daySeconds: 60,
|
||||||
farmShutdownGraceSeconds: 10
|
farmShutdownGraceSeconds: 10
|
||||||
|
|
@ -84,7 +84,46 @@ export const BALANCE = {
|
||||||
conveyor: {
|
conveyor: {
|
||||||
baseSpeed: 52,
|
baseSpeed: 52,
|
||||||
maxSpeed: 300,
|
maxSpeed: 300,
|
||||||
bearingSpeedMultiplier: 1.075
|
bearingSpeedMultiplier: 1.075,
|
||||||
|
minimumItemSpacingPx: 25
|
||||||
|
},
|
||||||
|
power: {
|
||||||
|
recalcHz: 10,
|
||||||
|
maxAssignmentPasses: 8,
|
||||||
|
wires: {
|
||||||
|
powerWire: { id: 'powerWire', name: 'Power Wire', shortName: 'WIRE', price: 10, lossV: 0.5 },
|
||||||
|
insulatedPowerWire: { id: 'insulatedPowerWire', name: 'Insulated Wire', shortName: 'INS WIRE', price: 22, lossV: 0.15 }
|
||||||
|
},
|
||||||
|
exempt: ['manualScanner', 'eggFarm', 'conveyor', 'boostConveyor', 'powerWire', 'insulatedPowerWire', 'footbridge', 'starterGenerator', 'biomassGenerator', 'coalGenerator'],
|
||||||
|
machines: {
|
||||||
|
mixer: { minVoltage: 10, drawV: 4, relayDropV: 4, powerPriority: 100 },
|
||||||
|
trash: { minVoltage: 8, drawV: 3, relayDropV: 3, powerPriority: 100 },
|
||||||
|
truck: { minVoltage: 6, drawV: 2, relayDropV: 2, powerPriority: 100 },
|
||||||
|
autoScanner: { minVoltage: 8, drawV: 2, relayDropV: 2, powerPriority: 50 },
|
||||||
|
sausageMaker: { minVoltage: 12, drawV: 5, relayDropV: 5, powerPriority: 50 },
|
||||||
|
maintenanceRoom: { minVoltage: 6, drawV: 1, relayDropV: 1, powerPriority: 50 },
|
||||||
|
inboundTruck: { minVoltage: 5, drawV: 1, relayDropV: 1, powerPriority: 50 }
|
||||||
|
},
|
||||||
|
generators: {
|
||||||
|
starterGenerator: { outputV: 25 },
|
||||||
|
biomassGenerator: { baseOutputV: 0, pulseVoltage: 5, pulseSeconds: 2 },
|
||||||
|
coalGenerator: { baseOutputV: 0, pulseVoltage: 15, pulseSeconds: 3 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
imports: {
|
||||||
|
combinedLimitPerTruck: 80,
|
||||||
|
prices: { coal: 2, zundaMochi: 4 },
|
||||||
|
jitterRatio: 0.15
|
||||||
|
},
|
||||||
|
farmQuality: {
|
||||||
|
overflowCoefficient: 0.45,
|
||||||
|
overflowExponent: 0.85,
|
||||||
|
minMultiplier: 1,
|
||||||
|
maxMultiplier: 2.5,
|
||||||
|
zundaSeconds: 8,
|
||||||
|
zundaMaxSeconds: 30,
|
||||||
|
zundaIntervalMultiplier: 0.55,
|
||||||
|
safeMinIntervalSeconds: 0.25
|
||||||
},
|
},
|
||||||
effects: {
|
effects: {
|
||||||
explosionHorizontalVelocityMultiplier: 0.82
|
explosionHorizontalVelocityMultiplier: 0.82
|
||||||
|
|
@ -406,7 +445,11 @@ export const BALANCE = {
|
||||||
autoScanner: 850,
|
autoScanner: 850,
|
||||||
mixer: 775,
|
mixer: 775,
|
||||||
trash: 950,
|
trash: 950,
|
||||||
sausageMaker: 825
|
sausageMaker: 825,
|
||||||
|
starterGenerator: 1000,
|
||||||
|
biomassGenerator: 900,
|
||||||
|
coalGenerator: 900,
|
||||||
|
inboundTruck: 850
|
||||||
},
|
},
|
||||||
processingDelayMaxSeconds: {
|
processingDelayMaxSeconds: {
|
||||||
mixer: 1.20,
|
mixer: 1.20,
|
||||||
|
|
@ -427,6 +470,18 @@ export const BALANCE = {
|
||||||
id: 'boostConveyor', type: 'conveyor', name: 'Boost Conveyor', shortName: 'BOOST BELT', price: 75,
|
id: 'boostConveyor', type: 'conveyor', name: 'Boost Conveyor', shortName: 'BOOST BELT', price: 75,
|
||||||
buildable: true, upgradeable: false, speedMultiplier: 2
|
buildable: true, upgradeable: false, speedMultiplier: 2
|
||||||
},
|
},
|
||||||
|
powerWire: {
|
||||||
|
id: 'powerWire', type: 'powerWire', name: 'Power Wire', shortName: 'WIRE', price: 10,
|
||||||
|
buildable: true, upgradeable: false
|
||||||
|
},
|
||||||
|
insulatedPowerWire: {
|
||||||
|
id: 'insulatedPowerWire', type: 'powerWire', name: 'Insulated Power Wire', shortName: 'INS WIRE', price: 22,
|
||||||
|
buildable: true, upgradeable: false
|
||||||
|
},
|
||||||
|
footbridge: {
|
||||||
|
id: 'footbridge', type: 'footbridge', name: 'Footbridge', shortName: 'BRIDGE', price: 90,
|
||||||
|
buildable: true, upgradeable: false, footprint: { cols: 1, rows: 3 }
|
||||||
|
},
|
||||||
eggFarm: {
|
eggFarm: {
|
||||||
id: 'eggFarm', type: 'eggFarm', name: 'Egg Farm', shortName: 'EGG', price: 250,
|
id: 'eggFarm', type: 'eggFarm', name: 'Egg Farm', shortName: 'EGG', price: 250,
|
||||||
buildable: true, upgradeable: true, maxLevel: 4
|
buildable: true, upgradeable: true, maxLevel: 4
|
||||||
|
|
@ -448,13 +503,29 @@ export const BALANCE = {
|
||||||
buildable: true, upgradeable: true, body: { w: 180, h: 100 }
|
buildable: true, upgradeable: true, body: { w: 180, h: 100 }
|
||||||
},
|
},
|
||||||
truck: {
|
truck: {
|
||||||
id: 'truck', type: 'facility', name: 'Truck', shortName: 'TRUCK', price: 450,
|
id: 'truck', type: 'facility', name: 'Shipping Truck', shortName: 'SHIP 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 }
|
||||||
},
|
},
|
||||||
sausageMaker: {
|
sausageMaker: {
|
||||||
id: 'sausageMaker', type: 'facility', name: 'Sausage Machine', shortName: 'SAUSAGE', price: 380,
|
id: 'sausageMaker', type: 'facility', name: 'Sausage Machine', shortName: 'SAUSAGE', price: 380,
|
||||||
buildable: true, upgradeable: true, incomeKey: 'sausage', footprint: { cols: 3, rows: 3 }, body: { w: 138, h: 138 }, outputProduct: 'sausage'
|
buildable: true, upgradeable: true, incomeKey: 'sausage', footprint: { cols: 3, rows: 3 }, body: { w: 138, h: 138 }, outputProduct: 'sausage'
|
||||||
},
|
},
|
||||||
|
starterGenerator: {
|
||||||
|
id: 'starterGenerator', type: 'facility', name: 'Starter Generator', shortName: 'STARTER', price: 0,
|
||||||
|
buildable: false, upgradeable: false, footprint: { cols: 2, rows: 2 }, body: { w: 92, h: 92 }
|
||||||
|
},
|
||||||
|
biomassGenerator: {
|
||||||
|
id: 'biomassGenerator', type: 'facility', name: 'Biomass Generator', shortName: 'BIOMASS', price: 520,
|
||||||
|
buildable: true, upgradeable: false, footprint: { cols: 3, rows: 3 }, body: { w: 138, h: 138 }
|
||||||
|
},
|
||||||
|
coalGenerator: {
|
||||||
|
id: 'coalGenerator', type: 'facility', name: 'Coal Generator', shortName: 'COAL GEN', price: 680,
|
||||||
|
buildable: true, upgradeable: false, footprint: { cols: 2, rows: 3 }, body: { w: 92, h: 138 }
|
||||||
|
},
|
||||||
|
inboundTruck: {
|
||||||
|
id: 'inboundTruck', type: 'facility', name: 'Inbound Truck', shortName: 'INBOUND', price: 350,
|
||||||
|
buildable: true, upgradeable: false, body: { w: 176, h: 120 }
|
||||||
|
},
|
||||||
maintenanceRoom: {
|
maintenanceRoom: {
|
||||||
id: 'maintenanceRoom', type: 'maintenanceRoom', name: 'Maintenance Room', shortName: 'REPAIR ROOM', price: 220,
|
id: 'maintenanceRoom', type: 'maintenanceRoom', name: 'Maintenance Room', shortName: 'REPAIR ROOM', price: 220,
|
||||||
buildable: true, upgradeable: false, footprint: { cols: 2, rows: 2 }
|
buildable: true, upgradeable: false, footprint: { cols: 2, rows: 2 }
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,9 @@ 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', 'sausageMaker', 'maintenanceRoom'];
|
export const BUILD_TOOL_IDS = ['conveyor', 'boostConveyor', 'powerWire', 'insulatedPowerWire', 'footbridge', 'eggFarm', 'autoScanner', 'manualScanner', 'mixer', 'trash', 'truck', 'inboundTruck', 'sausageMaker', 'biomassGenerator', 'coalGenerator', 'maintenanceRoom'];
|
||||||
export const EDGE_FACILITY_IDS = ['mixer', 'trash', 'truck'];
|
export const EDGE_FACILITY_IDS = ['mixer', 'trash', 'truck', 'inboundTruck'];
|
||||||
export const GRID_FACILITY_IDS = ['sausageMaker'];
|
export const GRID_FACILITY_IDS = ['sausageMaker', 'starterGenerator', 'biomassGenerator', 'coalGenerator'];
|
||||||
export const MACHINE_FACILITY_IDS = [...EDGE_FACILITY_IDS, ...GRID_FACILITY_IDS];
|
export const MACHINE_FACILITY_IDS = [...EDGE_FACILITY_IDS, ...GRID_FACILITY_IDS];
|
||||||
export const REQUIRED_RECEIVER_FACILITY_IDS = ['mixer', 'trash', 'truck'];
|
export const REQUIRED_RECEIVER_FACILITY_IDS = ['mixer', 'trash', 'truck'];
|
||||||
export const INCOME_FACILITY_IDS = ['mixer', 'truck', 'sausageMaker'];
|
export const INCOME_FACILITY_IDS = ['mixer', 'truck', 'sausageMaker'];
|
||||||
|
|
|
||||||
|
|
@ -108,8 +108,9 @@ function facilityKind(facility) { return facility?.baseId || facility?.id || '';
|
||||||
|
|
||||||
export function syncGridFacilityGeometry(facility) {
|
export function syncGridFacilityGeometry(facility) {
|
||||||
if (!facility?.gridPlaced) return facility;
|
if (!facility?.gridPlaced) return facility;
|
||||||
facility.wCells = Math.max(1, facility.wCells || 3);
|
const def = FACILITY_DEFS[facilityKind(facility)] || {};
|
||||||
facility.hCells = Math.max(1, facility.hCells || 3);
|
facility.wCells = Math.max(1, facility.wCells || def.footprint?.cols || 3);
|
||||||
|
facility.hCells = Math.max(1, facility.hCells || def.footprint?.rows || 3);
|
||||||
facility.x = GRID.x + facility.col * GRID.cell;
|
facility.x = GRID.x + facility.col * GRID.cell;
|
||||||
facility.y = GRID.y + facility.row * GRID.cell;
|
facility.y = GRID.y + facility.row * GRID.cell;
|
||||||
facility.w = facility.wCells * GRID.cell;
|
facility.w = facility.wCells * GRID.cell;
|
||||||
|
|
@ -118,6 +119,10 @@ export function syncGridFacilityGeometry(facility) {
|
||||||
facility.entry = { col: facility.col - 1, row: facility.row + Math.floor(facility.hCells / 2) };
|
facility.entry = { col: facility.col - 1, row: facility.row + Math.floor(facility.hCells / 2) };
|
||||||
facility.side = 'grid';
|
facility.side = 'grid';
|
||||||
}
|
}
|
||||||
|
if (facilityKind(facility) === 'biomassGenerator' || facilityKind(facility) === 'coalGenerator') {
|
||||||
|
facility.entry = { col: facility.col - 1, row: facility.row + Math.floor(facility.hCells / 2) };
|
||||||
|
facility.side = 'grid';
|
||||||
|
}
|
||||||
return facility;
|
return facility;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,6 +198,7 @@ export function createFacility(game, id, p, price) {
|
||||||
price: price || spec.price,
|
price: price || spec.price,
|
||||||
builtSession: game.buildSession
|
builtSession: game.buildSession
|
||||||
};
|
};
|
||||||
|
if (id === 'inboundTruck') f.inboundOrder = { coalPerDay: 0, zundaMochiPerDay: 0 };
|
||||||
return layoutFacilityOnEdge(f, entry, side);
|
return layoutFacilityOnEdge(f, entry, side);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,13 +210,14 @@ export function createGridFacility(game, id, col, row, price) {
|
||||||
name: def?.name || id,
|
name: def?.name || id,
|
||||||
baseId: id,
|
baseId: id,
|
||||||
col, row,
|
col, row,
|
||||||
wCells: id === 'sausageMaker' ? 3 : 1,
|
wCells: def?.footprint?.cols || (id === 'sausageMaker' ? 3 : 1),
|
||||||
hCells: id === 'sausageMaker' ? 3 : 1,
|
hCells: def?.footprint?.rows || (id === 'sausageMaker' ? 3 : 1),
|
||||||
gridPlaced: true,
|
gridPlaced: true,
|
||||||
level: 1,
|
level: 1,
|
||||||
price: price || def?.price || 0,
|
price: price || def?.price || 0,
|
||||||
builtSession: game.buildSession
|
builtSession: game.buildSession
|
||||||
};
|
};
|
||||||
|
if (id === 'biomassGenerator' || id === 'coalGenerator') f.fuelPulses = [];
|
||||||
return syncGridFacilityGeometry(f);
|
return syncGridFacilityGeometry(f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,30 @@ export function newTurnStats() {
|
||||||
explosions: 0,
|
explosions: 0,
|
||||||
eventResult: null,
|
eventResult: null,
|
||||||
conveyorPasses: 0,
|
conveyorPasses: 0,
|
||||||
eggProduced: 0
|
eggProduced: 0,
|
||||||
|
coalOrdered: 0,
|
||||||
|
coalReleased: 0,
|
||||||
|
coalConsumed: 0,
|
||||||
|
coalUndelivered: 0,
|
||||||
|
zundaOrdered: 0,
|
||||||
|
zundaReleased: 0,
|
||||||
|
zundaConsumed: 0,
|
||||||
|
zundaUndelivered: 0,
|
||||||
|
zundaBoostSeconds: 0,
|
||||||
|
zundaBoostSecondsByFarm: {},
|
||||||
|
importPurchaseCost: 0,
|
||||||
|
biomassFuelConsumed: 0,
|
||||||
|
maxGeneratorOutput: {},
|
||||||
|
qualityRevenue: 0,
|
||||||
|
farmTheoreticalRate: 0,
|
||||||
|
farmActualOutputRate: 0,
|
||||||
|
qualityMultiplierSum: 0,
|
||||||
|
qualityMultiplierCount: 0,
|
||||||
|
maxQualityMultiplier: 1,
|
||||||
|
generatorAssignmentChanges: 0,
|
||||||
|
underVoltageSeconds: 0,
|
||||||
|
underVoltageSecondsByMachine: {},
|
||||||
|
unreachableRepairTargets: 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,7 +112,22 @@ export function newTotalStats() {
|
||||||
eventSuccess: 0,
|
eventSuccess: 0,
|
||||||
eventFailed: 0,
|
eventFailed: 0,
|
||||||
conveyorPasses: 0,
|
conveyorPasses: 0,
|
||||||
eggProduced: 0
|
eggProduced: 0,
|
||||||
|
coalOrdered: 0,
|
||||||
|
coalReleased: 0,
|
||||||
|
coalConsumed: 0,
|
||||||
|
coalUndelivered: 0,
|
||||||
|
zundaOrdered: 0,
|
||||||
|
zundaReleased: 0,
|
||||||
|
zundaConsumed: 0,
|
||||||
|
zundaUndelivered: 0,
|
||||||
|
zundaBoostSeconds: 0,
|
||||||
|
importPurchaseCost: 0,
|
||||||
|
biomassFuelConsumed: 0,
|
||||||
|
qualityRevenue: 0,
|
||||||
|
generatorAssignmentChanges: 0,
|
||||||
|
underVoltageSeconds: 0,
|
||||||
|
unreachableRepairTargets: 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,6 +153,7 @@ export function createGame() {
|
||||||
ownedCells: new Set(),
|
ownedCells: new Set(),
|
||||||
blockedCells: new Set(),
|
blockedCells: new Set(),
|
||||||
buildTool: null,
|
buildTool: null,
|
||||||
|
footbridgeOrientation: 'vertical',
|
||||||
buildSession: 0,
|
buildSession: 0,
|
||||||
undoStack: [],
|
undoStack: [],
|
||||||
redoStack: [],
|
redoStack: [],
|
||||||
|
|
@ -130,8 +169,15 @@ export function createGame() {
|
||||||
floatingTexts: [],
|
floatingTexts: [],
|
||||||
shake: { time: 0, strength: 0 },
|
shake: { time: 0, strength: 0 },
|
||||||
truckCargo: [],
|
truckCargo: [],
|
||||||
|
shippedCargo: [],
|
||||||
conveyorTiles: new Set(),
|
conveyorTiles: new Set(),
|
||||||
conveyorMeta: new Map(),
|
conveyorMeta: new Map(),
|
||||||
|
powerWires: new Map(),
|
||||||
|
footbridges: new Map(),
|
||||||
|
powerOverlay: false,
|
||||||
|
power: { dirty: true, elapsed: 0, routes: new Map(), assignments: new Map(), sources: new Map(), underVoltage: new Set() },
|
||||||
|
imports: { schedules: new Map() },
|
||||||
|
walkabilityVersion: 0,
|
||||||
eggFarms: [],
|
eggFarms: [],
|
||||||
scanners: [],
|
scanners: [],
|
||||||
facilities: {},
|
facilities: {},
|
||||||
|
|
@ -316,9 +362,73 @@ function protectedInitialCells(game) {
|
||||||
for (const p of farmOutputCells(farm)) add(p);
|
for (const p of farmOutputCells(farm)) add(p);
|
||||||
}
|
}
|
||||||
for (const f of Object.values(game.facilities)) add(f.entry);
|
for (const f of Object.values(game.facilities)) add(f.entry);
|
||||||
|
for (const f of Object.values(game.facilities)) {
|
||||||
|
if (f.gridPlaced) {
|
||||||
|
for (let dr = 0; dr < (f.hCells || 1); dr += 1) for (let dc = 0; dc < (f.wCells || 1); dc += 1) add({ col: f.col + dc, row: f.row + dr });
|
||||||
|
}
|
||||||
|
}
|
||||||
return reserved;
|
return reserved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addPowerWire(game, col, row, kind = 'powerWire', free = false) {
|
||||||
|
if (!inGrid(col, row)) return;
|
||||||
|
game.powerWires.set(key(col, row), {
|
||||||
|
kind,
|
||||||
|
price: free ? 0 : (BALANCE.power.wires[kind]?.price || 0),
|
||||||
|
builtSession: free ? null : game.buildSession
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function installStarterGenerator(game) {
|
||||||
|
const def = FACILITY_DEFS.starterGenerator;
|
||||||
|
const starter = {
|
||||||
|
type: 'facility',
|
||||||
|
id: 'starterGenerator',
|
||||||
|
baseId: 'starterGenerator',
|
||||||
|
name: def.name,
|
||||||
|
col: 6,
|
||||||
|
row: 6,
|
||||||
|
wCells: def.footprint.cols,
|
||||||
|
hCells: def.footprint.rows,
|
||||||
|
gridPlaced: true,
|
||||||
|
level: 1,
|
||||||
|
price: 0,
|
||||||
|
builtSession: null,
|
||||||
|
unsellable: true
|
||||||
|
};
|
||||||
|
starter.x = GRID.x + starter.col * GRID.cell;
|
||||||
|
starter.y = GRID.y + starter.row * GRID.cell;
|
||||||
|
starter.w = starter.wCells * GRID.cell;
|
||||||
|
starter.h = starter.hCells * GRID.cell;
|
||||||
|
game.facilities.starterGenerator = starter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStarterPowerLayout(game) {
|
||||||
|
game.powerWires = new Map();
|
||||||
|
const routeCells = [
|
||||||
|
[5, 6], [4, 6], [3, 6], [2, 6], [1, 6], [0, 6], [0, 5], [0, 4],
|
||||||
|
[8, 7], [8, 8], [9, 8], [10, 8], [11, 8], [12, 8], [12, 9],
|
||||||
|
[13, 8], [14, 8], [15, 8], [16, 8], [17, 8], [18, 8], [18, 9]
|
||||||
|
];
|
||||||
|
for (const [col, row] of routeCells) addPowerWire(game, col, row, 'powerWire', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateStarterPowerLayout(game) {
|
||||||
|
const bodyCells = new Set();
|
||||||
|
for (const f of Object.values(game.facilities || {})) {
|
||||||
|
if (!f.gridPlaced) continue;
|
||||||
|
for (let dr = 0; dr < (f.hCells || 1); dr += 1) {
|
||||||
|
for (let dc = 0; dc < (f.wCells || 1); dc += 1) bodyCells.add(key(f.col + dc, f.row + dr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const k of game.powerWires.keys()) {
|
||||||
|
if (game.blockedCells.has(k) || bodyCells.has(k)) throw new Error(`Invalid starter power wire at ${k}`);
|
||||||
|
}
|
||||||
|
for (const id of ['starterGenerator', 'mixer', 'trash', 'truck']) {
|
||||||
|
if (!game.facilities[id]) throw new Error(`Missing starter power facility: ${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function installInitialConveyorBackbone(game) {
|
function installInitialConveyorBackbone(game) {
|
||||||
const cells = INITIAL_CONVEYOR_BACKBONE.map(([col, row]) => ({ col, row }));
|
const cells = INITIAL_CONVEYOR_BACKBONE.map(([col, row]) => ({ col, row }));
|
||||||
for (const { col, row } of cells) addConveyorTile(game, col, row);
|
for (const { col, row } of cells) addConveyorTile(game, col, row);
|
||||||
|
|
@ -347,11 +457,14 @@ export function resetLayout(game) {
|
||||||
}
|
}
|
||||||
game.conveyorTiles.clear();
|
game.conveyorTiles.clear();
|
||||||
game.conveyorMeta.clear();
|
game.conveyorMeta.clear();
|
||||||
|
game.powerWires = new Map();
|
||||||
|
game.footbridges = new Map();
|
||||||
game.maintenanceRooms = [];
|
game.maintenanceRooms = [];
|
||||||
game.repairmen = [];
|
game.repairmen = [];
|
||||||
game.blockedCells = new Set();
|
game.blockedCells = new Set();
|
||||||
installInitialConveyorBackbone(game);
|
installInitialConveyorBackbone(game);
|
||||||
game.facilities = defaultFacilities();
|
game.facilities = defaultFacilities();
|
||||||
|
installStarterGenerator(game);
|
||||||
game.scanners = INITIAL_SCANNERS.map(scanner => ({ ...scanner, keys: scanner.keys ? { left: { ...scanner.keys.left }, right: { ...scanner.keys.right } } : null, queue: [] }));
|
game.scanners = INITIAL_SCANNERS.map(scanner => ({ ...scanner, keys: scanner.keys ? { left: { ...scanner.keys.left }, right: { ...scanner.keys.right } } : null, queue: [] }));
|
||||||
game.eggFarms = INITIAL_EGG_FARMS.map(farm => {
|
game.eggFarms = INITIAL_EGG_FARMS.map(farm => {
|
||||||
const nextSpawn = nextSpawnDelay(farm);
|
const nextSpawn = nextSpawnDelay(farm);
|
||||||
|
|
@ -363,10 +476,16 @@ export function resetLayout(game) {
|
||||||
// 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 3% of the initial map is blocked.
|
// when roughly 3% of the initial map is blocked.
|
||||||
|
createStarterPowerLayout(game);
|
||||||
const reserved = protectedInitialCells(game);
|
const reserved = protectedInitialCells(game);
|
||||||
|
for (const k of game.powerWires.keys()) reserved.add(k);
|
||||||
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);
|
||||||
installInitialConveyorBackbone(game);
|
installInitialConveyorBackbone(game);
|
||||||
|
createStarterPowerLayout(game);
|
||||||
|
validateStarterPowerLayout(game);
|
||||||
|
game.power = { dirty: true, elapsed: 0, routes: new Map(), assignments: new Map(), sources: new Map(), underVoltage: new Set() };
|
||||||
|
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
|
||||||
game.routingVersion = (game.routingVersion || 0) + 1;
|
game.routingVersion = (game.routingVersion || 0) + 1;
|
||||||
game.factoryGraph = null;
|
game.factoryGraph = null;
|
||||||
game.factoryGraphCommittedVersion = -1;
|
game.factoryGraphCommittedVersion = -1;
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,11 @@ export const TEXT = {
|
||||||
routeLabels: {
|
routeLabels: {
|
||||||
mixer: 'MIXER',
|
mixer: 'MIXER',
|
||||||
truck: 'TRUCK',
|
truck: 'TRUCK',
|
||||||
|
inboundTruck: 'INBOUND',
|
||||||
trash: 'WASTE',
|
trash: 'WASTE',
|
||||||
sausageMaker: 'SAUSAGE',
|
sausageMaker: 'SAUSAGE',
|
||||||
|
biomassGenerator: 'BIOMASS',
|
||||||
|
coalGenerator: 'COAL GEN',
|
||||||
scanner: 'NEXT',
|
scanner: 'NEXT',
|
||||||
'scanner-role-1': 'NEXT',
|
'scanner-role-1': 'NEXT',
|
||||||
input: 'IN'
|
input: 'IN'
|
||||||
|
|
@ -71,7 +74,13 @@ export function buildToolFlavorText(id) {
|
||||||
manualScanner: 'A manual checkpoint. The operator is the algorithm.',
|
manualScanner: 'A manual checkpoint. The operator is the algorithm.',
|
||||||
mixer: 'Makes meat when OUT is clear; otherwise falls back to direct sale.',
|
mixer: 'Makes meat when OUT is clear; otherwise falls back to direct sale.',
|
||||||
trash: 'A polite shredder for poop and other regrets.',
|
trash: 'A polite shredder for poop and other regrets.',
|
||||||
truck: 'Ships target chicks and all sausages. Wrong live cargo still leaves a paper trail.',
|
truck: 'Shipping Truck. Ships target chicks and all sausages.',
|
||||||
|
powerWire: 'Overlay wire. Loses 0.5V per tile.',
|
||||||
|
insulatedPowerWire: 'Overlay wire. Loses 0.15V per tile.',
|
||||||
|
footbridge: 'Lets repair workers cross conveyor cells.',
|
||||||
|
inboundTruck: 'Imports coal and zunda mochi during the day.',
|
||||||
|
biomassGenerator: 'Fuel generator. Chicks and poop add timed voltage.',
|
||||||
|
coalGenerator: 'Fuel generator. Coal adds timed voltage.',
|
||||||
sausageMaker: 'A 3×3 grid machine. Left inlet takes 2 meat; right outlet emits 1 sausage.',
|
sausageMaker: 'A 3×3 grid machine. Left inlet takes 2 meat; right outlet emits 1 sausage.',
|
||||||
maintenanceRoom: '2×2 repair bay. Each room dispatches one repairman during the day.'
|
maintenanceRoom: '2×2 repair bay. Each room dispatches one repairman during the day.'
|
||||||
};
|
};
|
||||||
|
|
|
||||||
35
src/game.js
35
src/game.js
|
|
@ -14,6 +14,8 @@ import { CARD_DEFS, cardById, conveyorSpeedForGame, createCardSystem, debugCardT
|
||||||
import { floating, shockwave, sparkBurst, updateEffects } from './systems/effects.js';
|
import { floating, shockwave, sparkBurst, updateEffects } from './systems/effects.js';
|
||||||
import { EVENT_TARGETS, createEventOffer, rollEventOffer, activateEventOffer, clearActiveEvent, resolveEvent, eventDaySeconds, fairiesTributeMultiplierFromEvent } from './systems/events.js';
|
import { EVENT_TARGETS, createEventOffer, rollEventOffer, activateEventOffer, clearActiveEvent, resolveEvent, eventDaySeconds, fairiesTributeMultiplierFromEvent } from './systems/events.js';
|
||||||
import { ensureMaintenanceState, activateRepairmanForDay, deactivateRepairman, conveyorSpeedFactorForKey } from './systems/maintenance.js';
|
import { ensureMaintenanceState, activateRepairmanForDay, deactivateRepairman, conveyorSpeedFactorForKey } from './systems/maintenance.js';
|
||||||
|
import { updatePowerSystem, recalculatePowerNetwork } from './systems/power.js';
|
||||||
|
import { startInboundOrders, settleUndeliveredImports } from './systems/imports.js';
|
||||||
|
|
||||||
const canvas = document.getElementById('gameCanvas');
|
const canvas = document.getElementById('gameCanvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
@ -42,7 +44,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'), sausageMaker: document.getElementById('buildSausageMakerButton'), maintenanceRoom: document.getElementById('buildMaintenanceRoomButton'), erase: document.getElementById('eraseButton'), undo: document.getElementById('undoButton'), redo: document.getElementById('redoButton')
|
nextTurn: document.getElementById('nextTurnButton'), conveyor: document.getElementById('buildConveyorButton'), boostConveyor: document.getElementById('buildBoostConveyorButton'), powerWire: document.getElementById('buildPowerWireButton'), insulatedPowerWire: document.getElementById('buildInsulatedPowerWireButton'), powerOverlay: document.getElementById('powerOverlayButton'), footbridge: document.getElementById('buildFootbridgeButton'), eggFarm: document.getElementById('buildEggFarmButton'), autoScanner: document.getElementById('buildAutoScannerButton'), manualScanner: document.getElementById('buildManualScannerButton'), mixer: document.getElementById('buildMixerButton'), trash: document.getElementById('buildTrashButton'), truck: document.getElementById('buildTruckButton'), inboundTruck: document.getElementById('buildInboundTruckButton'), sausageMaker: document.getElementById('buildSausageMakerButton'), biomassGenerator: document.getElementById('buildBiomassGeneratorButton'), coalGenerator: document.getElementById('buildCoalGeneratorButton'), maintenanceRoom: document.getElementById('buildMaintenanceRoomButton'), erase: document.getElementById('eraseButton'), undo: document.getElementById('undoButton'), redo: document.getElementById('redoButton')
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -51,13 +53,19 @@ function initializeStaticText() {
|
||||||
const buttonToolMap = {
|
const buttonToolMap = {
|
||||||
conveyor: 'conveyor',
|
conveyor: 'conveyor',
|
||||||
boostConveyor: 'boostConveyor',
|
boostConveyor: 'boostConveyor',
|
||||||
|
powerWire: 'powerWire',
|
||||||
|
insulatedPowerWire: 'insulatedPowerWire',
|
||||||
|
footbridge: 'footbridge',
|
||||||
eggFarm: 'eggFarm',
|
eggFarm: 'eggFarm',
|
||||||
autoScanner: 'autoScanner',
|
autoScanner: 'autoScanner',
|
||||||
manualScanner: 'manualScanner',
|
manualScanner: 'manualScanner',
|
||||||
mixer: 'mixer',
|
mixer: 'mixer',
|
||||||
trash: 'trash',
|
trash: 'trash',
|
||||||
truck: 'truck',
|
truck: 'truck',
|
||||||
|
inboundTruck: 'inboundTruck',
|
||||||
sausageMaker: 'sausageMaker',
|
sausageMaker: 'sausageMaker',
|
||||||
|
biomassGenerator: 'biomassGenerator',
|
||||||
|
coalGenerator: 'coalGenerator',
|
||||||
maintenanceRoom: 'maintenanceRoom'
|
maintenanceRoom: 'maintenanceRoom'
|
||||||
};
|
};
|
||||||
for (const [buttonKey, toolId] of Object.entries(buttonToolMap)) {
|
for (const [buttonKey, toolId] of Object.entries(buttonToolMap)) {
|
||||||
|
|
@ -285,6 +293,10 @@ function startNextTurn() {
|
||||||
}
|
}
|
||||||
ensureMaintenanceState(game);
|
ensureMaintenanceState(game);
|
||||||
commitFactoryGraphForDay(game);
|
commitFactoryGraphForDay(game);
|
||||||
|
const powerState = recalculatePowerNetwork(game);
|
||||||
|
if (powerState.underVoltage?.size) {
|
||||||
|
floating(game, canvas.width / 2 - game.view.x, 136 - game.view.y, `${powerState.underVoltage.size} UNDER-VOLTAGE`, THEME.warn);
|
||||||
|
}
|
||||||
activateRepairmanForDay(game);
|
activateRepairmanForDay(game);
|
||||||
const priorProfit = Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0));
|
const priorProfit = Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0));
|
||||||
const tribute = collectFairiesTribute(game, Math.max(1, game.turn || 1), priorProfit);
|
const tribute = collectFairiesTribute(game, Math.max(1, game.turn || 1), priorProfit);
|
||||||
|
|
@ -303,15 +315,17 @@ function startNextTurn() {
|
||||||
game.floatingTexts = [];
|
game.floatingTexts = [];
|
||||||
game.shake = { time: 0, strength: 0 };
|
game.shake = { time: 0, strength: 0 };
|
||||||
game.truckCargo = [];
|
game.truckCargo = [];
|
||||||
|
game.shippedCargo = [];
|
||||||
resetCameraToFactoryStart();
|
resetCameraToFactoryStart();
|
||||||
game.stats = newTurnStats();
|
game.stats = newTurnStats();
|
||||||
|
startInboundOrders(game);
|
||||||
game.stats.fairiesTribute = game.lastStartFees?.fairiesTribute || 0;
|
game.stats.fairiesTribute = game.lastStartFees?.fairiesTribute || 0;
|
||||||
if (game.stats.fairiesTribute > 0) {
|
if (game.stats.fairiesTribute > 0) {
|
||||||
game.stats.penalty += game.stats.fairiesTribute;
|
game.stats.penalty += game.stats.fairiesTribute;
|
||||||
game.stats.profit -= game.stats.fairiesTribute;
|
game.stats.profit -= game.stats.fairiesTribute;
|
||||||
}
|
}
|
||||||
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; farm.zundaBoostTime = 0; }
|
||||||
game.buildTool = null;
|
game.buildTool = null;
|
||||||
game.selected = null;
|
game.selected = null;
|
||||||
game.groupDrag = null;
|
game.groupDrag = null;
|
||||||
|
|
@ -325,6 +339,11 @@ function startNextTurn() {
|
||||||
|
|
||||||
function completeTurn() {
|
function completeTurn() {
|
||||||
if (game.phase !== 'running') return;
|
if (game.phase !== 'running') return;
|
||||||
|
settleUndeliveredImports(game);
|
||||||
|
for (const facility of Object.values(game.facilities || {})) {
|
||||||
|
if (facility.fuelPulses) facility.fuelPulses = [];
|
||||||
|
}
|
||||||
|
for (const farm of game.eggFarms || []) farm.zundaBoostTime = 0;
|
||||||
const ship = settleTruckRevenue(game);
|
const ship = settleTruckRevenue(game);
|
||||||
const activeEvent = game.eventActive ? { ...game.eventActive } : null;
|
const activeEvent = game.eventActive ? { ...game.eventActive } : null;
|
||||||
const eventResult = resolveEvent(game);
|
const eventResult = resolveEvent(game);
|
||||||
|
|
@ -356,6 +375,7 @@ function completeTurn() {
|
||||||
function enterBuildPhase() {
|
function enterBuildPhase() {
|
||||||
game.phase = 'build';
|
game.phase = 'build';
|
||||||
game.truckCargo = [];
|
game.truckCargo = [];
|
||||||
|
game.shippedCargo = [];
|
||||||
game.chicks = [];
|
game.chicks = [];
|
||||||
game.shutdownTimeLeft = 0;
|
game.shutdownTimeLeft = 0;
|
||||||
game.cleanupTimer = null;
|
game.cleanupTimer = null;
|
||||||
|
|
@ -415,6 +435,7 @@ function update(timestamp) {
|
||||||
game.lastTimestamp = timestamp;
|
game.lastTimestamp = timestamp;
|
||||||
game.frameId = (game.frameId || 0) + 1;
|
game.frameId = (game.frameId || 0) + 1;
|
||||||
chicks.beginFrame?.(game.frameId);
|
chicks.beginFrame?.(game.frameId);
|
||||||
|
updatePowerSystem(game, dt);
|
||||||
if (game.phase === 'running') chicks.updateRunning(dt, { closeFarmShutters, completeTurn });
|
if (game.phase === 'running') chicks.updateRunning(dt, { closeFarmShutters, completeTurn });
|
||||||
updateEffects(game, dt, canvas, build.equipmentHitBoxes);
|
updateEffects(game, dt, canvas, build.equipmentHitBoxes);
|
||||||
chicks.updateCongestion(game.frameId);
|
chicks.updateCongestion(game.frameId);
|
||||||
|
|
@ -591,6 +612,7 @@ function clickSelect(event) {
|
||||||
const obj = build.selectedObject();
|
const obj = build.selectedObject();
|
||||||
if (obj?.type === 'scanner' && obj.kind === 'manual') build.showManualScannerMenu(obj);
|
if (obj?.type === 'scanner' && obj.kind === 'manual') build.showManualScannerMenu(obj);
|
||||||
if (obj?.type === 'scanner' && obj.kind === 'auto') build.showAutoScannerMenu(obj);
|
if (obj?.type === 'scanner' && obj.kind === 'auto') build.showAutoScannerMenu(obj);
|
||||||
|
if (obj?.type === 'facility' && (obj.baseId || obj.id) === 'inboundTruck') build.showInboundTruckMenu(obj);
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelBuildAction() {
|
function cancelBuildAction() {
|
||||||
|
|
@ -807,7 +829,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.sausageMaker.addEventListener('click', () => build.setBuildTool('sausageMaker')); ui.buttons.maintenanceRoom.addEventListener('click', () => build.setBuildTool('maintenanceRoom')); 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.powerWire?.addEventListener('click', () => build.setBuildTool('powerWire')); ui.buttons.insulatedPowerWire?.addEventListener('click', () => build.setBuildTool('insulatedPowerWire')); ui.buttons.powerOverlay?.addEventListener('click', () => { game.powerOverlay = !game.powerOverlay; uiSystem.updatePanels(); }); ui.buttons.footbridge?.addEventListener('click', () => build.setBuildTool('footbridge')); 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.inboundTruck?.addEventListener('click', () => build.setBuildTool('inboundTruck')); ui.buttons.sausageMaker.addEventListener('click', () => build.setBuildTool('sausageMaker')); ui.buttons.biomassGenerator?.addEventListener('click', () => build.setBuildTool('biomassGenerator')); ui.buttons.coalGenerator?.addEventListener('click', () => build.setBuildTool('coalGenerator')); ui.buttons.maintenanceRoom.addEventListener('click', () => build.setBuildTool('maintenanceRoom')); ui.buttons.erase.addEventListener('click', () => build.setBuildTool('erase'));
|
||||||
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);
|
||||||
|
|
@ -830,6 +852,13 @@ window.addEventListener('keydown', event => {
|
||||||
}
|
}
|
||||||
if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && name === 'z') { event.preventDefault(); if (undo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } }
|
if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && name === 'z') { event.preventDefault(); if (undo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } }
|
||||||
if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && (name === 'y' || (event.shiftKey && name === 'z'))) { event.preventDefault(); if (redo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } }
|
if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && (name === 'y' || (event.shiftKey && name === 'z'))) { event.preventDefault(); if (redo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } }
|
||||||
|
if (game.phase === 'build' && game.buildTool === 'footbridge' && name === 'r') {
|
||||||
|
event.preventDefault();
|
||||||
|
game.footbridgeOrientation = game.footbridgeOrientation === 'horizontal' ? 'vertical' : 'horizontal';
|
||||||
|
floating(game, canvas.width / 2 - game.view.x, 86 - game.view.y, `FOOTBRIDGE ${game.footbridgeOrientation.toUpperCase()}`, THEME.green);
|
||||||
|
uiSystem.updatePanels();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.key === 'Escape' && game.cardTargetPick?.pending) {
|
if (event.key === 'Escape' && game.cardTargetPick?.pending) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (game.cardTargetPick.mode === 'autoScannerMenu') cancelBuildAction();
|
if (game.cardTargetPick.mode === 'autoScannerMenu') cancelBuildAction();
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { upgradedTruckPrice, upgradedSausagePrice } from '../systems/economy.js'
|
||||||
import { cardTargetBounds, conveyorSpeedForGame } from '../systems/cards.js';
|
import { cardTargetBounds, conveyorSpeedForGame } from '../systems/cards.js';
|
||||||
import { wearRatio, conveyorSpeedFactorForKey } from '../systems/maintenance.js';
|
import { wearRatio, conveyorSpeedFactorForKey } from '../systems/maintenance.js';
|
||||||
import { facilityBodyPortPoint } from '../core/entities.js';
|
import { facilityBodyPortPoint } from '../core/entities.js';
|
||||||
|
import { powerDetailsFor, currentGeneratorOutputV } from '../systems/power.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.
|
||||||
|
|
@ -67,6 +68,7 @@ function makeCanvas(width, height) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function drawAll(ctx, canvas, game, helpers) {
|
export function drawAll(ctx, canvas, game, helpers) {
|
||||||
|
ctx.__gameRef = game;
|
||||||
ctx.imageSmoothingEnabled = false;
|
ctx.imageSmoothingEnabled = false;
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
drawBackground(ctx, canvas, game);
|
drawBackground(ctx, canvas, game);
|
||||||
|
|
@ -79,6 +81,8 @@ export function drawAll(ctx, canvas, game, helpers) {
|
||||||
drawGrid(ctx, game);
|
drawGrid(ctx, game);
|
||||||
drawExpansionOffers(ctx, game);
|
drawExpansionOffers(ctx, game);
|
||||||
drawConveyors(ctx, game);
|
drawConveyors(ctx, game);
|
||||||
|
drawPowerWires(ctx, game);
|
||||||
|
drawFootbridges(ctx, game);
|
||||||
drawScannerConnectors(ctx, game);
|
drawScannerConnectors(ctx, game);
|
||||||
drawMaintenanceRooms(ctx, game);
|
drawMaintenanceRooms(ctx, game);
|
||||||
drawFacilities(ctx, game);
|
drawFacilities(ctx, game);
|
||||||
|
|
@ -565,6 +569,54 @@ function drawConveyors(ctx, game) {
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function drawPowerWires(ctx, game) {
|
||||||
|
if (!game.powerWires?.size) return;
|
||||||
|
ctx.save();
|
||||||
|
for (const [k, wire] of game.powerWires) {
|
||||||
|
const p = parseKey(k);
|
||||||
|
const c = cellCenter(p.col, p.row);
|
||||||
|
const insulated = wire.kind === 'insulatedPowerWire';
|
||||||
|
ctx.globalAlpha = game.powerOverlay || game.phase === 'build' ? 0.95 : 0.32;
|
||||||
|
ctx.strokeStyle = insulated ? '#d7eef7' : '#1e252b';
|
||||||
|
ctx.lineWidth = insulated ? 8 : 5;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(c.x - 15, c.y);
|
||||||
|
ctx.lineTo(c.x + 15, c.y);
|
||||||
|
ctx.moveTo(c.x, c.y - 15);
|
||||||
|
ctx.lineTo(c.x, c.y + 15);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.strokeStyle = insulated ? '#2c8ec4' : '#f3c33d';
|
||||||
|
ctx.lineWidth = insulated ? 3 : 2;
|
||||||
|
ctx.stroke();
|
||||||
|
if (game.powerOverlay) label(ctx, c.x, c.y - 14, insulated ? 'I' : 'V', insulated ? '#2c8ec4' : THEME.warn);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawFootbridges(ctx, game) {
|
||||||
|
if (!game.footbridges?.size) return;
|
||||||
|
ctx.save();
|
||||||
|
for (const k of game.footbridges.keys()) {
|
||||||
|
const p = parseKey(k);
|
||||||
|
const c = cellCenter(p.col, p.row);
|
||||||
|
ctx.fillStyle = 'rgba(210,222,228,.52)';
|
||||||
|
ctx.strokeStyle = '#e6edf2';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
rect(ctx, c.x - 20, c.y - 14, 40, 28, true, true);
|
||||||
|
ctx.strokeStyle = '#34404a';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(c.x - 18, c.y - 9);
|
||||||
|
ctx.lineTo(c.x + 18, c.y - 9);
|
||||||
|
ctx.moveTo(c.x - 18, c.y + 9);
|
||||||
|
ctx.lineTo(c.x + 18, c.y + 9);
|
||||||
|
ctx.stroke();
|
||||||
|
drawSelection(ctx, game, 'footbridge', k, c.x, c.y, 44, 34);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export function drawAnimatedConveyorStripes(ctx, game, center, tileKey, meta = {}, ratio = 0) {
|
export function drawAnimatedConveyorStripes(ctx, game, center, tileKey, meta = {}, ratio = 0) {
|
||||||
const boost = meta.kind === 'boostConveyor';
|
const boost = meta.kind === 'boostConveyor';
|
||||||
|
|
@ -1065,11 +1117,22 @@ function drawFacilityDetails(ctx, f, accent = THEME.green) {
|
||||||
rect(ctx, f.x + f.w - 44, f.y + 12, 30, 22, true, true);
|
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 = 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.fillStyle = THEME.danger; ctx.fillRect(f.x + f.w - 27, f.y + 18, 6, 6);
|
||||||
|
const power = powerDetailsFor(nullSafeGame(ctx), f);
|
||||||
|
if (power && power.receivedV < power.minVoltage) {
|
||||||
|
ctx.fillStyle = THEME.danger;
|
||||||
|
ctx.font = '900 18px ui-monospace, monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('X⚡', f.x + f.w - 28, f.y + 52);
|
||||||
|
}
|
||||||
ctx.strokeStyle = '#47515f'; ctx.lineWidth = 3;
|
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.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();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nullSafeGame(ctx) {
|
||||||
|
return ctx.__gameRef || {};
|
||||||
|
}
|
||||||
|
|
||||||
function drawFacilityTexture(ctx, image, f, maxCells = 1.45) {
|
function drawFacilityTexture(ctx, image, f, maxCells = 1.45) {
|
||||||
if (!image?.loaded) return false;
|
if (!image?.loaded) return false;
|
||||||
// Keep external-machine sprites at roughly the same pixel density as a grid object.
|
// Keep external-machine sprites at roughly the same pixel density as a grid object.
|
||||||
|
|
@ -1122,12 +1185,53 @@ function drawFacilities(ctx, game) {
|
||||||
else if (kind === 'trash') drawTrash(ctx, game, f);
|
else if (kind === 'trash') drawTrash(ctx, game, f);
|
||||||
else if (kind === 'truck') drawTruck(ctx, game, f);
|
else if (kind === 'truck') drawTruck(ctx, game, f);
|
||||||
else if (kind === 'sausageMaker') drawSausageMaker(ctx, game, f);
|
else if (kind === 'sausageMaker') drawSausageMaker(ctx, game, f);
|
||||||
|
else if (kind === 'starterGenerator' || kind === 'biomassGenerator' || kind === 'coalGenerator') drawGenerator(ctx, game, f, kind);
|
||||||
|
else if (kind === 'inboundTruck') drawInboundTruck(ctx, game, f);
|
||||||
}
|
}
|
||||||
for (const f of Object.values(game.facilities || {})) {
|
for (const f of Object.values(game.facilities || {})) {
|
||||||
drawFacilityReceiver(ctx, game, f);
|
drawFacilityReceiver(ctx, game, f);
|
||||||
drawFacilityOutput(ctx, game, f);
|
drawFacilityOutput(ctx, game, f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function drawGenerator(ctx, game, f, kind) {
|
||||||
|
ctx.save();
|
||||||
|
const accent = kind === 'starterGenerator' ? THEME.warn : (kind === 'biomassGenerator' ? THEME.green : '#d0d5dc');
|
||||||
|
drawMetalPanel(ctx, f.x, f.y, f.w, f.h, '#252b31', THEME.ink, accent);
|
||||||
|
drawFacilityDetails(ctx, f, accent);
|
||||||
|
ctx.fillStyle = THEME.white;
|
||||||
|
ctx.font = '900 13px ui-monospace, monospace';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
const title = kind === 'starterGenerator' ? 'STARTER' : (kind === 'biomassGenerator' ? 'BIOMASS' : 'COAL GEN');
|
||||||
|
ctx.fillText(title, f.x + 12, f.y + 18);
|
||||||
|
ctx.font = '900 18px ui-monospace, monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillStyle = accent;
|
||||||
|
ctx.fillText(kind === 'biomassGenerator' ? 'LEAF' : '⚡', f.x + f.w / 2, f.y + f.h / 2 - 4);
|
||||||
|
ctx.font = '900 12px ui-monospace, monospace';
|
||||||
|
ctx.fillText(`${currentGeneratorOutputV(f).toFixed(0)}V`, f.x + f.w / 2, f.y + f.h / 2 + 20);
|
||||||
|
drawSelection(ctx, game, 'facility', f.id, f.x + f.w / 2, f.y + f.h / 2, f.w + 12, f.h + 12);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawInboundTruck(ctx, game, t) {
|
||||||
|
ctx.save();
|
||||||
|
drawExternalDuct(ctx, t);
|
||||||
|
drawMetalPanel(ctx, t.x, t.y, t.w, t.h, '#29313a', THEME.ink, '#8bd0ff');
|
||||||
|
drawFacilityDetails(ctx, t, '#8bd0ff');
|
||||||
|
ctx.fillStyle = THEME.white;
|
||||||
|
ctx.font = '900 14px ui-monospace, monospace';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText('INBOUND', t.x + 14, t.y + 18);
|
||||||
|
ctx.fillStyle = '#8bd0ff';
|
||||||
|
ctx.font = '900 11px ui-monospace, monospace';
|
||||||
|
ctx.fillText(`COAL ${t.inboundOrder?.coalPerDay || 0} / ZUNDA ${t.inboundOrder?.zundaMochiPerDay || 0}`, t.x + 14, t.y + 36);
|
||||||
|
ctx.fillStyle = 'rgba(10,18,24,.9)';
|
||||||
|
rect(ctx, t.x + 18, t.y + 54, t.w - 36, t.h - 70, true, true);
|
||||||
|
label(ctx, t.x + t.w / 2, t.y + t.h - 18, `Q ${t.inboundQueue?.length || 0}`, '#8bd0ff');
|
||||||
|
drawSelection(ctx, game, 'facility', t.id, t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
function drawFacilityOutput(ctx, game, f) {
|
function drawFacilityOutput(ctx, game, f) {
|
||||||
const p = facilityOutputCell(game, f);
|
const p = facilityOutputCell(game, f);
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
|
|
@ -1263,7 +1367,7 @@ function drawTruck(ctx, game, t = game.facilities.truck) {
|
||||||
drawMetalPanel(ctx, t.x, t.y, t.w, t.h, '#343128', THEME.ink, THEME.truckPink);
|
drawMetalPanel(ctx, t.x, t.y, t.w, t.h, '#343128', THEME.ink, THEME.truckPink);
|
||||||
drawFacilityDetails(ctx, t, THEME.truckPink);
|
drawFacilityDetails(ctx, t, THEME.truckPink);
|
||||||
ctx.fillStyle = THEME.white; ctx.font = '900 14px ui-monospace, monospace'; ctx.textAlign = 'left';
|
ctx.fillStyle = THEME.white; ctx.font = '900 14px ui-monospace, monospace'; ctx.textAlign = 'left';
|
||||||
ctx.fillText(`TRUCK L${t.level}`, t.x + 14, t.y + 18);
|
ctx.fillText(`SHIP TRUCK L${t.level}`, t.x + 14, t.y + 18);
|
||||||
ctx.fillStyle = THEME.muted; ctx.font = '900 10px ui-monospace, monospace';
|
ctx.fillStyle = THEME.muted; ctx.font = '900 10px ui-monospace, monospace';
|
||||||
ctx.fillText(`UNIT ${yen(upgradedTruckPrice(game))}`, t.x + 14, t.y + 34);
|
ctx.fillText(`UNIT ${yen(upgradedTruckPrice(game))}`, t.x + 14, t.y + 34);
|
||||||
drawFacilityTexture(ctx, assets.truck, t, 2.0);
|
drawFacilityTexture(ctx, assets.truck, t, 2.0);
|
||||||
|
|
@ -1296,6 +1400,8 @@ 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 if (chick.sex === 'meat') drawMeatItem(ctx, chick.x, y, chick.radius);
|
else if (chick.sex === 'meat') drawMeatItem(ctx, chick.x, y, chick.radius);
|
||||||
else if (chick.sex === 'sausage') drawSausageItem(ctx, chick.x, y, chick.radius);
|
else if (chick.sex === 'sausage') drawSausageItem(ctx, chick.x, y, chick.radius);
|
||||||
|
else if (chick.sex === 'coal') drawCoalItem(ctx, chick.x, y, chick.radius);
|
||||||
|
else if (chick.sex === 'zundaMochi') drawZundaItem(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;
|
||||||
const spriteDrawn = 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);
|
||||||
|
|
@ -1367,6 +1473,31 @@ function drawSausageItem(ctx, x, y, radius = 10) {
|
||||||
}
|
}
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
function drawCoalItem(ctx, x, y, radius = 10) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = '#111318';
|
||||||
|
ctx.strokeStyle = '#050609';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
rect(ctx, x - radius, y - radius * 0.85, radius * 2, radius * 1.7, true, true);
|
||||||
|
ctx.fillStyle = '#3b424b';
|
||||||
|
ctx.fillRect(x - radius * 0.5, y - radius * 0.5, radius * 0.8, radius * 0.35);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
function drawZundaItem(ctx, x, y, radius = 10) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = '#6fce57';
|
||||||
|
ctx.strokeStyle = THEME.ink;
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, y - radius);
|
||||||
|
ctx.lineTo(x + radius, y);
|
||||||
|
ctx.lineTo(x, y + radius);
|
||||||
|
ctx.lineTo(x - radius, y);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
function drawTinyChick(ctx, x, y, sex, warning = false) {
|
function drawTinyChick(ctx, x, y, sex, warning = false) {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
const bodyColor = sex === 'male' ? THEME.male : THEME.female;
|
const bodyColor = sex === 'male' ? THEME.male : THEME.female;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ import { record } from './history.js';
|
||||||
import { floating, eraseEffect } from './effects.js';
|
import { floating, eraseEffect } from './effects.js';
|
||||||
import { createSelectionSystem } from './selectionSystem.js';
|
import { createSelectionSystem } from './selectionSystem.js';
|
||||||
import { ensureMaintenanceState, remainingPercent, performanceFactor, autoScannerDelayMultiplier, eggSpawnDelayMultiplier, facilityProcessingDelay, durabilityCapFor } from './maintenance.js';
|
import { ensureMaintenanceState, remainingPercent, performanceFactor, autoScannerDelayMultiplier, eggSpawnDelayMultiplier, facilityProcessingDelay, durabilityCapFor } from './maintenance.js';
|
||||||
|
import { markPowerDirty, powerDetailsFor } from './power.js';
|
||||||
|
import { boostOutputCapacityPerSecond, farmTheoreticalRatePerSecond, qualityForRate, qualityGrade } from './farmQuality.js';
|
||||||
|
|
||||||
export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels }) {
|
export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels }) {
|
||||||
function updatePanels() { if (onUpdatePanels) onUpdatePanels(); }
|
function updatePanels() { if (onUpdatePanels) onUpdatePanels(); }
|
||||||
|
|
@ -42,6 +44,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
||||||
game.blockedCells = shiftRowIndexedSet(game.blockedCells, rowDelta);
|
game.blockedCells = shiftRowIndexedSet(game.blockedCells, rowDelta);
|
||||||
game.conveyorTiles = shiftRowIndexedSet(game.conveyorTiles, rowDelta);
|
game.conveyorTiles = shiftRowIndexedSet(game.conveyorTiles, rowDelta);
|
||||||
game.conveyorMeta = shiftRowIndexedMap(game.conveyorMeta, rowDelta);
|
game.conveyorMeta = shiftRowIndexedMap(game.conveyorMeta, rowDelta);
|
||||||
|
game.powerWires = shiftRowIndexedMap(game.powerWires || new Map(), rowDelta);
|
||||||
|
game.footbridges = shiftRowIndexedMap(game.footbridges || new Map(), rowDelta);
|
||||||
for (const farm of game.eggFarms || []) farm.row += rowDelta;
|
for (const farm of game.eggFarms || []) farm.row += rowDelta;
|
||||||
for (const scanner of game.scanners || []) scanner.row += rowDelta;
|
for (const scanner of game.scanners || []) scanner.row += rowDelta;
|
||||||
for (const facility of Object.values(game.facilities || {})) {
|
for (const facility of Object.values(game.facilities || {})) {
|
||||||
|
|
@ -59,6 +63,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
||||||
game.blockedCells = shiftColIndexedSet(game.blockedCells, colDelta);
|
game.blockedCells = shiftColIndexedSet(game.blockedCells, colDelta);
|
||||||
game.conveyorTiles = shiftColIndexedSet(game.conveyorTiles, colDelta);
|
game.conveyorTiles = shiftColIndexedSet(game.conveyorTiles, colDelta);
|
||||||
game.conveyorMeta = shiftColIndexedMap(game.conveyorMeta, colDelta);
|
game.conveyorMeta = shiftColIndexedMap(game.conveyorMeta, colDelta);
|
||||||
|
game.powerWires = shiftColIndexedMap(game.powerWires || new Map(), colDelta);
|
||||||
|
game.footbridges = shiftColIndexedMap(game.footbridges || new Map(), colDelta);
|
||||||
for (const farm of game.eggFarms || []) farm.col += colDelta;
|
for (const farm of game.eggFarms || []) farm.col += colDelta;
|
||||||
for (const scanner of game.scanners || []) scanner.col += colDelta;
|
for (const scanner of game.scanners || []) scanner.col += colDelta;
|
||||||
for (const room of game.maintenanceRooms || []) room.col += colDelta;
|
for (const room of game.maintenanceRooms || []) room.col += colDelta;
|
||||||
|
|
@ -245,16 +251,22 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
||||||
}
|
}
|
||||||
|
|
||||||
function gridFacilityPlacementBlocked(id, col, row, moving = null) {
|
function gridFacilityPlacementBlocked(id, col, row, moving = null) {
|
||||||
const draft = { baseId: id, id, gridPlaced: true, col, row, wCells: id === 'sausageMaker' ? 3 : 1, hCells: id === 'sausageMaker' ? 3 : 1 };
|
const def = BALANCE.facilities[id] || {};
|
||||||
|
const draft = { baseId: id, id, gridPlaced: true, col, row, wCells: def.footprint?.cols || (id === 'sausageMaker' ? 3 : 1), hCells: def.footprint?.rows || (id === 'sausageMaker' ? 3 : 1) };
|
||||||
for (const cell of gridFacilityFootprintCells(draft)) {
|
for (const cell of gridFacilityFootprintCells(draft)) {
|
||||||
if (!pointInGrid(cell.col, cell.row) || isBlockedCell(cell.col, cell.row) || isEquipmentCell(cell.col, cell.row, moving) || facilityPortAt(cell.col, cell.row, moving)) return true;
|
if (!pointInGrid(cell.col, cell.row) || isBlockedCell(cell.col, cell.row) || isEquipmentCell(cell.col, cell.row, moving) || facilityPortAt(cell.col, cell.row, moving)) return true;
|
||||||
}
|
}
|
||||||
if (id === 'sausageMaker') {
|
if (id === 'sausageMaker' || id === 'biomassGenerator' || id === 'coalGenerator') {
|
||||||
const input = { col: col - 1, row: row + 1 };
|
const input = { col: col - 1, row: row + 1 };
|
||||||
const output = { col: col + 3, row: row + 1 };
|
const output = { col: col + (draft.wCells || 1), row: row + 1 };
|
||||||
if (!pointInGrid(input.col, input.row) || !pointInGrid(output.col, output.row)) return true;
|
const biomassPorts = [
|
||||||
if (isBlockedCell(input.col, input.row) || isBlockedCell(output.col, output.row)) return true;
|
input,
|
||||||
for (const port of [input, output]) {
|
{ col: col + 1, row: row - 1 },
|
||||||
|
{ col: col + 1, row: row + (draft.hCells || 3) }
|
||||||
|
];
|
||||||
|
const ports = id === 'sausageMaker' ? [input, output] : (id === 'biomassGenerator' ? biomassPorts : [input]);
|
||||||
|
for (const port of ports) {
|
||||||
|
if (!pointInGrid(port.col, port.row) || isBlockedCell(port.col, port.row)) return true;
|
||||||
const portGridFacility = gridFacilityAt(game, port.col, port.row);
|
const portGridFacility = gridFacilityAt(game, port.col, port.row);
|
||||||
if (portGridFacility && portGridFacility.id !== moving?.ref?.id) return true;
|
if (portGridFacility && portGridFacility.id !== moving?.ref?.id) return true;
|
||||||
if (farmAt(game, port.col, port.row) || scannerAt(game, port.col, port.row) || maintenanceRoomAt(game, port.col, port.row)) return true;
|
if (farmAt(game, port.col, port.row) || scannerAt(game, port.col, port.row) || maintenanceRoomAt(game, port.col, port.row)) return true;
|
||||||
|
|
@ -269,6 +281,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
||||||
if (game.selected.type === 'eggFarm') return game.eggFarms.find(f => f.id === game.selected.id) || null;
|
if (game.selected.type === 'eggFarm') return game.eggFarms.find(f => f.id === game.selected.id) || null;
|
||||||
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 === 'powerWire') return game.powerWires?.has(game.selected.id) ? { type: 'powerWire', id: game.selected.id } : null;
|
||||||
|
if (game.selected.type === 'footbridge') return game.footbridges?.has(game.selected.id) ? { type: 'footbridge', 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;
|
if (game.selected.type === 'maintenanceRoom') return (game.maintenanceRooms || []).find(r => r.id === game.selected.id) || null;
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -279,6 +293,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
||||||
if (obj.type === 'eggFarm') return `${equipmentName('eggFarm')} #${obj.id}`;
|
if (obj.type === 'eggFarm') return `${equipmentName('eggFarm')} #${obj.id}`;
|
||||||
if (obj.type === 'scanner') return `${obj.kind === 'auto' ? equipmentName('autoScanner') : equipmentName('manualScanner')} #${obj.id}`;
|
if (obj.type === 'scanner') return `${obj.kind === 'auto' ? equipmentName('autoScanner') : equipmentName('manualScanner')} #${obj.id}`;
|
||||||
if (obj.type === 'conveyor') return game.conveyorMeta.get(obj.id)?.kind === 'boostConveyor' ? equipmentName('boostConveyor') : equipmentName('conveyor');
|
if (obj.type === 'conveyor') return game.conveyorMeta.get(obj.id)?.kind === 'boostConveyor' ? equipmentName('boostConveyor') : equipmentName('conveyor');
|
||||||
|
if (obj.type === 'powerWire') return game.powerWires?.get(obj.id)?.kind === 'insulatedPowerWire' ? equipmentName('insulatedPowerWire') : equipmentName('powerWire');
|
||||||
|
if (obj.type === 'footbridge') return equipmentName('footbridge');
|
||||||
if (obj.type === 'facility') return obj.name;
|
if (obj.type === 'facility') return obj.name;
|
||||||
if (obj.type === 'maintenanceRoom') return `${equipmentName('maintenanceRoom')} #${obj.id}`;
|
if (obj.type === 'maintenanceRoom') return `${equipmentName('maintenanceRoom')} #${obj.id}`;
|
||||||
return 'Equipment';
|
return 'Equipment';
|
||||||
|
|
@ -286,6 +302,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
||||||
|
|
||||||
function equipmentPrice(hit) {
|
function equipmentPrice(hit) {
|
||||||
if (hit?.type === 'conveyor') return game.conveyorMeta?.get(hit.oldKey || hit.ref?.id)?.price || equipmentBasePrice(hit);
|
if (hit?.type === 'conveyor') return game.conveyorMeta?.get(hit.oldKey || hit.ref?.id)?.price || equipmentBasePrice(hit);
|
||||||
|
if (hit?.type === 'powerWire') return game.powerWires?.get(hit.oldKey || hit.ref?.id)?.price || 0;
|
||||||
|
if (hit?.type === 'footbridge') return game.footbridges?.get(hit.oldKey || hit.ref?.id)?.price || BALANCE.facilities.footbridge.price;
|
||||||
return equipmentBasePrice(hit);
|
return equipmentBasePrice(hit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -307,6 +325,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
||||||
|
|
||||||
function lastProtectedSaleReason(hit) {
|
function lastProtectedSaleReason(hit) {
|
||||||
const obj = hit?.ref || hit || {};
|
const obj = hit?.ref || hit || {};
|
||||||
|
if (obj.unsellable || (obj.baseId || obj.id) === 'starterGenerator') return 'Starter Generator cannot be sold.';
|
||||||
if (hit?.type === 'eggFarm' || obj.type === 'eggFarm') {
|
if (hit?.type === 'eggFarm' || obj.type === 'eggFarm') {
|
||||||
return game.eggFarms.length <= 1 ? 'Cannot sell the last EGG FARM.' : '';
|
return game.eggFarms.length <= 1 ? 'Cannot sell the last EGG FARM.' : '';
|
||||||
}
|
}
|
||||||
|
|
@ -643,6 +662,50 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
||||||
conveyorDragRecordedDirectionEdit = false;
|
conveyorDragRecordedDirectionEdit = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function overlayPlacementBlocked(col, row) {
|
||||||
|
return !pointInGrid(col, row) || isBlockedCell(col, row) || farmAt(game, col, row) || scannerAt(game, col, row) || maintenanceRoomAt(game, col, row) || gridFacilityAt(game, col, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPowerWireCell(cell, kind) {
|
||||||
|
if (!cell) return { ok: false, reason: TEXT.fail.outOfGrid };
|
||||||
|
if (overlayPlacementBlocked(cell.col, cell.row)) return { ok: false, reason: 'Power wire cannot cross equipment or blocked ground.' };
|
||||||
|
const k = key(cell.col, cell.row);
|
||||||
|
const price = BALANCE.power.wires[kind]?.price || buildPrice(kind, game);
|
||||||
|
const existing = game.powerWires?.get(k);
|
||||||
|
const existingPrice = existing ? (BALANCE.power.wires[existing.kind]?.price || 0) : 0;
|
||||||
|
const delta = price - existingPrice;
|
||||||
|
if (delta > 0 && game.cash < delta) return { ok: false, reason: TEXT.fail.notEnoughCash };
|
||||||
|
record(game);
|
||||||
|
if (delta > 0) spendCash(game, delta);
|
||||||
|
if (delta < 0) refundCash(game, -delta);
|
||||||
|
game.powerWires = game.powerWires || new Map();
|
||||||
|
game.powerWires.set(k, { kind, price, builtSession: game.buildSession });
|
||||||
|
markPowerDirty(game);
|
||||||
|
game.selected = { type: 'powerWire', id: k };
|
||||||
|
updatePanels();
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFootbridgeCell(cell) {
|
||||||
|
if (!cell) return fail(TEXT.fail.outOfGrid);
|
||||||
|
const vertical = (game.footbridgeOrientation || 'vertical') !== 'horizontal';
|
||||||
|
const cells = vertical
|
||||||
|
? [cell.row - 1, cell.row, cell.row + 1].map(row => ({ col: cell.col, row }))
|
||||||
|
: [cell.col - 1, cell.col, cell.col + 1].map(col => ({ col, row: cell.row }));
|
||||||
|
if (cells.some(part => overlayPlacementBlocked(part.col, part.row))) return fail('Footbridge cannot cross equipment or blocked ground.');
|
||||||
|
if (cells.some(part => game.footbridges?.has(key(part.col, part.row)))) return fail('Footbridge already covers that cell.');
|
||||||
|
const cost = buildPrice('footbridge', game);
|
||||||
|
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
|
||||||
|
const bridgeId = `footbridge:${game.nextId++}`;
|
||||||
|
record(game);
|
||||||
|
spendCash(game, cost);
|
||||||
|
game.footbridges = game.footbridges || new Map();
|
||||||
|
for (const part of cells) game.footbridges.set(key(part.col, part.row), { kind: 'footbridge', bridgeId, orientation: vertical ? 'vertical' : 'horizontal', price: cost, builtSession: game.buildSession });
|
||||||
|
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
|
||||||
|
game.selected = { type: 'footbridge', id: key(cell.col, cell.row) };
|
||||||
|
updatePanels();
|
||||||
|
}
|
||||||
|
|
||||||
function buildAtCell(cell) {
|
function buildAtCell(cell) {
|
||||||
if (!cell) return fail(TEXT.fail.outOfGrid);
|
if (!cell) return fail(TEXT.fail.outOfGrid);
|
||||||
const { col, row } = cell;
|
const { col, row } = cell;
|
||||||
|
|
@ -652,6 +715,12 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
||||||
refreshRoutingAfterEdit(game);
|
refreshRoutingAfterEdit(game);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (game.buildTool === 'powerWire' || game.buildTool === 'insulatedPowerWire') {
|
||||||
|
const result = buildPowerWireCell(cell, game.buildTool);
|
||||||
|
if (!result.ok) return fail(result.reason || TEXT.fail.cellOccupied);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (game.buildTool === 'footbridge') return buildFootbridgeCell(cell);
|
||||||
if (isBlockedCell(col, row)) return fail('Cannot build on blocked ground.');
|
if (isBlockedCell(col, row)) return fail('Cannot build on blocked ground.');
|
||||||
if (facilityPortAt(col, row) && !['conveyor', 'boostConveyor'].includes(game.buildTool)) return fail('Machine port must remain open for a conveyor.');
|
if (facilityPortAt(col, row) && !['conveyor', 'boostConveyor'].includes(game.buildTool)) return fail('Machine port must remain open for a conveyor.');
|
||||||
if (game.buildTool === 'maintenanceRoom') {
|
if (game.buildTool === 'maintenanceRoom') {
|
||||||
|
|
@ -670,7 +739,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', 'maintenanceRoom', ...GRID_FACILITY_IDS].includes(game.buildTool)) return buildAtCell(cell);
|
if (['conveyor', 'boostConveyor', 'powerWire', 'insulatedPowerWire', 'footbridge', 'eggFarm', 'manualScanner', 'autoScanner', 'maintenanceRoom', ...GRID_FACILITY_IDS.filter(id => id !== 'starterGenerator')].includes(game.buildTool)) return buildAtCell(cell);
|
||||||
if (EDGE_FACILITY_IDS.includes(game.buildTool)) return buildFacility(p, game.buildTool);
|
if (EDGE_FACILITY_IDS.includes(game.buildTool)) return buildFacility(p, game.buildTool);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -712,6 +781,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
||||||
room.price = cost;
|
room.price = cost;
|
||||||
game.maintenanceRooms = game.maintenanceRooms || [];
|
game.maintenanceRooms = game.maintenanceRooms || [];
|
||||||
game.maintenanceRooms.push(room);
|
game.maintenanceRooms.push(room);
|
||||||
|
markPowerDirty(game);
|
||||||
|
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
|
||||||
game.selected = { type: 'maintenanceRoom', id: room.id };
|
game.selected = { type: 'maintenanceRoom', id: room.id };
|
||||||
const center = roomCenter(room);
|
const center = roomCenter(room);
|
||||||
floating(game, center.x, center.y, `-${yen(cost)}`, THEME.ink);
|
floating(game, center.x, center.y, `-${yen(cost)}`, THEME.ink);
|
||||||
|
|
@ -734,6 +805,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
||||||
spendCash(game, cost);
|
spendCash(game, cost);
|
||||||
game.facilities[storageId] = f;
|
game.facilities[storageId] = f;
|
||||||
refreshRoutingAfterEdit(game);
|
refreshRoutingAfterEdit(game);
|
||||||
|
markPowerDirty(game);
|
||||||
|
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
|
||||||
game.selected = { type: 'facility', id: storageId };
|
game.selected = { type: 'facility', id: storageId };
|
||||||
floating(game, f.x + f.w / 2, f.y + f.h / 2, `-${yen(cost)}`, THEME.ink);
|
floating(game, f.x + f.w / 2, f.y + f.h / 2, `-${yen(cost)}`, THEME.ink);
|
||||||
}
|
}
|
||||||
|
|
@ -754,6 +827,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
||||||
spendCash(game, cost);
|
spendCash(game, cost);
|
||||||
game.facilities[storageId] = f;
|
game.facilities[storageId] = f;
|
||||||
refreshRoutingAfterEdit(game);
|
refreshRoutingAfterEdit(game);
|
||||||
|
markPowerDirty(game);
|
||||||
|
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
|
||||||
game.selected = { type: 'facility', id: storageId };
|
game.selected = { type: 'facility', id: storageId };
|
||||||
floating(game, p.x, p.y - 14, `-${yen(cost)}`, THEME.ink);
|
floating(game, p.x, p.y - 14, `-${yen(cost)}`, THEME.ink);
|
||||||
}
|
}
|
||||||
|
|
@ -772,7 +847,24 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
||||||
const saleReason = lastProtectedSaleReason(hit);
|
const saleReason = lastProtectedSaleReason(hit);
|
||||||
if (saleReason) return fail(saleReason);
|
if (saleReason) return fail(saleReason);
|
||||||
record(game);
|
record(game);
|
||||||
if (hit.type === 'conveyor') {
|
if (hit.type === 'footbridge') {
|
||||||
|
const c = parseKey(hit.oldKey);
|
||||||
|
const center = cellCenter(c.col, c.row);
|
||||||
|
refundOrSell(hit, center.x, center.y);
|
||||||
|
const bridgeId = game.footbridges.get(hit.oldKey)?.bridgeId;
|
||||||
|
for (const [k, bridge] of game.footbridges || new Map()) {
|
||||||
|
if (!bridgeId || bridge.bridgeId === bridgeId) game.footbridges.delete(k);
|
||||||
|
}
|
||||||
|
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
|
||||||
|
eraseEffect(game, center.x, center.y);
|
||||||
|
} else if (hit.type === 'powerWire') {
|
||||||
|
const c = parseKey(hit.oldKey);
|
||||||
|
const center = cellCenter(c.col, c.row);
|
||||||
|
refundOrSell(hit, center.x, center.y);
|
||||||
|
game.powerWires.delete(hit.oldKey);
|
||||||
|
markPowerDirty(game);
|
||||||
|
eraseEffect(game, center.x, center.y);
|
||||||
|
} else if (hit.type === 'conveyor') {
|
||||||
const c = parseKey(hit.oldKey);
|
const c = parseKey(hit.oldKey);
|
||||||
const center = cellCenter(c.col, c.row);
|
const center = cellCenter(c.col, c.row);
|
||||||
refundOrSell(hit, center.x, center.y);
|
refundOrSell(hit, center.x, center.y);
|
||||||
|
|
@ -780,34 +872,38 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
||||||
game.conveyorMeta.delete(hit.oldKey);
|
game.conveyorMeta.delete(hit.oldKey);
|
||||||
eraseEffect(game, center.x, center.y);
|
eraseEffect(game, center.x, center.y);
|
||||||
refreshRoutingAfterEdit(game);
|
refreshRoutingAfterEdit(game);
|
||||||
}
|
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
|
||||||
if (hit.type === 'eggFarm') {
|
} else if (hit.type === 'eggFarm') {
|
||||||
const c = cellCenter(hit.ref.col, hit.ref.row);
|
const c = cellCenter(hit.ref.col, hit.ref.row);
|
||||||
refundOrSell(hit, c.x, c.y);
|
refundOrSell(hit, c.x, c.y);
|
||||||
game.eggFarms = game.eggFarms.filter(f => f.id !== hit.ref.id);
|
game.eggFarms = game.eggFarms.filter(f => f.id !== hit.ref.id);
|
||||||
eraseEffect(game, c.x, c.y);
|
eraseEffect(game, c.x, c.y);
|
||||||
refreshRoutingAfterEdit(game);
|
refreshRoutingAfterEdit(game);
|
||||||
}
|
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
|
||||||
if (hit.type === 'scanner') {
|
} else if (hit.type === 'scanner') {
|
||||||
const c = scannerCenter(hit.ref);
|
const c = scannerCenter(hit.ref);
|
||||||
refundOrSell(hit, c.x, c.y);
|
refundOrSell(hit, c.x, c.y);
|
||||||
game.scanners = game.scanners.filter(s => s.id !== hit.ref.id);
|
game.scanners = game.scanners.filter(s => s.id !== hit.ref.id);
|
||||||
eraseEffect(game, c.x, c.y);
|
eraseEffect(game, c.x, c.y);
|
||||||
refreshRoutingAfterEdit(game);
|
refreshRoutingAfterEdit(game);
|
||||||
}
|
markPowerDirty(game);
|
||||||
if (hit.type === 'facility') {
|
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
|
||||||
|
} else if (hit.type === 'facility') {
|
||||||
const f = hit.ref;
|
const f = hit.ref;
|
||||||
refundOrSell(hit, f.x + f.w / 2, f.y + f.h / 2);
|
refundOrSell(hit, f.x + f.w / 2, f.y + f.h / 2);
|
||||||
delete game.facilities[f.id];
|
delete game.facilities[f.id];
|
||||||
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);
|
||||||
}
|
markPowerDirty(game);
|
||||||
if (hit.type === 'maintenanceRoom') {
|
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
|
||||||
|
} else if (hit.type === 'maintenanceRoom') {
|
||||||
const room = hit.ref;
|
const room = hit.ref;
|
||||||
const center = roomCenter(room);
|
const center = roomCenter(room);
|
||||||
refundOrSell(hit, center.x, center.y);
|
refundOrSell(hit, center.x, center.y);
|
||||||
game.maintenanceRooms = (game.maintenanceRooms || []).filter(r => r.id !== room.id);
|
game.maintenanceRooms = (game.maintenanceRooms || []).filter(r => r.id !== room.id);
|
||||||
eraseEffect(game, center.x, center.y);
|
eraseEffect(game, center.x, center.y);
|
||||||
|
markPowerDirty(game);
|
||||||
|
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
|
||||||
}
|
}
|
||||||
game.selected = null;
|
game.selected = null;
|
||||||
game.multiSelected = [];
|
game.multiSelected = [];
|
||||||
|
|
@ -832,6 +928,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
||||||
const room = maintenanceRoomAt(game, cell.col, cell.row);
|
const room = maintenanceRoomAt(game, cell.col, cell.row);
|
||||||
if (room) return { type: 'maintenanceRoom', ref: room };
|
if (room) return { type: 'maintenanceRoom', ref: room };
|
||||||
const k = key(cell.col, cell.row);
|
const k = key(cell.col, cell.row);
|
||||||
|
if (game.footbridges?.has(k)) return { type: 'footbridge', oldKey: k, ref: { type: 'footbridge', id: k } };
|
||||||
|
if (game.powerOverlay && game.powerWires?.has(k)) return { type: 'powerWire', oldKey: k, ref: { type: 'powerWire', id: k } };
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
@ -846,6 +944,24 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
||||||
const obj = selectedObject();
|
const obj = selectedObject();
|
||||||
if (!obj) return;
|
if (!obj) return;
|
||||||
if (obj.type === 'conveyor') eraseAtPoint(cellCenter(...Object.values(parseKey(obj.id))));
|
if (obj.type === 'conveyor') eraseAtPoint(cellCenter(...Object.values(parseKey(obj.id))));
|
||||||
|
else if (obj.type === 'powerWire') {
|
||||||
|
const cell = parseKey(obj.id);
|
||||||
|
const meta = game.powerWires?.get(obj.id);
|
||||||
|
if (!meta) return;
|
||||||
|
record(game);
|
||||||
|
const center = cellCenter(cell.col, cell.row);
|
||||||
|
refundOrSell({ type: 'powerWire', oldKey: obj.id, ref: { id: obj.id, price: meta.price || 0, builtSession: meta.builtSession } }, center.x, center.y);
|
||||||
|
game.powerWires.delete(obj.id);
|
||||||
|
markPowerDirty(game);
|
||||||
|
eraseEffect(game, center.x, center.y);
|
||||||
|
game.selected = null;
|
||||||
|
game.multiSelected = [];
|
||||||
|
updatePanels();
|
||||||
|
}
|
||||||
|
else if (obj.type === 'footbridge') {
|
||||||
|
const cell = parseKey(obj.id);
|
||||||
|
eraseAtPoint(cellCenter(cell.col, cell.row));
|
||||||
|
}
|
||||||
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 });
|
||||||
|
|
@ -933,6 +1049,47 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
||||||
ui.modal.classList.add('visible', 'equipment-popover');
|
ui.modal.classList.add('visible', 'equipment-popover');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showInboundTruckMenu(truck) {
|
||||||
|
if (!truck || (truck.baseId || truck.id) !== 'inboundTruck') return;
|
||||||
|
truck.inboundOrder = truck.inboundOrder || { coalPerDay: 0, zundaMochiPerDay: 0 };
|
||||||
|
const limit = BALANCE.imports.combinedLimitPerTruck;
|
||||||
|
const coal = Math.max(0, Math.floor(truck.inboundOrder.coalPerDay || 0));
|
||||||
|
const zunda = Math.max(0, Math.floor(truck.inboundOrder.zundaMochiPerDay || 0));
|
||||||
|
const cost = coal * BALANCE.imports.prices.coal + zunda * BALANCE.imports.prices.zundaMochi;
|
||||||
|
const total = coal + zunda;
|
||||||
|
const avgInterval = total > 0 ? (BALANCE.time.daySeconds / total).toFixed(1) : 'none';
|
||||||
|
ui.modalTitle.textContent = `Inbound Truck #${truck.id}`;
|
||||||
|
ui.modalBody.innerHTML = `
|
||||||
|
<div class="equipment-menu-lines compact">
|
||||||
|
<label>Coal per day <input id="inboundCoalInput" type="number" min="0" max="${limit}" step="1" value="${coal}" /></label>
|
||||||
|
<label>Zunda mochi per day <input id="inboundZundaInput" type="number" min="0" max="${limit}" step="1" value="${zunda}" /></label>
|
||||||
|
<p>Combined total: <b>${total}/${limit}</b></p>
|
||||||
|
<p>Daily purchase cost: <b>${yen(cost)}</b></p>
|
||||||
|
<p>Average release interval: <b>${avgInterval}${total > 0 ? 's' : ''}</b></p>
|
||||||
|
<p>Output queue: <b>${truck.inboundQueue?.length || 0}</b></p>
|
||||||
|
</div>`;
|
||||||
|
ui.modalActions.innerHTML = '';
|
||||||
|
const applyButton = modalButton('Apply', () => {
|
||||||
|
if (game.phase !== 'build') return;
|
||||||
|
const nextCoal = Math.max(0, Math.floor(Number(document.getElementById('inboundCoalInput')?.value) || 0));
|
||||||
|
const nextZunda = Math.max(0, Math.floor(Number(document.getElementById('inboundZundaInput')?.value) || 0));
|
||||||
|
const total = nextCoal + nextZunda;
|
||||||
|
const scale = total > limit ? limit / total : 1;
|
||||||
|
record(game);
|
||||||
|
truck.inboundOrder = {
|
||||||
|
coalPerDay: Math.floor(nextCoal * scale),
|
||||||
|
zundaMochiPerDay: Math.floor(nextZunda * scale)
|
||||||
|
};
|
||||||
|
showInboundTruckMenu(truck);
|
||||||
|
updatePanels();
|
||||||
|
}, 'primary-button');
|
||||||
|
applyButton.disabled = game.phase !== 'build';
|
||||||
|
ui.modalActions.appendChild(applyButton);
|
||||||
|
ui.modalActions.appendChild(modalButton('Close', hideModal, 'facility-action'));
|
||||||
|
positionEquipmentPopover(truck);
|
||||||
|
ui.modal.classList.add('visible', 'equipment-popover');
|
||||||
|
}
|
||||||
|
|
||||||
function manualKeyConflicts(scanner) {
|
function manualKeyConflicts(scanner) {
|
||||||
const own = [
|
const own = [
|
||||||
{ side: 'left', binding: scanner.keys?.left },
|
{ side: 'left', binding: scanner.keys?.left },
|
||||||
|
|
@ -1055,6 +1212,18 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
||||||
const meta = game.conveyorMeta.get(k) || {};
|
const meta = game.conveyorMeta.get(k) || {};
|
||||||
boxes.push({ id: `conveyor:${k}`, price: meta.price || buildPrice(meta.kind === 'boostConveyor' ? 'boostConveyor' : 'conveyor', game), x: c.x - 22, y: c.y - 22, w: 44, h: 44 });
|
boxes.push({ id: `conveyor:${k}`, price: meta.price || buildPrice(meta.kind === 'boostConveyor' ? 'boostConveyor' : 'conveyor', game), x: c.x - 22, y: c.y - 22, w: 44, h: 44 });
|
||||||
}
|
}
|
||||||
|
if (game.powerOverlay) {
|
||||||
|
for (const [k, wire] of game.powerWires || new Map()) {
|
||||||
|
const p = parseKey(k);
|
||||||
|
const c = cellCenter(p.col, p.row);
|
||||||
|
boxes.push({ id: `powerWire:${k}`, price: wire.price || 0, x: c.x - 18, y: c.y - 18, w: 36, h: 36 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [k, bridge] of game.footbridges || new Map()) {
|
||||||
|
const p = parseKey(k);
|
||||||
|
const c = cellCenter(p.col, p.row);
|
||||||
|
boxes.push({ id: `footbridge:${k}`, price: bridge.price || buildPrice('footbridge', game), x: c.x - 22, y: c.y - 22, w: 44, h: 44 });
|
||||||
|
}
|
||||||
return boxes;
|
return boxes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1117,6 +1286,12 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
||||||
lines.push(`Level: ${obj.level}/4`);
|
lines.push(`Level: ${obj.level}/4`);
|
||||||
lines.push(`Random interval: ${min.toFixed(2)}-${max.toFixed(2)}s`);
|
lines.push(`Random interval: ${min.toFixed(2)}-${max.toFixed(2)}s`);
|
||||||
lines.push(`Durability: ${remainingPercent(obj)}% | Spawn delay x${eggSpawnDelayMultiplier(obj).toFixed(2)}`);
|
lines.push(`Durability: ${remainingPercent(obj)}% | Spawn delay x${eggSpawnDelayMultiplier(obj).toFixed(2)}`);
|
||||||
|
const cap = boostOutputCapacityPerSecond(game);
|
||||||
|
const theoretical = obj.theoreticalRate || farmTheoreticalRatePerSecond(game, obj, obj.lastInterval || 1);
|
||||||
|
const quality = qualityForRate(theoretical, cap);
|
||||||
|
lines.push(`Rate: ${theoretical.toFixed(2)}/s theoretical | ${Math.min(theoretical, cap).toFixed(2)}/s actual`);
|
||||||
|
lines.push(`Boost cap: ${cap.toFixed(2)}/s | Quality: x${quality.toFixed(2)} ${qualityGrade(quality)}`);
|
||||||
|
if (obj.zundaBoostTime > 0) lines.push(`Zunda boost: ${obj.zundaBoostTime.toFixed(1)}s`);
|
||||||
} else if (obj.type === 'scanner') {
|
} else if (obj.type === 'scanner') {
|
||||||
lines.push(`Price: ${yen(equipmentPrice(obj))}`);
|
lines.push(`Price: ${yen(equipmentPrice(obj))}`);
|
||||||
lines.push(`Sale value: ${yen(resaleValueFor({ type: obj.type, ref: obj }, game).amount)}`);
|
lines.push(`Sale value: ${yen(resaleValueFor({ type: obj.type, ref: obj }, game).amount)}`);
|
||||||
|
|
@ -1126,7 +1301,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
||||||
if (obj.kind === 'auto') lines.push(`Cooldown: ${obj.cooldown.toFixed(1)}s / ${autoScannerCooldownSeconds(obj, game).toFixed(1)}s`);
|
if (obj.kind === 'auto') lines.push(`Cooldown: ${obj.cooldown.toFixed(1)}s / ${autoScannerCooldownSeconds(obj, game).toFixed(1)}s`);
|
||||||
if (obj.kind === 'auto') lines.push(`Durability: ${remainingPercent(obj)}% | Delay x${autoScannerDelayMultiplier(obj).toFixed(2)}`);
|
if (obj.kind === 'auto') lines.push(`Durability: ${remainingPercent(obj)}% | Delay x${autoScannerDelayMultiplier(obj).toFixed(2)}`);
|
||||||
if (obj.kind === 'manual') lines.push('Durability: none | Always normal');
|
if (obj.kind === 'manual') lines.push('Durability: none | Always normal');
|
||||||
|
const power = powerDetailsFor(game, obj);
|
||||||
|
if (power) appendPowerLines(lines, power);
|
||||||
} else if (obj.type === 'conveyor') {
|
} else if (obj.type === 'conveyor') {
|
||||||
const meta = game.conveyorMeta.get(obj.id);
|
const meta = game.conveyorMeta.get(obj.id);
|
||||||
const comp = game.componentLookup?.get(obj.id);
|
const comp = game.componentLookup?.get(obj.id);
|
||||||
|
|
@ -1148,11 +1324,25 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
||||||
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 === 'powerWire') {
|
||||||
|
const meta = game.powerWires?.get(obj.id);
|
||||||
|
const def = BALANCE.power.wires[meta?.kind || 'powerWire'];
|
||||||
|
lines.push(`Price: ${yen(meta?.price || def.price)}`);
|
||||||
|
lines.push(`Cell: ${obj.id}`);
|
||||||
|
lines.push(`Type: ${def.name} | Loss ${def.lossV}V/tile`);
|
||||||
|
} else if (obj.type === 'footbridge') {
|
||||||
|
const meta = game.footbridges?.get(obj.id);
|
||||||
|
lines.push(`Price: ${yen(meta?.price || buildPrice('footbridge', game))}`);
|
||||||
|
lines.push(`Cell: ${obj.id}`);
|
||||||
|
lines.push(`Orientation: ${meta?.orientation || 'vertical'}`);
|
||||||
|
lines.push('Repair workers may cross this conveyor cell.');
|
||||||
} else if (obj.type === 'maintenanceRoom') {
|
} else if (obj.type === 'maintenanceRoom') {
|
||||||
lines.push(`Price: ${yen(equipmentPrice({ type: 'maintenanceRoom', ref: obj }))}`);
|
lines.push(`Price: ${yen(equipmentPrice({ type: 'maintenanceRoom', ref: obj }))}`);
|
||||||
lines.push(`Sale value: ${yen(resaleValueFor({ type: 'maintenanceRoom', ref: obj }, game).amount)}`);
|
lines.push(`Sale value: ${yen(resaleValueFor({ type: 'maintenanceRoom', ref: obj }, game).amount)}`);
|
||||||
lines.push('Size: 2×2 grid cells');
|
lines.push('Size: 2×2 grid cells');
|
||||||
lines.push('Dispatch: 1 repairman during each production day');
|
lines.push('Dispatch: 1 repairman during each production day');
|
||||||
|
const power = powerDetailsFor(game, obj);
|
||||||
|
if (power) appendPowerLines(lines, power);
|
||||||
} 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)}`); }
|
||||||
|
|
@ -1189,10 +1379,19 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
||||||
lines.push(`IN: ${input ? `${input.col},${input.row}` : 'none'} | OUT: ${out ? `${out.col},${out.row}` : 'none'} | conveyor ${facilityOutputHasConveyor(game, obj) ? 'connected' : 'missing'}`);
|
lines.push(`IN: ${input ? `${input.col},${input.row}` : 'none'} | OUT: ${out ? `${out.col},${out.row}` : 'none'} | conveyor ${facilityOutputHasConveyor(game, obj) ? 'connected' : 'missing'}`);
|
||||||
}
|
}
|
||||||
if (obj.entry) lines.push(`Receiver: edge cell ${obj.entry.col},${obj.entry.row} (${obj.side})`);
|
if (obj.entry) lines.push(`Receiver: edge cell ${obj.entry.col},${obj.entry.row} (${obj.side})`);
|
||||||
|
const power = powerDetailsFor(game, obj);
|
||||||
|
if (power) appendPowerLines(lines, power);
|
||||||
}
|
}
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendPowerLines(lines, power) {
|
||||||
|
lines.push(`Power: ${power.receivedV.toFixed(1)}V / ${power.minVoltage}V`);
|
||||||
|
lines.push(`Source: ${power.sourceName} | Source bus: ${power.sourceBusV.toFixed(1)}V`);
|
||||||
|
lines.push(`Route loss: ${power.routeLossV.toFixed(1)}V | Voltage Load: ${power.drawV}V`);
|
||||||
|
if (power.receivedV < power.minVoltage) lines.push('Status: UNDER VOLTAGE');
|
||||||
|
}
|
||||||
|
|
||||||
function disconnectedWarningFor(obj) {
|
function disconnectedWarningFor(obj) {
|
||||||
if (!obj || game.phase !== 'build') return '';
|
if (!obj || game.phase !== 'build') return '';
|
||||||
const match = disconnectedBuildWarnings(game).find(item => item.type === obj.type && item.id === obj.id);
|
const match = disconnectedBuildWarnings(game).find(item => item.type === obj.type && item.id === obj.id);
|
||||||
|
|
@ -1230,7 +1429,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
||||||
beginConveyorDrag, continueConveyorDrag, endConveyorDrag,
|
beginConveyorDrag, continueConveyorDrag, endConveyorDrag,
|
||||||
cycleSingleConveyorDirection,
|
cycleSingleConveyorDirection,
|
||||||
branchSwitcherAtPoint, cycleBranchModeAtPoint, cycleBranchModeForKey, branchSelectableExitDirsForKey,
|
branchSwitcherAtPoint, cycleBranchModeAtPoint, cycleBranchModeForKey, branchSelectableExitDirsForKey,
|
||||||
removeSelected, switchScannerRole, showManualScannerMenu,
|
removeSelected, switchScannerRole, showManualScannerMenu, showInboundTruckMenu,
|
||||||
showAutoScannerMenu, showSelectedMenu, setBuildTool, fail,
|
showAutoScannerMenu, showSelectedMenu, setBuildTool, fail,
|
||||||
isEquipmentCell, equipmentHitBoxes,
|
isEquipmentCell, equipmentHitBoxes,
|
||||||
routeFromFarmToScanner
|
routeFromFarmToScanner
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ import { productionMultiplier, truckTarget, isTargetTruckCargo, shouldFineMaleTr
|
||||||
import { floating, shake, spawnPulse, scannerPulse, meatEffect, sludgeEffect, shredEffect, truckLoadEffect, shockwave, sparkBurst, smokeBurst, flyingDebris, rageEffect } from './effects.js';
|
import { floating, shake, spawnPulse, scannerPulse, meatEffect, sludgeEffect, shredEffect, truckLoadEffect, shockwave, sparkBurst, smokeBurst, flyingDebris, rageEffect } from './effects.js';
|
||||||
import { countAutoSorted, countCorrect, countMistake, countMixer, countPoopDestination, countPoopSpawned, countTrash, countTruckCargo } from './stats.js';
|
import { countAutoSorted, countCorrect, countMistake, countMixer, countPoopDestination, countPoopSpawned, countTrash, countTruckCargo } from './stats.js';
|
||||||
import { eggSpawnDelayMultiplier, recordFarmProduction, recordConveyorPass, recordAutoScan, recordFacilityProcess, updateProcessingCooldowns, setFacilityCooldownAfterProcess, updateRepairman } from './maintenance.js';
|
import { eggSpawnDelayMultiplier, recordFarmProduction, recordConveyorPass, recordAutoScan, recordFacilityProcess, updateProcessingCooldowns, setFacilityCooldownAfterProcess, updateRepairman } from './maintenance.js';
|
||||||
|
import { isEquipmentPowered, addFuelPulse, markPowerDirty } from './power.js';
|
||||||
|
import { updateInboundTrucks } from './imports.js';
|
||||||
|
import { addZundaBoost, actualSpawnIntervalForRate, boostOutputCapacityPerSecond, farmTheoreticalRatePerSecond, qualityForRate } from './farmQuality.js';
|
||||||
|
|
||||||
export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck }) {
|
export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck }) {
|
||||||
function frameCache() {
|
function frameCache() {
|
||||||
|
|
@ -80,13 +83,18 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentSpawnDelay(farm) {
|
function currentSpawnDelay(farm) {
|
||||||
return nextSpawnDelay(farm) * eggSpawnDelayMultiplier(farm) * eggProductionDelayMultiplier(game) / productionMultiplier(game);
|
const base = nextSpawnDelay(farm) * eggSpawnDelayMultiplier(farm) * eggProductionDelayMultiplier(game) / productionMultiplier(game);
|
||||||
|
const rate = farmTheoreticalRatePerSecond(game, farm, base);
|
||||||
|
farm.theoreticalRate = rate;
|
||||||
|
farm.actualOutputRate = Math.min(rate, boostOutputCapacityPerSecond(game));
|
||||||
|
farm.qualityMultiplier = qualityForRate(rate, boostOutputCapacityPerSecond(game));
|
||||||
|
return Math.max(0.25, actualSpawnIntervalForRate(rate, boostOutputCapacityPerSecond(game)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function spawnChick(farm) {
|
function spawnChick(farm) {
|
||||||
const farmCenter = cellCenter(farm.col, farm.row);
|
const farmCenter = cellCenter(farm.col, farm.row);
|
||||||
const maxOutputs = 1 + extraEggOutletCount(game, farm);
|
const maxOutputs = 1 + extraEggOutletCount(game, farm);
|
||||||
const starts = farmOutputStarts(farm, maxOutputs);
|
const starts = farmOutputStarts(farm, maxOutputs).slice(0, 1);
|
||||||
if (!starts.length) {
|
if (!starts.length) {
|
||||||
suppressUnconnectedFarmSpawn(farm, farmCenter);
|
suppressUnconnectedFarmSpawn(farm, farmCenter);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -102,6 +110,13 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const chick = createChick(game, { stage: 'belt', scannerId: null, route: [start] });
|
const chick = createChick(game, { stage: 'belt', scannerId: null, route: [start] });
|
||||||
|
const theoretical = farm.theoreticalRate || farmTheoreticalRatePerSecond(game, farm, farm.lastInterval || currentSpawnDelay(farm));
|
||||||
|
chick.sourceQuality = qualityForRate(theoretical, boostOutputCapacityPerSecond(game));
|
||||||
|
game.stats.farmTheoreticalRate = Math.max(game.stats.farmTheoreticalRate || 0, theoretical);
|
||||||
|
game.stats.farmActualOutputRate = Math.max(game.stats.farmActualOutputRate || 0, Math.min(theoretical, boostOutputCapacityPerSecond(game)));
|
||||||
|
game.stats.qualityMultiplierSum = (game.stats.qualityMultiplierSum || 0) + chick.sourceQuality;
|
||||||
|
game.stats.qualityMultiplierCount = (game.stats.qualityMultiplierCount || 0) + 1;
|
||||||
|
game.stats.maxQualityMultiplier = Math.max(game.stats.maxQualityMultiplier || 1, chick.sourceQuality);
|
||||||
if (chick.sex === 'poop') countPoopSpawned(game);
|
if (chick.sex === 'poop') countPoopSpawned(game);
|
||||||
game.chicks.push(chick);
|
game.chicks.push(chick);
|
||||||
markChicksChanged();
|
markChicksChanged();
|
||||||
|
|
@ -140,6 +155,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
||||||
function updateRunning(dt, { closeFarmShutters, completeTurn }) {
|
function updateRunning(dt, { closeFarmShutters, completeTurn }) {
|
||||||
updateRepairman(game, dt);
|
updateRepairman(game, dt);
|
||||||
updateProcessingCooldowns(game, dt);
|
updateProcessingCooldowns(game, dt);
|
||||||
|
updateInboundTrucks(game, dt, markChicksChanged);
|
||||||
flushFacilityOutputs();
|
flushFacilityOutputs();
|
||||||
if (game.cleanupTimer !== null) {
|
if (game.cleanupTimer !== null) {
|
||||||
game.cleanupTimer = Math.max(0, game.cleanupTimer - dt);
|
game.cleanupTimer = Math.max(0, game.cleanupTimer - dt);
|
||||||
|
|
@ -157,6 +173,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
||||||
if (producing && game.timeLeft > 0) {
|
if (producing && game.timeLeft > 0) {
|
||||||
for (const farm of game.eggFarms) {
|
for (const farm of game.eggFarms) {
|
||||||
if (farm.noRouteNoticeCooldown > 0) farm.noRouteNoticeCooldown = Math.max(0, farm.noRouteNoticeCooldown - dt);
|
if (farm.noRouteNoticeCooldown > 0) farm.noRouteNoticeCooldown = Math.max(0, farm.noRouteNoticeCooldown - dt);
|
||||||
|
if (farm.zundaBoostTime > 0) farm.zundaBoostTime = Math.max(0, farm.zundaBoostTime - dt);
|
||||||
farm.nextSpawn -= dt;
|
farm.nextSpawn -= dt;
|
||||||
if (farm.nextSpawn <= 0) {
|
if (farm.nextSpawn <= 0) {
|
||||||
const spawned = spawnChick(farm);
|
const spawned = spawnChick(farm);
|
||||||
|
|
@ -170,6 +187,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
||||||
chick.bob += dt * 7;
|
chick.bob += dt * 7;
|
||||||
if (chick.stoppedTimer > 0) chick.stoppedTimer = Math.max(0, chick.stoppedTimer - dt);
|
if (chick.stoppedTimer > 0) chick.stoppedTimer = Math.max(0, chick.stoppedTimer - dt);
|
||||||
if (chick.stage === 'queued') continue;
|
if (chick.stage === 'queued') continue;
|
||||||
|
if (consumeAdjacentZunda(chick, i)) continue;
|
||||||
updateRoutedChick(chick, i, dt);
|
updateRoutedChick(chick, i, dt);
|
||||||
}
|
}
|
||||||
updateScannerQueues(dt);
|
updateScannerQueues(dt);
|
||||||
|
|
@ -279,12 +297,29 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
||||||
return Object.entries(game.facilities || {}).find(([, f]) => f?.entry && f.entry.col === cell.col && f.entry.row === cell.row) || null;
|
return Object.entries(game.facilities || {}).find(([, f]) => f?.entry && f.entry.col === cell.col && f.entry.row === cell.row) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function gridGeneratorReceivingFrom(cell, dirName) {
|
||||||
|
for (const facility of Object.values(game.facilities || {})) {
|
||||||
|
const kind = facilityKind(facility);
|
||||||
|
if (!facility.gridPlaced || !['biomassGenerator', 'coalGenerator'].includes(kind)) continue;
|
||||||
|
const ports = kind === 'biomassGenerator'
|
||||||
|
? [
|
||||||
|
{ col: facility.col - 1, row: facility.row + 1, dir: 'right' },
|
||||||
|
{ col: facility.col + 1, row: facility.row - 1, dir: 'down' },
|
||||||
|
{ col: facility.col + 1, row: facility.row + (facility.hCells || 3), dir: 'up' }
|
||||||
|
]
|
||||||
|
: [{ col: facility.col - 1, row: facility.row + 1, dir: 'right' }];
|
||||||
|
if (ports.some(port => port.col === cell.col && port.row === cell.row && port.dir === dirName)) return facility;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function facilityKind(f) {
|
function facilityKind(f) {
|
||||||
return f?.baseId || f?.id || '';
|
return f?.baseId || f?.id || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function dirExitsToFacility(facility, dirName) {
|
function dirExitsToFacility(facility, dirName) {
|
||||||
if (facility?.gridPlaced && facilityKind(facility) === 'sausageMaker') return dirName === 'right';
|
if (facility?.gridPlaced && facilityKind(facility) === 'sausageMaker') return dirName === 'right';
|
||||||
|
if (facility?.gridPlaced && ['biomassGenerator', 'coalGenerator'].includes(facilityKind(facility))) return dirName === 'right';
|
||||||
return (facility.side === 'left' && dirName === 'left')
|
return (facility.side === 'left' && dirName === 'left')
|
||||||
|| (facility.side === 'right' && dirName === 'right')
|
|| (facility.side === 'right' && dirName === 'right')
|
||||||
|| (facility.side === 'top' && dirName === 'up')
|
|| (facility.side === 'top' && dirName === 'up')
|
||||||
|
|
@ -299,7 +334,9 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
||||||
return { type: 'conveyor', dirName, target: cellCenter(next.col, next.row), nextCell: next };
|
return { type: 'conveyor', dirName, target: cellCenter(next.col, next.row), nextCell: next };
|
||||||
}
|
}
|
||||||
const scanner = scannerReceivingFrom(cell, dirName);
|
const scanner = scannerReceivingFrom(cell, dirName);
|
||||||
if (scanner && !['meat', 'sausage'].includes(item?.sex)) return { type: 'scanner', dirName, target: scannerCenter(scanner), scanner };
|
if (scanner && !['meat', 'sausage', 'coal', 'zundaMochi'].includes(item?.sex)) return { type: 'scanner', dirName, target: scannerCenter(scanner), scanner };
|
||||||
|
const generator = gridGeneratorReceivingFrom(cell, dirName);
|
||||||
|
if (generator) return { type: 'facility', dirName, facilityId: generator.id, facility: generator, target: cellCenter(cell.col, cell.row) };
|
||||||
const facilityPair = facilityAtEntryCell(cell);
|
const facilityPair = facilityAtEntryCell(cell);
|
||||||
if (facilityPair && dirExitsToFacility(facilityPair[1], dirName)) {
|
if (facilityPair && dirExitsToFacility(facilityPair[1], dirName)) {
|
||||||
return { type: 'facility', dirName, facilityId: facilityPair[0], facility: facilityPair[1], target: cellCenter(cell.col, cell.row) };
|
return { type: 'facility', dirName, facilityId: facilityPair[0], facility: facilityPair[1], target: cellCenter(cell.col, cell.row) };
|
||||||
|
|
@ -379,6 +416,8 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
||||||
else if (kind === 'truck') resolveTruck(index, option.facility);
|
else if (kind === 'truck') resolveTruck(index, option.facility);
|
||||||
else if (kind === 'trash') resolveTrash(index, option.facility);
|
else if (kind === 'trash') resolveTrash(index, option.facility);
|
||||||
else if (kind === 'sausageMaker') resolveSausageMaker(index, option.facility);
|
else if (kind === 'sausageMaker') resolveSausageMaker(index, option.facility);
|
||||||
|
else if (kind === 'biomassGenerator' || kind === 'coalGenerator') resolveGenerator(index, option.facility);
|
||||||
|
else if (kind === 'inboundTruck') { const chick = game.chicks[index]; if (chick) chick.stoppedTimer = 0.25; }
|
||||||
else removeChick(index, 'DONE');
|
else removeChick(index, 'DONE');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -387,6 +426,19 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
||||||
setSingleSegmentRoute(chick, option.target);
|
setSingleSegmentRoute(chick, option.target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function consumeAdjacentZunda(chick, index) {
|
||||||
|
if (chick.sex !== 'zundaMochi') return false;
|
||||||
|
const cell = pointToCell(chick.x, chick.y);
|
||||||
|
if (!cell) return false;
|
||||||
|
const farm = game.eggFarms.find(candidate => Math.abs(candidate.col - cell.col) + Math.abs(candidate.row - cell.row) === 1);
|
||||||
|
if (!farm) return false;
|
||||||
|
addZundaBoost(game, farm);
|
||||||
|
takeChick(index);
|
||||||
|
const c = cellCenter(farm.col, farm.row);
|
||||||
|
floating(game, c.x, c.y - 24, 'ZUNDA BOOST', THEME.green);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function conveyorKeyFromPoint(p) {
|
function conveyorKeyFromPoint(p) {
|
||||||
const cell = pointToCell(p.x, p.y);
|
const cell = pointToCell(p.x, p.y);
|
||||||
if (!cell) return null;
|
if (!cell) return null;
|
||||||
|
|
@ -484,9 +536,11 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
||||||
|
|
||||||
function updateScannerQueues(dt) {
|
function updateScannerQueues(dt) {
|
||||||
for (const scanner of game.scanners) {
|
for (const scanner of game.scanners) {
|
||||||
if (scanner.cooldown > 0) scanner.cooldown = Math.max(0, scanner.cooldown - dt);
|
const powered = scanner.kind !== 'auto' || isEquipmentPowered(game, scanner);
|
||||||
if (scanner.keyPressTime > 0) scanner.keyPressTime = Math.max(0, scanner.keyPressTime - dt);
|
if (scanner.keyPressTime > 0) scanner.keyPressTime = Math.max(0, scanner.keyPressTime - dt);
|
||||||
positionScannerQueue(scanner);
|
positionScannerQueue(scanner);
|
||||||
|
if (!powered) continue;
|
||||||
|
if (scanner.cooldown > 0) scanner.cooldown = Math.max(0, scanner.cooldown - dt);
|
||||||
if (scanner.kind !== 'auto' || scanner.cooldown > 0 || !scanner.queue.length) continue;
|
if (scanner.kind !== 'auto' || scanner.cooldown > 0 || !scanner.queue.length) continue;
|
||||||
const id = scanner.queue[0];
|
const id = scanner.queue[0];
|
||||||
const index = game.chicks.findIndex(c => c.id === id);
|
const index = game.chicks.findIndex(c => c.id === id);
|
||||||
|
|
@ -601,6 +655,11 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
||||||
if (!chick || chick.stage !== 'queued') return false;
|
if (!chick || chick.stage !== 'queued') return false;
|
||||||
const scanner = scannerById(game, chick.scannerId);
|
const scanner = scannerById(game, chick.scannerId);
|
||||||
if (!scanner) return false;
|
if (!scanner) return false;
|
||||||
|
if (scanner.kind === 'auto' && !isEquipmentPowered(game, scanner)) return false;
|
||||||
|
if (['coal', 'zundaMochi'].includes(chick.sex)) {
|
||||||
|
floating(game, chick.x, chick.y - 16, 'NOT SCANNABLE', THEME.warn);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const startCell = scannerOutputStartCell(scanner, side);
|
const startCell = scannerOutputStartCell(scanner, side);
|
||||||
if (!startCell) {
|
if (!startCell) {
|
||||||
if (!auto && scanner.kind === 'manual') resetManualCombo(scanner);
|
if (!auto && scanner.kind === 'manual') resetManualCombo(scanner);
|
||||||
|
|
@ -636,11 +695,17 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveMixer(index, mixer = game.facilities.mixer) {
|
function resolveMixer(index, mixer = game.facilities.mixer) {
|
||||||
|
if (!isEquipmentPowered(game, mixer)) {
|
||||||
|
const chick = game.chicks[index];
|
||||||
|
if (chick) chick.stoppedTimer = 0.25;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (mixer?.processingCooldown > 0) {
|
if (mixer?.processingCooldown > 0) {
|
||||||
const chick = game.chicks[index];
|
const chick = game.chicks[index];
|
||||||
if (chick) chick.stoppedTimer = 0.25;
|
if (chick) chick.stoppedTimer = 0.25;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isImportItem(game.chicks[index])) return rejectImportItem(index, 'CHICKS ONLY');
|
||||||
const chick = takeChick(index);
|
const chick = takeChick(index);
|
||||||
if (!chick) return;
|
if (!chick) return;
|
||||||
const { x, y } = chick;
|
const { x, y } = chick;
|
||||||
|
|
@ -669,6 +734,8 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mixer.meatBuffer = Math.max(0, mixer.meatBuffer || 0) + 1;
|
mixer.meatBuffer = Math.max(0, mixer.meatBuffer || 0) + 1;
|
||||||
|
mixer.meatQualityQueue = mixer.meatQualityQueue || [];
|
||||||
|
mixer.meatQualityQueue.push(chick.sourceQuality || 1);
|
||||||
game.stats.meatProduced = (game.stats.meatProduced || 0) + 1;
|
game.stats.meatProduced = (game.stats.meatProduced || 0) + 1;
|
||||||
game.totals.meatProduced = (game.totals.meatProduced || 0) + 1;
|
game.totals.meatProduced = (game.totals.meatProduced || 0) + 1;
|
||||||
meatEffect(game, x, y);
|
meatEffect(game, x, y);
|
||||||
|
|
@ -677,10 +744,16 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTruck(index, truck = game.facilities.truck) {
|
function resolveTruck(index, truck = game.facilities.truck) {
|
||||||
|
if (!isEquipmentPowered(game, truck)) {
|
||||||
|
const chick = game.chicks[index];
|
||||||
|
if (chick) chick.stoppedTimer = 0.25;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isImportItem(game.chicks[index])) return rejectImportItem(index, 'NO IMPORTS');
|
||||||
const chick = takeChick(index);
|
const chick = takeChick(index);
|
||||||
if (!chick) return;
|
if (!chick) return;
|
||||||
const { x, y } = chick;
|
const { x, y } = chick;
|
||||||
addTruckCargo(chick.sex, truck);
|
addTruckCargo(chick.sex, truck, chick.sourceQuality || 1);
|
||||||
truckLoadEffect(game, x, y, chick.sex);
|
truckLoadEffect(game, x, y, chick.sex);
|
||||||
countTruckCargo(game, chick.sex);
|
countTruckCargo(game, chick.sex);
|
||||||
const target = truckTarget(game);
|
const target = truckTarget(game);
|
||||||
|
|
@ -751,11 +824,15 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitFromFacility(facility, product, bufferKey) {
|
function emitFromFacility(facility, product, bufferKey) {
|
||||||
|
if (!isEquipmentPowered(game, facility)) return false;
|
||||||
const out = facilityOutputCell(game, facility);
|
const out = facilityOutputCell(game, facility);
|
||||||
if (!out || !facilityOutputHasConveyor(game, facility) || !outputCellFree(out)) return false;
|
if (!out || !facilityOutputHasConveyor(game, facility) || !outputCellFree(out)) return false;
|
||||||
if (Math.max(0, facility[bufferKey] || 0) <= 0) return false;
|
if (Math.max(0, facility[bufferKey] || 0) <= 0) return false;
|
||||||
facility[bufferKey] -= 1;
|
facility[bufferKey] -= 1;
|
||||||
game.chicks.push(createProduct(product, out));
|
const created = createProduct(product, out);
|
||||||
|
if (product === 'meat') created.sourceQuality = (facility.meatQualityQueue || []).shift() || 1;
|
||||||
|
if (product === 'sausage') created.sourceQuality = (facility.sausageQualityQueue || []).shift() || 1;
|
||||||
|
game.chicks.push(created);
|
||||||
markChicksChanged();
|
markChicksChanged();
|
||||||
const c = cellCenter(out.col, out.row);
|
const c = cellCenter(out.col, out.row);
|
||||||
floating(game, c.x, c.y - 16, product === 'meat' ? 'MEAT OUT' : 'SAUSAGE OUT', product === 'meat' ? THEME.meatRed : THEME.sausageOrange);
|
floating(game, c.x, c.y - 16, product === 'meat' ? 'MEAT OUT' : 'SAUSAGE OUT', product === 'meat' ? THEME.meatRed : THEME.sausageOrange);
|
||||||
|
|
@ -771,11 +848,17 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSausageMaker(index, machine) {
|
function resolveSausageMaker(index, machine) {
|
||||||
|
if (!isEquipmentPowered(game, machine)) {
|
||||||
|
const item = game.chicks[index];
|
||||||
|
if (item) item.stoppedTimer = 0.25;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (machine?.processingCooldown > 0) {
|
if (machine?.processingCooldown > 0) {
|
||||||
const item = game.chicks[index];
|
const item = game.chicks[index];
|
||||||
if (item) item.stoppedTimer = 0.25;
|
if (item) item.stoppedTimer = 0.25;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isImportItem(game.chicks[index])) return rejectImportItem(index, 'MEAT ONLY');
|
||||||
const item = takeChick(index);
|
const item = takeChick(index);
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
const { x, y } = item;
|
const { x, y } = item;
|
||||||
|
|
@ -787,9 +870,15 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
||||||
recordFacilityProcess(game, machine);
|
recordFacilityProcess(game, machine);
|
||||||
setFacilityCooldownAfterProcess(game, machine);
|
setFacilityCooldownAfterProcess(game, machine);
|
||||||
machine.meatBuffer = Math.max(0, machine.meatBuffer || 0) + 1;
|
machine.meatBuffer = Math.max(0, machine.meatBuffer || 0) + 1;
|
||||||
|
machine.meatQualityQueue = machine.meatQualityQueue || [];
|
||||||
|
machine.meatQualityQueue.push(item.sourceQuality || 1);
|
||||||
if (machine.meatBuffer >= 2) {
|
if (machine.meatBuffer >= 2) {
|
||||||
machine.meatBuffer -= 2;
|
machine.meatBuffer -= 2;
|
||||||
machine.sausageBuffer = Math.max(0, machine.sausageBuffer || 0) + 1;
|
machine.sausageBuffer = Math.max(0, machine.sausageBuffer || 0) + 1;
|
||||||
|
const q1 = machine.meatQualityQueue.shift() || 1;
|
||||||
|
const q2 = machine.meatQualityQueue.shift() || 1;
|
||||||
|
machine.sausageQualityQueue = machine.sausageQualityQueue || [];
|
||||||
|
machine.sausageQualityQueue.push((q1 + q2) / 2);
|
||||||
game.stats.sausageProduced = (game.stats.sausageProduced || 0) + 1;
|
game.stats.sausageProduced = (game.stats.sausageProduced || 0) + 1;
|
||||||
game.totals.sausageProduced = (game.totals.sausageProduced || 0) + 1;
|
game.totals.sausageProduced = (game.totals.sausageProduced || 0) + 1;
|
||||||
floating(game, x, y - 18, 'SAUSAGE +1', THEME.sausageOrange);
|
floating(game, x, y - 18, 'SAUSAGE +1', THEME.sausageOrange);
|
||||||
|
|
@ -800,11 +889,17 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTrash(index, trash = game.facilities.trash) {
|
function resolveTrash(index, trash = game.facilities.trash) {
|
||||||
|
if (!isEquipmentPowered(game, trash)) {
|
||||||
|
const chick = game.chicks[index];
|
||||||
|
if (chick) chick.stoppedTimer = 0.25;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (trash?.processingCooldown > 0) {
|
if (trash?.processingCooldown > 0) {
|
||||||
const chick = game.chicks[index];
|
const chick = game.chicks[index];
|
||||||
if (chick) chick.stoppedTimer = 0.25;
|
if (chick) chick.stoppedTimer = 0.25;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isImportItem(game.chicks[index])) return rejectImportItem(index, 'NO IMPORTS');
|
||||||
const chick = takeChick(index);
|
const chick = takeChick(index);
|
||||||
if (!chick) return;
|
if (!chick) return;
|
||||||
const { x, y } = chick;
|
const { x, y } = chick;
|
||||||
|
|
@ -833,16 +928,51 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveGenerator(index, generator) {
|
||||||
|
const item = game.chicks[index];
|
||||||
|
if (!item) return;
|
||||||
|
if (!addFuelPulse(generator, item.sex)) {
|
||||||
|
item.stoppedTimer = 0.25;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const used = takeChick(index);
|
||||||
|
if (!used) return;
|
||||||
|
if ((generator.baseId || generator.id) === 'biomassGenerator') {
|
||||||
|
game.stats.biomassFuelConsumed = (game.stats.biomassFuelConsumed || 0) + 1;
|
||||||
|
game.totals.biomassFuelConsumed = (game.totals.biomassFuelConsumed || 0) + 1;
|
||||||
|
}
|
||||||
|
if ((generator.baseId || generator.id) === 'coalGenerator') {
|
||||||
|
game.stats.coalConsumed = (game.stats.coalConsumed || 0) + 1;
|
||||||
|
game.totals.coalConsumed = (game.totals.coalConsumed || 0) + 1;
|
||||||
|
}
|
||||||
|
recordFacilityProcess(game, generator);
|
||||||
|
markPowerDirty(game);
|
||||||
|
floating(game, used.x, used.y - 18, 'FUEL +V', THEME.green);
|
||||||
|
}
|
||||||
|
|
||||||
function removeChick(index, label) {
|
function removeChick(index, label) {
|
||||||
const chick = takeChick(index);
|
const chick = takeChick(index);
|
||||||
if (!chick) return;
|
if (!chick) return;
|
||||||
floating(game, chick.x, chick.y - 12, label, THEME.muted);
|
floating(game, chick.x, chick.y - 12, label, THEME.muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTruckCargo(sex, truck = game.facilities.truck) {
|
function isImportItem(item) {
|
||||||
|
return item?.sex === 'coal' || item?.sex === 'zundaMochi';
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectImportItem(index, label) {
|
||||||
|
const item = game.chicks[index];
|
||||||
|
if (!item) return;
|
||||||
|
item.stoppedTimer = 0.25;
|
||||||
|
floating(game, item.x, item.y - 18, label, THEME.warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTruckCargo(sex, truck = game.facilities.truck, sourceQuality = 1) {
|
||||||
const t = truck || game.facilities.truck;
|
const t = truck || game.facilities.truck;
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
game.truckCargo.push({ sex, x: randomBetween(22, t.w - 22), y: randomBetween(66, t.h - 24) });
|
game.shippedCargo = game.shippedCargo || [];
|
||||||
|
game.shippedCargo.push({ sex, sourceQuality });
|
||||||
|
game.truckCargo.push({ sex, sourceQuality, x: randomBetween(22, t.w - 22), y: randomBetween(66, t.h - 24) });
|
||||||
if (game.truckCargo.length > 45) game.truckCargo.shift();
|
if (game.truckCargo.length > 45) game.truckCargo.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -257,12 +257,23 @@ export function applyExplosionDamage(game, price) {
|
||||||
|
|
||||||
export function settleTruckRevenue(game) {
|
export function settleTruckRevenue(game) {
|
||||||
const target = truckTarget(game);
|
const target = truckTarget(game);
|
||||||
const targetCount = targetTruckCount(game.stats, target);
|
|
||||||
const unitPrice = target === 'poop' ? Math.ceil(upgradedTruckPrice(game) * poopValueMultiplier(game)) : upgradedTruckPrice(game);
|
const unitPrice = target === 'poop' ? Math.ceil(upgradedTruckPrice(game) * poopValueMultiplier(game)) : upgradedTruckPrice(game);
|
||||||
const base = targetCount * unitPrice;
|
const cargo = Array.isArray(game.shippedCargo) ? game.shippedCargo : (Array.isArray(game.truckCargo) ? game.truckCargo : []);
|
||||||
const sausageCount = Math.max(0, game?.stats?.sausageShipped || 0);
|
const targetCargo = cargo.filter(item => item.sex === target);
|
||||||
|
const targetCount = targetCargo.length || targetTruckCount(game.stats, target);
|
||||||
|
const base = targetCargo.length
|
||||||
|
? targetCargo.reduce((sum, item) => sum + Math.ceil(unitPrice * (target === 'female' ? Math.max(1, item.sourceQuality || 1) : 1)), 0)
|
||||||
|
: targetCount * unitPrice;
|
||||||
|
const sausageCargo = cargo.filter(item => item.sex === 'sausage');
|
||||||
|
const sausageCount = sausageCargo.length || Math.max(0, game?.stats?.sausageShipped || 0);
|
||||||
const sausageUnitPrice = upgradedSausagePrice(game);
|
const sausageUnitPrice = upgradedSausagePrice(game);
|
||||||
const sausageRevenue = sausageCount * sausageUnitPrice;
|
const sausageRevenue = sausageCargo.length
|
||||||
|
? sausageCargo.reduce((sum, item) => sum + Math.ceil(sausageUnitPrice * Math.max(1, item.sourceQuality || 1)), 0)
|
||||||
|
: sausageCount * sausageUnitPrice;
|
||||||
|
const qualityBaseline = targetCount * unitPrice + sausageCount * sausageUnitPrice;
|
||||||
|
const qualityRevenue = Math.max(0, base + sausageRevenue - qualityBaseline);
|
||||||
|
game.stats.qualityRevenue = (game.stats.qualityRevenue || 0) + qualityRevenue;
|
||||||
|
game.totals.qualityRevenue = (game.totals.qualityRevenue || 0) + qualityRevenue;
|
||||||
const adjusted = positivePayout(game, base + sausageRevenue);
|
const adjusted = positivePayout(game, base + sausageRevenue);
|
||||||
game.stats.pendingTruckRevenue = adjusted;
|
game.stats.pendingTruckRevenue = adjusted;
|
||||||
game.stats.truckRevenue = adjusted;
|
game.stats.truckRevenue = adjusted;
|
||||||
|
|
|
||||||
42
src/systems/farmQuality.js
Normal file
42
src/systems/farmQuality.js
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { BALANCE } from '../core/balance.js';
|
||||||
|
import { conveyorSpeedForGame } from './cards.js';
|
||||||
|
|
||||||
|
export function boostOutputCapacityPerSecond(game) {
|
||||||
|
return conveyorSpeedForGame(game) * (BALANCE.facilities.boostConveyor.speedMultiplier || 2) / Math.max(1, BALANCE.conveyor.minimumItemSpacingPx || 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function farmTheoreticalRatePerSecond(game, farm, currentSpawnDelay) {
|
||||||
|
const delay = Math.max(BALANCE.farmQuality.safeMinIntervalSeconds, Number(currentSpawnDelay) || 1);
|
||||||
|
const zunda = (farm.zundaBoostTime || 0) > 0 ? BALANCE.farmQuality.zundaIntervalMultiplier : 1;
|
||||||
|
return 1 / Math.max(BALANCE.farmQuality.safeMinIntervalSeconds, delay * zunda);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function qualityForRate(rate, capacity) {
|
||||||
|
const rules = BALANCE.farmQuality;
|
||||||
|
const c = Math.max(0.0001, capacity);
|
||||||
|
const overflowRatio = Math.max(0, rate / c - 1);
|
||||||
|
return Math.max(rules.minMultiplier, Math.min(rules.maxMultiplier, 1 + rules.overflowCoefficient * Math.pow(overflowRatio, rules.overflowExponent)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function actualSpawnIntervalForRate(rate, capacity) {
|
||||||
|
return 1 / Math.max(0.0001, Math.min(rate, capacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function qualityGrade(multiplier) {
|
||||||
|
if (multiplier >= 1.75) return 'Luxury';
|
||||||
|
if (multiplier >= 1.35) return 'Premium';
|
||||||
|
if (multiplier >= 1.15) return 'Select';
|
||||||
|
return 'Standard';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addZundaBoost(game, farm) {
|
||||||
|
const before = farm.zundaBoostTime || 0;
|
||||||
|
farm.zundaBoostTime = Math.min(BALANCE.farmQuality.zundaMaxSeconds, before + BALANCE.farmQuality.zundaSeconds);
|
||||||
|
const added = Math.max(0, farm.zundaBoostTime - before);
|
||||||
|
game.stats.zundaConsumed = (game.stats.zundaConsumed || 0) + 1;
|
||||||
|
game.stats.zundaBoostSeconds = (game.stats.zundaBoostSeconds || 0) + added;
|
||||||
|
game.totals.zundaConsumed = (game.totals.zundaConsumed || 0) + 1;
|
||||||
|
game.totals.zundaBoostSeconds = (game.totals.zundaBoostSeconds || 0) + added;
|
||||||
|
game.stats.zundaBoostSecondsByFarm = game.stats.zundaBoostSecondsByFarm || {};
|
||||||
|
game.stats.zundaBoostSecondsByFarm[farm.id] = (game.stats.zundaBoostSecondsByFarm[farm.id] || 0) + added;
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,13 @@ export function snapshot(game) {
|
||||||
scanners: game.scanners.map(cleanScanner),
|
scanners: game.scanners.map(cleanScanner),
|
||||||
conveyorTiles: [...game.conveyorTiles],
|
conveyorTiles: [...game.conveyorTiles],
|
||||||
conveyorMeta: [...game.conveyorMeta.entries()],
|
conveyorMeta: [...game.conveyorMeta.entries()],
|
||||||
|
powerWires: [...(game.powerWires || new Map()).entries()],
|
||||||
|
footbridges: [...(game.footbridges || new Map()).entries()],
|
||||||
|
footbridgeOrientation: game.footbridgeOrientation || 'vertical',
|
||||||
|
powerOverlay: !!game.powerOverlay,
|
||||||
|
imports: {
|
||||||
|
schedules: [...(game.imports?.schedules || new Map()).entries()]
|
||||||
|
},
|
||||||
branchCounters: [...game.branchCounters.entries()],
|
branchCounters: [...game.branchCounters.entries()],
|
||||||
cardEffects: game.cardEffects,
|
cardEffects: game.cardEffects,
|
||||||
cardDraft: game.cardDraft,
|
cardDraft: game.cardDraft,
|
||||||
|
|
@ -67,6 +74,11 @@ export function restore(game, text) {
|
||||||
game.scanners = (data.scanners || []).map(s => ({ ...s, queue: [], cooldown: s.cooldown || 0 }));
|
game.scanners = (data.scanners || []).map(s => ({ ...s, queue: [], cooldown: s.cooldown || 0 }));
|
||||||
game.conveyorTiles = new Set(data.conveyorTiles || []);
|
game.conveyorTiles = new Set(data.conveyorTiles || []);
|
||||||
game.conveyorMeta = new Map(data.conveyorMeta || []);
|
game.conveyorMeta = new Map(data.conveyorMeta || []);
|
||||||
|
game.powerWires = new Map(data.powerWires || []);
|
||||||
|
game.footbridges = new Map(data.footbridges || []);
|
||||||
|
game.footbridgeOrientation = data.footbridgeOrientation || 'vertical';
|
||||||
|
game.powerOverlay = !!data.powerOverlay;
|
||||||
|
game.imports = { schedules: new Map(data.imports?.schedules || []) };
|
||||||
game.branchCounters = new Map(data.branchCounters || []);
|
game.branchCounters = new Map(data.branchCounters || []);
|
||||||
game.cardEffects = data.cardEffects || { bearing: 0, legalWork: 0, fairiesFlatteryNext: 0, scannerQueueSpacing: 0 };
|
game.cardEffects = data.cardEffects || { bearing: 0, legalWork: 0, fairiesFlatteryNext: 0, scannerQueueSpacing: 0 };
|
||||||
if (game.cardEffects.drawBonus != null) delete game.cardEffects.drawBonus;
|
if (game.cardEffects.drawBonus != null) delete game.cardEffects.drawBonus;
|
||||||
|
|
@ -78,6 +90,8 @@ export function restore(game, text) {
|
||||||
game.selectionBox = null;
|
game.selectionBox = null;
|
||||||
game.pan = null;
|
game.pan = null;
|
||||||
game.routingVersion = (game.routingVersion || 0) + 1;
|
game.routingVersion = (game.routingVersion || 0) + 1;
|
||||||
|
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
|
||||||
|
game.power = { ...(game.power || {}), dirty: true };
|
||||||
game.factoryGraph = null;
|
game.factoryGraph = null;
|
||||||
game.factoryGraphCommittedVersion = -1;
|
game.factoryGraphCommittedVersion = -1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
149
src/systems/imports.js
Normal file
149
src/systems/imports.js
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
import { BALANCE } from '../core/balance.js';
|
||||||
|
import { TURN_SECONDS } from '../core/config.js';
|
||||||
|
import { cellCenter, key } from '../core/utils.js';
|
||||||
|
import { applyPenalty } from './economy.js';
|
||||||
|
import { facilityOutputCell, facilityOutputHasConveyor } from './routing.js';
|
||||||
|
import { isEquipmentPowered } from './power.js';
|
||||||
|
import { recordFacilityProcess } from './maintenance.js';
|
||||||
|
|
||||||
|
function hash01(seed) {
|
||||||
|
let h = 2166136261;
|
||||||
|
for (let i = 0; i < String(seed).length; i += 1) {
|
||||||
|
h ^= String(seed).charCodeAt(i);
|
||||||
|
h = Math.imul(h, 16777619);
|
||||||
|
}
|
||||||
|
return ((h >>> 0) % 100000) / 100000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampInboundOrder(order = {}) {
|
||||||
|
const limit = BALANCE.imports.combinedLimitPerTruck;
|
||||||
|
let coal = Math.max(0, Math.floor(Number(order.coalPerDay) || 0));
|
||||||
|
let zunda = Math.max(0, Math.floor(Number(order.zundaMochiPerDay) || 0));
|
||||||
|
if (coal + zunda > limit) {
|
||||||
|
const scale = limit / Math.max(1, coal + zunda);
|
||||||
|
coal = Math.floor(coal * scale);
|
||||||
|
zunda = Math.max(0, limit - coal);
|
||||||
|
}
|
||||||
|
return { coalPerDay: coal, zundaMochiPerDay: zunda };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inboundOrderCost(order = {}) {
|
||||||
|
const clean = clampInboundOrder(order);
|
||||||
|
return clean.coalPerDay * BALANCE.imports.prices.coal + clean.zundaMochiPerDay * BALANCE.imports.prices.zundaMochi;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createImportSchedule(order, seed = 'imports') {
|
||||||
|
const clean = clampInboundOrder(order);
|
||||||
|
const items = [
|
||||||
|
...Array.from({ length: clean.coalPerDay }, () => ({ type: 'coal' })),
|
||||||
|
...Array.from({ length: clean.zundaMochiPerDay }, () => ({ type: 'zundaMochi' }))
|
||||||
|
];
|
||||||
|
const total = Math.max(1, items.length);
|
||||||
|
return items.map((item, i) => {
|
||||||
|
const interval = TURN_SECONDS / total;
|
||||||
|
const baseTime = ((i + 0.5) / total) * TURN_SECONDS;
|
||||||
|
const jitter = (hash01(`${seed}:${item.type}:${i}`) * 2 - 1) * BALANCE.imports.jitterRatio * interval;
|
||||||
|
return { type: item.type, due: Math.max(0, Math.min(TURN_SECONDS, baseTime + jitter)), released: false };
|
||||||
|
}).sort((a, b) => a.due - b.due || a.type.localeCompare(b.type));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startInboundOrders(game) {
|
||||||
|
game.imports = { schedules: new Map() };
|
||||||
|
for (const facility of Object.values(game.facilities || {})) {
|
||||||
|
if ((facility.baseId || facility.id) !== 'inboundTruck') continue;
|
||||||
|
facility.inboundOrder = clampInboundOrder(facility.inboundOrder);
|
||||||
|
facility.inboundQueue = [];
|
||||||
|
facility.importClock = 0;
|
||||||
|
const cost = inboundOrderCost(facility.inboundOrder);
|
||||||
|
if (cost > 0) {
|
||||||
|
applyPenalty(game, cost);
|
||||||
|
game.stats.importPurchaseCost = (game.stats.importPurchaseCost || 0) + cost;
|
||||||
|
game.stats.coalOrdered = (game.stats.coalOrdered || 0) + facility.inboundOrder.coalPerDay;
|
||||||
|
game.stats.zundaOrdered = (game.stats.zundaOrdered || 0) + facility.inboundOrder.zundaMochiPerDay;
|
||||||
|
game.totals.importPurchaseCost = (game.totals.importPurchaseCost || 0) + cost;
|
||||||
|
game.totals.coalOrdered = (game.totals.coalOrdered || 0) + facility.inboundOrder.coalPerDay;
|
||||||
|
game.totals.zundaOrdered = (game.totals.zundaOrdered || 0) + facility.inboundOrder.zundaMochiPerDay;
|
||||||
|
}
|
||||||
|
game.imports.schedules.set(facility.id, createImportSchedule(facility.inboundOrder, `${game.runSeed}:${game.turn}:${facility.id}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputCellFree(game, cell) {
|
||||||
|
if (!cell || !game.conveyorTiles.has(key(cell.col, cell.row))) return false;
|
||||||
|
const c = cellCenter(cell.col, cell.row);
|
||||||
|
return !game.chicks.some(item => item.stage !== 'flying' && Math.hypot(item.x - c.x, item.y - c.y) < 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createImportItem(game, type, cell) {
|
||||||
|
const c = cellCenter(cell.col, cell.row);
|
||||||
|
return {
|
||||||
|
id: game.nextId++,
|
||||||
|
sex: type,
|
||||||
|
x: c.x,
|
||||||
|
y: c.y,
|
||||||
|
route: [{ x: c.x, y: c.y }],
|
||||||
|
targetIndex: 1,
|
||||||
|
stage: 'belt',
|
||||||
|
scannerId: null,
|
||||||
|
nextScannerId: null,
|
||||||
|
pendingScannerId: null,
|
||||||
|
radius: type === 'coal' ? 10 : 11,
|
||||||
|
bob: 0,
|
||||||
|
queueIndex: -1,
|
||||||
|
queueRoute: null,
|
||||||
|
stoppedTimer: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateInboundTrucks(game, dt, markChicksChanged = () => {}) {
|
||||||
|
if (game.phase !== 'running') return;
|
||||||
|
for (const facility of Object.values(game.facilities || {})) {
|
||||||
|
if ((facility.baseId || facility.id) !== 'inboundTruck') continue;
|
||||||
|
if (!isEquipmentPowered(game, facility)) continue;
|
||||||
|
facility.importClock = (facility.importClock || 0) + dt;
|
||||||
|
const schedule = game.imports?.schedules?.get(facility.id) || [];
|
||||||
|
for (const item of schedule) {
|
||||||
|
if (!item.released && item.due <= facility.importClock) {
|
||||||
|
facility.inboundQueue = facility.inboundQueue || [];
|
||||||
|
facility.inboundQueue.push(item);
|
||||||
|
item.released = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out = facilityOutputCell(game, facility);
|
||||||
|
if (!facility.inboundQueue?.length || !facilityOutputHasConveyor(game, facility) || !outputCellFree(game, out)) continue;
|
||||||
|
const queued = facility.inboundQueue.shift();
|
||||||
|
const type = queued.type;
|
||||||
|
queued.delivered = true;
|
||||||
|
game.chicks.push(createImportItem(game, type, out));
|
||||||
|
if (type === 'coal') {
|
||||||
|
game.stats.coalReleased = (game.stats.coalReleased || 0) + 1;
|
||||||
|
game.totals.coalReleased = (game.totals.coalReleased || 0) + 1;
|
||||||
|
}
|
||||||
|
if (type === 'zundaMochi') {
|
||||||
|
game.stats.zundaReleased = (game.stats.zundaReleased || 0) + 1;
|
||||||
|
game.totals.zundaReleased = (game.totals.zundaReleased || 0) + 1;
|
||||||
|
}
|
||||||
|
recordFacilityProcess(game, facility);
|
||||||
|
markChicksChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function settleUndeliveredImports(game) {
|
||||||
|
for (const facility of Object.values(game.facilities || {})) {
|
||||||
|
if ((facility.baseId || facility.id) !== 'inboundTruck') continue;
|
||||||
|
const schedule = game.imports?.schedules?.get(facility.id) || [];
|
||||||
|
for (const item of schedule) {
|
||||||
|
const undelivered = !item.delivered;
|
||||||
|
if (!undelivered) continue;
|
||||||
|
if (item.type === 'coal') {
|
||||||
|
game.stats.coalUndelivered = (game.stats.coalUndelivered || 0) + 1;
|
||||||
|
game.totals.coalUndelivered = (game.totals.coalUndelivered || 0) + 1;
|
||||||
|
}
|
||||||
|
if (item.type === 'zundaMochi') {
|
||||||
|
game.stats.zundaUndelivered = (game.stats.zundaUndelivered || 0) + 1;
|
||||||
|
game.totals.zundaUndelivered = (game.totals.zundaUndelivered || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
facility.inboundQueue = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { BALANCE } from '../core/balance.js';
|
import { BALANCE } from '../core/balance.js';
|
||||||
import { GRID, THEME } from '../core/config.js';
|
import { DIRS, GRID, THEME } from '../core/config.js';
|
||||||
import { cellCenter, parseKey, key } from '../core/utils.js';
|
import { cellCenter, parseKey, key, inGrid } from '../core/utils.js';
|
||||||
import { floating } from './effects.js';
|
import { floating } from './effects.js';
|
||||||
|
import { isEquipmentPowered } from './power.js';
|
||||||
|
|
||||||
const RULES = BALANCE.maintenance;
|
const RULES = BALANCE.maintenance;
|
||||||
|
|
||||||
|
|
@ -69,6 +70,8 @@ export function ensureMaintenanceState(game) {
|
||||||
const kind = facilityKind(f);
|
const kind = facilityKind(f);
|
||||||
if (kind === 'mixer') attachMaintenance(f, 'mixer', game);
|
if (kind === 'mixer') attachMaintenance(f, 'mixer', game);
|
||||||
if (kind === 'trash') attachMaintenance(f, 'trash', game);
|
if (kind === 'trash') attachMaintenance(f, 'trash', game);
|
||||||
|
if (kind === 'sausageMaker') attachMaintenance(f, 'sausageMaker', game);
|
||||||
|
if (['starterGenerator', 'biomassGenerator', 'coalGenerator', 'inboundTruck'].includes(kind)) attachMaintenance(f, kind, game);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -195,7 +198,7 @@ export function recordFacilityProcess(game, target) {
|
||||||
ensureMaintenanceState(game);
|
ensureMaintenanceState(game);
|
||||||
const f = facilityFromTarget(game, target);
|
const f = facilityFromTarget(game, target);
|
||||||
const id = facilityKind(f || target);
|
const id = facilityKind(f || target);
|
||||||
if (!f || !['mixer', 'trash', 'sausageMaker'].includes(id)) return;
|
if (!f || !['mixer', 'trash', 'sausageMaker', 'starterGenerator', 'biomassGenerator', 'coalGenerator', 'inboundTruck'].includes(id)) return;
|
||||||
attachMaintenance(f, id);
|
attachMaintenance(f, id);
|
||||||
f.maintenance.uses = Math.min(f.maintenance.durability, (f.maintenance.uses || 0) + degradationUseMultiplier(game));
|
f.maintenance.uses = Math.min(f.maintenance.durability, (f.maintenance.uses || 0) + degradationUseMultiplier(game));
|
||||||
}
|
}
|
||||||
|
|
@ -211,6 +214,7 @@ export function setFacilityCooldownAfterProcess(game, target) {
|
||||||
export function updateProcessingCooldowns(game, dt) {
|
export function updateProcessingCooldowns(game, dt) {
|
||||||
for (const f of Object.values(game.facilities || {})) {
|
for (const f of Object.values(game.facilities || {})) {
|
||||||
if (!['mixer', 'trash', 'sausageMaker'].includes(f.baseId || f.id)) continue;
|
if (!['mixer', 'trash', 'sausageMaker'].includes(f.baseId || f.id)) continue;
|
||||||
|
if (!isEquipmentPowered(game, f)) continue;
|
||||||
if (f?.processingCooldown > 0) f.processingCooldown = Math.max(0, f.processingCooldown - dt);
|
if (f?.processingCooldown > 0) f.processingCooldown = Math.max(0, f.processingCooldown - dt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -223,7 +227,7 @@ export function equipmentMaintenanceTargets(game) {
|
||||||
for (const scanner of (game.scanners || []).filter(s => s.kind === 'auto')) targets.push({ type: 'autoScanner', ref: scanner, label: `AUTO #${scanner.id}`, center: cellCenter(scanner.col, scanner.row) });
|
for (const scanner of (game.scanners || []).filter(s => s.kind === 'auto')) targets.push({ type: 'autoScanner', ref: scanner, label: `AUTO #${scanner.id}`, center: cellCenter(scanner.col, scanner.row) });
|
||||||
for (const f of Object.values(game.facilities || {})) {
|
for (const f of Object.values(game.facilities || {})) {
|
||||||
const kind = facilityKind(f);
|
const kind = facilityKind(f);
|
||||||
if (!['mixer', 'trash', 'sausageMaker'].includes(kind)) continue;
|
if (!['mixer', 'trash', 'sausageMaker', 'starterGenerator', 'biomassGenerator', 'coalGenerator', 'inboundTruck'].includes(kind)) continue;
|
||||||
targets.push({ type: kind, ref: f, label: `${f.shortName || f.name || kind} #${f.id}`, center: { x: f.x + f.w / 2, y: f.y + f.h / 2 } });
|
targets.push({ type: kind, ref: f, label: `${f.shortName || f.name || kind} #${f.id}`, center: { x: f.x + f.w / 2, y: f.y + f.h / 2 } });
|
||||||
}
|
}
|
||||||
return targets;
|
return targets;
|
||||||
|
|
@ -251,7 +255,8 @@ export function activateRepairmanForDay(game) {
|
||||||
ensureMaintenanceState(game);
|
ensureMaintenanceState(game);
|
||||||
const rooms = game.maintenanceRooms || [];
|
const rooms = game.maintenanceRooms || [];
|
||||||
game.repairmen = rooms.map((room, index) => {
|
game.repairmen = rooms.map((room, index) => {
|
||||||
const home = repairmanHome(game, index);
|
const exitCell = nearestWalkableFromRoom(game, room);
|
||||||
|
const home = exitCell ? cellCenter(exitCell.col, exitCell.row) : repairmanHome(game, index);
|
||||||
return {
|
return {
|
||||||
id: `room-${room.id}`,
|
id: `room-${room.id}`,
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
|
|
@ -286,6 +291,7 @@ function resolveStoredTarget(game, stored) {
|
||||||
function chooseRepairTarget(game) {
|
function chooseRepairTarget(game) {
|
||||||
let best = null;
|
let best = null;
|
||||||
for (const t of equipmentMaintenanceTargets(game)) {
|
for (const t of equipmentMaintenanceTargets(game)) {
|
||||||
|
if (!repairWorkCell(game, t)) continue;
|
||||||
const w = targetWear(t);
|
const w = targetWear(t);
|
||||||
if (w <= 0.001) continue;
|
if (w <= 0.001) continue;
|
||||||
if (!best || w > targetWear(best)) best = t;
|
if (!best || w > targetWear(best)) best = t;
|
||||||
|
|
@ -311,8 +317,10 @@ export function updateRepairman(game, dt) {
|
||||||
|
|
||||||
function updateSingleRepairman(game, r, dt, assigned) {
|
function updateSingleRepairman(game, r, dt, assigned) {
|
||||||
if (!r.active) return;
|
if (!r.active) return;
|
||||||
|
const room = (game.maintenanceRooms || []).find(item => item.id === r.roomId);
|
||||||
|
if (room && !isEquipmentPowered(game, room)) return;
|
||||||
let target = resolveStoredTarget(game, r.target);
|
let target = resolveStoredTarget(game, r.target);
|
||||||
if (!target || targetWear(target) <= 0.001) {
|
if (!target || targetWear(target) <= 0.001 || !repairWorkCell(game, target)) {
|
||||||
if (r.target?.key) assigned.delete(r.target.key);
|
if (r.target?.key) assigned.delete(r.target.key);
|
||||||
target = chooseRepairTargetAvoiding(game, assigned);
|
target = chooseRepairTargetAvoiding(game, assigned);
|
||||||
}
|
}
|
||||||
|
|
@ -322,10 +330,34 @@ function updateSingleRepairman(game, r, dt, assigned) {
|
||||||
}
|
}
|
||||||
const tKey = targetKey(target);
|
const tKey = targetKey(target);
|
||||||
assigned.add(tKey);
|
assigned.add(tKey);
|
||||||
r.target = { key: tKey, label: target.label };
|
const workCell = repairWorkCell(game, target, r);
|
||||||
|
if (!workCell) {
|
||||||
|
game.stats.unreachableRepairTargets = (game.stats.unreachableRepairTargets || 0) + 1;
|
||||||
|
game.totals.unreachableRepairTargets = (game.totals.unreachableRepairTargets || 0) + 1;
|
||||||
|
assigned.delete(tKey);
|
||||||
|
r.target = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
r.target = { key: tKey, label: target.label, workKey: key(workCell.col, workCell.row) };
|
||||||
|
ensureRepairPath(game, r, workCell);
|
||||||
|
if (r.path?.length && r.pathIndex < r.path.length) {
|
||||||
|
const next = cellCenter(r.path[r.pathIndex].col, r.path[r.pathIndex].row);
|
||||||
|
const dx = next.x - r.x;
|
||||||
|
const dy = next.y - r.y;
|
||||||
|
const dist = Math.hypot(dx, dy);
|
||||||
|
if (dist > 4) {
|
||||||
|
const step = Math.min(dist, RULES.repairman.walkSpeed * dt);
|
||||||
|
r.x += dx / dist * step;
|
||||||
|
r.y += dy / dist * step;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
r.pathIndex += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const speed = RULES.repairman.walkSpeed;
|
const speed = RULES.repairman.walkSpeed;
|
||||||
const dx = target.center.x - r.x;
|
const workCenter = cellCenter(workCell.col, workCell.row);
|
||||||
const dy = target.center.y - r.y;
|
const dx = workCenter.x - r.x;
|
||||||
|
const dy = workCenter.y - r.y;
|
||||||
const dist = Math.hypot(dx, dy);
|
const dist = Math.hypot(dx, dy);
|
||||||
if (dist > 8) {
|
if (dist > 8) {
|
||||||
const step = Math.min(dist, speed * dt);
|
const step = Math.min(dist, speed * dt);
|
||||||
|
|
@ -351,6 +383,7 @@ function chooseRepairTargetAvoiding(game, assigned = new Set()) {
|
||||||
let best = null;
|
let best = null;
|
||||||
for (const t of equipmentMaintenanceTargets(game)) {
|
for (const t of equipmentMaintenanceTargets(game)) {
|
||||||
if (assigned.has(targetKey(t))) continue;
|
if (assigned.has(targetKey(t))) continue;
|
||||||
|
if (!repairWorkCell(game, t)) continue;
|
||||||
const w = targetWear(t);
|
const w = targetWear(t);
|
||||||
if (w <= 0.001) continue;
|
if (w <= 0.001) continue;
|
||||||
if (!best || w > targetWear(best)) best = t;
|
if (!best || w > targetWear(best)) best = t;
|
||||||
|
|
@ -358,6 +391,135 @@ function chooseRepairTargetAvoiding(game, assigned = new Set()) {
|
||||||
return best || chooseRepairTarget(game);
|
return best || chooseRepairTarget(game);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cellsForTarget(target) {
|
||||||
|
if (target.type === 'conveyor') return [parseKey(target.key)];
|
||||||
|
if (target.type === 'eggFarm') return [{ col: target.ref.col, row: target.ref.row }];
|
||||||
|
if (target.type === 'autoScanner') {
|
||||||
|
const s = target.ref;
|
||||||
|
return [{ col: s.col, row: s.row - 1 }, { col: s.col + 1, row: s.row - 1 }, { col: s.col, row: s.row }, { col: s.col + 1, row: s.row }];
|
||||||
|
}
|
||||||
|
const f = target.ref;
|
||||||
|
if (f?.gridPlaced) {
|
||||||
|
const cells = [];
|
||||||
|
for (let dr = 0; dr < (f.hCells || 1); dr += 1) for (let dc = 0; dc < (f.wCells || 1); dc += 1) cells.push({ col: f.col + dc, row: f.row + dr });
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
return f?.entry ? [f.entry] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function equipmentCellKeys(game) {
|
||||||
|
const blocked = new Set();
|
||||||
|
for (const farm of game.eggFarms || []) blocked.add(key(farm.col, farm.row));
|
||||||
|
for (const s of game.scanners || []) for (const cell of [{ col: s.col, row: s.row - 1 }, { col: s.col + 1, row: s.row - 1 }, { col: s.col, row: s.row }, { col: s.col + 1, row: s.row }]) blocked.add(key(cell.col, cell.row));
|
||||||
|
for (const room of game.maintenanceRooms || []) for (let dr = 0; dr < 2; dr += 1) for (let dc = 0; dc < 2; dc += 1) blocked.add(key(room.col + dc, room.row + dr));
|
||||||
|
for (const f of Object.values(game.facilities || {})) {
|
||||||
|
if (!f.gridPlaced) continue;
|
||||||
|
for (let dr = 0; dr < (f.hCells || 1); dr += 1) for (let dc = 0; dc < (f.wCells || 1); dc += 1) blocked.add(key(f.col + dc, f.row + dr));
|
||||||
|
}
|
||||||
|
return blocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkable(game, col, row) {
|
||||||
|
if (!inGrid(col, row) || !game.ownedCells?.has?.(key(col, row)) || game.blockedCells?.has?.(key(col, row))) return false;
|
||||||
|
const k = key(col, row);
|
||||||
|
if (equipmentCellKeys(game).has(k)) return false;
|
||||||
|
if (game.conveyorTiles?.has(k) && !game.footbridges?.has(k)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestWalkableFromRoom(game, room) {
|
||||||
|
if (!room) return { col: 0, row: Math.max(0, GRID.rows - 1) };
|
||||||
|
const candidates = [];
|
||||||
|
for (let dc = 0; dc < 2; dc += 1) {
|
||||||
|
candidates.push({ col: room.col + dc, row: room.row - 1 }, { col: room.col + dc, row: room.row + 2 });
|
||||||
|
}
|
||||||
|
for (let dr = 0; dr < 2; dr += 1) {
|
||||||
|
candidates.push({ col: room.col - 1, row: room.row + dr }, { col: room.col + 2, row: room.row + dr });
|
||||||
|
}
|
||||||
|
return candidates.find(cell => walkable(game, cell.col, cell.row)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bfsPath(game, start, goals) {
|
||||||
|
if (!start) return null;
|
||||||
|
const goalKeys = new Set(goals.map(cell => key(cell.col, cell.row)));
|
||||||
|
const startKey = key(start.col, start.row);
|
||||||
|
const queue = [start];
|
||||||
|
const cameFrom = new Map([[startKey, null]]);
|
||||||
|
while (queue.length) {
|
||||||
|
const current = queue.shift();
|
||||||
|
const currentKey = key(current.col, current.row);
|
||||||
|
if (goalKeys.has(currentKey)) {
|
||||||
|
const path = [];
|
||||||
|
let cursor = currentKey;
|
||||||
|
while (cursor) {
|
||||||
|
const cell = parseKey(cursor);
|
||||||
|
path.push(cell);
|
||||||
|
cursor = cameFrom.get(cursor);
|
||||||
|
}
|
||||||
|
return path.reverse();
|
||||||
|
}
|
||||||
|
for (const d of DIRS) {
|
||||||
|
const next = { col: current.col + d.dc, row: current.row + d.dr };
|
||||||
|
const nk = key(next.col, next.row);
|
||||||
|
if (cameFrom.has(nk) || !walkable(game, next.col, next.row)) continue;
|
||||||
|
cameFrom.set(nk, currentKey);
|
||||||
|
queue.push(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function repairCandidateCells(game, target) {
|
||||||
|
const candidates = [];
|
||||||
|
for (const cell of cellsForTarget(target)) {
|
||||||
|
if (target.type === 'conveyor' && game.footbridges?.has(key(cell.col, cell.row)) && walkable(game, cell.col, cell.row)) candidates.push(cell);
|
||||||
|
for (const d of DIRS) {
|
||||||
|
const candidate = { col: cell.col + d.dc, row: cell.row + d.dr };
|
||||||
|
if (walkable(game, candidate.col, candidate.row)) candidates.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const seen = new Set();
|
||||||
|
return candidates.filter(cell => {
|
||||||
|
const k = key(cell.col, cell.row);
|
||||||
|
if (seen.has(k)) return false;
|
||||||
|
seen.add(k);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function repairWorkCell(game, target, repairman = null) {
|
||||||
|
const goals = repairCandidateCells(game, target);
|
||||||
|
if (!goals.length) return null;
|
||||||
|
const room = (game.maintenanceRooms || []).find(item => item.id === repairman?.roomId) || (game.maintenanceRooms || [])[0];
|
||||||
|
const start = repairman ? repairmanPathStart(game, repairman, room) : nearestWalkableFromRoom(game, room);
|
||||||
|
const path = bfsPath(game, start, goals);
|
||||||
|
return path?.[path.length - 1] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointCell(x, y) {
|
||||||
|
const col = Math.floor((x - GRID.x) / GRID.cell);
|
||||||
|
const row = Math.floor((y - GRID.y) / GRID.cell);
|
||||||
|
return inGrid(col, row) ? { col, row } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function repairmanPathStart(game, repairman, room) {
|
||||||
|
const current = pointCell(repairman.x, repairman.y);
|
||||||
|
if (current && walkable(game, current.col, current.row)) return current;
|
||||||
|
return nearestWalkableFromRoom(game, room);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRepairPath(game, r, workCell) {
|
||||||
|
const workKey = key(workCell.col, workCell.row);
|
||||||
|
if (r.path && r.pathVersion === game.walkabilityVersion && r.target?.workKey === workKey && r.pathIndex < r.path.length) return;
|
||||||
|
const room = (game.maintenanceRooms || []).find(item => item.id === r.roomId);
|
||||||
|
const start = repairmanPathStart(game, r, room);
|
||||||
|
r.path = bfsPath(game, start, [workCell]) || [];
|
||||||
|
const current = pointCell(r.x, r.y);
|
||||||
|
const startsOnWalkablePath = current && start && current.col === start.col && current.row === start.row && walkable(game, current.col, current.row);
|
||||||
|
r.pathIndex = startsOnWalkablePath && r.path.length > 1 ? 1 : 0;
|
||||||
|
r.pathVersion = game.walkabilityVersion || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function worstDegradation(game) {
|
function worstDegradation(game) {
|
||||||
let worst = 0;
|
let worst = 0;
|
||||||
|
|
|
||||||
302
src/systems/power.js
Normal file
302
src/systems/power.js
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
import { BALANCE } from '../core/balance.js';
|
||||||
|
import { DIRS } from '../core/config.js';
|
||||||
|
import { key, parseKey } from '../core/utils.js';
|
||||||
|
import { gridFacilityFootprintCells, maintenanceRoomFootprint } from '../core/entities.js';
|
||||||
|
|
||||||
|
function facilityKind(item) {
|
||||||
|
return item?.baseId || item?.id || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function powerSpecFor(item) {
|
||||||
|
if (!item) return null;
|
||||||
|
const kind = item.type === 'scanner' ? `${item.kind}Scanner` : facilityKind(item) || item.type;
|
||||||
|
if (BALANCE.power.exempt.includes(kind)) return null;
|
||||||
|
return BALANCE.power.machines[kind] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generatorSpecFor(item) {
|
||||||
|
return BALANCE.power.generators[facilityKind(item)] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentGeneratorOutputV(generator) {
|
||||||
|
const spec = generatorSpecFor(generator);
|
||||||
|
if (!spec) return 0;
|
||||||
|
const base = spec.outputV || spec.baseOutputV || 0;
|
||||||
|
const pulses = (generator.fuelPulses || []).reduce((sum, pulse) => sum + Math.max(0, pulse.voltage || 0), 0);
|
||||||
|
return base + pulses;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceCells(generator) {
|
||||||
|
if (!generator?.gridPlaced) return [];
|
||||||
|
return gridFacilityFootprintCells(generator);
|
||||||
|
}
|
||||||
|
|
||||||
|
function contactCellsFor(item) {
|
||||||
|
if (!item) return [];
|
||||||
|
if (item.type === 'scanner') {
|
||||||
|
return [
|
||||||
|
{ col: item.col, row: item.row - 1 },
|
||||||
|
{ col: item.col + 1, row: item.row - 1 },
|
||||||
|
{ col: item.col, row: item.row },
|
||||||
|
{ col: item.col + 1, row: item.row }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (item.type === 'maintenanceRoom') return maintenanceRoomFootprint(item);
|
||||||
|
if (item.gridPlaced) {
|
||||||
|
const kind = facilityKind(item);
|
||||||
|
const midRow = item.row + Math.floor((item.hCells || 1) / 2);
|
||||||
|
if (kind === 'sausageMaker') return [
|
||||||
|
{ col: item.col - 1, row: midRow },
|
||||||
|
{ col: item.col + (item.wCells || 3), row: midRow }
|
||||||
|
];
|
||||||
|
return [
|
||||||
|
{ col: item.col - 1, row: midRow },
|
||||||
|
{ col: item.col + (item.wCells || 1), row: midRow },
|
||||||
|
{ col: item.col + Math.floor((item.wCells || 1) / 2), row: item.row - 1 },
|
||||||
|
{ col: item.col + Math.floor((item.wCells || 1) / 2), row: item.row + (item.hCells || 1) }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (item.entry) return [item.entry];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjacentWireLosses(game, cells, distances) {
|
||||||
|
let best = Infinity;
|
||||||
|
for (const cell of cells) {
|
||||||
|
for (const d of DIRS) {
|
||||||
|
const nk = key(cell.col + d.dc, cell.row + d.dr);
|
||||||
|
const distance = distances.get(nk);
|
||||||
|
if (distance != null) best = Math.min(best, distance);
|
||||||
|
}
|
||||||
|
const same = distances.get(key(cell.col, cell.row));
|
||||||
|
if (same != null) best = Math.min(best, same);
|
||||||
|
}
|
||||||
|
return Number.isFinite(best) ? best : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function propagateFromGenerator(game, generator) {
|
||||||
|
const distances = new Map();
|
||||||
|
const queue = [];
|
||||||
|
const push = (k, loss) => {
|
||||||
|
if (loss < 0 || loss >= currentGeneratorOutputV(generator)) return;
|
||||||
|
if (distances.has(k) && distances.get(k) <= loss) return;
|
||||||
|
distances.set(k, loss);
|
||||||
|
queue.push({ k, loss });
|
||||||
|
};
|
||||||
|
for (const cell of sourceCells(generator)) {
|
||||||
|
for (const d of DIRS) {
|
||||||
|
const nk = key(cell.col + d.dc, cell.row + d.dr);
|
||||||
|
if (game.powerWires?.has(nk)) push(nk, BALANCE.power.wires[game.powerWires.get(nk).kind]?.lossV || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (queue.length) {
|
||||||
|
queue.sort((a, b) => a.loss - b.loss);
|
||||||
|
const current = queue.shift();
|
||||||
|
if (distances.get(current.k) !== current.loss) continue;
|
||||||
|
const cell = parseKey(current.k);
|
||||||
|
for (const d of DIRS) {
|
||||||
|
const nk = key(cell.col + d.dc, cell.row + d.dr);
|
||||||
|
const wire = game.powerWires?.get(nk);
|
||||||
|
if (!wire) continue;
|
||||||
|
const step = BALANCE.power.wires[wire.kind]?.lossV ?? 0.5;
|
||||||
|
push(nk, current.loss + step);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return distances;
|
||||||
|
}
|
||||||
|
|
||||||
|
function poweredItems(game) {
|
||||||
|
const items = [];
|
||||||
|
for (const facility of Object.values(game.facilities || {})) if (powerSpecFor(facility)) items.push(facility);
|
||||||
|
for (const scanner of game.scanners || []) if (powerSpecFor(scanner)) items.push(scanner);
|
||||||
|
for (const room of game.maintenanceRooms || []) if (powerSpecFor(room)) items.push(room);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatorItems(game) {
|
||||||
|
return Object.values(game.facilities || {}).filter(generatorSpecFor);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableId(item) {
|
||||||
|
return String(item.id ?? `${item.type}:${item.col},${item.row}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOrder(item) {
|
||||||
|
return item.builtSession == null ? -1 : item.builtSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recalculatePowerNetwork(game) {
|
||||||
|
if (!game) return { dirty: false, routes: new Map(), assignments: new Map(), sources: new Map(), underVoltage: new Set() };
|
||||||
|
const routesBySource = new Map();
|
||||||
|
const sources = generatorItems(game).map(generator => {
|
||||||
|
const distances = propagateFromGenerator(game, generator);
|
||||||
|
routesBySource.set(generator.id, distances);
|
||||||
|
return { generator, outputV: currentGeneratorOutputV(generator), admitted: [], busVoltage: currentGeneratorOutputV(generator) };
|
||||||
|
});
|
||||||
|
const candidates = [];
|
||||||
|
for (const item of poweredItems(game)) {
|
||||||
|
const spec = powerSpecFor(item);
|
||||||
|
const routesForItem = [];
|
||||||
|
for (const source of sources) {
|
||||||
|
const loss = adjacentWireLosses(game, contactCellsFor(item), routesBySource.get(source.generator.id));
|
||||||
|
if (loss == null) continue;
|
||||||
|
routesForItem.push({ source, loss, openCircuit: source.outputV - loss });
|
||||||
|
}
|
||||||
|
routesForItem.sort((a, b) => b.openCircuit - a.openCircuit || a.loss - b.loss || stableId(a.source.generator).localeCompare(stableId(b.source.generator)));
|
||||||
|
if (routesForItem[0]) candidates.push({ item, spec, routes: routesForItem });
|
||||||
|
}
|
||||||
|
candidates.sort((a, b) =>
|
||||||
|
(b.spec.powerPriority || 50) - (a.spec.powerPriority || 50)
|
||||||
|
|| a.routes[0].loss - b.routes[0].loss
|
||||||
|
|| buildOrder(a.item) - buildOrder(b.item)
|
||||||
|
|| stableId(a.item).localeCompare(stableId(b.item))
|
||||||
|
);
|
||||||
|
let assignments = new Map();
|
||||||
|
let previousSignature = '';
|
||||||
|
let stablePasses = 0;
|
||||||
|
const maxPasses = Math.max(1, BALANCE.power.maxAssignmentPasses || 8);
|
||||||
|
for (let pass = 0; pass < maxPasses; pass += 1) {
|
||||||
|
stablePasses = pass + 1;
|
||||||
|
for (const source of sources) {
|
||||||
|
source.admitted = [];
|
||||||
|
source.busVoltage = source.outputV;
|
||||||
|
}
|
||||||
|
const nextAssignments = new Map();
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
for (const route of candidate.routes) {
|
||||||
|
const source = route.source;
|
||||||
|
const nextLoad = source.admitted.reduce((sum, entry) => sum + entry.spec.drawV, 0) + candidate.spec.drawV;
|
||||||
|
const nextBus = source.outputV - nextLoad;
|
||||||
|
const admittedRemainPowered = source.admitted.every(entry => nextBus - entry.loss >= entry.spec.minVoltage);
|
||||||
|
const candidatePowered = nextBus - route.loss >= candidate.spec.minVoltage;
|
||||||
|
if (!admittedRemainPowered || !candidatePowered) continue;
|
||||||
|
source.admitted.push({ item: candidate.item, spec: candidate.spec, loss: route.loss });
|
||||||
|
source.busVoltage = nextBus;
|
||||||
|
nextAssignments.set(stableId(candidate.item), {
|
||||||
|
sourceId: source.generator.id,
|
||||||
|
sourceName: source.generator.name || source.generator.shortName || source.generator.id,
|
||||||
|
sourceBusV: nextBus,
|
||||||
|
routeLossV: route.loss,
|
||||||
|
receivedV: nextBus - route.loss,
|
||||||
|
minVoltage: candidate.spec.minVoltage,
|
||||||
|
drawV: candidate.spec.drawV
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const signature = [...nextAssignments.entries()].map(([id, assignment]) => `${id}:${assignment.sourceId}:${assignment.receivedV.toFixed(3)}`).join('|');
|
||||||
|
assignments = nextAssignments;
|
||||||
|
if (signature === previousSignature) break;
|
||||||
|
previousSignature = signature;
|
||||||
|
}
|
||||||
|
const routes = new Map();
|
||||||
|
const sourceSummary = new Map();
|
||||||
|
for (const source of sources) {
|
||||||
|
sourceSummary.set(source.generator.id, {
|
||||||
|
outputV: source.outputV,
|
||||||
|
busVoltage: source.busVoltage,
|
||||||
|
admitted: source.admitted.length,
|
||||||
|
stablePasses
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const [id, data] of assignments) routes.set(id, data);
|
||||||
|
const previousUnder = game.power?.underVoltage || new Set();
|
||||||
|
const underVoltage = new Set();
|
||||||
|
for (const item of poweredItems(game)) if (!assignments.has(stableId(item))) underVoltage.add(stableId(item));
|
||||||
|
const previousAssignments = game.power?.assignments || new Map();
|
||||||
|
let assignmentChanges = 0;
|
||||||
|
for (const [id, assignment] of assignments) {
|
||||||
|
if (previousAssignments.get(id)?.sourceId !== assignment.sourceId) assignmentChanges += 1;
|
||||||
|
}
|
||||||
|
if (game.phase === 'running' && game.stats) {
|
||||||
|
game.stats.generatorAssignmentChanges = (game.stats.generatorAssignmentChanges || 0) + assignmentChanges;
|
||||||
|
if (game.totals) game.totals.generatorAssignmentChanges = (game.totals.generatorAssignmentChanges || 0) + assignmentChanges;
|
||||||
|
}
|
||||||
|
game.power = { ...(game.power || {}), dirty: false, routes, assignments, sources: sourceSummary, underVoltage, previousUnder };
|
||||||
|
return game.power;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensurePowerState(game) {
|
||||||
|
if (!game.power || game.power.dirty) recalculatePowerNetwork(game);
|
||||||
|
return game.power;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markPowerDirty(game) {
|
||||||
|
if (!game.power) game.power = {};
|
||||||
|
game.power.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePowerSystem(game, dt) {
|
||||||
|
let pulseChanged = false;
|
||||||
|
for (const generator of generatorItems(game)) {
|
||||||
|
if (game.stats) {
|
||||||
|
game.stats.maxGeneratorOutput = game.stats.maxGeneratorOutput || {};
|
||||||
|
const id = generator.id || facilityKind(generator);
|
||||||
|
game.stats.maxGeneratorOutput[id] = Math.max(game.stats.maxGeneratorOutput[id] || 0, currentGeneratorOutputV(generator));
|
||||||
|
}
|
||||||
|
if (!generator.fuelPulses?.length) continue;
|
||||||
|
for (const pulse of generator.fuelPulses) pulse.timeLeft -= dt;
|
||||||
|
const before = generator.fuelPulses.length;
|
||||||
|
generator.fuelPulses = generator.fuelPulses.filter(pulse => pulse.timeLeft > 0);
|
||||||
|
if (generator.fuelPulses.length !== before) pulseChanged = true;
|
||||||
|
}
|
||||||
|
if (pulseChanged) markPowerDirty(game);
|
||||||
|
if (!game.power) game.power = { dirty: true, elapsed: 0 };
|
||||||
|
game.power.elapsed = (game.power.elapsed || 0) + dt;
|
||||||
|
const interval = 1 / Math.max(1, BALANCE.power.recalcHz || 10);
|
||||||
|
if (game.power.dirty || game.power.elapsed >= interval) {
|
||||||
|
game.power.elapsed = 0;
|
||||||
|
recalculatePowerNetwork(game);
|
||||||
|
}
|
||||||
|
const powered = ensurePowerState(game);
|
||||||
|
if (game.phase === 'running') {
|
||||||
|
const count = powered.underVoltage?.size || 0;
|
||||||
|
if (count) {
|
||||||
|
game.stats.underVoltageSeconds = (game.stats.underVoltageSeconds || 0) + count * dt;
|
||||||
|
game.stats.underVoltageSecondsByMachine = game.stats.underVoltageSecondsByMachine || {};
|
||||||
|
for (const id of powered.underVoltage) {
|
||||||
|
game.stats.underVoltageSecondsByMachine[id] = (game.stats.underVoltageSecondsByMachine[id] || 0) + dt;
|
||||||
|
}
|
||||||
|
game.totals.underVoltageSeconds = (game.totals.underVoltageSeconds || 0) + count * dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function receivedVoltageFor(game, equipment) {
|
||||||
|
return ensurePowerState(game).assignments.get(stableId(equipment))?.receivedV || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEquipmentPowered(game, equipment) {
|
||||||
|
if (!powerSpecFor(equipment)) return true;
|
||||||
|
return ensurePowerState(game).assignments.has(stableId(equipment));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function powerDetailsFor(game, equipment) {
|
||||||
|
const spec = powerSpecFor(equipment);
|
||||||
|
if (!spec) return null;
|
||||||
|
return ensurePowerState(game).assignments.get(stableId(equipment)) || {
|
||||||
|
sourceName: 'None',
|
||||||
|
sourceBusV: 0,
|
||||||
|
routeLossV: 0,
|
||||||
|
receivedV: 0,
|
||||||
|
minVoltage: spec.minVoltage,
|
||||||
|
drawV: spec.drawV
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function powerSummary(game) {
|
||||||
|
const state = ensurePowerState(game);
|
||||||
|
const sources = [...(state.sources || new Map()).entries()].map(([id, data]) => `${id} ${data.outputV.toFixed(0)}V / ${data.admitted} powered`);
|
||||||
|
return `POWER: ${sources.join(' | ') || 'none'} / ${state.underVoltage?.size || 0} under-voltage`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addFuelPulse(generator, itemType) {
|
||||||
|
const kind = facilityKind(generator);
|
||||||
|
const spec = BALANCE.power.generators[kind];
|
||||||
|
if (!spec) return false;
|
||||||
|
if (kind === 'biomassGenerator' && !['male', 'female', 'poop'].includes(itemType)) return false;
|
||||||
|
if (kind === 'coalGenerator' && itemType !== 'coal') return false;
|
||||||
|
generator.fuelPulses = generator.fuelPulses || [];
|
||||||
|
generator.fuelPulses.push({ voltage: spec.pulseVoltage, timeLeft: spec.pulseSeconds });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
@ -51,7 +51,7 @@ export function facilityInputCell(game, facility) {
|
||||||
export function facilityOutputCell(game, facility) {
|
export function facilityOutputCell(game, facility) {
|
||||||
const f = typeof facility === 'string' ? game?.facilities?.[facility] : facility;
|
const f = typeof facility === 'string' ? game?.facilities?.[facility] : facility;
|
||||||
const kind = facilityKind(f);
|
const kind = facilityKind(f);
|
||||||
if (!f || !['mixer', 'sausageMaker'].includes(kind)) return null;
|
if (!f || !['mixer', 'sausageMaker', 'inboundTruck'].includes(kind)) return null;
|
||||||
if (kind === 'sausageMaker' && f.gridPlaced) {
|
if (kind === 'sausageMaker' && f.gridPlaced) {
|
||||||
const p = { col: f.col + (f.wCells || 3), row: f.row + Math.floor((f.hCells || 3) / 2) };
|
const p = { col: f.col + (f.wCells || 3), row: f.row + Math.floor((f.hCells || 3) / 2) };
|
||||||
return usableOwnedCell(game, p) ? p : null;
|
return usableOwnedCell(game, p) ? p : null;
|
||||||
|
|
@ -559,7 +559,7 @@ export function autoSideFor(scanner, chick) {
|
||||||
|
|
||||||
export function destinationLabel(dest) { return routeLabel(dest); }
|
export function destinationLabel(dest) { return routeLabel(dest); }
|
||||||
export function destinationColor(dest) {
|
export function destinationColor(dest) {
|
||||||
return { mixer: '#2477ff', truck: '#ff6aa8', trash: '#22b94f', sausageMaker: '#d78b3f', 'scanner-role-1': '#526456', scanner: '#526456', input: '#526456' }[dest] || '#102015';
|
return { mixer: '#2477ff', truck: '#ff6aa8', inboundTruck: '#8bd0ff', trash: '#22b94f', sausageMaker: '#d78b3f', biomassGenerator: '#7bd84f', coalGenerator: '#d0d5dc', 'scanner-role-1': '#526456', scanner: '#526456', input: '#526456' }[dest] || '#102015';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function routeFromFarmToScanner(game, farm, advance = false) {
|
export function routeFromFarmToScanner(game, farm, advance = false) {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { nearestGridEdge, layoutFacilityOnEdge, maintenanceRoomFootprint, gridFa
|
||||||
import { farmAt, scannerAt, scannerCenter, scannerConnector, scannerFootprintCells, gridFacilityAt, facilityInputCell, facilityOutputCell, refreshRoutingAfterEdit } from './routing.js';
|
import { farmAt, scannerAt, scannerCenter, scannerConnector, scannerFootprintCells, gridFacilityAt, facilityInputCell, facilityOutputCell, refreshRoutingAfterEdit } from './routing.js';
|
||||||
import { snapshot } from './history.js';
|
import { snapshot } from './history.js';
|
||||||
import { buildPrice } from './economy.js';
|
import { buildPrice } from './economy.js';
|
||||||
|
import { markPowerDirty } from './power.js';
|
||||||
|
|
||||||
export function createSelectionSystem({ game, canvasPoint, updatePanels, equipmentAtPoint, fail, pointInGrid, rectOfFacility, rectsOverlap }) {
|
export function createSelectionSystem({ game, canvasPoint, updatePanels, equipmentAtPoint, fail, pointInGrid, rectOfFacility, rectsOverlap }) {
|
||||||
function selectionToken(item) {
|
function selectionToken(item) {
|
||||||
|
|
@ -32,6 +33,8 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
|
||||||
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 === '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;
|
||||||
|
if (sel.type === 'powerWire') return game.powerWires?.has(sel.id) ? { type: 'powerWire', id: sel.id } : null;
|
||||||
|
if (sel.type === 'footbridge') return game.footbridges?.has(sel.id) ? { type: 'footbridge', id: sel.id } : null;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,6 +45,16 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
|
||||||
const c = cellCenter(p.col, p.row);
|
const c = cellCenter(p.col, p.row);
|
||||||
items.push({ type: 'conveyor', id: k, center: c, cell: p });
|
items.push({ type: 'conveyor', id: k, center: c, cell: p });
|
||||||
}
|
}
|
||||||
|
if (game.powerOverlay) {
|
||||||
|
for (const [k] of game.powerWires || new Map()) {
|
||||||
|
const p = parseKey(k);
|
||||||
|
items.push({ type: 'powerWire', id: k, center: cellCenter(p.col, p.row), cell: p });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [k] of game.footbridges || new Map()) {
|
||||||
|
const p = parseKey(k);
|
||||||
|
items.push({ type: 'footbridge', id: k, center: cellCenter(p.col, p.row), cell: p });
|
||||||
|
}
|
||||||
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 || []) {
|
for (const room of game.maintenanceRooms || []) {
|
||||||
|
|
@ -123,6 +136,22 @@ 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 === 'powerWire') {
|
||||||
|
const cell = parseKey(sel.id);
|
||||||
|
return { ...sel, obj, oldKey: sel.id, currentKey: sel.id, meta: { ...(game.powerWires?.get(sel.id) || {}) }, col: cell.col, row: cell.row };
|
||||||
|
}
|
||||||
|
if (sel.type === 'footbridge') {
|
||||||
|
const cell = parseKey(sel.id);
|
||||||
|
const meta = game.footbridges?.get(sel.id) || {};
|
||||||
|
const cells = [];
|
||||||
|
for (const [k, bridge] of game.footbridges || new Map()) {
|
||||||
|
if ((meta.bridgeId && bridge.bridgeId === meta.bridgeId) || k === sel.id) {
|
||||||
|
const part = parseKey(k);
|
||||||
|
cells.push({ key: k, dc: part.col - cell.col, dr: part.row - cell.row, meta: { ...bridge } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...sel, obj, oldKey: sel.id, currentKey: sel.id, meta: { ...meta }, cells, dragToken: `footbridge:${meta.bridgeId || sel.id}`, col: cell.col, row: cell.row };
|
||||||
|
}
|
||||||
if (sel.type === 'eggFarm' || sel.type === 'scanner' || sel.type === 'maintenanceRoom') 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 obj.gridPlaced
|
if (sel.type === 'facility') return obj.gridPlaced
|
||||||
? { ...sel, obj, gridPlaced: true, col: obj.col, row: obj.row, 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 } }
|
? { ...sel, obj, gridPlaced: true, col: obj.col, row: obj.row, 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 } }
|
||||||
|
|
@ -135,7 +164,14 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
|
||||||
const hit = equipmentAtPoint(p);
|
const hit = equipmentAtPoint(p);
|
||||||
if (!hit) return false;
|
if (!hit) return false;
|
||||||
const selections = selectedSetForDrag(hit);
|
const selections = selectedSetForDrag(hit);
|
||||||
const origins = selections.map(selectionOrigin).filter(Boolean);
|
const origins = [];
|
||||||
|
const seenOrigins = new Set();
|
||||||
|
for (const origin of selections.map(selectionOrigin).filter(Boolean)) {
|
||||||
|
const token = origin.dragToken || selectionToken(origin);
|
||||||
|
if (seenOrigins.has(token)) continue;
|
||||||
|
seenOrigins.add(token);
|
||||||
|
origins.push(origin);
|
||||||
|
}
|
||||||
if (!origins.length) return false;
|
if (!origins.length) return false;
|
||||||
game.groupDrag = { start: p, selections, origins, historySnapshot: snapshot(game), committed: false, lastDCol: 0, lastDRow: 0 };
|
game.groupDrag = { start: p, selections, origins, historySnapshot: snapshot(game), committed: false, lastDCol: 0, lastDRow: 0 };
|
||||||
game.multiSelected = selections;
|
game.multiSelected = selections;
|
||||||
|
|
@ -194,6 +230,20 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function overlayTargetBlocked(origin, col, row, selectedTokens) {
|
||||||
|
const k = key(col, row);
|
||||||
|
if (!pointInGrid(col, row) || game.blockedCells?.has?.(k)) return 'Selection hits blocked ground';
|
||||||
|
if (farmAt(game, col, row) || scannerAt(game, col, row)) return 'Cell occupied';
|
||||||
|
const room = (game.maintenanceRooms || []).find(item => maintenanceRoomFootprint(item).some(cell => cell.col === col && cell.row === row));
|
||||||
|
if (room && !selectedTokens.has(`maintenanceRoom:${room.id}`)) return 'Cell occupied';
|
||||||
|
const gridFacility = gridFacilityAt(game, col, row);
|
||||||
|
if (gridFacility && !selectedTokens.has(`facility:${gridFacility.id}`)) return 'Cell occupied';
|
||||||
|
const originOwnsCell = origin.cells?.some(part => part.key === k);
|
||||||
|
if (origin.type === 'powerWire' && game.powerWires?.has(k) && !selectedTokens.has(`powerWire:${k}`)) return 'Power wire already covers that cell';
|
||||||
|
if (origin.type === 'footbridge' && game.footbridges?.has(k) && !selectedTokens.has(`footbridge:${k}`) && !originOwnsCell) return 'Footbridge already covers that cell';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
function facilityDragWouldOverlap(dx, dy, selectedTokens) {
|
function facilityDragWouldOverlap(dx, dy, selectedTokens) {
|
||||||
const candidateRects = [];
|
const candidateRects = [];
|
||||||
for (const origin of game.groupDrag.origins) {
|
for (const origin of game.groupDrag.origins) {
|
||||||
|
|
@ -254,6 +304,19 @@ 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' && !origin.gridPlaced) continue;
|
if (origin.type === 'facility' && !origin.gridPlaced) continue;
|
||||||
const col = origin.col + dcol, row = origin.row + drow;
|
const col = origin.col + dcol, row = origin.row + drow;
|
||||||
|
if (origin.type === 'powerWire' || origin.type === 'footbridge') {
|
||||||
|
const parts = origin.type === 'footbridge' && origin.cells?.length
|
||||||
|
? origin.cells.map(part => ({ col: col + part.dc, row: row + part.dr }))
|
||||||
|
: [{ col, row }];
|
||||||
|
for (const part of parts) {
|
||||||
|
const reason = overlayTargetBlocked(origin, part.col, part.row, selectedTokens);
|
||||||
|
if (reason) return fail(reason);
|
||||||
|
const tk = `${origin.type}:${key(part.col, part.row)}`;
|
||||||
|
if (targetCells.has(tk)) return fail('Selection overlap');
|
||||||
|
targetCells.add(tk);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const cells = origin.type === 'scanner'
|
const cells = origin.type === 'scanner'
|
||||||
? scannerFootprintCells({ col, row })
|
? scannerFootprintCells({ col, row })
|
||||||
: (origin.type === 'maintenanceRoom'
|
: (origin.type === 'maintenanceRoom'
|
||||||
|
|
@ -293,6 +356,25 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
|
||||||
game.conveyorMeta.set(newK, o.meta || { price: buildPrice('conveyor'), builtSession: null, uses: 0 });
|
game.conveyorMeta.set(newK, o.meta || { price: buildPrice('conveyor'), builtSession: null, uses: 0 });
|
||||||
o.currentKey = newK;
|
o.currentKey = newK;
|
||||||
}
|
}
|
||||||
|
const wireOrigins = game.groupDrag.origins.filter(o => o.type === 'powerWire');
|
||||||
|
for (const o of wireOrigins) game.powerWires.delete(o.currentKey || o.oldKey);
|
||||||
|
for (const o of wireOrigins) {
|
||||||
|
const newK = key(o.col + dcol, o.row + drow);
|
||||||
|
game.powerWires.set(newK, o.meta || {});
|
||||||
|
o.currentKey = newK;
|
||||||
|
}
|
||||||
|
const footbridgeOrigins = game.groupDrag.origins.filter(o => o.type === 'footbridge');
|
||||||
|
for (const o of footbridgeOrigins) {
|
||||||
|
for (const part of o.cells || [{ key: o.currentKey || o.oldKey }]) game.footbridges.delete(part.key);
|
||||||
|
}
|
||||||
|
for (const o of footbridgeOrigins) {
|
||||||
|
const newK = key(o.col + dcol, o.row + drow);
|
||||||
|
for (const part of o.cells || [{ dc: 0, dr: 0, meta: o.meta || {} }]) {
|
||||||
|
const partKey = key(o.col + dcol + (part.dc || 0), o.row + drow + (part.dr || 0));
|
||||||
|
game.footbridges.set(partKey, { ...(part.meta || o.meta || {}) });
|
||||||
|
}
|
||||||
|
o.currentKey = newK;
|
||||||
|
}
|
||||||
for (const origin of game.groupDrag.origins) {
|
for (const origin of game.groupDrag.origins) {
|
||||||
if (origin.type === 'eggFarm') {
|
if (origin.type === 'eggFarm') {
|
||||||
origin.obj.col = origin.col + dcol;
|
origin.obj.col = origin.col + dcol;
|
||||||
|
|
@ -314,10 +396,12 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
|
||||||
layoutFacilityOnEdge(origin.obj, entry, side);
|
layoutFacilityOnEdge(origin.obj, entry, side);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
game.groupDrag.selections = game.groupDrag.origins.map(o => o.type === 'conveyor' ? { type: 'conveyor', id: o.currentKey || o.oldKey } : { type: o.type, id: o.id });
|
game.groupDrag.selections = game.groupDrag.origins.map(o => ['conveyor', 'powerWire', 'footbridge'].includes(o.type) ? { type: o.type, id: o.currentKey || o.oldKey } : { type: o.type, id: o.id });
|
||||||
game.multiSelected = [...game.groupDrag.selections];
|
game.multiSelected = [...game.groupDrag.selections];
|
||||||
game.selected = game.multiSelected[0] || null;
|
game.selected = game.multiSelected[0] || null;
|
||||||
refreshRoutingAfterEdit(game);
|
refreshRoutingAfterEdit(game);
|
||||||
|
if (game.groupDrag.origins.some(origin => origin.type !== 'powerWire')) game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
|
||||||
|
if (game.groupDrag.origins.some(origin => origin.type === 'powerWire' || (origin.type !== 'conveyor' && origin.type !== 'footbridge'))) markPowerDirty(game);
|
||||||
updatePanels();
|
updatePanels();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { routeFromFarmToScanner, facilityConnectionIssues } from './routing.js';
|
||||||
import { buildPrice, fairiesTributeInfo, finalScore, maleTruckPenalty, mixerPoopPenalty, truckPoopPenalty } from './economy.js';
|
import { buildPrice, fairiesTributeInfo, finalScore, maleTruckPenalty, mixerPoopPenalty, truckPoopPenalty } from './economy.js';
|
||||||
import { conveyorSpeedForGame } from './cards.js';
|
import { conveyorSpeedForGame } from './cards.js';
|
||||||
import { maintenanceSummary } from './maintenance.js';
|
import { maintenanceSummary } from './maintenance.js';
|
||||||
|
import { powerSummary } from './power.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) {
|
||||||
|
|
@ -35,6 +36,8 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
|
||||||
if (!btn?.classList?.contains('tool-button')) continue;
|
if (!btn?.classList?.contains('tool-button')) continue;
|
||||||
btn.classList.toggle('active', game.buildTool === name);
|
btn.classList.toggle('active', game.buildTool === name);
|
||||||
}
|
}
|
||||||
|
ui.buttons.powerOverlay?.classList.toggle('active', !!game.powerOverlay);
|
||||||
|
if (ui.buttons.powerOverlay) ui.buttons.powerOverlay.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending;
|
||||||
for (const id of BUILD_TOOL_IDS) {
|
for (const id of BUILD_TOOL_IDS) {
|
||||||
const btn = ui.buttons[id];
|
const btn = ui.buttons[id];
|
||||||
if (!btn) continue;
|
if (!btn) continue;
|
||||||
|
|
@ -81,6 +84,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
|
||||||
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`,
|
||||||
`Penalties: <b>wrong shipment -${yen(maleTruckPenalty(game))}</b> | <b>Mixer poop -${yen(mixerPoopPenalty(game))}</b> | <b>Shipment poop -${yen(truckPoopPenalty(game))}</b>`,
|
`Penalties: <b>wrong shipment -${yen(maleTruckPenalty(game))}</b> | <b>Mixer poop -${yen(mixerPoopPenalty(game))}</b> | <b>Shipment poop -${yen(truckPoopPenalty(game))}</b>`,
|
||||||
|
powerSummary(game),
|
||||||
`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>`,
|
`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>`,
|
||||||
`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>`,
|
`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>`,
|
||||||
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>' : '',
|
||||||
|
|
@ -184,6 +188,10 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
|
||||||
const negative = value => `<span class="${value > 0 ? 'cash-negative' : ''}">${value > 0 ? '-' : ''}${yen(value)}</span>`;
|
const negative = value => `<span class="${value > 0 ? 'cash-negative' : ''}">${value > 0 ? '-' : ''}${yen(value)}</span>`;
|
||||||
const netClass = r.profit >= 0 ? 'cash-positive' : 'cash-negative';
|
const netClass = r.profit >= 0 ? 'cash-positive' : 'cash-negative';
|
||||||
const penalties = (r.mixerPoopFine || 0) + (r.truckPoopFine || 0) + (r.maleTruckFine || 0) + (r.explosionDamage || 0) + (r.cardRerollCost || 0);
|
const penalties = (r.mixerPoopFine || 0) + (r.truckPoopFine || 0) + (r.maleTruckFine || 0) + (r.explosionDamage || 0) + (r.cardRerollCost || 0);
|
||||||
|
const avgQuality = r.qualityMultiplierCount ? (r.qualityMultiplierSum || 0) / r.qualityMultiplierCount : 1;
|
||||||
|
const qualityRevenue = r.qualityRevenue || 0;
|
||||||
|
const qualityClass = qualityRevenue > 0 ? 'cash-positive' : '';
|
||||||
|
const maxGeneratorOutput = Object.values(r.maxGeneratorOutput || {}).reduce((max, value) => Math.max(max, Number(value) || 0), 0);
|
||||||
ui.modalTitle.textContent = `Day ${r.turn} Settlement`;
|
ui.modalTitle.textContent = `Day ${r.turn} Settlement`;
|
||||||
ui.modalBody.innerHTML = `
|
ui.modalBody.innerHTML = `
|
||||||
<div class="settlement-net ${netClass}"><span>Net result</span><strong>${yen(r.profit)}</strong></div>
|
<div class="settlement-net ${netClass}"><span>Net result</span><strong>${yen(r.profit)}</strong></div>
|
||||||
|
|
@ -192,6 +200,14 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
|
||||||
<div><strong>Poop shipment income</strong>${positive(r.poopShipmentIncome || 0)}</div>
|
<div><strong>Poop shipment income</strong>${positive(r.poopShipmentIncome || 0)}</div>
|
||||||
<div><strong>Sausage shipment income</strong>${positive(r.sausageRevenue || 0)}</div>
|
<div><strong>Sausage shipment income</strong>${positive(r.sausageRevenue || 0)}</div>
|
||||||
<div><strong>Meat / sausages</strong><span>${r.meatProduced || 0} meat -> ${r.sausageProduced || 0} made / ${r.sausageShipped || 0} shipped</span></div>
|
<div><strong>Meat / sausages</strong><span>${r.meatProduced || 0} meat -> ${r.sausageProduced || 0} made / ${r.sausageShipped || 0} shipped</span></div>
|
||||||
|
<div><strong>Imports</strong><span>coal ${r.coalReleased || 0}/${r.coalOrdered || 0}, zunda ${r.zundaReleased || 0}/${r.zundaOrdered || 0}</span></div>
|
||||||
|
<div><strong>Import use</strong><span>coal fuel ${r.coalConsumed || 0}, zunda used ${r.zundaConsumed || 0}, undelivered ${r.coalUndelivered || 0}/${r.zundaUndelivered || 0}</span></div>
|
||||||
|
<div><strong>Import cost</strong>${negative(r.importPurchaseCost || 0)}</div>
|
||||||
|
<div><strong>Generator fuel</strong><span>biomass ${r.biomassFuelConsumed || 0}, max output ${maxGeneratorOutput.toFixed(0)}V</span></div>
|
||||||
|
<div><strong>Quality revenue</strong><span><b class="${qualityClass}">${qualityRevenue > 0 ? '+' : ''}${yen(qualityRevenue)}</b> avg x${avgQuality.toFixed(2)} / max x${(r.maxQualityMultiplier || 1).toFixed(2)}</span></div>
|
||||||
|
<div><strong>Zunda boost</strong><span>${(r.zundaBoostSeconds || 0).toFixed(1)} farm-seconds</span></div>
|
||||||
|
<div><strong>Power trouble</strong><span>${(r.underVoltageSeconds || 0).toFixed(1)} machine-seconds / ${r.generatorAssignmentChanges || 0} source changes</span></div>
|
||||||
|
<div><strong>Repair access</strong><span>${r.unreachableRepairTargets || 0} unreachable target checks</span></div>
|
||||||
<div><strong>Composter income</strong>${positive(r.composterIncome || 0)}</div>
|
<div><strong>Composter income</strong>${positive(r.composterIncome || 0)}</div>
|
||||||
<div><strong>DUD refund income</strong>${positive(r.dudRefundIncome || 0)}</div>
|
<div><strong>DUD refund income</strong>${positive(r.dudRefundIncome || 0)}</div>
|
||||||
<div><strong>Manual combo bonus</strong>${positive(r.manualComboBonus || 0)}</div>
|
<div><strong>Manual combo bonus</strong>${positive(r.manualComboBonus || 0)}</div>
|
||||||
|
|
|
||||||
177
test_v29_00.mjs
Normal file
177
test_v29_00.mjs
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { BALANCE } from './src/core/balance.js';
|
||||||
|
import { createGame, resetLayout } from './src/core/state.js';
|
||||||
|
import { key } from './src/core/utils.js';
|
||||||
|
import { recalculatePowerNetwork, isEquipmentPowered, currentGeneratorOutputV, addFuelPulse, receivedVoltageFor, updatePowerSystem } from './src/systems/power.js';
|
||||||
|
import { clampInboundOrder, createImportSchedule, inboundOrderCost } from './src/systems/imports.js';
|
||||||
|
import { boostOutputCapacityPerSecond, qualityForRate } from './src/systems/farmQuality.js';
|
||||||
|
|
||||||
|
function approx(actual, expected, epsilon = 1e-6) {
|
||||||
|
assert.ok(Math.abs(actual - expected) <= epsilon, `expected ${actual} to be within ${epsilon} of ${expected}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyPowerGame() {
|
||||||
|
const game = createGame();
|
||||||
|
resetLayout(game);
|
||||||
|
game.facilities = {};
|
||||||
|
game.scanners = [];
|
||||||
|
game.maintenanceRooms = [];
|
||||||
|
game.powerWires = new Map();
|
||||||
|
game.power = { dirty: true, elapsed: 0, routes: new Map(), assignments: new Map(), sources: new Map(), underVoltage: new Set() };
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
function starter(id, col, row, outputV = 25) {
|
||||||
|
const generator = {
|
||||||
|
type: 'facility',
|
||||||
|
id,
|
||||||
|
baseId: 'starterGenerator',
|
||||||
|
name: id,
|
||||||
|
col,
|
||||||
|
row,
|
||||||
|
wCells: 2,
|
||||||
|
hCells: 2,
|
||||||
|
gridPlaced: true,
|
||||||
|
fuelPulses: []
|
||||||
|
};
|
||||||
|
if (outputV !== 25) generator.fuelPulses = [{ voltage: outputV - 25, timeLeft: 10 }];
|
||||||
|
return generator;
|
||||||
|
}
|
||||||
|
|
||||||
|
function biomass(id, col, row) {
|
||||||
|
return { type: 'facility', id, baseId: 'biomassGenerator', name: id, col, row, wCells: 3, hCells: 3, gridPlaced: true, fuelPulses: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgeMachine(id, baseId, col, row, builtSession = 1) {
|
||||||
|
return { type: 'facility', id, baseId, name: id, entry: { col, row }, side: 'left', builtSession };
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireLine(game, row, fromCol, toCol, kind = 'powerWire') {
|
||||||
|
const step = fromCol <= toCol ? 1 : -1;
|
||||||
|
for (let col = fromCol; col !== toCol + step; col += step) game.powerWires.set(key(col, row), { kind, price: 0, builtSession: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
assert.equal(BALANCE.version, 'v29.00');
|
||||||
|
assert.equal(BALANCE.power.wires.powerWire.lossV, 0.5);
|
||||||
|
assert.equal(BALANCE.power.wires.insulatedPowerWire.lossV, 0.15);
|
||||||
|
assert.equal(BALANCE.imports.prices.coal, 2);
|
||||||
|
assert.equal(BALANCE.imports.prices.zundaMochi, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const game = createGame();
|
||||||
|
resetLayout(game);
|
||||||
|
recalculatePowerNetwork(game);
|
||||||
|
assert.ok(game.facilities.starterGenerator, 'new games include starter generator');
|
||||||
|
assert.ok(game.powerWires.size > 0, 'new games include starter wires');
|
||||||
|
assert.ok(isEquipmentPowered(game, game.facilities.mixer), 'initial mixer is powered');
|
||||||
|
assert.ok(isEquipmentPowered(game, game.facilities.trash), 'initial shredder is powered');
|
||||||
|
assert.ok(isEquipmentPowered(game, game.facilities.truck), 'initial shipping truck is powered');
|
||||||
|
const loads = ['mixer', 'trash', 'truck'].reduce((sum, id) => sum + BALANCE.power.machines[id].drawV, 0);
|
||||||
|
assert.equal(loads, 9);
|
||||||
|
for (const [wireKey, wire] of game.powerWires) {
|
||||||
|
assert.equal(wire.price, 0, `starter wire ${wireKey} is free`);
|
||||||
|
assert.equal(game.blockedCells.has(wireKey), false, `starter wire ${wireKey} avoids obstacles`);
|
||||||
|
}
|
||||||
|
assert.equal(game.facilities.starterGenerator.price, 0);
|
||||||
|
assert.equal(game.facilities.starterGenerator.unsellable, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const game = emptyPowerGame();
|
||||||
|
game.facilities.gen = starter('gen', 5, 5);
|
||||||
|
game.facilities.mixer = edgeMachine('mixer', 'mixer', 3, 5);
|
||||||
|
wireLine(game, 5, 4, 4, 'powerWire');
|
||||||
|
game.phase = 'build';
|
||||||
|
recalculatePowerNetwork(game);
|
||||||
|
approx(receivedVoltageFor(game, game.facilities.mixer), 20.5);
|
||||||
|
assert.equal(game.stats.generatorAssignmentChanges, 0, 'build-mode power summaries do not affect daily assignment stats');
|
||||||
|
game.phase = 'running';
|
||||||
|
game.powerWires.set(key(4, 4), { kind: 'powerWire', price: 0, builtSession: null });
|
||||||
|
game.power.dirty = true;
|
||||||
|
recalculatePowerNetwork(game);
|
||||||
|
assert.equal(game.stats.generatorAssignmentChanges, 0, 'unchanged running assignment does not increment source changes');
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const game = emptyPowerGame();
|
||||||
|
game.facilities.gen = starter('gen', 12, 5);
|
||||||
|
game.facilities.mixer = edgeMachine('mixer', 'mixer', 1, 5);
|
||||||
|
wireLine(game, 5, 11, 2, 'powerWire');
|
||||||
|
recalculatePowerNetwork(game);
|
||||||
|
approx(receivedVoltageFor(game, game.facilities.mixer), 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const game = emptyPowerGame();
|
||||||
|
game.facilities.gen = starter('gen', 12, 5);
|
||||||
|
game.facilities.mixer = edgeMachine('mixer', 'mixer', 1, 5);
|
||||||
|
wireLine(game, 5, 11, 2, 'insulatedPowerWire');
|
||||||
|
recalculatePowerNetwork(game);
|
||||||
|
approx(receivedVoltageFor(game, game.facilities.mixer), 19.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const game = emptyPowerGame();
|
||||||
|
game.facilities.gen = starter('gen', 12, 5);
|
||||||
|
game.facilities.mixer = edgeMachine('mixer', 'mixer', 1, 5);
|
||||||
|
for (let col = 11; col >= 7; col -= 1) game.powerWires.set(key(col, 5), { kind: 'powerWire', price: 0, builtSession: null });
|
||||||
|
for (let col = 6; col >= 2; col -= 1) game.powerWires.set(key(col, 5), { kind: 'insulatedPowerWire', price: 0, builtSession: null });
|
||||||
|
recalculatePowerNetwork(game);
|
||||||
|
approx(receivedVoltageFor(game, game.facilities.mixer), 17.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const game = emptyPowerGame();
|
||||||
|
game.facilities.genA = biomass('genA', 2, 2);
|
||||||
|
game.facilities.genB = biomass('genB', 7, 3);
|
||||||
|
addFuelPulse(game.facilities.genA, 'male');
|
||||||
|
addFuelPulse(game.facilities.genB, 'female');
|
||||||
|
game.facilities.truck = edgeMachine('truck', 'truck', 5, 5);
|
||||||
|
game.powerWires.set(key(5, 4), { kind: 'powerWire', price: 0, builtSession: null });
|
||||||
|
game.powerWires.set(key(6, 5), { kind: 'powerWire', price: 0, builtSession: null });
|
||||||
|
recalculatePowerNetwork(game);
|
||||||
|
assert.equal(isEquipmentPowered(game, game.facilities.truck), false, 'separate generators do not sum voltage');
|
||||||
|
addFuelPulse(game.facilities.genA, 'poop');
|
||||||
|
recalculatePowerNetwork(game);
|
||||||
|
assert.equal(isEquipmentPowered(game, game.facilities.truck), true, 'one source with enough stacked pulse can power the truck');
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const generator = { baseId: 'biomassGenerator', fuelPulses: [] };
|
||||||
|
addFuelPulse(generator, 'male');
|
||||||
|
addFuelPulse(generator, 'female');
|
||||||
|
addFuelPulse(generator, 'poop');
|
||||||
|
assert.equal(currentGeneratorOutputV(generator), 15);
|
||||||
|
const coal = { baseId: 'coalGenerator', fuelPulses: [] };
|
||||||
|
addFuelPulse(coal, 'coal');
|
||||||
|
assert.equal(currentGeneratorOutputV(coal), 15);
|
||||||
|
updatePowerSystem({ facilities: { coal }, stats: {}, totals: {}, power: { dirty: false, elapsed: 0 } }, 3.1);
|
||||||
|
assert.equal(currentGeneratorOutputV(coal), 0);
|
||||||
|
assert.equal(addFuelPulse(coal, 'poop'), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
assert.deepEqual(clampInboundOrder({ coalPerDay: 100, zundaMochiPerDay: 100 }), { coalPerDay: 40, zundaMochiPerDay: 40 });
|
||||||
|
assert.equal(inboundOrderCost({ coalPerDay: 10, zundaMochiPerDay: 5 }), 40);
|
||||||
|
const sixty = createImportSchedule({ coalPerDay: 60 }, 'test');
|
||||||
|
assert.equal(sixty.length, 60);
|
||||||
|
approx(sixty[1].due - sixty[0].due, 1, 0.35);
|
||||||
|
const six = createImportSchedule({ coalPerDay: 6 }, 'test');
|
||||||
|
approx(six[1].due - six[0].due, 10, 3.1);
|
||||||
|
const mixed = createImportSchedule({ coalPerDay: 40, zundaMochiPerDay: 40 }, 'test');
|
||||||
|
assert.equal(mixed.length, BALANCE.imports.combinedLimitPerTruck);
|
||||||
|
approx(mixed[1].due - mixed[0].due, 0.75, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const game = createGame();
|
||||||
|
const cap = boostOutputCapacityPerSecond(game);
|
||||||
|
assert.ok(cap > 0);
|
||||||
|
assert.equal(qualityForRate(cap, cap), 1);
|
||||||
|
assert.ok(qualityForRate(cap * 2, cap) > qualityForRate(cap * 1.5, cap));
|
||||||
|
assert.equal(qualityForRate(cap * 100, cap), BALANCE.farmQuality.maxMultiplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('v29.00 tests passed');
|
||||||
Loading…
Add table
Add a link
Reference in a new issue